Skip to main content
All posts

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

Abe Wheeler
Claude ConnectorsClaude Connector FrameworkClaude Connector TestingClaude AppsMCP AppsMCP App FrameworkstructuredContent
How data moves from a Claude Connector tool to the model, the host, and an MCP App View.

How data moves from a Claude Connector tool to the model, the host, and an MCP App View.

A Claude Connector can read almost any backing system, but the model and user see only the result your MCP tool returns. Good data access design therefore has two parts: fetch the right records, then place the right fields in the right result channel.

The second part is easy to get wrong. structuredContent is not a private UI payload. _meta is not secret storage. A pagination cursor is not authorization. A host’s maximum result size is not a useful response target.

TL;DR: Put a short, sourced answer in content. Put typed, model-safe View data in structuredContent and declare outputSchema. Use _meta only for component metadata that may pass through the host. Authorize every row and follow-up call, return explicit short-lived page handles, and keep initial results small enough that Claude can reason about them and an MCP App can hydrate reliably.

The Current Tool Result Contract

An MCP tool result has three data channels with different readers.

ChannelIntended readersGood usesDo not put here
contentModel, host, View, and text-only clientsAnswer summary, counts, warnings, source URLs, recoverable errorsRaw API dumps, unbounded tables, credentials
structuredContentModel, View, and compatible clientsTyped rows, chart series, status objects, safe paging handlesFields the model must not see, secrets, oversized blobs
_metaHost and app runtime, outside intended model contextView IDs, trace IDs, cache hints, private UI lookup keysPasswords, tokens, cookies, answer-critical facts

The most important correction to older guidance is that structuredContent is model-visible. The View can render it without parsing prose, but that does not make the data private. If a field would violate policy when copied into a model prompt, do not put it in structuredContent.

_meta is the component lane, but it still crosses the host. The official SDK guidance treats it as a convention between the server and client, not a promise that no implementation will inspect or log it. Namespace custom keys such as com.example/searchCursor; the io.modelcontextprotocol/* namespace is reserved.

Use one rule across all three fields: keep credentials and raw upstream responses on the server.

Write content as an Answer, Not a Payload

For model-called tools, content should tell Claude what happened without making it reverse-engineer JSON. A useful search result states what matched, whether the result is complete or partial, the facts needed for the next step, where the data came from, and what the model can call next.

return {
  content: [
    {
      type: 'text',
      text: [
        'Found 37 tickets. Showing the 5 most recently updated.',
        'T-1234 | Deploy API v2 | In Progress | Alex Kim',
        'T-1198 | Remove v1 routes | Open | Unassigned',
        'Source: Engineering Tickets, read 2026-08-27T10:30:00Z.',
        'Call get_ticket with a ticket key for comments and history.',
      ].join('\n'),
    },
  ],
};

The coverage sentence tells Claude not to claim that five rows are the whole dataset. The source and read time support provenance, while the next-step sentence gives it a deterministic path to detail.

Avoid returning JSON.stringify(upstreamResponse) in a text block. It spends context on transport fields, internal IDs, permission objects, and repeated keys. It can also leak a field added by an upstream API without a review of your connector contract.

Project the result into a connector-owned shape instead.

Pair structuredContent with outputSchema

Use structuredContent when an MCP App View needs typed data or the model benefits from a machine-readable result. Declare an outputSchema so the server, client, View, and tests agree on that shape.

The current sunpeak tool-file contract uses separate schema and outputSchema exports:

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

export const tool: AppToolConfig = {
  resource: 'ticket-search',
  title: 'Search Tickets',
  description: 'Search tickets the current user can access and show matching results.',
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    openWorldHint: false,
  },
  _meta: { ui: { visibility: ['model', 'app'] } },
};

export const schema = {
  query: z.string().min(1).describe('Words to match in ticket titles and summaries'),
  limit: z.number().int().min(1).max(20).default(5),
};

const TicketSummary = z.object({
  key: z.string(),
  title: z.string(),
  status: z.string(),
  assignee: z.string().nullable(),
  updatedAt: z.string(),
  sourceUrl: z.string().url(),
});

export const outputSchema = {
  total: z.number().int().nonnegative(),
  returned: z.number().int().nonnegative(),
  tickets: z.array(TicketSummary),
  nextPageToken: z.string().optional(),
  retrievedAt: z.string(),
  partial: z.boolean(),
  warnings: z.array(z.string()),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function handler(args: Args, extra: ToolHandlerExtra) {
  const actor = requireAuthorizedActor(extra.authInfo);
  const result = await searchTicketsForActor(actor, args);

  return {
    content: [{ type: 'text' as const, text: formatTicketSearch(result) }],
    structuredContent: {
      total: result.total,
      returned: result.tickets.length,
      tickets: result.tickets.map(toSafeTicketSummary),
      nextPageToken: result.nextPageToken,
      retrievedAt: result.retrievedAt,
      partial: result.partial,
      warnings: result.warnings,
    },
    _meta: {
      'com.example/traceId': result.traceId,
    },
  };
}

The handler does not pass database rows through. toSafeTicketSummary is an allowlist, so a new upstream field cannot silently appear in the model or View.

For broad host compatibility, keep the root of structuredContent as an object. Core MCP 2026-07-28 allows any JSON value and full JSON Schema 2020-12 output schemas, but deployed 2025-era hosts and SDKs still require an object root. { tickets: [...] } works in both eras; a bare array does not.

The View reads the typed output without scraping content:

import { SafeArea, useOpenLink, useToolData } from 'sunpeak';

type SearchInput = { query: string; limit?: number };
type TicketSummary = {
  key: string;
  title: string;
  status: string;
  assignee: string | null;
  updatedAt: string;
  sourceUrl: string;
};
type SearchOutput = {
  total: number;
  returned: number;
  tickets: TicketSummary[];
  nextPageToken?: string;
  retrievedAt: string;
  partial: boolean;
  warnings: string[];
};

export function TicketSearch() {
  const { input, output, isLoading } = useToolData<SearchInput, SearchOutput>();
  const openLink = useOpenLink();

  if (isLoading || !output) return <p>Searching for {input?.query ?? 'tickets'}...</p>;

  return (
    <SafeArea>
      <p>Showing {output.returned} of {output.total}</p>
      <ul>
        {output.tickets.map((ticket) => (
          <li key={ticket.key}>
            <button onClick={() => openLink({ url: ticket.sourceUrl })}>
              {ticket.key}: {ticket.title}
            </button>
          </li>
        ))}
      </ul>
    </SafeArea>
  );
}

The text fallback remains necessary. Hosts can decline the MCP Apps extension, fail to load the View, or show the result in a surface that supports normal tools but not interactive UI.

Authorize Before You Shape the Result

Data minimization starts after authorization, not instead of it. Every tool call needs the same checks you would put on a normal API:

  1. Authenticate the principal from server-side request context.
  2. Resolve tenant and account membership.
  3. Check the OAuth scope for the requested operation.
  4. Apply record-level and field-level policy in the query.
  5. Project the authorized records into the connector result.

Do not fetch a broad dataset, put the allowed rows in content, and hide the rest in _meta. The host and View still receive _meta. Unauthorized fields should never leave the trusted server boundary.

Apply the same checks to detail, paging, export, and app-only tools. _meta.ui.visibility: ['app'] keeps a helper out of the model’s tool catalog; it does not make the helper trusted.

The safest data-access query carries the actor into the storage layer:

const tickets = await db.ticket.findMany({
  where: {
    tenantId: actor.tenantId,
    viewers: { some: { userId: actor.userId } },
    title: { contains: query, mode: 'insensitive' },
  },
  select: {
    key: true,
    title: true,
    status: true,
    assigneeName: true,
    updatedAt: true,
  },
  take: limit + 1,
});

This prevents a later formatter bug from exposing a cross-tenant row that should not have been fetched.

Use Explicit, Bound Pagination Handles

Core MCP 2026-07-28 removed transport sessions. A multi-call workflow should return an explicit handle and accept it on the next call. That is also safer for 2025 hosts because the server can validate the handle independently of one in-memory connection.

A page token should be opaque, tamper-evident, short-lived, and bound to the user, tenant, original query, filters, and sort order. Reauthorize it on every use. Possession of another user’s cursor must not grant access to their next page.

Choose the paging tool based on the interaction:

User flowTool visibilityResult channel
User asks Claude to show more['model', 'app']Return a model-safe token in structuredContent
User clicks Next in an existing View['app']Return the token and page rows to the View
View needs a private lookup key['app']Put the key in _meta and read it through the app result event

For a normal Next button, an app-only helper is enough:

export const tool: AppToolConfig = {
  title: 'Load Ticket Page',
  description: 'Load another authorized page for the open ticket search View.',
  annotations: { readOnlyHint: true, openWorldHint: false },
  _meta: { ui: { visibility: ['app'] } },
};

export const schema = {
  pageToken: z.string().min(1),
};

export const outputSchema = {
  tickets: z.array(TicketSummary),
  nextPageToken: z.string().optional(),
};

export default async function loadPage(
  { pageToken }: { pageToken: string },
  extra: ToolHandlerExtra,
) {
  const actor = requireAuthorizedActor(extra.authInfo);
  const page = await loadAuthorizedPage(actor, pageToken);

  return {
    content: [
      { type: 'text' as const, text: 'Loaded ' + page.tickets.length + ' tickets.' },
    ],
    structuredContent: {
      tickets: page.tickets.map(toSafeTicketSummary),
      nextPageToken: page.nextPageToken,
    },
  };
}

The View calls that tool with useCallServerTool() and checks every failure path:

const callServerTool = useCallServerTool();

async function loadMore(pageToken: string) {
  const result = await callServerTool({
    name: 'load-ticket-page',
    arguments: { pageToken },
  });

  if (!result || result.isError || !result.structuredContent) {
    setError('Could not load the next page.');
    return;
  }

  const page = result.structuredContent as {
    tickets: TicketSummary[];
    nextPageToken?: string;
  };

  setTickets((current) => [...current, ...page.tickets]);
  setNextPageToken(page.nextPageToken);
}

Use updateModelContext only when a View interaction changes a fact the model should know later, such as the selected ticket. Paging through rows does not need to copy every loaded row into future model context.

Search Shallow, Fetch Detail on Demand

Search and detail tools solve different problems:

ToolReturnWhy
search_ticketsKey, title, status, assignee, short snippet, source URLEnough for the model or user to choose
get_ticketFull description, selected comments, linked work, historyLoaded only for one authorized record
load_ticket_pageAnother bounded page for the current queryKeeps the initial result small
export_ticketsA durable file or resource referenceKeeps large bytes out of the conversation

Do not return every comment, attachment, and audit event for every search match. Large first responses are slower, cost more context, and make it harder for the model to associate one detail with the correct record.

For documents and binary data, return a stable ID, title, MIME type, size, and preview first. Let a later tool or resource read fetch the full content. Core MCP supports text, image, audio, resource links, and embedded resources, but host support varies. Claude’s current custom connector docs list text and image tool results as supported, so a concise text fallback and source URL remain the portable baseline.

Design Below Claude’s Result Limits

Claude surfaces do not all handle large MCP results the same way.

SurfaceCurrent documented behavior
Claude.ai and Claude DesktopAbout 150,000 characters per tool result and a 300-second timeout
Claude MCP App with code execution activeNear the size ceiling, Claude may store the result as a file, so the View receives a pointer instead of the structuredContent it needs
Claude Code25,000-token default, configurable with MAX_MCP_OUTPUT_TOKENS; timeout is configurable with MCP_TOOL_TIMEOUT
Claude Managed AgentsResults above 100,000 characters are written to the sandbox with a truncated preview

These are failure boundaries, not response budgets. Set a smaller product budget, such as 32 KB for a normal search result, and test it. Large files should use paging, chunks, or server resources. Base64 makes binary data larger, so do not place a full PDF or image set in the initial structuredContent.

The Claude MCP App troubleshooting guide recommends pagination, detail-on-demand, and deferred heavy content when an app fails to hydrate.

Return Provenance and Partial-State Signals

A trustworthy result tells Claude where the data came from. Include stable source names and URLs in content, and add typed provenance to each row when the View needs links.

Useful fields include sourceUrl, retrievedAt, total, returned, hasMore, and record-level updatedAt. Add partial and warnings when one backend fails.

Do not return “No matches” when one of three backing services timed out. Return the rows you have with an explicit warning:

return {
  content: [
    {
      type: 'text',
      text:
        'Found 4 matching tickets in Engineering Tickets. ' +
        'Results are partial because the Archive service timed out.',
    },
  ],
  structuredContent: {
    total: 4,
    returned: 4,
    tickets: safeTickets,
    partial: true,
    warnings: ['Archive service unavailable'],
    retrievedAt,
  },
};

Declare those fields in outputSchema. Stable partial-state semantics let the View show a warning and stop the model from turning an incomplete result into a confident global claim.

Report Execution Errors as Tool Results

Use isError: true for failures that happen while running a valid tool call. Give the model a short, safe recovery path:

try {
  return await searchAuthorizedTickets(args, extra);
} catch (error) {
  logger.error({ error, traceId }, 'ticket search failed');

  return {
    isError: true,
    content: [
      {
        type: 'text' as const,
        text:
          'Ticket search failed because the data service did not respond. ' +
          'The user can retry in a few minutes.',
      },
    ],
    _meta: {
      'com.example/traceId': traceId,
    },
  };
}

Do not copy raw exception messages into the result. They often include tenant IDs, query strings, internal hosts, or upstream payload fragments. Log diagnostic detail on the server and return a stable error category to the model and View.

Test Authorization, Shape, Size, and Rendering

Start with a protocol test because it isolates the server contract:

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

test('search_tickets returns bounded, model-safe data', async ({ mcp }) => {
  const result = await mcp.callTool('search_tickets', {
    query: 'API migration',
    limit: 5,
  });

  expect(result.isError).toBeFalsy();

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

  expect(result.structuredContent).toMatchObject({
    total: expect.any(Number),
    returned: expect.any(Number),
    tickets: expect.any(Array),
    retrievedAt: expect.any(String),
    partial: expect.any(Boolean),
    warnings: expect.any(Array),
  });

  expect(JSON.stringify(result).length).toBeLessThan(32_000);
});

Add authorization tests that call the same tool as two users and two tenants. Assert that a page token from one principal fails for the other, revoked access blocks a previously valid detail ID, and each row’s fields match the caller’s scope.

Then pin UI states in tests/simulations/:

{
  "tool": "search-tickets",
  "userMessage": "Find API migration tickets",
  "toolInput": {
    "query": "API migration",
    "limit": 5
  },
  "toolResult": {
    "content": [
      {
        "type": "text",
        "text": "Found 37 tickets. Showing 5. Source: Engineering Tickets."
      }
    ],
    "structuredContent": {
      "total": 37,
      "returned": 5,
      "tickets": [
        {
          "key": "T-1234",
          "title": "Deploy API v2",
          "status": "In Progress",
          "assignee": "Alex Kim",
          "updatedAt": "2026-08-27T09:45:00Z",
          "sourceUrl": "https://tickets.example.com/T-1234"
        }
      ],
      "nextPageToken": "page_test_2",
      "retrievedAt": "2026-08-27T10:30:00Z",
      "partial": false,
      "warnings": []
    }
  },
  "serverTools": {
    "load-ticket-page": {
      "content": [{ "type": "text", "text": "Loaded 1 ticket." }],
      "structuredContent": {
        "tickets": []
      }
    }
  }
}

Cover normal, empty, partial, unauthorized, expired-cursor, oversized, and error states. Render each state in the sunpeak Inspector, then run E2E and visual checks in the replicated Claude and ChatGPT runtimes.

As of sunpeak 0.20.81, projects use @modelcontextprotocol/ext-apps 1.7.5 and MCP SDK 1.30.0. sunpeak can test the current 2025-11-25 host lifecycle, MCP App result and View behavior, app-only calls, simulations, and host replicas. Its --stateless mode does not enable the MCP 2026-07-28 wire protocol, so add a separate protocol-version test when your server adopts that revision.

A Shipping Checklist

  • content gives the model a short answer, coverage statement, source, and next step.
  • structuredContent contains only model-safe fields and matches outputSchema.
  • The root structured result remains an object for 2025 and 2026 host compatibility.
  • _meta contains no credentials and uses namespaced custom keys.
  • Every search, detail, page, export, and app-only call authorizes the current principal.
  • Page handles are short-lived, tamper-evident, and bound to user, tenant, query, and sort.
  • Search returns shallow rows; detail and heavy content load on demand.
  • Results stay below a tested product budget, not merely below the host ceiling.
  • Empty, partial, and error results have different explicit states.
  • Source URLs, read times, totals, and hasMore or cursor state are accurate.
  • App-only tools use _meta.ui.visibility: ['app'] and still enforce authorization.
  • Protocol, cross-tenant, simulation, E2E, and visual tests cover the result contract.

sunpeak’s Claude Connector framework keeps tool files, output schemas, simulations, protocol tests, and host rendering in one project. That makes it practical to test the data boundary before each change reaches a live Claude account or spends model credits.

Keep the initial result small, typed, sourced, and authorized. Load detail only when the model or user asks for it, and treat every follow-up tool as a new access decision.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What data can Claude access through connectors?

Claude can access only the data returned by tools on the connected MCP server. The server may read a database, API, file store, or internal service, but it must authenticate the caller and authorize every record and field before returning them. A cursor, record ID, or MCP App action never replaces a server-side authorization check.

What is the difference between content, structuredContent, and _meta?

content contains readable MCP content blocks and is part of the model-facing tool result. structuredContent is typed JSON used by the model and an MCP App View, so it is not private. _meta carries host or View metadata outside the intended model context, but hosts still process it and may log it. Do not put secrets in any tool-result field.

Should a Claude Connector return raw JSON?

Usually not in content. Return a short answer with counts, coverage, source names, stable identifiers, and a clear next step. Put model-safe rows in structuredContent with an outputSchema. Keep the upstream response on the server and project only the fields the user and View need.

How much data should a Claude Connector tool return?

Use a product budget well below each host limit. Search tools usually need 5 to 10 shallow rows, a total or hasMore flag, and a detail or pagination path. Claude.ai and Claude Desktop document an approximate 150,000-character ceiling, but an MCP App can stop hydrating near that ceiling when Claude stores the result as a file, so treat it as a failure boundary rather than a target.

How should pagination work in MCP 2026-07-28?

Return an explicit page handle or cursor because core MCP 2026 no longer has transport sessions. Bind the handle to the authenticated principal, tenant, query, sort order, and expiry, then authorize every page request. Use a model-visible paging tool for conversational follow-up or an app-only tool for a Next button in an MCP App.

Can I put private data in _meta?

Do not put credentials, access tokens, session cookies, or data the View does not need in _meta. The field is intended to stay out of model context, but the host and app runtime receive it, implementations may log it, and the protocol does not make it encrypted storage. Namespace custom keys and keep secrets on the server.

Which MCP tool result content types does Claude support?

Core MCP defines text, image, audio, resource links, and embedded resources. Claude's connector documentation currently lists text and image tool results as the portable hosted baseline. For broad compatibility, include a concise text block and stable source URLs or IDs, then feature-detect richer resource behavior instead of assuming every Claude surface handles every MCP content type.

How do I test Claude Connector data access patterns?

Test the protocol result and rendered View separately. Assert outputSchema and structuredContent agree, authorization filters every row, private fields do not enter content or structuredContent, page handles cannot cross users or tenants, error and partial states are explicit, payloads stay under your own budget, and representative states render in Claude and ChatGPT host replicas.