End-to-End TypeScript Types in MCP Apps: From Zod Schema to React Component (May 2026)
End-to-end TypeScript types in an MCP App: from Zod schema to React component.
TL;DR: Define a Zod schema in your tool file and return typed structuredContent. In your resource component, pass matching types to useToolData<InputType, OutputType>(). Share output interfaces via a types.ts file that both the tool and component import. TypeScript validates the entire pipeline at build time.
Every MCP App has the same data flow: the AI model calls your tool, the tool handler runs and returns structured content, and your React resource component renders that content. TypeScript can cover this entire pipeline, but only if you wire the types correctly at each step.
This guide walks through each step in order, using the MCP Apps protocol (@modelcontextprotocol/ext-apps v1.7.1) as the underlying standard.
The Type Pipeline
Here is the full path a piece of data takes through an MCP App, with the TypeScript types at each stage:
AI model
→ calls tool with args (typed by Zod schema)
→ tool handler runs (Args type)
→ returns structuredContent (OutputType)
→ resource component renders (useToolData<InputType, OutputType>)
You control the types at every boundary. Getting them right means your tool handler, resource component, and tests all agree on the shape of the data, and TypeScript catches mismatches at build time rather than at runtime in ChatGPT or Claude.
Step 1: Define the Zod Schema
Tool files live in src/tools/{name}.ts. Each file exports three things: tool, schema, and a default handler.
The schema is a plain object with Zod field values, not a z.object() call. sunpeak iterates the fields to generate the JSON Schema the AI host uses when calling your tool.
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'albums',
title: 'Show Albums',
description: 'Show photo albums filtered by category or search term',
annotations: { readOnlyHint: true },
};
export const schema = {
category: z.string().describe('Filter by category, e.g. travel, food, family'),
search: z.string().describe('Search term to filter by title or description'),
limit: z.number().describe('Maximum number of albums to return'),
};
Each field maps to an argument the AI model can pass when it calls your tool. The .describe() text becomes the field description in the JSON Schema. Write it clearly, because it is what GPT-4o, Claude, and other models read to decide what to pass. Clear descriptions improve tool-calling accuracy across models.
Step 2: Derive the Args Type
Derive the handler’s input type from the schema with z.infer<z.ZodObject<typeof schema>>:
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
const title = args.category
? `${args.category.charAt(0).toUpperCase() + args.category.slice(1)} Photos`
: args.search
? `"${args.search}" Results`
: 'My Albums';
// ...
}
z.infer<z.ZodObject<typeof schema>> wraps your schema object into the type that Zod would infer from a matching z.object(). With the schema above, Args resolves to:
type Args = {
category: string;
search: string;
limit: number;
}
All fields from Zod schemas are required by default. The model may not provide every field in practice, so access them defensively with args.field ?? defaultValue.
Step 3: Make Fields Optional
Use .optional() when a field is not always needed. This tells both the AI model and TypeScript that the field can be omitted:
export const schema = {
category: z.string().optional().describe('Filter by category'),
search: z.string().optional().describe('Search term'),
limit: z.number().optional().describe('Max results (default: 10)'),
};
The Args type becomes:
type Args = {
category?: string;
search?: string;
limit?: number;
}
Optional chaining and nullish coalescing handle the gaps cleanly in the handler:
const limit = args.limit ?? 10;
const results = db.query({ limit });
Step 4: Define the Output Type
Your handler returns { structuredContent: { ... } }. Define the output type explicitly so the resource component can import and use it:
// In src/resources/albums/types.ts
export interface Album {
id: string;
title: string;
cover: string;
photos: Array<{
id: string;
title: string;
url: string;
}>;
}
export interface AlbumsData {
albums: Album[];
}
Return it from the handler with an explicit annotation:
import type { AlbumsData } from '../resources/albums/types';
export default async function (args: Args, _extra: ToolHandlerExtra): Promise<{ structuredContent: AlbumsData }> {
const albums = await db.getAlbums({ category: args.category, limit: args.limit ?? 10 });
return {
structuredContent: { albums },
};
}
Annotating the return type makes TypeScript warn you if the handler returns the wrong shape, before the resource component ever tries to render it.
Step 5: Shared Type Files
Put your output interfaces in src/resources/{name}/types.ts. Both the tool file and the resource component import from there:
src/
├── tools/
│ └── show-albums.ts ← imports AlbumsData from ../resources/albums/types
└── resources/
└── albums/
├── types.ts ← exports Album, AlbumsData
├── albums.tsx ← imports AlbumsData from ./types
└── components/
└── albums.tsx ← imports Album from ../types
The tool file lives in src/tools/ and imports from src/resources/ only for types. The resource file does not import from the tool file. There is no circular dependency.
Step 6: Type useToolData
In your resource component, pass both generics to useToolData. The hook returns seven fields that map to the four protocol notifications your resource receives from the host:
import { useToolData } from 'sunpeak';
import type { AlbumsData } from './types';
interface AlbumsInput {
category?: string;
search?: string;
limit?: number;
}
export function Albums() {
const { output, input, inputPartial, isLoading, isError, isCancelled, cancelReason } =
useToolData<AlbumsInput, AlbumsData>();
// output: AlbumsData | null (from ui/notifications/tool-result)
// input: AlbumsInput | null (from ui/notifications/tool-input)
// inputPartial: Partial<AlbumsInput> | null (from ui/notifications/tool-input-partial)
// isLoading: boolean (true until tool-result or tool-cancelled)
// isError: boolean (isError flag from tool-result)
// isCancelled: boolean (from ui/notifications/tool-cancelled)
// cancelReason: string | null (reason from tool-cancelled)
}
The first generic (AlbumsInput) types the input and inputPartial fields. The second generic (AlbumsData) types output. If you omit a generic, that field is typed as unknown.
Step 7: Use inputPartial for Context-Aware Loading
inputPartial arrives via the ui/notifications/tool-input-partial protocol notification while the model is still streaming the tool call. It is a partial version of your input, with some fields present and others not yet.
Use it to show context-aware loading states instead of a generic spinner:
if (isLoading) {
const category = inputPartial?.category;
return (
<div className="flex items-center gap-2 p-8">
<Spinner />
<span>
{category ? `Loading ${category} albums…` : 'Loading albums…'}
</span>
</div>
);
}
inputPartial?.category is typed as string | undefined because inputPartial is Partial<AlbumsInput> | null. Optional chaining handles both the null case and the case where the field has not arrived yet.
Step 8: Handle All States Before Rendering
With the types in place, handle all seven fields before accessing output. See the MCP App Error Handling guide for the full pattern:
export function Albums() {
const { output, input, inputPartial, isLoading, isError, isCancelled, cancelReason } =
useToolData<AlbumsInput, AlbumsData>();
if (isLoading) {
const hint = inputPartial?.category || inputPartial?.search;
return <LoadingState hint={hint} />;
}
if (isError) {
return <ErrorState />;
}
if (isCancelled) {
return <CancelledState reason={cancelReason} />;
}
// output is AlbumsData here
const albums = output?.albums ?? [];
if (albums.length === 0) {
return <EmptyState />;
}
return <AlbumList albums={albums} />;
}
TypeScript does not narrow output to non-null automatically after checking the boolean flags. Use output?.albums ?? [] to handle the null case safely even after the loading checks pass.
Step 9: Complex Schema Patterns
Nested Objects
export const schema = {
location: z
.object({
lat: z.number(),
lng: z.number(),
})
.describe('Center coordinates for the search'),
radius: z.number().describe('Search radius in miles'),
};
The Args type becomes { location: { lat: number; lng: number }; radius: number }.
Arrays
export const schema = {
priceRange: z
.array(z.enum(['$', '$$', '$$$', '$$$$']))
.describe('Price range filter'),
};
The Args type becomes { priceRange: ('$' | '$$' | '$$$' | '$$$$')[] }.
Enums
export const schema = {
schedule: z
.enum(['now', 'scheduled'])
.describe('When to publish the post'),
visibility: z
.enum(['public', 'connections', 'private'])
.describe('Post visibility'),
};
Using .enum() instead of .string() gives you union types in Args ('now' | 'scheduled') and constrains the model to valid values.
Arrays of Enums
export const schema = {
platforms: z
.array(z.enum(['x', 'linkedin', 'facebook', 'instagram']))
.describe('Platforms to post to'),
};
Step 10: Testing Tool Handlers
Because tool handlers are plain async functions, they test directly with Vitest without any framework setup:
import { describe, it, expect } from 'vitest';
import handler, { tool, schema } from './show-albums';
const extra = {} as Parameters<typeof handler>[1];
describe('show-albums tool', () => {
it('exports correct tool config', () => {
expect(tool.resource).toBe('albums');
expect(tool.title).toBe('Show Albums');
expect(tool.annotations?.readOnlyHint).toBe(true);
});
it('has expected schema fields', () => {
expect(schema.category).toBeDefined();
expect(schema.search).toBeDefined();
expect(schema.limit).toBeDefined();
});
it('returns albums when called with no filters', async () => {
const result = await handler({ category: '', search: '', limit: 10 }, extra);
expect(result.structuredContent.albums).toHaveLength(1);
expect(result.structuredContent.albums[0].title).toBe('My Albums');
});
it('uses category in title when provided', async () => {
const result = await handler({ category: 'travel', search: '', limit: 5 }, extra);
expect(result.structuredContent.albums[0].title).toBe('Travel Photos');
});
});
const extra = {} as Parameters<typeof handler>[1] gives you a mock ToolHandlerExtra typed correctly without constructing the full object. If your handler uses extra.authInfo, construct just the fields you need:
const extra = { authInfo: { clientId: 'user-123', token: 'test', scopes: [] } } as Parameters<typeof handler>[1];
For more testing patterns, see Unit Testing MCP Apps and Mocking and Stubbing in MCP App Tests.
The Complete Pattern
Here is the full pattern in one place. Tool file, types file, and resource component:
src/resources/albums/types.ts
export interface Album {
id: string;
title: string;
cover: string;
photos: Array<{ id: string; title: string; url: string }>;
}
export interface AlbumsData {
albums: Album[];
}
src/tools/show-albums.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
import type { AlbumsData } from '../resources/albums/types';
export const tool: AppToolConfig = {
resource: 'albums',
title: 'Show Albums',
description: 'Show photo albums',
annotations: { readOnlyHint: true },
};
export const schema = {
category: z.string().optional().describe('Filter by category'),
search: z.string().optional().describe('Search term'),
limit: z.number().optional().describe('Max results'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra): Promise<{ structuredContent: AlbumsData }> {
const title = args.category
? `${args.category.charAt(0).toUpperCase() + args.category.slice(1)} Photos`
: args.search
? `"${args.search}" Results`
: 'My Albums';
return {
structuredContent: {
albums: [
{
id: '1',
title,
cover: 'https://cdn.example.com/cover.jpg',
photos: [
{ id: 'p1', title: 'Photo 1', url: 'https://cdn.example.com/1.jpg' },
],
},
],
},
};
}
src/resources/albums/albums.tsx
import { useToolData } from 'sunpeak';
import type { AlbumsData, Album } from './types';
interface AlbumsInput {
category?: string;
search?: string;
limit?: number;
}
export function Albums() {
const { output, input, inputPartial, isLoading, isError, isCancelled, cancelReason } =
useToolData<AlbumsInput, AlbumsData>();
if (isLoading) {
const hint = inputPartial?.category || inputPartial?.search;
return <div>{hint ? `Loading ${hint} albums…` : 'Loading albums…'}</div>;
}
if (isError) return <div>Failed to load albums</div>;
if (isCancelled) return <div>{cancelReason ?? 'Cancelled'}</div>;
const albums = output?.albums ?? [];
if (albums.length === 0) return <div>No albums found</div>;
return (
<div>
{albums.map((album: Album) => (
<div key={album.id}>
<img src={album.cover} alt={album.title} />
<h3>{album.title}</h3>
</div>
))}
</div>
);
}
TypeScript validates the entire pipeline at build time. If your handler returns a field the component does not expect, or the component accesses a field the handler does not return, you get a type error before you ship to ChatGPT or Claude.
Get Started
npx sunpeak new
Further Reading
- MCP App Tutorial - build your first resource and tool from scratch.
- MCP App Error Handling - handle all seven useToolData fields including loading, error, and cancelled.
- Unit Testing MCP Apps - Vitest patterns for tool handlers and resource components.
- Testing Multi-Tool MCP Apps - type and test apps with multiple coordinated tools.
- Tool file API reference
- useToolData hook reference
- MCP Apps protocol specification (ext-apps)
Frequently Asked Questions
How do I type the args in an MCP App tool handler?
Export a schema object with Zod fields, then derive the Args type with z.infer<z.ZodObject<typeof schema>>. Use that type annotation on the first parameter of your default handler function. sunpeak reads the schema object to generate the JSON Schema the AI host uses to call your tool.
What are the two generics on useToolData?
useToolData<InputType, OutputType> takes two type parameters. InputType types the input, inputPartial fields. OutputType types the output field (structuredContent from your tool handler). Both default to unknown if omitted.
How many fields does useToolData return in sunpeak?
useToolData returns seven fields: output (structuredContent or null), input (complete tool arguments from tool-input), inputPartial (streaming partial arguments from tool-input-partial), isLoading (true until result or cancellation), isError (from tool-result), isCancelled (from tool-cancelled), and cancelReason (optional reason string).
How do I share types between my tool file and my resource component?
Create a shared type file like src/resources/albums/types.ts that exports your output interfaces. Import from it in both your tool file (to type the structuredContent return) and your resource component (to pass as the second generic to useToolData). Your tool file stays in src/tools/ and imports from src/resources/ just for types, so there is no circular dependency.
What is inputPartial in useToolData?
inputPartial is a partial version of your tool input that arrives via the ui/notifications/tool-input-partial protocol notification while the AI model is still generating the tool call. It lets you show context-aware loading states before the full input arrives. It is typed as Partial<InputType> | null.
How do I test an MCP App tool handler with Vitest?
Import the default handler, tool config, and schema directly from the tool file. Call the handler with explicit args and a mock extra object (cast with "as Parameters<typeof handler>[1]"). Assert on result.structuredContent fields. Because the handler is a plain async function, it runs in Vitest without any framework setup.
Do Zod schemas in tool files need to use z.object()?
No. sunpeak tool schemas are plain JavaScript objects with Zod fields as values, not a z.object() call. This lets sunpeak iterate the fields to build the JSON Schema for the AI host. You only wrap it with z.ZodObject<typeof schema> in the Args type inference line.
Can I use TypeScript types for MCP Apps in both ChatGPT and Claude?
Yes. MCP Apps built on the ext-apps protocol standard work across ChatGPT, Claude, VS Code (Copilot), Goose, and other MCP-compatible hosts. The TypeScript type system (Zod schemas, useToolData generics, shared type files) is host-agnostic because it types the protocol layer, not a host-specific API. sunpeak builds your resources as self-contained HTML bundles that any host can render.