* docs: add tool visibility and restructure _meta.ui - Restructure tool metadata: `_meta["ui/resourceUri"]` → `_meta.ui.resourceUri` - Add `visibility` array field: ["model"], ["apps"], or ["model", "apps"] - Default ["model"] preserves standard MCP behavior - ["apps"] enables widget-only tools hidden from agent - Add McpUiToolMeta interface for type safety - Add Design Decision #4 explaining approach vs OpenAI's two-field model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * address PR review comments - Change default visibility to ["model", "apps"] - Add deprecation notice for flat ui/resourceUri format - Add McpUiToolMeta and McpUiToolVisibility to spec.types.ts - Improve tools/list and tools/call behavior wording 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: regenerate schemas for McpUiToolMeta types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: tool visibility and nested _meta.ui format - Rename visibility "apps" → "app" in McpUiToolVisibility type - Add _meta.ui.resourceUri nested format (deprecate flat format) - Add getToolUiResourceUri() utility with backward compatibility - Add visibility demo to system-monitor-server: - get-system-stats: visibility ["model"] with resourceUri - refresh-stats: visibility ["app"] (app-only polling) - Update all example servers to use new _meta.ui format - Add 11 unit tests for getToolUiResourceUri() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: regenerate schemas with updated dependencies * feat: export McpUiToolMeta type and add type annotations - Export McpUiToolMeta and McpUiToolVisibility types from types.ts - Export corresponding Zod schemas (McpUiToolMetaSchema, McpUiToolVisibilitySchema) - Add `as McpUiToolMeta` type annotations to all example servers - Update docs/quickstart.md with proper typing Ensures type safety for `_meta.ui` tool metadata across the codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add server helpers and optional connect() transport Add `src/server/` with convenience functions for registering MCP App tools and resources: - `registerAppTool(server, name, config, handler)` - `registerAppResource(server, name, uri, config, callback)` The `transport` parameter in `App.connect()` is now optional, defaulting to `PostMessageTransport(window.parent)`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update src/server/index.ts Co-authored-by: Jonathan Hefner <jonathan@hefner.pro> * feat: update McpUiAppToolConfig to support both _meta.ui and flat formats * refactor: simplify server helper imports * docs: add deprecation notice and improve resourceUri documentation * feat: add backward compat normalization in registerAppTool - If _meta.ui.resourceUri is set, also set legacy flat key - If legacy flat key is set, also set _meta.ui.resourceUri - Preserves existing visibility when merging - Does not overwrite if both formats already set * refactor: avoid mutating config arg in registerAppTool * style: format with prettier --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Olivier Chafik <ochafik@anthropic.com> Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
7.7 KiB
| title |
|---|
| Quickstart |
Build Your First MCP App
This tutorial walks you through building an MCP App—a tool with an interactive UI that renders inside MCP hosts like Claude Desktop.
What You'll Build
A simple app that fetches the current server time and displays it in a clickable UI. You'll learn the core pattern: MCP Apps = Tool + UI Resource.
Note
The complete example is available at
examples/basic-server-vanillajs.
Prerequisites
- Node.js 18+
- Familiarity with the MCP TypeScript SDK
1. Project Setup
Create a new directory and initialize:
mkdir my-mcp-app && cd my-mcp-app
npm init -y
Install dependencies:
npm install github:modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["*.ts", "src/**/*.ts"]
}
Create vite.config.ts — this bundles your UI into a single HTML file:
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: process.env.INPUT,
},
},
});
Add to your package.json:
{
"type": "module",
"scripts": {
"build": "INPUT=mcp-app.html vite build",
"serve": "npx tsx server.ts"
}
}
Note
Full files:
package.json,tsconfig.json,vite.config.ts
2. Create the Server
MCP Apps use a two-part registration:
- A tool that the LLM/host calls
- A resource that serves the UI HTML
The tool's _meta field links them together.
Create server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
RESOURCE_MIME_TYPE,
type McpUiToolMeta,
} from "@modelcontextprotocol/ext-apps";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
import * as z from "zod";
const server = new McpServer({
name: "My MCP App Server",
version: "1.0.0",
});
// Two-part registration: tool + resource
const resourceUri = "ui://get-time/mcp-app.html";
server.registerTool(
"get-time",
{
title: "Get Time",
description: "Returns the current server time.",
inputSchema: {},
outputSchema: { time: z.string() },
_meta: { ui: { resourceUri } as McpUiToolMeta }, // Links tool to UI
},
async () => {
const time = new Date().toISOString();
return {
content: [{ type: "text", text: time }],
structuredContent: { time },
};
},
);
server.registerResource(
resourceUri,
resourceUri,
{ mimeType: "text/html;profile=mcp-app" },
async () => {
const html = await fs.readFile(
path.join(import.meta.dirname, "dist", "mcp-app.html"),
"utf-8",
);
return {
contents: [
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
],
};
},
);
// Express server for MCP endpoint
const app = express();
app.use(cors());
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3001, (err) => {
if (err) {
console.error("Error starting server:", err);
process.exit(1);
}
console.log("Server listening on http://localhost:3001/mcp");
});
Note
Full file:
server.ts
Then, verify your server compiles:
npx tsc --noEmit server.ts
No output means success. If you see errors, check for typos in server.ts.
3. Build the UI
Create mcp-app.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Get Time App</title>
</head>
<body>
<p>
<strong>Server Time:</strong> <code id="server-time">Loading...</code>
</p>
<button id="get-time-btn">Get Server Time</button>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
Create src/mcp-app.ts:
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
// Get element references
const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;
// Create app instance
const app = new App({ name: "Get Time App", version: "1.0.0" });
// Register handlers BEFORE connecting
app.ontoolresult = (result) => {
const { time } = (result.structuredContent as { time?: string }) ?? {};
serverTimeEl.textContent = time ?? "[ERROR]";
};
// Wire up button click
getTimeBtn.addEventListener("click", async () => {
const result = await app.callServerTool({ name: "get-time", arguments: {} });
const { time } = (result.structuredContent as { time?: string }) ?? {};
serverTimeEl.textContent = time ?? "[ERROR]";
});
// Connect to host
app.connect(new PostMessageTransport(window.parent));
Build the UI:
npm run build
Note
Full files:
mcp-app.html,src/mcp-app.ts
This produces dist/mcp-app.html which contains your bundled app:
$ ls dist/mcp-app.html
dist/mcp-app.html
4. Test It
You'll need two terminals.
Terminal 1 — Build and start your server:
npm run build && npm run serve
Terminal 2 — Run the test host (from the ext-apps repo):
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host
npm install
npm run start
Open http://localhost:8080 in your browser:
- Select get-time from the "Tool Name" dropdown
- Click Call Tool
- Your UI renders in the sandbox below
- Click Get Server Time — the current time appears!
Next Steps
- Host communication: Add
sendMessage(),sendLog(), andsendOpenLink()to interact with the host — seesrc/mcp-app.ts - React version: Compare with
basic-server-reactfor a React-based UI - API reference: See the full API documentation