MCP App Tutorial: Build and Test Your First MCP App
An MCP App running in sunpeak's multi-host simulator.
TL;DR: Install sunpeak, scaffold a project, build a React component, write a tool, add mock data, and run the multi-host simulator. Working MCP App on localhost in 5 minutes, no paid accounts needed.
An MCP App is a web application that renders inside AI hosts like ChatGPT and Claude. When the AI model calls a tool, instead of returning plain text, your app renders the result as interactive UI: cards, charts, forms, maps, whatever you build with React.
The MCP App standard defines how this works across hosts. You write your app once, and it runs in ChatGPT, Claude, Goose, and VS Code from the same codebase. In this tutorial, you will build a contact card app from scratch and test it across both hosts locally.
Prerequisites
You need Node.js 20 or later and pnpm (npm or yarn work too, but this tutorial uses pnpm). You do not need a ChatGPT or Claude account. sunpeak’s local simulator handles everything during development.
Step 1: Create a New Project
Install sunpeak and scaffold a project:
pnpm add -g sunpeak
sunpeak new
The CLI asks you to name your project and pick which starter resources to include. For this tutorial, the selection doesn’t matter because we are building a new resource from scratch. sunpeak creates the project directory, installs dependencies, and sets up TypeScript, Tailwind CSS, Vitest, and Playwright.
cd into your new project directory. You should see:
src/resources/where your app UI livessrc/tools/where your server-side tool handlers livetests/simulations/where mock data lives
Step 2: Write Your First Resource
A Resource is a React component that renders tool data from the AI host. Each resource has two parts: a config object that describes it and a component that renders it.
sunpeak auto-discovers resources by directory convention. Any file at src/resources/{name}/{name}.tsx becomes a resource. Create the file src/resources/contact/contact.tsx:
import { useToolData, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
title: 'Contact',
description: 'Display a contact card',
};
interface ContactData {
name: string;
role: string;
company: string;
email: string;
phone: string;
location: string;
}
export function ContactResource() {
const { output } = useToolData<unknown, ContactData>(undefined, undefined);
if (!output) return null;
return (
<SafeArea className="p-6 font-sans max-w-sm mx-auto">
<div className="text-center mb-4">
<div className="w-16 h-16 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-2xl font-bold mx-auto mb-3">
{output.name.charAt(0)}
</div>
<h1 className="text-xl font-bold">{output.name}</h1>
<p className="text-sm text-gray-500">{output.role} at {output.company}</p>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3 px-3 py-2 bg-gray-50 rounded-lg">
<span className="text-gray-400">@</span>
<span>{output.email}</span>
</div>
<div className="flex items-center gap-3 px-3 py-2 bg-gray-50 rounded-lg">
<span className="text-gray-400">#</span>
<span>{output.phone}</span>
</div>
<div className="flex items-center gap-3 px-3 py-2 bg-gray-50 rounded-lg">
<span className="text-gray-400">~</span>
<span>{output.location}</span>
</div>
</div>
</SafeArea>
);
}
Here’s what each piece does:
resourcetells the AI host the name and purpose of your UI. See the resource docs for all config options.useToolDatais a React hook that gives your component the data the AI model sent. The generic types<unknown, ContactData>mean we don’t care about the tool input, but we expect the output to match ourContactDatashape.SafeAreawraps your content with proper insets so it renders correctly across display modes.- The
if (!output) return nullcheck handles the brief moment before data arrives.
The rest is standard React with Tailwind classes. Nothing framework-specific about the UI itself.
Step 3: Write the Tool
A resource renders the UI. A Tool triggers it. When the AI model decides to call your tool, the tool handler runs on your server, returns structured data, and the host renders your resource with that data.
sunpeak auto-discovers tools from src/tools/*.ts. Create src/tools/show-contact.ts:
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'contact',
title: 'Show Contact',
description: 'Look up a contact and display their card',
annotations: { readOnlyHint: true },
};
export const schema = {
name: z.string().describe('Contact name to look up'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
// In production, query your contacts API or database using args.name
return {
structuredContent: {
name: 'Alice Zhang',
role: 'Engineering Lead',
company: 'Acme Corp',
email: 'alice@acme.dev',
phone: '+1 (555) 234-5678',
location: 'San Francisco, CA',
},
};
}
Three exports, each with a specific job:
toollinks this tool to thecontactresource and describes it to the AI model. Theresourcefield is the directory name of your resource (src/resources/contact/). See the tool docs for all config options.schemadefines the tool’s input parameters using Zod. The AI model reads the field descriptions to decide what arguments to pass.- The default export is the handler that runs when the model calls the tool. It returns
structuredContentmatching the shape your resource component expects.
Step 4: Add Test Data
The simulator needs mock data to render your resource. A simulation file defines what the user said, what tool the model called, and what data came back.
Create tests/simulations/show-contact.json:
{
"tool": "show-contact",
"userMessage": "Show me Alice's contact info",
"toolInput": {
"name": "Alice Zhang"
},
"toolResult": {
"structuredContent": {
"name": "Alice Zhang",
"role": "Engineering Lead",
"company": "Acme Corp",
"email": "alice@acme.dev",
"phone": "+1 (555) 234-5678",
"location": "San Francisco, CA"
}
}
}
The tool field is the filename of your tool without the .ts extension. The toolResult.structuredContent object is exactly what useToolData returns as output in your component.
You can create multiple simulation files for the same tool to test different scenarios. A show-contact-bob.json with different contact data would let you switch between test cases in the simulator dropdown.
Step 5: Run the Simulator
Start the development server:
sunpeak dev
Open http://localhost:3000. You should see the simulator with your contact card rendered inside a chat conversation. The user message “Show me Alice’s contact info” appears in the chat, and your ContactResource component renders the structured card below it, like this:
Test across hosts
The simulator ships with both ChatGPT and Claude hosts built in. Use the Host dropdown in the simulator sidebar to switch between them. Your resource renders identically on both because it’s built against the MCP App standard, not a host-specific API.
Try these other controls while you’re in the simulator:
- Display Mode: toggle between inline (inside the chat), picture-in-picture, and fullscreen. The display mode reference covers each mode.
- Theme: switch between light and dark.
- Device: test mobile and desktop viewports.
Changes to your component hot reload instantly.
How It Works
The data flow in an MCP App is the same regardless of which host runs it:
- The user asks the AI something (“Show me Alice’s contact info”).
- The model decides to call a tool (
show-contact). Your MCP server executes the tool handler and returns structured data. - The host passes that data to your resource component via
structuredContent. - Your component receives it through
useToolDataand renders it as UI.
Your resource is pure UI. It doesn’t call APIs or run server logic. The AI model handles the intelligence, your tool handler provides the data, and your resource handles the presentation. This separation is why MCP Apps are portable: the same component works in ChatGPT, Claude, and any other host that implements the MCP App standard.
During development, the simulation file stands in for your backend and the AI host. In production, real tool calls replace the mock data, and your component renders identically because the data shape is the same.
Add Automated Tests
sunpeak projects come with Vitest and Playwright preconfigured.
Unit tests
Test your resource component with @testing-library/react. Create src/resources/contact/contact.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ContactResource } from './contact';
vi.mock('sunpeak', () => ({
useToolData: () => ({
output: {
name: 'Alice Zhang',
role: 'Engineering Lead',
company: 'Acme Corp',
email: 'alice@acme.dev',
phone: '+1 (555) 234-5678',
location: 'San Francisco, CA',
},
input: null,
inputPartial: null,
isError: false,
isLoading: false,
isCancelled: false,
cancelReason: null,
}),
useHostContext: () => null,
useDisplayMode: () => 'inline',
useApp: () => null,
SafeArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
}));
describe('ContactResource', () => {
it('renders the contact name and details', () => {
render(<ContactResource />);
expect(screen.getByText('Alice Zhang')).toBeInTheDocument();
expect(screen.getByText('alice@acme.dev')).toBeInTheDocument();
expect(screen.getByText('Engineering Lead at Acme Corp')).toBeInTheDocument();
});
});
Run it:
pnpm test
End-to-end tests
Test your resource across both hosts with Playwright. Create tests/e2e/contact.spec.ts:
import { test, expect } from '@playwright/test';
import { createSimulatorUrl } from 'sunpeak/chatgpt';
const hosts = ['chatgpt', 'claude'] as const;
for (const host of hosts) {
test.describe(`Contact Resource [${host}]`, () => {
test('should render contact card', async ({ page }) => {
await page.goto(
createSimulatorUrl({ simulation: 'show-contact', host })
);
const iframe = page.frameLocator('iframe');
await expect(iframe.locator('text=Alice Zhang')).toBeVisible();
await expect(iframe.locator('text=alice@acme.dev')).toBeVisible();
});
});
}
Run it:
pnpm test:e2e
This runs your contact card in both the ChatGPT and Claude simulator shells and validates that it renders correctly in each. See the complete guide to testing MCP Apps for more patterns, including full matrix testing across hosts, themes, and display modes.
Build and Deploy
When you’re ready to ship:
sunpeak build
sunpeak start
sunpeak build compiles each resource into a self-contained HTML bundle and each tool into a Node.js module. sunpeak start launches a production MCP server that exposes your tools and resources at an MCP endpoint.
Connect any MCP-compatible host to your server’s /mcp endpoint and your app is live. See the deployment guide for hosting options and connecting to ChatGPT and Claude.
Get Started
pnpm add -g sunpeak && sunpeak new
Further Reading
- MCP App architecture - covers cross-host design decisions and when to add host-specific code.
- Testing guide - shows how to write unit tests and e2e tests with full cross-host coverage.
- How to debug ChatGPT Apps - walks through common errors and how to fix them.
- MCP Overview
- Tools
- Resources
- MCP Apps introduction - has the protocol architecture and lifecycle docs.
- Hook reference - covers all available hooks: useToolData, useHostContext, useDisplayMode, useAppState, and more.
- Tool reference - documents the full tool file API including schema, annotations, and handler extras.
Frequently Asked Questions
What is the fastest way to create an MCP App?
Install sunpeak globally with pnpm add -g sunpeak, then run sunpeak new to scaffold a project. Run sunpeak dev to launch the local simulator with ChatGPT and Claude hosts built in. You can have a working MCP App running in a few minutes without any paid accounts.
Do I need a ChatGPT or Claude account to build an MCP App?
No. sunpeak includes a local simulator that replicates the ChatGPT and Claude App runtimes. You can build, test, and iterate entirely on localhost without any AI host accounts. You only need accounts when you are ready to deploy your finished app.
What is the difference between an MCP App resource and a tool?
A resource is iframed HTML (usually built from React) that renders UI inside the AI host. A tool is a server-side function that the AI model calls to trigger that UI. The tool handler returns structured data, and the resource component receives it via the useToolData hook and renders it. One resource can be triggered by multiple tools.
How do I test an MCP App across ChatGPT and Claude locally?
sunpeak's simulator ships with both ChatGPT and Claude hosts built in. Use the host dropdown in the simulator sidebar to switch between them, or use createSimulatorUrl with a host parameter in Playwright e2e tests to automate cross-host testing. Your resource renders identically on both hosts because they implement the same MCP App standard.
What is a simulation file in sunpeak?
A simulation file is a JSON file in tests/simulations/ that contains mock tool data for local development. It defines the real tool name to mock, user message, tool input arguments, and tool result with structured content. The simulator loads these files so you can develop and test your UI without connecting to a real AI host.
Can I use an MCP App in ChatGPT, Claude, and other hosts from the same codebase?
Yes. MCP Apps are built on the MCP App standard, an open protocol that ChatGPT, Claude, Goose, and VS Code all implement. Code written against the standard APIs (useToolData, useHostContext, useDisplayMode) runs on every host without changes. sunpeak builds your resources as self-contained HTML bundles that any MCP-compatible host can render.
How do I deploy an MCP App to production?
Run sunpeak build to compile your resources and tools, then sunpeak start to launch a production MCP server. The server exposes an MCP endpoint that ChatGPT, Claude, or any MCP-compatible host can connect to. See the sunpeak deployment guide for hosting options.
What languages and frameworks does sunpeak support for MCP Apps?
sunpeak uses React for resource components (the UI that renders inside AI hosts) and TypeScript for tool handlers (the server-side logic). Projects include Tailwind CSS by default. Tool handlers run on Node.js and can call any API or database you need.