Fetching Data in MCP Apps: Server-Side vs Client-Side Patterns for ChatGPT and Claude (May 2026)
Server-side and client-side data fetching patterns for MCP Apps.
TL;DR: MCP Apps have two places to fetch data: the tool handler (server-side, has access to secrets, data goes into model context) and the resource component (client-side, can refresh and poll, needs CSP domains configured). Use the tool handler for initial data. Use client-side fetch() when you need to refresh, poll, paginate, or load data based on user actions. Most production apps combine both.
Every MCP App tutorial shows data flowing one way: the model calls a tool, the tool returns structuredContent, and the resource renders it. That works when your data is a static card. It breaks down when you need a live dashboard, a paginated table, or a search bar that queries an API on every keystroke.
The MCP Apps architecture gives you two places to put your data fetching logic, and the right choice depends on what the data is for.
Two Patterns, One App
An MCP App has a tool handler that runs on your MCP server and a resource component that runs in a sandboxed iframe inside the host (ChatGPT, Claude, VS Code, Goose). Both can make HTTP requests, but from very different positions.
Server-side fetching happens in the tool handler. Your server code calls an API, shapes the response, and returns it as structuredContent. The host delivers it to the resource via useToolData. The data is also visible to the model as context.
Client-side fetching happens in the resource component. Your React code calls fetch() from inside the iframe, after the tool lifecycle has already completed. The host does not see this data and the model cannot reference it unless you sync it back with useAppState.
Here is when to use each:
| Use case | Pattern | Why |
|---|---|---|
| Data the model needs to reference | Server-side | structuredContent goes into model context |
| Data that requires server secrets | Server-side | Tool handler has access to env vars |
| Data that refreshes after tool call | Client-side | Can poll or refetch without a new tool call |
| Paginated or lazy-loaded data | Client-side | User drives navigation, not the model |
| Data loaded from user interaction | Client-side | Responding to clicks, search input, filters |
| Initial data + live updates | Both | Tool provides seed data, resource refreshes |
Server-Side: Fetching in the Tool Handler
The simplest pattern. Your tool handler calls an external API and returns the response as structuredContent. The resource receives it through useToolData.
// src/tools/get-weather.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'weather',
title: 'Get Weather',
description: 'Show current weather for a city',
};
export const schema = {
city: z.string().describe('City name'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
const res = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${args.city}`
);
const data = await res.json();
return {
structuredContent: {
city: args.city,
tempC: data.current.temp_c,
condition: data.current.condition.text,
icon: data.current.condition.icon,
},
};
}
// src/resources/weather/weather.tsx
import { useToolData, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
description: 'Display current weather',
};
interface WeatherData {
city: string;
tempC: number;
condition: string;
icon: string;
}
export function WeatherResource() {
const { output, isLoading, isError, isCancelled } = useToolData<unknown, WeatherData>(
undefined,
undefined
);
if (isLoading) {
return (
<SafeArea className="p-5 font-sans">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-200 rounded w-1/3" />
<div className="h-10 bg-gray-200 rounded w-1/4" />
</div>
</SafeArea>
);
}
if (isError) return <SafeArea className="p-5 text-sm text-red-600">Failed to load weather.</SafeArea>;
if (isCancelled) return <SafeArea className="p-5 text-sm text-gray-400">Stopped.</SafeArea>;
if (!output) return null;
return (
<SafeArea className="p-5 font-sans">
<p className="text-sm text-gray-500">{output.city}</p>
<p className="text-3xl font-bold">{output.tempC}°C</p>
<p className="text-sm text-gray-600">{output.condition}</p>
</SafeArea>
);
}
This pattern has three properties that make it the default choice:
- The API key stays on the server.
process.env.WEATHER_API_KEYnever leaves your MCP server process. The resource only sees the shaped response. - The model gets context. Because the weather data is in
structuredContent, the model can say “It’s currently 22°C in Tokyo” in its response text. Client-side fetched data is invisible to the model. - No CSP configuration. The
fetch()call runs on your server, not in the iframe. You don’t needconnectDomains.
The downside: the data is a snapshot. If the user stares at the weather card for 30 minutes, it still shows what it fetched at tool call time. For static data like contact records or order details, that is fine. For live data, you need client-side fetching.
Client-Side: Fetching in the Resource Component
When your resource needs to refresh, poll, or load data on demand, fetch from inside the component. This requires configuring CSP domains so the browser allows the request to leave the sandboxed iframe.
Here is a dashboard that loads usage metrics and refreshes every 30 seconds:
// src/tools/open-dashboard.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'dashboard',
title: 'Open Dashboard',
description: 'Show a live usage dashboard',
};
export const schema = {
teamId: z.string().describe('Team ID'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
return {
structuredContent: {
teamId: args.teamId,
apiToken: process.env.DASHBOARD_API_TOKEN,
},
};
}
The tool handler returns the team ID and an API token. It does not fetch the metrics itself because those need to refresh.
// src/resources/dashboard/dashboard.tsx
import { useState, useEffect } from 'react';
import { useToolData, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
description: 'Live usage dashboard with auto-refresh',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
},
},
};
interface ToolOutput {
teamId: string;
apiToken: string;
}
interface Metrics {
requests: number;
errors: number;
p95Ms: number;
}
export function DashboardResource() {
const { output, isLoading, isError, isCancelled } = useToolData<unknown, ToolOutput>(
undefined,
undefined
);
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [fetchError, setFetchError] = useState(false);
useEffect(() => {
if (!output) return;
const load = async () => {
try {
const res = await fetch(`https://api.example.com/metrics/${output.teamId}`, {
headers: { Authorization: `Bearer ${output.apiToken}` },
});
if (!res.ok) throw new Error(res.statusText);
setMetrics(await res.json());
setFetchError(false);
} catch {
setFetchError(true);
}
};
load();
const interval = setInterval(load, 30_000);
return () => clearInterval(interval);
}, [output?.teamId]);
// Tool lifecycle states
if (isLoading) {
return (
<SafeArea className="p-5 font-sans">
<div className="animate-pulse space-y-3">
<div className="h-5 bg-gray-200 rounded w-1/2" />
<div className="h-16 bg-gray-200 rounded" />
</div>
</SafeArea>
);
}
if (isError) return <SafeArea className="p-5 text-sm text-red-600">Tool failed.</SafeArea>;
if (isCancelled) return <SafeArea className="p-5 text-sm text-gray-400">Stopped.</SafeArea>;
if (!output) return null;
// Client-side fetch states
if (fetchError) {
return (
<SafeArea className="p-5 text-sm text-red-600">
Could not load metrics. Will retry in 30s.
</SafeArea>
);
}
if (!metrics) {
return (
<SafeArea className="p-5 font-sans">
<div className="animate-pulse space-y-3">
<div className="h-5 bg-gray-200 rounded w-1/3" />
<div className="h-16 bg-gray-200 rounded" />
</div>
</SafeArea>
);
}
return (
<SafeArea className="p-5 font-sans space-y-3">
<p className="text-sm text-gray-500">Team {output.teamId}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-2xl font-bold">{metrics.requests.toLocaleString()}</p>
<p className="text-xs text-gray-500">Requests</p>
</div>
<div>
<p className="text-2xl font-bold">{metrics.errors}</p>
<p className="text-xs text-gray-500">Errors</p>
</div>
<div>
<p className="text-2xl font-bold">{metrics.p95Ms}ms</p>
<p className="text-xs text-gray-500">p95 Latency</p>
</div>
</div>
</SafeArea>
);
}
Notice there are two layers of loading states here. First, useToolData isLoading covers the tool lifecycle (waiting for the host to deliver tool-result). Second, the !metrics check covers the client-side fetch that starts after the tool completes. These are sequential: the tool lifecycle finishes, output arrives with the team ID and token, then the component starts its own fetch. See the error handling guide for more on the tool lifecycle states.
The Hybrid Pattern
Most production MCP Apps combine both approaches. The tool handler fetches initial data so the model has context, and the resource component fetches updates so the UI stays current.
// src/tools/get-orders.ts
export default async function (args: Args, _extra: ToolHandlerExtra) {
const res = await fetch(`https://api.example.com/orders?status=open&limit=10`, {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
const orders = await res.json();
return {
structuredContent: {
orders: orders.items,
total: orders.total,
apiToken: process.env.API_TOKEN,
cursor: orders.nextCursor,
},
};
}
// src/resources/orders/orders.tsx (simplified)
export function OrdersResource() {
const { output } = useToolData<unknown, OrdersOutput>(undefined, undefined);
const [orders, setOrders] = useState<Order[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
// Seed from tool output
useEffect(() => {
if (!output) return;
setOrders(output.orders);
setCursor(output.cursor);
}, [output]);
const loadMore = async () => {
if (!cursor || !output) return;
const res = await fetch(
`https://api.example.com/orders?status=open&limit=10&cursor=${cursor}`,
{ headers: { Authorization: `Bearer ${output.apiToken}` } }
);
const data = await res.json();
setOrders(prev => [...prev, ...data.items]);
setCursor(data.nextCursor);
};
// ... render orders with a "Load more" button that calls loadMore()
}
The model can reference the first 10 orders because they are in structuredContent. When the user clicks “Load more,” the resource fetches the next page client-side. The model does not see the additional orders, but the user does.
If you need the model to know about the additional data, sync it back with useAppState:
const [appState, setAppState] = useAppState({ totalLoaded: 0 });
const loadMore = async () => {
// ... fetch next page
const newOrders = [...orders, ...data.items];
setOrders(newOrders);
setAppState({ totalLoaded: newOrders.length });
};
Now the model can see how many orders the user has loaded and respond accordingly.
Passing Tokens Securely
The sandboxed iframe has no access to localStorage, sessionStorage, or cookies. Your resource component cannot store credentials on its own. The recommended pattern is to pass tokens through structuredContent:
- Tool handler reads the token from
process.envor a secure server-side store - Tool handler includes the token in
structuredContent - Resource component reads it from
useToolDataoutput - Resource component uses it in
fetch()headers
// Tool handler
return {
structuredContent: {
data: fetchedData,
apiToken: process.env.INTERNAL_API_TOKEN,
},
};
// Resource component
const { output } = useToolData<unknown, { data: any; apiToken: string }>(undefined, undefined);
const res = await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${output.apiToken}` },
});
The token travels from the server to the resource inside the structured content envelope, only when the tool runs. It does not persist in the browser between sessions.
For user-scoped tokens (OAuth), the flow is different. The MCP server handles the OAuth exchange, stores the access token server-side, and passes it through structuredContent the same way. See the OAuth authentication guide for the full pattern.
Polling and WebSockets
For data that changes frequently, poll with setInterval:
useEffect(() => {
if (!output) return;
const poll = async () => {
const res = await fetch(`https://api.example.com/status/${output.jobId}`, {
headers: { Authorization: `Bearer ${output.apiToken}` },
});
const data = await res.json();
setStatus(data);
if (data.complete) clearInterval(interval);
};
poll();
const interval = setInterval(poll, 5_000);
return () => clearInterval(interval);
}, [output?.jobId]);
For true real-time updates, WebSocket connections also go through connectDomains. Declare the WebSocket origin with wss://:
export const resource: ResourceConfig = {
description: 'Live feed',
_meta: {
ui: {
csp: {
connectDomains: ['wss://stream.example.com'],
},
},
},
};
Then connect from your component:
useEffect(() => {
if (!output) return;
const ws = new WebSocket(`wss://stream.example.com/feed/${output.channelId}`);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
setMessages(prev => [...prev, update]);
};
return () => ws.close();
}, [output?.channelId]);
Both patterns work across ChatGPT, Claude, VS Code, and Goose as long as the domain is declared in connectDomains.
Pagination
Client-side pagination lets users browse through data without triggering new tool calls. The tool handler provides the first page and a cursor or page count. The resource component fetches subsequent pages on demand.
interface PaginatedOutput {
items: Item[];
totalPages: number;
apiToken: string;
}
export function PaginatedListResource() {
const { output } = useToolData<unknown, PaginatedOutput>(undefined, undefined);
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (output) setItems(output.items);
}, [output]);
const goToPage = async (p: number) => {
if (!output) return;
setLoading(true);
try {
const res = await fetch(`https://api.example.com/items?page=${p}`, {
headers: { Authorization: `Bearer ${output.apiToken}` },
});
const data = await res.json();
setItems(data.items);
setPage(p);
} finally {
setLoading(false);
}
};
if (!output) return null;
return (
<SafeArea className="p-5 font-sans">
{loading ? (
<p className="text-sm text-gray-400">Loading...</p>
) : (
<ul className="space-y-2">
{items.map(item => (
<li key={item.id} className="text-sm">{item.name}</li>
))}
</ul>
)}
<div className="flex gap-2 mt-4">
<button disabled={page <= 1} onClick={() => goToPage(page - 1)} className="text-sm px-3 py-1 border rounded">
Prev
</button>
<span className="text-sm text-gray-500 py-1">Page {page} of {output.totalPages}</span>
<button disabled={page >= output.totalPages} onClick={() => goToPage(page + 1)} className="text-sm px-3 py-1 border rounded">
Next
</button>
</div>
</SafeArea>
);
}
Error Handling for Client-Side Fetches
The useToolData error states (isError, isCancelled) cover the tool lifecycle. Client-side fetch errors are your own responsibility. Keep them separate so users know what went wrong.
const [data, setData] = useState(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const load = async () => {
try {
setRetrying(true);
const res = await fetch(`https://api.example.com/data/${output.id}`, {
headers: { Authorization: `Bearer ${output.apiToken}` },
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
setData(await res.json());
setFetchError(null);
} catch (err) {
setFetchError(err instanceof Error ? err.message : 'Request failed');
} finally {
setRetrying(false);
}
};
Show the error with a retry button:
if (fetchError) {
return (
<SafeArea className="p-5 font-sans space-y-2">
<p className="text-sm text-red-600">{fetchError}</p>
<button onClick={load} disabled={retrying} className="text-sm px-3 py-1 border rounded">
{retrying ? 'Retrying...' : 'Retry'}
</button>
</SafeArea>
);
}
If the error is a CSP violation (the request was blocked before it left the iframe), the browser logs the violation in the console but your catch block sees a generic TypeError: Failed to fetch. The fix is always adding the origin to connectDomains. If you see Failed to fetch and the API works in a regular browser tab, CSP is almost certainly the cause.
Testing Server-Side Fetching
For the tool handler, create a simulation file with the expected structuredContent. The simulation bypasses the handler entirely, delivering mock data straight to the resource:
{
"tool": "get-weather",
"userMessage": "What is the weather in Tokyo?",
"toolInput": { "city": "Tokyo" },
"toolResult": {
"structuredContent": {
"city": "Tokyo",
"tempC": 22,
"condition": "Partly cloudy",
"icon": "//cdn.weatherapi.com/weather/64x64/day/116.png"
}
}
}
Test it with Playwright:
import { test, expect } from 'sunpeak/test';
test('weather resource renders city and temperature', async ({ inspector }) => {
const result = await inspector.renderTool('get-weather', {});
const app = result.app();
await expect(app.locator('text=Tokyo')).toBeVisible();
await expect(app.locator('text=22°C')).toBeVisible();
});
To unit test the handler itself, mock the external API call and assert the returned shape:
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import handler from './get-weather';
describe('get-weather handler', () => {
beforeEach(() => {
process.env.WEATHER_API_KEY = 'test-key';
});
it('returns shaped structuredContent', async () => {
mockFetch.mockResolvedValueOnce({
json: async () => ({
current: { temp_c: 22, condition: { text: 'Partly cloudy', icon: '//cdn/116.png' } },
}),
});
const result = await handler({ city: 'Tokyo' }, {} as any);
expect(result.structuredContent).toEqual({
city: 'Tokyo',
tempC: 22,
condition: 'Partly cloudy',
icon: '//cdn/116.png',
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('key=test-key&q=Tokyo')
);
});
});
Testing Client-Side Fetching
For the resource component that calls fetch(), intercept the request in your Playwright test with page.route():
import { test, expect } from 'sunpeak/test';
test('dashboard fetches and displays metrics', async ({ inspector, page }) => {
await page.route('https://api.example.com/metrics/team_42', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ requests: 1200, errors: 3, p95Ms: 142 }),
})
);
const result = await inspector.renderTool('open-dashboard', {});
const app = result.app();
await expect(app.locator('text=1,200')).toBeVisible();
await expect(app.locator('text=142ms')).toBeVisible();
});
Test the error case by returning a failing response:
test('dashboard shows error when API fails', async ({ inspector, page }) => {
await page.route('https://api.example.com/metrics/team_42', route =>
route.fulfill({ status: 500 })
);
const result = await inspector.renderTool('open-dashboard', {});
const app = result.app();
await expect(app.locator('text=Could not load metrics')).toBeVisible();
});
For unit tests, mock both sunpeak and fetch:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
vi.mock('sunpeak', () => ({
useToolData: () => ({
output: { teamId: 'team_42', apiToken: 'tok_test' },
isLoading: false,
isError: false,
isCancelled: false,
input: null,
inputPartial: null,
cancelReason: null,
}),
useHostContext: () => null,
useDisplayMode: () => 'inline',
SafeArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
}));
import { DashboardResource } from './dashboard';
describe('DashboardResource', () => {
it('fetches metrics and renders them', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ requests: 1200, errors: 3, p95Ms: 142 }),
});
render(<DashboardResource />);
await waitFor(() => {
expect(screen.getByText('1,200')).toBeInTheDocument();
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/metrics/team_42',
expect.objectContaining({
headers: { Authorization: 'Bearer tok_test' },
})
);
});
});
No real API calls, no AI credits, no paid host accounts. Run pnpm test to execute both unit and e2e tests locally with the sunpeak testing framework.
Common Mistakes
Fetching in the tool handler when the data needs to refresh. If your dashboard fetches metrics in the handler, the numbers freeze at the moment the tool ran. Move the fetch to the resource component and poll.
Forgetting connectDomains for client-side fetches. The error is a generic TypeError: Failed to fetch with no CSP-specific message in JavaScript. If fetch() works in a regular browser tab but fails in your MCP App, add the origin to connectDomains.
Hardcoding API keys in the resource component. The iframe source is visible to the host and to browser dev tools. Pass tokens through structuredContent from the tool handler.
Treating client-side fetch loading as useToolData isLoading. They are different states. isLoading from useToolData covers the tool lifecycle. Your own fetch has its own loading state. Handle both, and handle them separately.
Not cleaning up intervals and WebSockets. Return a cleanup function from useEffect to prevent memory leaks when the resource unmounts or the dependencies change.
Get Started
npx sunpeak new
Further Reading
- MCP App CSP Domains - configure connectDomains, resourceDomains, and frameDomains for external access.
- MCP App Error Handling - handle loading, error, and cancelled states from the tool lifecycle.
- Interactive MCP Apps: useAppState - sync user actions back to the model after fetching data.
- MCP App Authentication: OAuth 2.1 - full OAuth flow for MCP Apps that need user-scoped API access.
- E2E Testing MCP Apps - simulation files and Playwright patterns for testing data flows.
- Mocking and Stubbing in MCP App Tests - patterns for mocking useToolData, fetch, and external APIs.
- MCP App framework
Frequently Asked Questions
Should I fetch data in the MCP tool handler or the resource component?
Fetch in the tool handler when the data is static for that tool call, when you need access to server-side secrets like API keys, or when you want the fetched data included in the model's context. Fetch in the resource component when you need to refresh data without a new tool call, poll for updates, paginate through results, or load data in response to user interactions. Many production MCP Apps use a hybrid pattern where the tool handler provides initial data and an auth token, and the resource component refreshes client-side.
How do I make a fetch() call from inside an MCP App resource?
Add the API origin to connectDomains in your resource _meta.ui.csp configuration. Without this, the browser blocks the request because MCP App resources run in sandboxed iframes with no external access by default. Once the domain is declared, use standard fetch() in your React component. The API server also needs CORS headers that accept your iframe origin, though public APIs with Access-Control-Allow-Origin: * work without extra configuration.
How do I pass API keys to an MCP App resource for client-side fetching?
Pass tokens through structuredContent in your tool handler return value. The resource component reads the token from useToolData output and includes it in its fetch() Authorization header. This keeps secrets on the server until the tool runs. Never hardcode API keys in your resource component code because the iframe source is visible to the host and to browser dev tools.
Can an MCP App resource poll an API for real-time updates?
Yes. Use setInterval or setTimeout in a useEffect hook inside your resource component, with the API origin declared in connectDomains. Clean up the interval in the useEffect return function to prevent memory leaks when the component unmounts. Polling intervals under 5 seconds work but check your API rate limits. For true real-time updates, WebSocket connections also work through connectDomains.
How do I test fetch() calls in MCP App Playwright tests?
Use page.route() to intercept outgoing requests and return mock responses. This keeps tests deterministic and fast. Example: page.route('https://api.example.com/data', route => route.fulfill({ status: 200, body: JSON.stringify(mockData) })). The test runs against the sunpeak inspector with no real API calls, no paid accounts, and no AI credits.
How do I test server-side data fetching in MCP App tool handlers?
Use simulation files with the expected structuredContent already filled in. The simulation bypasses your tool handler entirely, delivering the mock data directly to the resource. For unit testing the handler itself, mock the external API call (e.g., with vi.mock or msw) and assert that the handler returns the correct structuredContent shape.
How do I handle loading states for client-side fetches in MCP Apps?
Track loading state with React useState alongside your fetch call. The useToolData isLoading field only covers the tool lifecycle (waiting for the host to deliver tool-result). Once you have output and start your own fetch, manage that loading state separately. Show a skeleton or spinner while the client-side request is in flight, and an error message if it fails.
What is the difference between useToolData loading and client-side fetch loading in MCP Apps?
useToolData isLoading is true from the moment the resource iframe mounts until the host delivers a tool-result or tool-cancelled notification. It covers the MCP protocol lifecycle. Client-side fetch loading is your own state that tracks an in-flight HTTP request from the resource component to an external API. They are sequential: first the tool lifecycle completes (isLoading becomes false, output arrives), then your component starts its own fetch if needed.