Fetching Data in MCP Apps: Server-Side vs Client-Side Patterns for ChatGPT and Claude (July 2026)

Server-side and client-side data fetching patterns for MCP Apps.
TL;DR: Fetch data on the server when the model needs it, when the request uses secrets, or when the result should be validated with outputSchema. Fetch from the resource when the user needs refresh, polling, pagination, or UI-only details. For complex apps, split data tools from render tools: return reusable structuredContent first, then open UI only when there is something useful to render.
Every MCP App has at least two data paths:
- The MCP server calls tools, fetches data, and returns a tool result.
- The UI resource renders inside a sandboxed iframe and may fetch or request more data after it mounts.
That split is why “just call fetch()” is not enough guidance for ChatGPT Apps, Claude Connectors, and portable MCP Apps. The placement decides what the model can see, what the user can inspect, where credentials live, how often the UI refreshes, and how you test the app.
The current MCP Apps pattern is also more explicit than it was earlier in 2026. OpenAI’s ChatGPT plugin docs now recommend the shared MCP Apps fields first, such as _meta.ui.resourceUri, ui/notifications/tool-result, and tools/call, with ChatGPT-specific window.openai helpers layered on only when the shared standard does not cover the capability. The official MCP Tools specification also treats outputSchema as the contract for structuredContent.
The practical questions are:
- Which facts should the model reason about?
- Which data should only the UI see?
- Which request needs server credentials?
- Which interaction should happen without remounting the UI?
- Which states must be deterministic in tests?
The Three Useful Patterns
Most MCP App data fetching fits one of these patterns.
| Pattern | Where data is fetched | Best for | Model sees |
|---|---|---|---|
| Server-side tool result | MCP tool handler | Initial facts, secure API calls, validated contracts | content and structuredContent |
| Client-side resource fetch | UI iframe | Refresh, polling, pagination, local search, UI-only detail | Nothing unless you sync it back |
| Decoupled data and render tools | Data tool first, render tool second | Multi-step workflows, model-filtered data, avoiding repeated iframe remounts | Data tool result and final render input |
Start with the server-side pattern. Move work into the resource only when the UI needs to react after the tool call. Add a separate render tool when the model should prepare or filter data before opening UI.
Server-Side Fetching: The Default
Server-side fetching happens in the MCP tool handler. The tool validates input, checks auth, calls an API or database, shapes the result, and returns model-readable data.
Use it when:
- The model should answer follow-up questions about the data.
- The request needs an API key, OAuth token, database credential, or service account.
- The result should match an
outputSchema. - The UI can render from a snapshot.
- The same result should work in hosts that do not render UI.
Here is a small sunpeak tool that fetches account health data and returns a typed result:
// src/tools/get-account-health.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'account-health',
title: 'Get Account Health',
description: 'Show account health metrics for a workspace',
outputSchema: {
workspaceName: z.string(),
openIncidents: z.number().int().min(0),
plan: z.enum(['free', 'team', 'enterprise']),
p95LatencyMs: z.number().int().min(0),
},
};
export const schema = {
workspaceId: z.string().describe('Workspace ID'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
const res = await fetch(`https://api.example.com/workspaces/${args.workspaceId}/health`, {
headers: {
Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
},
});
if (!res.ok) {
return {
isError: true,
content: [{ type: 'text', text: `Health API returned ${res.status}.` }],
};
}
const data = await res.json();
return {
content: [
{
type: 'text',
text: `${data.workspaceName} has ${data.openIncidents} open incidents.`,
},
],
structuredContent: {
workspaceName: data.workspaceName,
openIncidents: data.openIncidents,
plan: data.plan,
p95LatencyMs: data.p95LatencyMs,
},
};
}
The key detail is what the tool does not return. It does not return INTERNAL_API_TOKEN. It does not return raw API payloads. It does not return every internal field just because the UI might need it later.
structuredContent is for concise facts the model and component can use. The MCP spec says structured results must match the declared outputSchema when one is provided, and OpenAI’s reference states that structuredContent is surfaced to both the model and the component. Treat it as public conversation data.
The resource can render from that result:
// src/resources/account-health/account-health.tsx
import { SafeArea, useToolData } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
description: 'Account health summary',
};
interface AccountHealth {
workspaceName: string;
openIncidents: number;
plan: 'free' | 'team' | 'enterprise';
p95LatencyMs: number;
}
export function AccountHealthResource() {
const { output, isLoading, isError, isCancelled } = useToolData<unknown, AccountHealth>(
undefined,
undefined
);
if (isLoading) {
return (
<SafeArea className="p-5 font-sans">
<div className="h-20 animate-pulse rounded bg-gray-200" />
</SafeArea>
);
}
if (isError) {
return <SafeArea className="p-5 text-sm text-red-600">Could not load account health.</SafeArea>;
}
if (isCancelled) {
return <SafeArea className="p-5 text-sm text-gray-500">Stopped.</SafeArea>;
}
if (!output) return null;
return (
<SafeArea className="space-y-3 p-5 font-sans">
<p className="text-sm text-gray-500">{output.workspaceName}</p>
<div className="grid grid-cols-3 gap-4">
<Metric label="Open incidents" value={output.openIncidents} />
<Metric label="Plan" value={output.plan} />
<Metric label="p95 latency" value={`${output.p95LatencyMs}ms`} />
</div>
</SafeArea>
);
}
This version works even if the host only uses the tool result. The UI improves the experience, but the tool still returns enough model-readable context to answer the user.
Put UI-Only Data in _meta
Sometimes the UI needs more data than the model needs.
Examples:
- A map of records by internal ID.
- A pagination cursor.
- A chart color assignment.
- A log correlation ID.
- A short-lived signed URL reference.
- A UI display hint.
That data belongs in tool result _meta when the host supports it, not in structuredContent. OpenAI’s reference describes _meta as component-only data that does not appear in the transcript.
return {
structuredContent: {
workspaceName: data.workspaceName,
openIncidents: data.openIncidents,
p95LatencyMs: data.p95LatencyMs,
},
_meta: {
incidentIds: data.incidents.map((incident) => incident.id),
nextCursor: data.nextCursor,
requestId: data.requestId,
},
};
That still is not a place for raw secrets. _meta is hidden from the model, but it is delivered to the component running in the user’s browser context. Use it for UI hydration, not credential storage.
Client-Side Fetching: Use It for UI Interaction
Client-side fetching happens after the resource mounts. The component calls fetch() from the sandboxed iframe.
Use it when:
- The data changes after the tool call.
- The user can page, filter, sort, or search without asking the model.
- The UI needs a live status view.
- The data is too large for the model transcript.
- The model does not need to reason about every returned row.
The resource must declare its network access:
// src/resources/incident-list/incident-list.tsx
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
description: 'Incident list with client-side pagination',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
},
},
};
connectDomains is for fetch() and XHR. Static images, fonts, scripts, and styles use resourceDomains. Nested iframes use frameDomains. If the API request fails inside the app but works in a normal browser tab, check CSP and CORS before rewriting your fetch code.
Here is a UI resource that starts with tool output, then loads more rows from a server endpoint:
import { useEffect, useState } from 'react';
import { SafeArea, useToolData } from 'sunpeak';
interface Incident {
id: string;
title: string;
severity: 'low' | 'medium' | 'high';
}
interface IncidentOutput {
workspaceId: string;
incidents: Incident[];
nextCursor?: string;
}
export function IncidentListResource() {
const { output } = useToolData<unknown, IncidentOutput>(undefined, undefined);
const [incidents, setIncidents] = useState<Incident[]>([]);
const [cursor, setCursor] = useState<string | undefined>();
const [loadingMore, setLoadingMore] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
useEffect(() => {
if (!output) return;
setIncidents(output.incidents);
setCursor(output.nextCursor);
}, [output]);
const loadMore = async () => {
if (!output || !cursor) return;
setLoadingMore(true);
setFetchError(null);
try {
const params = new URLSearchParams({
workspaceId: output.workspaceId,
cursor,
});
const res = await fetch(`https://api.example.com/incidents?${params}`);
if (!res.ok) throw new Error(`Request failed with ${res.status}`);
const data = await res.json();
setIncidents((prev) => [...prev, ...data.incidents]);
setCursor(data.nextCursor);
} catch (err) {
setFetchError(err instanceof Error ? err.message : 'Request failed');
} finally {
setLoadingMore(false);
}
};
return (
<SafeArea className="space-y-4 p-5 font-sans">
<ul className="space-y-2">
{incidents.map((incident) => (
<li key={incident.id} className="rounded border p-3">
<p className="text-sm font-medium">{incident.title}</p>
<p className="text-xs text-gray-500">{incident.severity}</p>
</li>
))}
</ul>
{fetchError && <p className="text-sm text-red-600">{fetchError}</p>}
<button disabled={!cursor || loadingMore} onClick={loadMore}>
{loadingMore ? 'Loading...' : 'Load more'}
</button>
</SafeArea>
);
}
This gives the user a normal app interaction. It does not tell the model about the later pages. If the model needs to know what changed, use a server tool or sync a small summary through app state.
Do Not Ship Secrets to the Browser
The older shortcut was to pass an API token through structuredContent, read it with useToolData, and call the API from the component. That works technically, but it is usually the wrong default now.
OpenAI’s security guidance says structured content should include only the data required for the current prompt and should avoid embedding secrets or tokens in component props. The safer MCP App patterns are:
- Fetch on the server and return shaped data.
- Let the UI call a server tool for follow-up work.
- Use a server-side proxy that checks the user and resource instance.
- Pass a short-lived signed reference, not a reusable API token.
- Scope any browser credential to one user, one operation, one origin, and a short time window.
For user-scoped OAuth, keep the OAuth access token on the MCP server. The component can request more data by calling a server tool or a narrow API route that rechecks authorization server-side.
Decouple Data Tools from Render Tools
OpenAI’s current UI guidance recommends separating data-processing tools from render tools when a workflow benefits from it. The data tool fetches, computes, or mutates data and returns structuredContent without a UI resource. The render tool opens the UI once the model has the final data to display.
That pattern helps when:
- The model needs to combine results from more than one tool.
- The user asks a follow-up filter before seeing UI.
- You do not want to remount the iframe for every fetch.
- Some hosts or flows should use the tool without UI.
Example:
// src/tools/search-incidents.ts
export const tool = {
title: 'Search Incidents',
description: 'Find incidents matching a query',
outputSchema: {
incidentIds: z.array(z.string()),
count: z.number().int().min(0),
summary: z.string(),
},
};
export default async function searchIncidents(args: Args) {
const result = await searchIncidentIndex(args.query);
return {
structuredContent: {
incidentIds: result.incidents.map((incident) => incident.id),
count: result.incidents.length,
summary: result.summary,
},
_meta: {
incidentsById: Object.fromEntries(
result.incidents.map((incident) => [incident.id, incident])
),
},
};
}
Then the render tool opens the UI for a chosen set:
// src/tools/render-incidents.ts
export const tool = {
resource: 'incident-list',
title: 'Render Incidents',
description: 'Render a selected incident list. Call search-incidents first.',
};
export const schema = {
incidentIds: z.array(z.string()).describe('Incident IDs to render'),
};
With this flow, the model can search broadly, answer a text question, narrow the result, then render only the useful records. The UI can still call follow-up server tools for local interactions like “refresh” or “load latest.”
Polling, WebSockets, and Long-Running Jobs
For short-running live views, polling in the resource is fine:
useEffect(() => {
if (!output?.jobId) return;
let active = true;
const poll = async () => {
const res = await fetch(`https://api.example.com/jobs/${output.jobId}`);
const data = await res.json();
if (!active) return;
setJob(data);
if (data.status === 'complete') {
clearInterval(interval);
}
};
poll();
const interval = setInterval(poll, 5_000);
return () => {
active = false;
clearInterval(interval);
};
}, [output?.jobId]);
For WebSockets, declare the wss:// origin in connectDomains:
export const resource: ResourceConfig = {
description: 'Live deployment log',
_meta: {
ui: {
csp: {
connectDomains: ['wss://stream.example.com'],
},
},
},
};
For long-running work, prefer an explicit application handle such as jobId, runId, or cursor. The 2026-07-28 MCP specification release candidate removes protocol-level sessions, but even before that change, app data should not depend on sticky protocol sessions. Make application state explicit so any server instance can serve the next request.
Loading and Error States
Separate tool lifecycle state from browser fetch state.
useToolData tells you whether the host has delivered the tool result, errored, or cancelled. Your component’s own fetch() call has its own loading and error state.
const { output, isLoading, isError, isCancelled } = useToolData<unknown, IncidentOutput>(
undefined,
undefined
);
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
The UI should make the difference clear:
- Tool error: the server tool did not produce the initial result.
- Tool cancelled: the user or host stopped the tool lifecycle.
- Fetch error: the resource loaded, but a later browser request failed.
- Empty state: the request succeeded and returned no rows.
- Stale state: the resource still shows old data because a refresh failed.
That distinction matters for testing because each state needs a different fixture or mock.
Testing Server-Side Fetching
Test the tool handler as a normal server function:
import { beforeEach, describe, expect, it, vi } from 'vitest';
import handler from './get-account-health';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
describe('get-account-health', () => {
beforeEach(() => {
process.env.INTERNAL_API_TOKEN = 'test-token';
mockFetch.mockReset();
});
it('returns model-readable structuredContent', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
workspaceName: 'Acme',
openIncidents: 2,
plan: 'team',
p95LatencyMs: 145,
}),
});
const result = await handler({ workspaceId: 'ws_123' }, {} as any);
expect(result.structuredContent).toEqual({
workspaceName: 'Acme',
openIncidents: 2,
plan: 'team',
p95LatencyMs: 145,
});
expect(result.structuredContent).not.toHaveProperty('apiToken');
});
});
Then test the rendered resource with a simulation fixture:
{
"tool": "get-account-health",
"userMessage": "Show account health for Acme",
"toolInput": { "workspaceId": "ws_123" },
"toolResult": {
"content": [{ "type": "text", "text": "Acme has 2 open incidents." }],
"structuredContent": {
"workspaceName": "Acme",
"openIncidents": 2,
"plan": "team",
"p95LatencyMs": 145
},
"_meta": {
"requestId": "req_test"
}
}
}
In sunpeak, simulation files let you render the resource in local ChatGPT and Claude runtime replicas without calling the real tool handler. That makes empty, error, partial, and large-data states cheap to test in CI.
Testing Client-Side Fetching
For resource-level fetches, intercept browser requests in Playwright:
import { expect, test } from 'sunpeak/test';
test('loads the next incident page', async ({ inspector, page }) => {
await page.route('https://api.example.com/incidents?workspaceId=ws_123&cursor=next_1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
incidents: [{ id: 'inc_3', title: 'Cache miss spike', severity: 'medium' }],
nextCursor: null,
}),
})
);
const result = await inspector.renderTool('get-incidents', {
workspaceId: 'ws_123',
});
const app = result.app();
await app.getByRole('button', { name: 'Load more' }).click();
await expect(app.getByText('Cache miss spike')).toBeVisible();
});
Also test the failure path:
test('shows a fetch error without losing existing rows', async ({ inspector, page }) => {
await page.route('https://api.example.com/incidents?workspaceId=ws_123&cursor=next_1', (route) =>
route.fulfill({ status: 500 })
);
const result = await inspector.renderTool('get-incidents', {
workspaceId: 'ws_123',
});
const app = result.app();
await app.getByRole('button', { name: 'Load more' }).click();
await expect(app.getByText('Request failed with 500')).toBeVisible();
await expect(app.getByText('Original incident')).toBeVisible();
});
For a deployed server, use MCP Inspector before a real host test. It can list resources, inspect resource metadata, list tools, show schemas, call tools with custom input, and display tool execution results.
A Practical Decision Tree
Use server-side fetching when any of these are true:
- The data should be in the model’s answer.
- The request needs credentials.
- The result should be validated by
outputSchema. - The workflow should work without UI.
- The data set is small enough to put in the transcript.
Use client-side fetching when any of these are true:
- The data needs to refresh without another model turn.
- The user is paging, sorting, filtering, or searching locally.
- The model should not see every row.
- The UI is watching a status, stream, or dashboard.
Use decoupled data and render tools when any of these are true:
- The model should transform or filter data before rendering.
- Several data calls feed one UI.
- You want to avoid remounting the iframe on each data call.
- The tool is useful even when no component renders.
Common Mistakes
- Putting secrets in
structuredContent:structuredContentis model-readable and can appear in the transcript. Return shaped data instead. - Using
_metaas a secret store:_metais hidden from the model, but the component receives it. Use it for UI-only data, not durable credentials. - Skipping
outputSchema: If a tool returnsstructuredContent, declare the shape and test it. This catches contract drift before hosts or components see it. - Mounting UI for every data tool: Search, fetch, and mutation tools often should return data only. Render UI when there is something worth inspecting.
- Forgetting CSP and CORS: Browser fetches need
_meta.ui.csp.connectDomains, and the API still needs CORS headers that allow the request. - Treating all loading as tool loading:
useToolDataloading ends when the host delivers the tool result. Resource fetch loading starts after your component makes its own request.
Where sunpeak Fits
You can build these patterns with the MCP SDK, React, your own bundler, and Playwright. The work gets tedious when you need confidence across hosts, themes, display modes, tool results, cancelled states, and browser network paths.
sunpeak packages that loop. It gives you a local MCP App inspector with ChatGPT and Claude runtime replicas, simulation files for tool input and tool results, E2E tests, visual tests, live host checks, and evals. That means you can test the server-side result, the UI resource, the _meta payload, client-side fetch failures, and host-specific behavior without burning credits on every edit.
If you are building a data-heavy MCP App, start with one server-side tool result and a strict outputSchema. Add resource fetches only for interactions that need to happen after the tool call. Split data and render tools when the model should prepare the final view before the user sees it.
Get Started
npx sunpeak newFurther Reading
- MCP App Tool Results - content, structuredContent, and _meta
- MCP App outputSchema - validate structuredContent before hosts see it
- MCP App CSP Domains - configure connectDomains, resourceDomains, and frameDomains
- Interactive MCP Apps: useAppState - sync UI state back to the model
- MCP App Error Handling - separate tool failures from UI fetch failures
- E2E Testing MCP Apps - simulation files and Playwright patterns for data flows
- MCP App framework
- Official MCP Apps overview
- OpenAI: Add UI to your MCP server
- MCP Tools specification
Frequently Asked Questions
Should an MCP App fetch data in the tool handler or the UI resource?
Fetch in the tool handler when the model needs the data, when the request uses server-side credentials, when the result should match an outputSchema, or when the app can render from a stable snapshot. Fetch in the UI resource when the user needs refresh, polling, pagination, local filters, or UI-only detail that should not enter the model transcript. Many production MCP Apps use both: a data tool returns model-readable structuredContent, and a render tool or resource handles the interactive view.
What should go in structuredContent versus _meta in an MCP App tool result?
Put concise facts the model may reason about in structuredContent, and make them match the declared outputSchema. Put UI-only helper data in _meta, such as row maps, cursors, display hints, correlation IDs, and short-lived references. Do not put API keys, access tokens, secrets, or large hidden payloads in structuredContent because structuredContent is visible to the model and can appear in the conversation transcript.
Can I pass API keys to an MCP App component for client-side fetches?
Avoid passing raw API keys or OAuth access tokens to the component. OpenAI security guidance says structured content and component props should include only data required for the current prompt and should not embed secrets. Prefer server-side fetching, UI-triggered server tools, or short-lived signed references scoped to one resource, one user, and one narrow operation. If the browser must call an API directly, use a constrained proxy or an expiring token with least privilege.
How do I make fetch() work from inside a ChatGPT App or MCP App iframe?
Declare the API origin in _meta.ui.csp.connectDomains on the UI resource, and make sure the API sends CORS headers that allow the iframe origin. Static assets use resourceDomains, and nested iframes use frameDomains. If fetch works in a browser tab but fails inside the app with a generic TypeError, check the browser console for CSP or CORS failures first.
When should I use a separate render tool in an MCP App?
Use a separate render tool when the model should fetch, combine, filter, or validate data before opening UI. The data tool returns reusable structuredContent with no UI resource attached. The render tool receives the final IDs or payload and includes _meta.ui.resourceUri. This avoids remounting UI for every data call and lets the model decide when a component is useful.
How should MCP Apps handle polling and pagination?
For UI-only refresh, poll or paginate from the resource and keep that state local. For model-visible changes, call a server tool or sync a small summary back through app state so the model knows what changed. Store cursors in _meta or a short-lived server handle when they are not useful to the model. Do not rely on protocol-level session state for application data.
How do I test MCP App data fetching?
Test the server tool contract with unit tests and outputSchema validation. Test the rendered resource with simulation files that pin tool input, structuredContent, and _meta. For client-side fetches, intercept network requests in Playwright with page.route(). For deployed servers, use MCP Inspector to list tools, call tools, inspect resources, and check edge cases before testing inside ChatGPT or Claude.
Does sunpeak help test server-side and client-side MCP App data flows?
Yes. sunpeak includes a local inspector that replicates ChatGPT and Claude runtimes, simulation fixtures for tool input and tool results, Playwright-based E2E tests, visual tests, live host checks, and evals. That means you can test data states, loading states, errors, themes, display modes, and host differences locally and in CI without spending host credits on every edit.