All posts

Claude Connector Data Access Patterns: How to Structure What Your Connector Returns (June 2026)

Abe Wheeler
Claude Connectors Claude Connector Framework Claude Connector Testing Claude Apps MCP Apps MCP App Framework structuredContent
How data flows from your Claude Connector tools to Claude and to users.

How data flows from your Claude Connector tools to Claude and to users.

Every Claude Connector is an MCP server that lets Claude call tools against your data. The connector can search tickets, read invoices, fetch account records, inspect files, or call an internal API. The hard part is not usually fetching the data. The hard part is returning the right slice of data in the right lane.

That matters more now because MCP Apps define a richer tool-result contract. A tool can return content, structuredContent, and _meta. A host can render an iframe resource, pass the result to that resource, and still give Claude a concise summary for reasoning. If you treat all of that as “just JSON,” you get bloated context, leaky UI data, and brittle connector behavior.

TL;DR: Put short, model-readable summaries in content. Put typed render data in structuredContent. Put UI-only cursors, cache keys, and view hints in _meta when your host supports it. Declare an output schema for structured results. Default list tools to 5 to 10 rows, pair search with detail tools, and test that internal fields do not leak into model-visible output.

The Three Tool Result Lanes

Most connector data bugs come from mixing the three result lanes.

LanePrimary jobGood examplesAvoid
contentTell Claude and the user what happenedShort summaries, counts, names, statuses, source linksFull API payloads, private IDs, huge tables
structuredContentGive the app UI typed data to renderTable rows, card fields, chart series, form defaultsSecrets, private UI state, unbounded raw data
_metaCarry UI-only metadata where supportedCursors, view IDs, cache keys, row expansion tokensAnything Claude must know, long-lived credentials

The safest default is simple: if Claude needs to mention it, put it in content. If the resource component needs to render it, put it in structuredContent. If only the resource needs it for UI mechanics, put it in _meta.

Do not assume _meta is a secret vault. Treat it as a UI-only transport field, not as storage for OAuth refresh tokens, API keys, session cookies, or private conversation data.

Model-Readable Text Content

Text content should be short and explicit. Claude is good at reading prose, but raw JSON still costs tokens and pushes the model to infer meaning from key names.

This pattern works poorly:

export async function handler({ query }: { query: string }) {
  const tickets = await searchTickets(query);

  return {
    content: [
      {
        type: 'text' as const,
        text: JSON.stringify(tickets),
      },
    ],
  };
}

Claude receives a blob like this:

[{"id":"T-1234","title":"Deploy API v2","status":"in_progress","assignee":"alice@example.com","priority":2,"created":"2026-06-01T10:00:00Z"}]

It can parse that, but the connector is making the model do unnecessary work. A better formatter gives Claude the fields that matter, with labels and counts:

function formatTicketSummary(tickets: Ticket[], total: number) {
  if (tickets.length === 0) {
    return 'No tickets match that query.';
  }

  const rows = tickets.map(
    (ticket) =>
      `Ticket: ${ticket.key}\n` +
      `Title: ${ticket.title}\n` +
      `Status: ${ticket.status}\n` +
      `Assignee: ${ticket.assigneeName ?? 'Unassigned'}\n` +
      `Priority: ${ticket.priority}`
  );

  return `Showing ${tickets.length} of ${total} matching tickets:\n\n${rows.join('\n\n')}`;
}

That gives Claude enough information to answer clearly:

  • It knows the result is partial because the header says “showing 5 of 37.”
  • It knows which field is a status, priority, or assignee.
  • It can handle zero results without guessing what an empty array means.

Use this format for search results, account summaries, status checks, short audit findings, and anything else Claude should talk about directly.

Structured Content for Interactive Connectors

Interactive connectors and MCP Apps need a different lane. The UI should not scrape text. It should render typed data.

In a sunpeak project, a tool can return structuredContent for a resource component:

import { z } from 'zod';
import type { AppToolConfig } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'ticket-card',
  title: 'Show Ticket',
  description: 'Show a single ticket by key',
  annotations: { readOnlyHint: true },
};

export const schema = {
  key: z.string().describe('Ticket key, such as T-1234'),
};

export default async function handler({ key }: { key: string }) {
  const ticket = await getTicket(key);

  return {
    content: [
      {
        type: 'text' as const,
        text:
          `Ticket ${ticket.key}: ${ticket.title}\n` +
          `Status: ${ticket.status}\n` +
          `Assignee: ${ticket.assigneeName ?? 'Unassigned'}`,
      },
    ],
    structuredContent: {
      key: ticket.key,
      title: ticket.title,
      status: ticket.status,
      assignee: ticket.assigneeName,
      priority: ticket.priority,
      description: ticket.description,
      updatedAt: ticket.updatedAt,
    },
  };
}

The resource reads the typed output with useToolData():

import { SafeArea, useToolData } from 'sunpeak';

interface TicketOutput {
  key: string;
  title: string;
  status: string;
  assignee?: string;
  priority: string;
  description: string;
  updatedAt: string;
}

export function TicketCard() {
  const { output } = useToolData<unknown, TicketOutput>();

  if (!output) return null;

  return (
    <SafeArea>
      <article>
        <h2>{output.title}</h2>
        <p>{output.key}</p>
        <p>Status: {output.status}</p>
        <p>Assignee: {output.assignee ?? 'Unassigned'}</p>
        <p>{output.description}</p>
      </article>
    </SafeArea>
  );
}

The important part is the pairing. content gives Claude a compact answer. structuredContent gives the UI stable data. Users get a rendered card, and clients that do not support UI still get a useful text result.

Add Output Schemas for Structured Results

When a tool returns structured data, declare the shape. The current Apps SDK reference says tools that return structuredContent should declare an outputSchema so clients can validate results and reason about follow-up tool calls.

Even if your framework infers some types for you, treat the output shape as a public contract. A ticket resource that expects status and assignee will break if a handler silently changes them to state and owner.

For a plain MCP registration, the shape looks like this:

server.registerTool(
  'search_tickets',
  {
    title: 'Search Tickets',
    description: 'Search tickets by keyword, project, status, or assignee',
    inputSchema: {
      query: z.string(),
      limit: z.number().int().min(1).max(20).optional(),
    },
    outputSchema: {
      total: z.number().int(),
      tickets: z.array(
        z.object({
          key: z.string(),
          title: z.string(),
          status: z.string(),
          assignee: z.string().nullable(),
        })
      ),
    },
    annotations: { readOnlyHint: true },
  },
  async ({ query, limit = 5 }) => {
    const { tickets, total } = await searchTickets({ query, limit });

    return {
      content: [
        {
          type: 'text',
          text: `Found ${total} tickets. Showing ${tickets.length}.`,
        },
      ],
      structuredContent: { total, tickets },
    };
  }
);

That schema is useful for more than validation. It documents the contract between the connector, the host, the resource component, and your tests.

Keep UI-Only Data Out of Model Context

Some data is needed by the UI but not by Claude. Pagination cursors are the obvious example. The model does not need to reason about eyJwYWdlIjoyfQ==. The UI needs it for a “Next” button.

Use _meta for that data where your target host supports result metadata:

export default async function handler({
  query,
  limit = 10,
}: {
  query: string;
  limit?: number;
}) {
  const result = await searchTickets({ query, limit });

  return {
    content: [
      {
        type: 'text' as const,
        text: `Found ${result.total} tickets. Showing ${result.tickets.length}.`,
      },
    ],
    structuredContent: {
      total: result.total,
      tickets: result.tickets.map((ticket) => ({
        key: ticket.key,
        title: ticket.title,
        status: ticket.status,
        assignee: ticket.assigneeName,
      })),
    },
    _meta: {
      nextCursor: result.nextCursor,
      searchSessionId: result.searchSessionId,
    },
  };
}

This split keeps model-visible output clean. It also makes testing easier because you can assert that nextCursor never appears in content or structuredContent.

If a host does not support _meta the way your UI expects, fall back to a narrower pattern: return a public page token in structuredContent, or expose a model-visible “get next page” tool with a clear description. Do that deliberately, and avoid copying private internal IDs into the model-visible fields.

Pagination: Model Tool or App-Only Tool?

There are two valid pagination patterns.

For standard, conversation-only connectors, expose pagination through model-visible tools:

export const schema = {
  query: z.string(),
  page: z.number().int().min(1).optional().describe('Page number, defaults to 1'),
  pageSize: z.number().int().min(1).max(20).optional().describe('Results per page, defaults to 10'),
};

Claude can call page 2 when the user asks for more.

For interactive connectors, pagination is often a UI action. The model opens the result set, then the user clicks “Next” inside the resource. In that case, hide the paging tool from the model with _meta.ui.visibility: ['app'].

If your current runtime exposes tool-result _meta to resource code, store the cursor there. If your framework does not expose result metadata yet, keep a narrow cursor in structuredContent as a UI field and test that it never gets copied into content.

import type { AppToolConfig } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  title: 'Load More Tickets',
  description: 'Load the next page of tickets for the current ticket search UI',
  annotations: { readOnlyHint: true },
  _meta: {
    ui: {
      visibility: ['app'],
    },
  },
};

The resource can call the app-only tool with useCallServerTool():

import { useCallServerTool, useToolData } from 'sunpeak';

interface TicketSearchOutput {
  tickets: Array<{ key: string; title: string; status: string }>;
  nextCursor?: string;
}

export function TicketSearch() {
  const { output } = useToolData<unknown, TicketSearchOutput>();
  const callServerTool = useCallServerTool();

  if (!output) return null;

  async function loadMore() {
    if (!output.nextCursor) return;

    await callServerTool({
      name: 'load-more-tickets',
      arguments: { cursor: output.nextCursor },
    });
  }

  return (
    <button onClick={loadMore} disabled={!output.nextCursor}>
      Next page
    </button>
  );
}

Use this rule: if pagination is part of the conversation, make it model-visible. If pagination is only a button inside an already-rendered UI, make it app-only.

Search Plus Detail Tools

Search results should be shallow. Detail tools should be deep.

A good connector often has this pair:

ToolReturnsWhy
search_ticketsKey, title, status, assignee, short snippetEnough for Claude to pick the right item
get_ticketFull description, comments, linked PRs, historyOnly fetched after the user asks for detail

That design keeps first responses fast and small. It also gives Claude a natural next step. When the user says “tell me more about T-1234,” Claude calls the detail tool instead of making another broad search.

Avoid search tools that return the full record for every match. They waste context and increase the chance that Claude reads the wrong detail from the wrong row.

Field Selection: Return Useful Data, Not Available Data

Most APIs return more data than a connector should expose. Before including a field, ask what the field helps Claude or the UI do.

Good fields for model-visible content:

  • Human-readable names and titles
  • Statuses, dates, totals, and counts
  • Short descriptions or snippets
  • Source names and links the user can verify

Fields that often belong in _meta or nowhere:

  • Internal numeric IDs when public keys exist
  • Pagination cursors
  • Cache keys
  • Raw permission objects
  • Audit fields not relevant to the user request
  • Large nested records from the upstream API

This is a better detail formatter:

function formatTicketDetail(ticket: Ticket) {
  return [
    `Ticket: ${ticket.key}`,
    `Title: ${ticket.title}`,
    `Status: ${ticket.status}`,
    `Assignee: ${ticket.assigneeName ?? 'Unassigned'}`,
    `Priority: ${ticket.priority}`,
    `Updated: ${ticket.updatedAt}`,
    `Summary: ${ticket.summary}`,
  ].join('\n');
}

It returns what a user can understand. It does not return every foreign key, webhook timestamp, internal workflow state, or raw JSON field that happened to come back from the API.

Empty Results and Errors

Empty results are still useful results. Return a clear message:

if (results.length === 0) {
  return {
    content: [
      {
        type: 'text' as const,
        text: `No tickets found for "${query}". Try a broader keyword, a project key, or a different status filter.`,
      },
    ],
    structuredContent: {
      total: 0,
      tickets: [],
    },
  };
}

For errors, tell the host the tool failed and give Claude enough context to explain the failure:

try {
  const tickets = await api.searchTickets(query);

  return {
    content: [{ type: 'text' as const, text: formatTicketSummary(tickets, tickets.length) }],
  };
} catch (error) {
  return {
    isError: true,
    content: [
      {
        type: 'text' as const,
        text:
          'Could not search tickets because the ticket API did not respond. ' +
          'Ask the user to try again in a few minutes.',
      },
    ],
  };
}

Do not leak raw exception payloads from upstream services. They may contain URLs, query strings, tenant IDs, or internal implementation details. Log the full error server-side. Return the user-facing failure reason in the tool result.

Remote Connector Data Boundaries

Claude custom connectors using remote MCP are available across Claude surfaces, including Claude web, desktop, mobile, and team environments. The practical data-access point is simple: Claude connects to your remote MCP server from Anthropic’s cloud infrastructure, so a remote connector must be reachable from that environment.

That changes how you should think about data flow:

  • Use delegated, per-user OAuth when the connector reads user-owned data.
  • Enforce authorization on your server for every tool call.
  • Return only data the current user is allowed to see.
  • Keep tool descriptions honest about which backing system a tool reaches.
  • Avoid broad “query anything” tools when narrower tools can express the allowed operation.

This is not just a review concern. It affects answer quality. If a connector returns data from several scopes without labeling sources and permissions, Claude cannot explain where an answer came from or why some results are missing.

Testing the Contract

Test the tool result before you test the browser. With sunpeak, you can call the MCP tool directly and assert the lanes:

import { test, expect } from 'sunpeak/test';

test('search_tickets returns clean model and UI data', async ({ mcp }) => {
  const result = await mcp.callTool('search_tickets', {
    query: 'API migration',
  });

  expect(result.isError).toBeFalsy();

  const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
  expect(text).toContain('Found');
  expect(text).not.toContain('nextCursor');
  expect(text).not.toContain('internalAccountId');

  expect(result.structuredContent).toMatchObject({
    total: expect.any(Number),
    tickets: expect.any(Array),
  });

  expect(result.structuredContent.tickets[0]).toMatchObject({
    key: expect.any(String),
    title: expect.any(String),
    status: expect.any(String),
  });
});

Then render representative states in the sunpeak inspector. Simulations let you pin the exact tool input and result:

{
  "tool": "search-tickets",
  "userMessage": "Find API migration tickets",
  "toolInput": {
    "query": "API migration"
  },
  "toolResult": {
    "content": [
      {
        "type": "text",
        "text": "Found 37 tickets. Showing 5."
      }
    ],
    "structuredContent": {
      "total": 37,
      "tickets": [
        {
          "key": "T-1234",
          "title": "Deploy API v2",
          "status": "In Progress"
        }
      ]
    },
    "_meta": {
      "nextCursor": "cursor_abc123"
    }
  }
}

Run npx sunpeak inspect --server http://localhost:8000/mcp for an existing MCP server, or pnpm dev inside a sunpeak project. The inspector replicates Claude and ChatGPT MCP App runtimes locally, including host context, themes, display modes, iframe rendering, and editable tool results. That means you can test the data contract without a paid host account, a tunnel, or live API calls.

A Practical Checklist

Use this checklist before shipping a connector tool:

  • Does content answer the user in one short paragraph or a small labeled list?
  • Does structuredContent match the resource component’s expected shape?
  • Is there an output schema for structured results where your framework supports it?
  • Are cursors, cache keys, and UI mechanics kept out of model-visible text?
  • Are search tools limited to 5 to 10 results by default?
  • Is there a detail tool for records that need long descriptions or history?
  • Do empty results return a clear message?
  • Do errors set isError: true and avoid raw upstream exception payloads?
  • Are app-only tools hidden from the model with _meta.ui.visibility: ['app']?
  • Do tests prove that private or internal fields do not leak into content?

The best Claude Connector result is boring in a good way. Claude gets a small, labeled summary it can reason about. The UI gets typed data it can render. The server keeps private implementation details on the server or in the narrowest supported UI-only lane. That is how connectors stay useful as the MCP App ecosystem adds richer hosts, richer UIs, and more ways for tools to return data.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What data can Claude access through connectors?

Claude can access the data your MCP server exposes through connector tools. A tool can fetch from a database, REST API, SaaS platform, file store, or internal service, then return a tool result. The main design choice is not the data source. It is how much data you return, which fields are visible to the model, which fields are only for the UI, and whether the result is small enough for Claude to use well.

What is the difference between content, structuredContent, and _meta in Claude Connectors?

content is the concise, human-readable tool result Claude can use in conversation. structuredContent is typed JSON for MCP App resources and, depending on the host, may also be visible to the model. _meta is for UI-only metadata where the host supports that separation. Use content for summaries and citations, structuredContent for render data, and _meta for cursors, cache keys, view IDs, or other fields the model should not reason about.

Should a Claude Connector return raw JSON?

Usually no. Raw JSON is useful for machines, but it makes the model spend tokens parsing nested fields. For model-visible content, return labeled, short text with counts, dates, statuses, and source names. Put structured rows in structuredContent when an MCP App UI needs them. Keep raw upstream API payloads out of tool results unless the user explicitly asked for raw data.

How much data should a Claude Connector tool return?

Return the smallest result that can answer the user request. For search and list tools, default to 5 to 10 items, include the total count when you know it, and expose pagination or detail tools for follow-up. Large tool responses consume context, make answers slower, and raise the chance that the model misses the important row.

When should I use structuredContent in a Claude Connector?

Use structuredContent when a resource component needs typed data to render a table, card, dashboard, form, map, or chart. Pair it with an output schema when your framework supports one, and include a short content summary so clients without UI support still have a useful result.

What should go in _meta for an MCP App tool result?

_meta is the right place for UI-only details such as pagination cursors, row IDs used only for follow-up UI actions, cache keys, view IDs, or prefetch hints. Do not put long-lived secrets in _meta. Treat _meta as less visible than content, not as a place to store passwords, refresh tokens, API keys, or data you would be afraid to log.

How do I test Claude Connector data access patterns locally?

Use sunpeak inspect or sunpeak dev to render tool results in replicated Claude and ChatGPT runtimes. Add simulation files with representative content, structuredContent, and _meta values, then write Playwright or integration tests that assert the model-visible summary is small, the UI data matches the resource schema, and internal fields do not leak into content.

What are common mistakes when returning data from Claude Connector tools?

Common mistakes include dumping whole API responses, omitting a content fallback for UI tools, returning too many rows, hiding important facts only in UI data, putting private fields in structuredContent, using vague field names, forgetting output schemas, and treating pagination as a model-visible tool when it should be an app-only UI action.