All posts

MCP App Tutorial: Build and Test Your First MCP App

Abe Wheeler
MCP Apps MCP App Framework Tutorial Getting Started ChatGPT Apps ChatGPT App Framework MCP App Testing
An MCP App running in sunpeak's multi-host simulator.

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 lives
  • src/tools/ where your server-side tool handlers live
  • tests/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:

  • resource tells the AI host the name and purpose of your UI. See the resource docs for all config options.
  • useToolData is 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 our ContactData shape.
  • SafeArea wraps your content with proper insets so it renders correctly across display modes.
  • The if (!output) return null check 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:

  • tool links this tool to the contact resource and describes it to the AI model. The resource field is the directory name of your resource (src/resources/contact/). See the tool docs for all config options.
  • schema defines 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 structuredContent matching 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:

localhost:3000

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:

  1. The user asks the AI something (“Show me Alice’s contact info”).
  2. The model decides to call a tool (show-contact). Your MCP server executes the tool handler and returns structured data.
  3. The host passes that data to your resource component via structuredContent.
  4. Your component receives it through useToolData and 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

Documentation →
pnpm add -g sunpeak && sunpeak new

Further Reading

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.