Claude Connector Data Access Patterns: How to Structure What Your Connector Returns
How data flows from your Claude Connector tools to Claude and to users.
TL;DR: Text content goes to Claude for reasoning. Structured content goes to your UI component. Format text as labeled fields, not raw JSON. Return only what the query needs. Paginate anything over 10 results. Handle empty results with a clear message.
Every Claude Connector is an MCP server that returns data through tool calls. The connector fetches data from your source, whether that’s a database, REST API, or SaaS platform, and sends it back to Claude. How you format that data determines whether Claude gives the user a useful answer or a garbled summary of a JSON blob.
This post covers the patterns for returning data from your Claude Connector tools, so Claude can reason about it correctly and your users get clean results.
Two Types of Tool Output
MCP tools return data in two ways, and they map to different use cases.
Text content is a string that goes directly into Claude’s context. Claude reads it, reasons about it, and works the information into its response. Your tool handler returns a content array with type: "text" blocks.
// Tool handler returning text content
export async function handler({ query }: { query: string }) {
const results = await searchTickets(query);
return {
content: [
{
type: 'text' as const,
text: formatTickets(results),
},
],
};
}
Structured content is typed JSON that gets passed to a React component inside the chat. The component renders a card, table, chart, or form. Your tool handler returns a structuredContent object, and your resource component receives it via useToolData().
// Tool handler returning structured content for UI rendering
export async function handler({ ticketId }: { ticketId: string }) {
const ticket = await getTicket(ticketId);
return {
structuredContent: {
id: ticket.id,
title: ticket.title,
status: ticket.status,
assignee: ticket.assignee,
priority: ticket.priority,
description: ticket.description,
},
};
}
The distinction is straightforward: text content is for Claude to talk about, structured content is for your UI to render. A standard connector uses text content. An interactive connector uses structured content. Some connectors use both across different tools.
Formatting Text Content for Claude
Claude is good at reading text, but it still has preferences. Raw JSON makes Claude work harder to extract meaning, and the result is often a worse summary. Labeled fields on separate lines are easier for Claude to parse and reference.
Here is a bad pattern:
function formatTickets(tickets: Ticket[]) {
return JSON.stringify(tickets);
}
Claude receives something like [{"id":"T-1234","title":"Deploy API v2","status":"in_progress","assignee":"alice@example.com","priority":2,"created":"2026-04-01T10:00:00Z"}] and has to parse the nested JSON mentally. It works, but the summaries are less precise.
Here is a better pattern:
function formatTickets(tickets: Ticket[]) {
if (tickets.length === 0) {
return 'No tickets match that query.';
}
const formatted = tickets.map(
(t) =>
`Ticket: ${t.id}\n` +
`Title: ${t.title}\n` +
`Status: ${t.status}\n` +
`Assignee: ${t.assignee}\n` +
`Priority: P${t.priority}`
);
return (
`Found ${tickets.length} ticket(s):\n\n` + formatted.join('\n\n')
);
}
Claude now receives clearly labeled data with a count header. It can say “I found 3 tickets. The highest priority one is T-1234, assigned to Alice” without guessing at field meanings.
A few formatting rules that help:
- Label every field. “Status: In Progress” is better than just “in_progress” because Claude knows what the value represents without inferring from JSON keys.
- Include counts. “Found 3 of 47 tickets” tells Claude (and the user) whether more data exists. Without this, Claude might say “here are all the tickets” when it only has a page of results.
- Handle empty results explicitly. Return “No tickets match that query” instead of an empty array. Claude handles explicit absence better than it handles
[], and the user gets a clear answer instead of silence. - Skip irrelevant fields. If the user asked “who is working on the API migration?”, your tool does not need to return created timestamps, internal IDs, or audit logs. Return the fields that answer the question.
Structured Content for Interactive Connectors
When your connector renders UI inside the conversation, use structuredContent to pass typed data to your resource component. The component receives this data through the useToolData() hook from sunpeak.
import { useToolData } from 'sunpeak';
interface TicketData {
id: string;
title: string;
status: string;
assignee: string;
priority: number;
description: string;
}
export function TicketCard() {
const ticket = useToolData<TicketData>();
return (
<div className="ticket-card">
<h2>{ticket.title}</h2>
<span className="status">{ticket.status}</span>
<p>Assigned to {ticket.assignee}</p>
<p>{ticket.description}</p>
</div>
);
}
The data shape is entirely yours. Return whatever your component needs to render. The key difference from text content is that Claude does not reason about structured content the same way. If you want Claude to also reference the data in its text response, return both:
export async function handler({ ticketId }: { ticketId: string }) {
const ticket = await getTicket(ticketId);
return {
structuredContent: {
id: ticket.id,
title: ticket.title,
status: ticket.status,
assignee: ticket.assignee,
priority: ticket.priority,
description: ticket.description,
},
content: [
{
type: 'text' as const,
text:
`Ticket ${ticket.id}: ${ticket.title}\n` +
`Status: ${ticket.status}\n` +
`Assignee: ${ticket.assignee}`,
},
],
};
}
Now Claude can say “Here’s the ticket for the API migration, it’s assigned to Alice and currently in progress” while the user also sees a rendered card with full details.
Handling Large Datasets
The biggest mistake developers make with Claude Connectors is returning too much data. Your database might have 10,000 records, but dumping all of them into a tool response does two things: it consumes Claude’s context window, and it forces Claude to summarize a wall of text that nobody asked for.
Set Default Limits
Every list or search tool should have a limit parameter with a sensible default. Five to ten results is usually right for a first response.
export const schema = {
query: z.string().describe('Search keywords'),
limit: z
.number()
.optional()
.describe('Max results to return (default 5)'),
};
export async function handler({
query,
limit = 5,
}: {
query: string;
limit?: number;
}) {
const results = await searchTickets(query, { limit });
return {
content: [
{
type: 'text' as const,
text: formatTickets(results),
},
],
};
}
If the user wants more, Claude will call the tool again with a higher limit or use your pagination tool.
Expose a Pagination Tool
For datasets where the user might need to browse, add a separate tool that fetches the next page. This is better than making your search tool handle offsets because Claude can decide whether to paginate based on the user’s follow-up.
// src/tools/list-tickets.ts
export const schema = {
status: z.enum(['open', 'closed', 'all']).optional(),
page: z.number().optional().describe('Page number (default 1)'),
pageSize: z.number().optional().describe('Results per page (default 10)'),
};
export async function handler({
status = 'all',
page = 1,
pageSize = 10,
}: {
status?: string;
page?: number;
pageSize?: number;
}) {
const { tickets, total } = await listTickets({ status, page, pageSize });
const header =
`Showing ${tickets.length} of ${total} tickets ` +
`(page ${page} of ${Math.ceil(total / pageSize)})`;
return {
content: [
{
type: 'text' as const,
text: header + '\n\n' + formatTickets(tickets),
},
],
};
}
The “page 2 of 12” metadata tells Claude that more data exists, which means it can offer to show more when the user asks.
Separate Detail from Summary
A common pattern is pairing a search tool with a get-by-ID tool. The search returns a brief summary of each result (ID, title, status). The detail tool returns the full record for a specific item. This keeps search results compact and gives the user a way to zoom in.
// search-tickets returns: ID, title, status, assignee
// get-ticket returns: everything, including description, comments, history
Claude naturally picks up this pattern. When a user says “find tickets about the API migration,” Claude calls search. When the user says “tell me more about T-1234,” Claude calls get-ticket.
Field Selection
Not every API field belongs in your tool response. Internal IDs, audit timestamps, system metadata, and raw foreign keys are noise. They consume tokens and add nothing to Claude’s reasoning.
Before returning data, ask: would this field help Claude answer the user’s question? If not, skip it.
// Too much: returning the raw API response
export async function handler({ id }: { id: string }) {
const ticket = await api.getTicket(id);
return {
content: [{ type: 'text' as const, text: JSON.stringify(ticket) }],
};
}
// Better: selecting fields that matter
export async function handler({ id }: { id: string }) {
const ticket = await api.getTicket(id);
return {
content: [
{
type: 'text' as const,
text:
`Ticket: ${ticket.id}\n` +
`Title: ${ticket.title}\n` +
`Status: ${ticket.status}\n` +
`Assignee: ${ticket.assignee?.name || 'Unassigned'}\n` +
`Priority: P${ticket.priority}\n` +
`Description: ${ticket.description}`,
},
],
};
}
If different queries need different fields, that’s a sign you might want separate tools. A “search tickets” tool returns summary fields. A “get ticket details” tool returns everything including comments and history.
Error and Empty State Patterns
Your tool handler will sometimes have nothing to return, or the upstream API will fail. How you communicate these states to Claude matters because Claude passes the message to the user.
Empty Results
Always return an explicit message, not an empty array or null.
if (results.length === 0) {
return {
content: [
{
type: 'text' as const,
text: `No tickets found matching "${query}". Try broader search terms or check the status filter.`,
},
],
};
}
The suggestion to try broader terms gives Claude something to work with. It might tell the user “I didn’t find any tickets for that query, but you could try searching for just the project name.”
API Errors
When your upstream API fails, return a text response that describes the problem. Do not throw an unhandled error because the MCP transport will convert it to a generic error message that Claude cannot explain to the user.
try {
const tickets = await api.searchTickets(query);
return { content: [{ type: 'text' as const, text: formatTickets(tickets) }] };
} catch (error) {
return {
content: [
{
type: 'text' as const,
text: `Could not search tickets: ${error instanceof Error ? error.message : 'Unknown error'}. The ticket system may be temporarily unavailable.`,
},
],
isError: true,
};
}
The isError: true flag tells the host that the tool call failed, which Claude uses to adjust its response accordingly.
Testing Data Access Patterns
Getting data formatting right requires testing with real inputs. sunpeak lets you create simulation files that define tool inputs and expected outputs so you can verify formatting without calling real APIs or needing a Claude account.
A simulation file defines what the tool receives and what it should return:
{
"tools": {
"search-tickets": {
"args": { "query": "API migration", "limit": 3 },
"result": {
"content": [
{
"type": "text",
"text": "Found 3 of 12 tickets:\n\nTicket: T-1234\nTitle: Deploy API v2\nStatus: In Progress\nAssignee: alice@example.com\nPriority: P1\n\nTicket: T-1235\nTitle: Update API docs\nStatus: Open\nAssignee: bob@example.com\nPriority: P2\n\nTicket: T-1236\nTitle: Deprecate v1 endpoints\nStatus: Open\nAssignee: alice@example.com\nPriority: P3"
}
]
}
}
}
}
Run pnpm dev to see how each response looks in the inspector. For interactive connectors, the inspector renders your resource component with the structured content so you can verify the UI matches the data shape.
You can also write automated tests with pnpm test to verify that your formatting is consistent across different tool inputs, which is especially useful when multiple developers contribute to the same connector. See MCP App CI/CD for setting up these tests in GitHub Actions.
Summary of Patterns
| Pattern | When to use | Example |
|---|---|---|
| Text content | Data Claude should talk about | Search results, status lookups, summaries |
| Structured content | Data your UI should render | Dashboards, cards, forms, charts |
| Both combined | UI with Claude commentary | Ticket card where Claude also summarizes the status |
| Default limits | Any list or search tool | limit param defaulting to 5-10 |
| Pagination | Datasets over 10-20 items | Separate page param or next-page tool |
| Summary + detail | Multiple levels of information depth | Search returns titles, get-by-ID returns everything |
| Explicit empty state | Zero results | ”No tickets found” with suggestions |
| Error with context | API failures | Descriptive message with isError: true |
The common thread is returning the minimum useful data in a format Claude can read and reference clearly. Your connector is a translator between your data source and a conversation, and the best translators know what to leave out.
Get Started
npx sunpeak new
Further Reading
- What are Claude Connectors - overview, data access, and auth
- Designing Claude Connector tools - schemas, descriptions, and patterns for reliable tool calls
- Claude Connector examples - 5 practical patterns with code
- Claude Connectors vs Claude Apps - standard vs interactive connectors explained
- MCP concepts explained - tools, resources, and how MCP Apps use them
- Claude Connectors tutorial - build and deploy a connector from scratch
- Claude Connector Framework - sunpeak overview
- sunpeak documentation - quickstart and API reference
Frequently Asked Questions
What data can Claude access through connectors?
Claude can access whatever data your connector tools return. Each connector is an MCP server that registers tools. When Claude calls a tool, your handler runs, fetches data from any source (database, REST API, file system, SaaS platform), and returns it as text content or structured content. Text content goes into the conversation for Claude to reason about. Structured content renders as interactive UI inside the chat. There is no limit on what data sources you connect, only on how much data you return per tool call.
What is the difference between text content and structured content in Claude Connectors?
Text content is a string that Claude receives and weaves into its response. Use it when the data is best consumed as part of conversation, like search results, summaries, or status lookups. Structured content is typed JSON data passed to a React component that renders UI inside the chat. Use it when the data benefits from visual presentation, like dashboards, tables, forms, or charts. A single connector can use both across different tools.
How much data can a Claude Connector tool return?
There is no hard byte limit documented in the MCP specification, but practical limits exist. Claude has a context window, and large tool responses consume tokens from that window. Returning 50 search results when the user asked for one wastes context and makes Claude less effective. Return the minimum data needed to answer the query, and offer pagination tools for large datasets.
How should I format text content so Claude can reason about it?
Use labeled fields on separate lines instead of raw JSON. Write "Title: Deploy API v2" and "Status: In Progress" rather than returning a JSON blob. Include only the fields relevant to the query. Add context that helps Claude summarize, like "3 of 47 results shown" or "Last updated 2 minutes ago." Claude handles plain text better than deeply nested structures.
When should I paginate Claude Connector results?
Paginate when your data source can return more than 10-20 items. Expose a search or list tool with a limit parameter (default to 5-10 results) and a separate tool for fetching the next page or a specific item by ID. This keeps individual responses small and lets Claude request more data only when the user needs it.
Can a Claude Connector tool return both text and UI?
Yes. If your tool handler returns structuredContent, that data is passed to your resource component for UI rendering. Claude also receives metadata about the tool call. For tools that need both a visual result and text Claude can reference, return structuredContent for the UI and include a content array with a text block summarizing the data for Claude context.
How do I test Claude Connector data access patterns locally?
Use sunpeak dev to start a local inspector that replicates the Claude runtime. Create simulation files with sample tool inputs and outputs to test how your connector formats data without calling real APIs. The inspector shows both the rendered UI (for structured content) and the raw response, so you can verify formatting before deploying.
What are common mistakes when returning data from Claude Connector tools?
The most common mistakes are returning too much data (dumping entire API responses instead of selecting relevant fields), returning raw JSON instead of formatted text, not including count or pagination metadata, using vague field names that Claude cannot interpret, and forgetting to handle empty results with a clear message like "No tickets match that query."