All posts

End-to-End TypeScript Types in MCP Apps: From Zod Schema to React Component

Abe Wheeler
MCP Apps MCP App Framework TypeScript Zod Tutorial ChatGPT Apps Claude Apps
End-to-end TypeScript types in an MCP App: from Zod schema to React component.

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.

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.

The Type Pipeline

Here is the full path a piece of data takes through an MCP App, with the TypeScript types involved 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.

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 the model reads to understand what to pass.

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 or let TypeScript infer it from the return value:

import type { AlbumsData } from '../resources/albums/types';

export default async function (args: Args, _extra: ToolHandlerExtra) {
  const albums = await db.getAlbums({ category: args.category, limit: args.limit ?? 10 });

  const result: { structuredContent: AlbumsData } = {
    structuredContent: { albums },
  };

  return result;
}

Annotating the return value directly 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:

import { useToolData } from 'sunpeak';
import type { AlbumsData } from './types';

// InputType matches the tool's schema shape
interface AlbumsInput {
  category?: string;
  search?: string;
  limit?: number;
}

export function Albums() {
  const { output, inputPartial, isLoading, isError, isCancelled, cancelReason } =
    useToolData<AlbumsInput, AlbumsData>();

  // output is AlbumsData | null
  // inputPartial is Partial<AlbumsInput> | null
  // isLoading, isError, isCancelled are boolean
  // cancelReason is string | null
}

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 — some fields may be present, 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, the full state-handling pattern is:

export function Albums() {
  const { output, 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 — TypeScript narrows it to non-null
  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 — no mocking needed:

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 having to construct 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];

Run tests with:

pnpm test

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/components/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, 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.

Get Started

Documentation →
pnpm add -g sunpeak && sunpeak new

Further Reading

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 is the shape of your tool input arguments — it types the input and inputPartial fields. OutputType is the shape of the structuredContent your tool handler returns — it types the output field. Both default to unknown if omitted.

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 — there is no circular dependency since the resource file does not import from the tool.

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 with no framework dependencies, it runs in Vitest without any 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.

How do I make a tool argument optional in an MCP App?

Use .optional() on any Zod field in the schema: z.string().optional(). This makes the field optional in the AI model output (the model may or may not include it), and the resulting TypeScript type will be string | undefined. Access it safely with optional chaining: args.fieldName ?? defaultValue.

Can I infer the output type from the handler return instead of writing it manually?

Yes. Define the handler first and extract the return type: type Output = Awaited<ReturnType<typeof handler>>["structuredContent"]. This keeps the type in sync with the actual return value automatically. The trade-off is that the resource component must import the handler to infer the type, which creates a dependency from src/resources/ to src/tools/. Most projects prefer an explicit shared type file instead.