-This repo contains the SDK and [specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) for MCP Apps Extension ([SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)).
+
+
+ Excalidraw built with MCP Apps, running in Claude
+
-MCP Apps are a proposed standard inspired by [MCP-UI](https://mcpui.dev/) and [OpenAI's Apps SDK](https://developers.openai.com/apps-sdk/) to allow MCP Servers to display interactive UI elements in conversational MCP clients / chatbots.
+## Table of Contents
+
+- [Build with Agent Skills](#build-with-agent-skills)
+- [Supported Clients](#supported-clients)
+- [Why MCP Apps?](#why-mcp-apps)
+- [How It Works](#how-it-works)
+- [Getting Started](#getting-started)
+- [Using the SDK](#using-the-sdk)
+- [Examples](#examples)
+- [Specification](#specification)
+- [Resources](#resources)
+- [Contributing](#contributing)
+
+## Build with Agent Skills
+
+The fastest way to build an MCP App is to let your AI coding agent do it. This
+repo ships four [Agent Skills](https://agentskills.io/) โ install them once,
+then just ask:
+
+| Skill | What it does | Try it |
+| --------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------- |
+| [`create-mcp-app`](./plugins/mcp-apps/skills/create-mcp-app/SKILL.md) | Scaffolds a new MCP App with an interactive UI from scratch | _"Create an MCP App"_ |
+| [`migrate-oai-app`](./plugins/mcp-apps/skills/migrate-oai-app/SKILL.md) | Converts an existing OpenAI App to use MCP Apps | _"Migrate from OpenAI Apps SDK"_ |
+| [`add-app-to-server`](./plugins/mcp-apps/skills/add-app-to-server/SKILL.md) | Adds interactive UI to an existing MCP server's tools | _"Add UI to my MCP server"_ |
+| [`convert-web-app`](./plugins/mcp-apps/skills/convert-web-app/SKILL.md) | Turns an existing web app into a hybrid web + MCP App | _"Add MCP App support to my web app"_ |
+
+### Install the Skills
+
+**Claude Code** โ install via the plugin marketplace:
+
+```
+/plugin marketplace add modelcontextprotocol/ext-apps
+/plugin install mcp-apps@modelcontextprotocol-ext-apps
+```
+
+**Other agents** โ any AI coding agent that supports
+[Agent Skills](https://agentskills.io/) can use these skills. See the
+[agent skills guide](./docs/agent-skills.md) for manual installation
+instructions.
+
+Once installed, verify by asking your agent _"What skills do you have?"_ โ you
+should see `create-mcp-app`, `migrate-oai-app`, `add-app-to-server`, and
+`convert-web-app` in the list. Then just ask it to create or migrate an app and
+it will guide you through the rest.
+
+## Supported Clients
+
+
+
+
+
+
+
+
+
+
+
+
+> [!NOTE]
+> MCP Apps is an extension to the
+> [core MCP specification](https://modelcontextprotocol.io/specification). Host
+> support varies โ see the
+> [clients page](https://modelcontextprotocol.io/clients) for the full list.
+
+## Why MCP Apps?
+
+MCP tools return text and structured data. That works for many cases, but not
+when you need an interactive UI, like a chart, form, design canvas or video player.
+
+MCP Apps provide a standardized way to deliver interactive UIs from MCP servers.
+Your UI renders inline in the conversation, in context, in any compliant host.
## How It Works
-MCP Apps extend the Model Context Protocol to let servers deliver **interactive UIs** to MCP hosts. Here's how it works:
+MCP Apps extend the Model Context Protocol by letting tools declare UI
+resources:
-1. **Tool call** โ The LLM calls a tool on your server
-2. **UI Resource** โ The tool's definition links to a predeclared `ui://` resource containing its HTML interface
-3. **Host renders** โ The host fetches the resource and displays it in a sandboxed iframe
-4. **Bidirectional communication** โ The host passes tool data to the UI via notifications, and the UI can call other tools through the host
+1. **Tool definition** โ Your tool declares a `ui://` resource containing its
+ HTML interface
+2. **Tool call** โ The LLM calls the tool on your server
+3. **Host renders** โ The host fetches the resource and displays it in a
+ sandboxed iframe
+4. **Bidirectional communication** โ The host passes tool data to the UI via
+ notifications, and the UI can call other tools through the host
-This enables dashboards, forms, visualizations, and other rich experiences inside chat interfaces.
+## Getting Started
-## Overview
+Requires Node.js 20+. The base MCP SDK packages are `^2.0.0` peers of
+`ext-apps` (`@modelcontextprotocol/core` is a required peer that `client` already depends on, so npm installs it for you).
-This SDK serves two audiences:
-
-### App Developers
-
-Build interactive UIs that run inside MCP-enabled chat clients.
-
-- **SDK for Apps**: `@modelcontextprotocol/ext-apps` โ [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html)
-- **React hooks**: `@modelcontextprotocol/ext-apps/react` โ [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html)
-
-### Host Developers
-
-Embed and communicate with MCP Apps in your chat application.
-
-- **SDK for Hosts**: `@modelcontextprotocol/ext-apps/app-bridge` โ [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html)
-
-There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example).
-
-We have [contributed a tentative implementation](https://github.com/MCP-UI-Org/mcp-ui/pull/147) of hosting / iframing / sandboxing logic to the [MCP-UI](https://github.com/idosal/mcp-ui) repository, and expect OSS clients may use it, while other clients might roll their own hosting logic.
-
-## Installation
+For a View or host:
```bash
-npm install -S @modelcontextprotocol/ext-apps
+npm install -S @modelcontextprotocol/ext-apps \
+ @modelcontextprotocol/client@^2.0.0 \
+ zod@^4.2.0
```
-Or edit your `package.json` manually:
+For an MCP server, add the server package (and, for HTTP transports, the Node
+and Express adapters):
-```json
-{
- "dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.0.1"
- }
-}
+```bash
+npm install -S @modelcontextprotocol/ext-apps \
+ @modelcontextprotocol/client@^2.0.0 \
+ @modelcontextprotocol/server@^2.0.0 \
+ @modelcontextprotocol/node@^2.0.0 \
+ @modelcontextprotocol/express@^2.0.0 \
+ zod@^4.2.0
```
+The wire protocol is unchanged between `ext-apps` 1.x and 2.x: a 2.x View works
+in a 1.x host and a 2.x host renders 1.x Views. See the
+[migration guide](https://apps.extensions.modelcontextprotocol.io/api/documents/migrate-to-v2.html)
+when upgrading from 1.x.
+
+**New here?** Start with the
+[Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html)
+to build your first MCP App.
+
+## Using the SDK
+
+The SDK serves three roles: app developers building interactive Views, host
+developers embedding those Views, and MCP server authors registering tools with
+UI metadata.
+
+| Package | Purpose | Docs |
+| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs โ](https://apps.extensions.modelcontextprotocol.io/api/modules/app.html) |
+| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs โ](https://apps.extensions.modelcontextprotocol.io/api/modules/_modelcontextprotocol_ext-apps_react.html) |
+| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs โ](https://apps.extensions.modelcontextprotocol.io/api/modules/app-bridge.html) |
+| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs โ](https://apps.extensions.modelcontextprotocol.io/api/modules/server-helpers.html) |
+
+There's no _supported_ host implementation in this repo (beyond the
+[examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host)
+example).
+
+The [MCP-UI](https://github.com/idosal/mcp-ui) client SDK offers a
+fully-featured MCP Apps framework used by a few hosts. Clients may choose to use
+it or roll their own implementation.
+
## Examples
+The
+[`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples)
+directory contains demo apps showcasing real-world use cases.
+
| | | |
|:---:|:---:|:---:|
-| [](examples/map-server) | [](examples/threejs-server) | [](examples/shadertoy-server) |
-| [**Map**](examples/map-server) | [**Three.js**](examples/threejs-server) | [**ShaderToy**](examples/shadertoy-server) |
-| [](examples/sheet-music-server) | [](examples/wiki-explorer-server) | [](examples/cohort-heatmap-server) |
-| [**Sheet Music**](examples/sheet-music-server) | [**Wiki Explorer**](examples/wiki-explorer-server) | [**Cohort Heatmap**](examples/cohort-heatmap-server) |
-| [](examples/scenario-modeler-server) | [](examples/budget-allocator-server) | [](examples/customer-segmentation-server) |
-| [**Scenario Modeler**](examples/scenario-modeler-server) | [**Budget Allocator**](examples/budget-allocator-server) | [**Customer Segmentation**](examples/customer-segmentation-server) |
-| [](examples/system-monitor-server) | [](examples/transcript-server) | [](examples/video-resource-server) |
-| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) |
-| [](examples/pdf-server) | [](examples/qr-server) | |
-| [**PDF Server**](examples/pdf-server) | [**QR Code (Python)**](examples/qr-server) | |
+| [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) |
+| [**Map**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) | [**Three.js**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server) | [**ShaderToy**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) |
+| [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/wiki-explorer-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/cohort-heatmap-server) |
+| [**Sheet Music**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) | [**Wiki Explorer**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/wiki-explorer-server) | [**Cohort Heatmap**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/cohort-heatmap-server) |
+| [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/scenario-modeler-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/budget-allocator-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/customer-segmentation-server) |
+| [**Scenario Modeler**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/scenario-modeler-server) | [**Budget Allocator**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/budget-allocator-server) | [**Customer Segmentation**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/customer-segmentation-server) |
+| [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/video-resource-server) |
+| [**System Monitor**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) | [**Transcript**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) | [**Video Resource**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/video-resource-server) |
+| [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server) | [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/say-server) |
+| [**PDF Server**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) | [**QR Code (Python)**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server) | [**Say Demo**](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/say-server) |
### Starter Templates
| | |
|:---:|:---|
-| [](examples/basic-server-react) | The same app built with different frameworks โ pick your favorite!
[React](examples/basic-server-react) ยท [Vue](examples/basic-server-vue) ยท [Svelte](examples/basic-server-svelte) ยท [Preact](examples/basic-server-preact) ยท [Solid](examples/basic-server-solid) ยท [Vanilla JS](examples/basic-server-vanillajs) |
+| [](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) | The same app built with different frameworks โ pick your favorite!
[React](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) ยท [Vue](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue) ยท [Svelte](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte) ยท [Preact](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact) ยท [Solid](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) ยท [Vanilla JS](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) |
-The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) directory contains additional demo apps showcasing real-world use cases.
+### Running the Examples
-To run all examples:
+#### With basic-host
+
+To run all examples locally using
+[basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host)
+(the reference host implementation included in this repo):
```bash
+git clone https://github.com/modelcontextprotocol/ext-apps.git
+cd ext-apps
npm install
npm start
```
Then open http://localhost:8080/.
+#### With MCP Clients
+
+Every Node.js example is published as `@modelcontextprotocol/server-`. To
+add one to an MCP client that supports stdio (Claude Desktop, VS Code, etc.),
+use this pattern:
+
+```json
+{
+ "mcpServers": {
+ "": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-", "--stdio"]
+ }
+ }
+}
+```
+
+For example, to add the map server: `@modelcontextprotocol/server-map`. The
+Python examples (`qr-server`, `say-server`) use `uv run` instead โ see their
+READMEs for details.
+
+#### Local Development
+
+To test local modifications with an MCP client, clone the repo, install, then
+point your client at a local build:
+
+```json
+{
+ "mcpServers": {
+ "": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/-server && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
+## Specification
+
+
+
+| Version | Status | Link |
+| -------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| **2026-01-26** | Stable | [specification/2026-01-26/apps.mdx](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) |
+| **draft** | Development | [specification/draft/apps.mdx](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) |
+
+
+
## Resources
-- [Quickstart Guide](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html)
-- [API Documentation](https://modelcontextprotocol.github.io/ext-apps/api/)
-- [Draft Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx)
+- [Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html)
+- [API Documentation](https://apps.extensions.modelcontextprotocol.io/api/)
+- [Specification (2026-01-26)](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)
+ ([Draft](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx))
- [SEP-1865 Discussion](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)
+
+## Contributing
+
+Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for
+guidelines on how to get started, submit pull requests, and report issues.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..5029242
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,21 @@
+# Security Policy
+
+Thank you for helping keep the Model Context Protocol and its ecosystem secure.
+
+## Reporting Security Issues
+
+If you discover a security vulnerability in this repository, please report it through
+the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
+for this repository.
+
+Please **do not** report security vulnerabilities through public GitHub issues, discussions,
+or pull requests.
+
+## What to Include
+
+To help us triage and respond quickly, please include:
+
+- A description of the vulnerability
+- Steps to reproduce the issue
+- The potential impact
+- Any suggested fixes (optional)
diff --git a/build.bun.ts b/build.bun.ts
index 0e53418..61361e6 100644
--- a/build.bun.ts
+++ b/build.bun.ts
@@ -1,9 +1,18 @@
#!/usr/bin/env bun
import { $ } from "bun";
+import { cpSync, mkdirSync, rmSync } from "node:fs";
+
+// Avoid publishing artifacts left behind by an earlier build or branch.
+rmSync("dist", { recursive: true, force: true });
// Run TypeScript compiler for type declarations
await $`tsc`;
+// Copy schema.json (tsc is emitDeclarationOnly, Bun.build doesn't emit JSON assets).
+// Needed for the "./schema.json" package export.
+mkdirSync("dist/src/generated", { recursive: true });
+cpSync("src/generated/schema.json", "dist/src/generated/schema.json");
+
const isDevelopment = Bun.env.NODE_ENV === "development";
// Build all JavaScript/TypeScript files
@@ -25,10 +34,20 @@ function buildJs(
});
}
+// Peer dependencies stay external in the standard entry points so consumers
+// share one base MCP SDK and Zod instance. The *-with-deps entry points keep
+// bundling these dependencies for standalone browser use.
+const PEER_EXTERNALS = [
+ "@modelcontextprotocol/client",
+ "@modelcontextprotocol/core",
+ "@modelcontextprotocol/server",
+ "zod",
+];
+
await Promise.all([
buildJs("src/app.ts", {
outdir: "dist/src",
- external: ["@modelcontextprotocol/sdk"],
+ external: PEER_EXTERNALS,
}),
buildJs("src/app.ts", {
outdir: "dist/src",
@@ -36,19 +55,19 @@ await Promise.all([
}),
buildJs("src/app-bridge.ts", {
outdir: "dist/src",
- external: ["@modelcontextprotocol/sdk"],
+ external: PEER_EXTERNALS,
}),
buildJs("src/react/index.tsx", {
outdir: "dist/src/react",
- external: ["react", "react-dom", "@modelcontextprotocol/sdk"],
+ external: ["react", "react-dom", ...PEER_EXTERNALS],
}),
buildJs("src/react/index.tsx", {
outdir: "dist/src/react",
- external: ["react", "react-dom", "@modelcontextprotocol/sdk"],
+ external: ["react", "react-dom"],
naming: { entry: "react-with-deps.js" },
}),
buildJs("src/server/index.ts", {
outdir: "dist/src/server",
- external: ["@modelcontextprotocol/sdk"],
+ external: PEER_EXTERNALS,
}),
]);
diff --git a/docs/agent-skills.md b/docs/agent-skills.md
new file mode 100644
index 0000000..d27ad71
--- /dev/null
+++ b/docs/agent-skills.md
@@ -0,0 +1,73 @@
+---
+title: Agent Skills
+group: Getting Started
+description: Use Agent Skills to build, migrate, and extend MCP Apps with AI coding agents. Install skills that scaffold new apps, convert OpenAI Apps, and add UI to existing servers.
+---
+
+# Agent Skills
+
+[Agent Skills](https://agentskills.io/) are instruction sets that guide AI coding agents through tasks. When you invoke a skill, the agent takes the lead โ it asks clarifying questions, makes decisions based on your codebase, and executes the work.
+
+This repository provides four skills:
+
+- [**create-mcp-app**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/create-mcp-app/SKILL.md) โ scaffolds a new MCP App with an interactive UI
+- [**migrate-oai-app**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/migrate-oai-app/SKILL.md) โ migrates an existing OpenAI App to the MCP Apps SDK
+- [**add-app-to-server**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/add-app-to-server/SKILL.md) โ adds interactive UI to an existing MCP server's tools
+- [**convert-web-app**](https://github.com/modelcontextprotocol/ext-apps/blob/main/plugins/mcp-apps/skills/convert-web-app/SKILL.md) โ converts an existing web application into an MCP App
+
+## Install the Skills
+
+Choose one of the following installation methods based on your agent:
+
+### Option 1: Claude Code Plugin
+
+Install via Claude Code:
+
+```
+/plugin marketplace add modelcontextprotocol/ext-apps
+/plugin install mcp-apps@modelcontextprotocol-ext-apps
+```
+
+### Option 2: Vercel Skills CLI
+
+Use the [Vercel Skills CLI](https://skills.sh/) to install skills across different AI coding agents:
+
+```bash
+npx skills add modelcontextprotocol/ext-apps
+```
+
+### Option 3: Manual Installation
+
+Clone the repository:
+
+```bash
+git clone https://github.com/modelcontextprotocol/ext-apps.git
+```
+
+Then copy the skills from `plugins/mcp-apps/skills/` to your agent's skills directory. See your agent's documentation for the correct location:
+
+- [Claude Code](https://docs.anthropic.com/en/docs/claude-code/skills)
+- [VS Code](https://code.visualstudio.com/docs/copilot/customization/agent-skills) / [GitHub Copilot](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills)
+- [Codex](https://developers.openai.com/codex/skills/)
+- [Gemini CLI](https://geminicli.com/docs/cli/skills/)
+- [Cline](https://docs.cline.bot/features/skills#skills)
+- [Goose](https://block.github.io/goose/docs/guides/context-engineering/using-skills/)
+
+## Verify Installation
+
+Ask your agent "What skills do you have?" โ you should see `create-mcp-app`, `migrate-oai-app`, `add-app-to-server`, and `convert-web-app` among the available skills.
+
+## Invoke a Skill
+
+Try invoking the skills by asking your agent:
+
+- "Create an MCP App" โ scaffolds a new MCP App with an interactive UI
+- "Migrate from OpenAI Apps SDK" โ converts an existing OpenAI App to use the MCP Apps SDK
+- "Add UI to my MCP server" โ adds interactive UI to an existing MCP server's tools
+- "Convert my web app to an MCP App" โ converts an existing web application into an MCP App
+
+The agent will guide you through the process, asking clarifying questions as needed.
+
+## Test Your App
+
+After creating or migrating your MCP App, see the [Testing MCP Apps](./testing-mcp-apps.md) guide to run and debug it locally.
diff --git a/docs/authorization.md b/docs/authorization.md
new file mode 100644
index 0000000..df7adc6
--- /dev/null
+++ b/docs/authorization.md
@@ -0,0 +1,211 @@
+---
+title: Authorization
+group: Security
+description: Learn how to protect MCP App tools with OAuth authorization, including per-server and per-tool auth patterns, token verification, and UI-initiated auth escalation.
+---
+
+# Authorization
+
+MCP Apps can protect tools behind OAuth-based authorization, as defined in the [MCP specification](https://modelcontextprotocol.io/specification/latest/basic/authorization). There are two approaches:
+
+- **Per-server authorization** โ The entire MCP server requires authorization at connection time. Every request must include a valid token, regardless of which tool is being called. This is the simpler model when all tools are sensitive.
+- **Per-tool authorization** โ Only specific tools require authorization. Public tools work without a token, and the OAuth flow is triggered only when the user calls a protected tool. This lets you mix public and protected tools in the same server.
+
+## Shared setup
+
+Regardless of which approach you choose, you need OAuth discovery metadata and token verification. These are the same for both.
+
+### OAuth discovery metadata
+
+The MCP specification requires servers to implement [authorization server discovery](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-server-discovery) so clients know how to obtain authorization. Two well-known endpoints are needed:
+
+**[Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)** (`/.well-known/oauth-protected-resource`) โ describes the resource server and identifies which authorization server(s) can issue tokens for it. The MCP SDK's `mcpAuthRouter` handles this automatically.
+
+**Authorization Server Metadata** (`/.well-known/oauth-authorization-server`) โ advertises the authorization and token endpoints, supported scopes, and whether [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/latest/basic/authorization#client-id-metadata-documents) (CIMD) is supported:
+
+```ts
+app.get("/.well-known/oauth-authorization-server", (_req, res) => {
+ res.json({
+ ...oauthMetadata,
+ client_id_metadata_document_supported: true,
+ });
+});
+```
+
+Setting `client_id_metadata_document_supported: true` tells MCP clients to use CIMD instead of [Dynamic Client Registration](https://modelcontextprotocol.io/specification/latest/basic/authorization#dynamic-client-registration) (DCR). With CIMD, the `client_id` is a URL that serves the client's metadata document, removing the need for a registration endpoint. See [Client Registration Approaches](https://modelcontextprotocol.io/specification/latest/basic/authorization#client-registration-approaches) in the spec for the full list of options and priority order.
+
+### Token verification
+
+Verify access tokens as JWTs against the identity provider's JWKS endpoint. The `jose` library handles key fetching and caching:
+
+```ts
+import { createRemoteJWKSet, jwtVerify } from "jose";
+
+const JWKS = createRemoteJWKSet(new URL(`${IDP_DOMAIN}/.well-known/jwks.json`));
+
+const { payload } = await jwtVerify(token, JWKS, {
+ issuer: IDP_DOMAIN,
+});
+```
+
+MCP servers must validate that tokens were issued specifically for them โ see [Token Handling](https://modelcontextprotocol.io/specification/latest/basic/authorization#token-handling) and [Access Token Privilege Restriction](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-privilege-restriction) in the spec for the full requirements.
+
+## Per-server authorization
+
+With per-server authorization, every request to the `/mcp` endpoint must include a valid Bearer token. Any unauthorized request receives HTTP `401`, and the host must complete the OAuth flow before the client can use any tools. This is the right choice when all tools are sensitive and there's no value in allowing unauthorized access.
+
+The TypeScript MCP SDK supports this out of the box via `mcpAuthRouter` and `ProxyOAuthServerProvider` โ no custom HTTP handler logic is needed. See the [MCP SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk) for setup details.
+
+## Per-tool authorization
+
+With per-tool authorization, the `/mcp` endpoint handler inspects the raw JSON-RPC request body, checks whether any message targets a protected tool, and only enforces authorization for those calls. Public tools pass through without a token.
+
+### How it works
+
+1. The server maintains a set of tool names that require authorization
+2. When a JSON-RPC request arrives at the `/mcp` endpoint, the server inspects the request body to determine if any message is a `tools/call` targeting a protected tool
+3. If a protected tool is being called and no valid Bearer token is present, the server returns HTTP `401` with a [`WWW-Authenticate` header](https://modelcontextprotocol.io/specification/latest/basic/authorization#protected-resource-metadata-discovery-requirements) pointing to its [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)
+4. The MCP host (e.g., Claude Desktop) sees the `401`, discovers the authorization server via the metadata URL, runs the [OAuth flow](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-flow-steps) with the user, and retries the request with the acquired token
+5. On retry, the server verifies the token, extracts the user identity, and creates a per-request MCP server instance with that auth context
+6. Unprotected tools pass through without any token check โ they work for everyone
+
+This design means authorization is enforced at the HTTP boundary (as [required by the spec](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-usage)), not as a tool-level error. The MCP server itself never sees unauthorized requests for protected tools.
+
+### Enforcing HTTP 401
+
+The [MCP auth specification](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-usage) requires protected resources to return HTTP `401` responses โ not tool-level errors.
+
+Start by defining which tools require authorization. Then, in the `/mcp` endpoint handler, inspect the raw JSON-RPC request body, check whether any message targets a protected tool, and either verify the Bearer token or return `401` before the request ever reaches the MCP server:
+
+```ts
+/** Tools that require a valid Bearer token โ checked at the HTTP level for proper 401. */
+const PROTECTED_TOOLS = new Set(["get_account_balance", "manage_branch_admin"]);
+
+app.all("/mcp", async (req, res) => {
+ // Parse the JSON-RPC body โ it may be a single message or a batch
+ const messages = Array.isArray(req.body) ? req.body : [req.body];
+
+ // Check if any message is a tools/call for a protected tool
+ const needsAuth = messages.some(
+ (msg: any) =>
+ msg?.method === "tools/call" && PROTECTED_TOOLS.has(msg.params?.name),
+ );
+
+ // Extract and verify the Bearer token
+ let authInfo: AuthInfo | undefined;
+ const authHeader = req.headers.authorization;
+
+ if (authHeader?.startsWith("Bearer ")) {
+ try {
+ const token = authHeader.slice(7);
+ const { payload } = await jwtVerify(token, JWKS, {
+ issuer: IDP_DOMAIN,
+ });
+ authInfo = { token, sub: payload.sub as string };
+ } catch {
+ if (needsAuth) {
+ return res
+ .status(401)
+ .set(
+ "WWW-Authenticate",
+ `Bearer resource_metadata="${resourceMetadataUrl}"`,
+ )
+ .json({
+ error: "invalid_token",
+ error_description: "The access token is invalid",
+ });
+ }
+ }
+ } else if (needsAuth) {
+ return res
+ .status(401)
+ .set(
+ "WWW-Authenticate",
+ `Bearer resource_metadata="${resourceMetadataUrl}"`,
+ )
+ .json({
+ error: "invalid_token",
+ error_description: "Authorization required",
+ });
+ }
+
+ // Create a per-request MCP server with the auth context.
+ // authInfo is undefined for public tool calls, populated for
+ // authenticated requests โ tool handlers use it to scope data
+ // to the authenticated user.
+ const server = createServer(authInfo);
+ // ... handle the request with transport
+});
+```
+
+The `WWW-Authenticate` header includes the [Protected Resource Metadata](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-server-location) URL, which tells the client where to discover the authorization server.
+
+### Defence-in-depth in tool handlers
+
+Even though the HTTP layer enforces authorization, protected tool handlers should also verify `authInfo` as a defence-in-depth measure. If the HTTP layer is misconfigured or bypassed, the tool handler catches unauthorized access:
+
+```ts
+registerAppTool(
+ server,
+ "get_account_balance",
+ {
+ description: "Get account balance",
+ inputSchema: z.object({ accountId: z.string() }),
+ },
+ async ({ accountId }) => {
+ if (!authInfo) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: "text",
+ text: "Authorization required to access account data.",
+ },
+ ],
+ };
+ }
+
+ const balance = await getBalance(authInfo.sub, accountId);
+ return {
+ content: [{ type: "text", text: `Balance: ${balance}` }],
+ };
+ },
+);
+```
+
+### UI-initiated auth escalation
+
+A powerful pattern is mixing public and protected tools in the same app. The app loads with public data (no authorization required), and the OAuth flow is triggered only when the user performs a protected action. This is a practical application of the [step-up authorization flow](https://modelcontextprotocol.io/specification/latest/basic/authorization#step-up-authorization-flow) described in the spec:
+
+1. A public tool (e.g., `manage_branch`) loads the UI without requiring authorization
+2. The user clicks a button that calls a protected tool via `app.callServerTool()`
+3. The MCP host receives HTTP `401` and automatically runs the OAuth flow
+4. After the user completes the OAuth flow, the host retries the tool call with the acquired token
+5. The protected data appears in the UI
+
+```tsx
+function BranchItem({ branch }: { branch: Branch }) {
+ const [adminData, setAdminData] = useState(null);
+
+ async function handleManage() {
+ // This call may trigger the OAuth flow if the user
+ // hasn't been authorized yet โ the host handles it
+ // transparently.
+ const result = await app.callServerTool({
+ name: "manage_branch_admin",
+ arguments: { branch_id: branch.id },
+ });
+ setAdminData(result.structuredContent);
+ }
+
+ return (
+
+ {branch.name}
+
+ {adminData && }
+
+ );
+}
+```
+
+This pattern keeps the initial experience fast (no login wall) while securing sensitive operations behind authorization. The host manages the entire OAuth flow โ the app code simply calls the tool and handles the result.
diff --git a/docs/csp-cors.md b/docs/csp-cors.md
new file mode 100644
index 0000000..8978ab6
--- /dev/null
+++ b/docs/csp-cors.md
@@ -0,0 +1,64 @@
+---
+title: CSP and CORS
+group: Security
+description: Configure Content Security Policy and CORS for MCP Apps that make network requests from sandboxed iframes, including connectDomains, resourceDomains, and stable origin setup.
+---
+
+# CSP & CORS
+
+Unlike regular web apps, MCP Apps HTML is served as an MCP resource and runs in a sandboxed iframe with no same-origin server. Any app that makes network requests must configure Content Security Policy (CSP) and possibly CORS.
+
+**CSP** controls what the _browser_ allows. You must declare _all_ origins in {@link types!McpUiResourceMeta.csp `_meta.ui.csp`} ({@link types!McpUiResourceCsp `McpUiResourceCsp`}) โ including `localhost` during development. Declare `connectDomains` for fetch/XHR/WebSocket requests and `resourceDomains` for scripts, stylesheets, images, and fonts.
+
+**CORS** controls what the _API server_ allows. Public APIs that respond with `Access-Control-Allow-Origin: *` or use API key authentication work without CORS configuration. For APIs that allowlist specific origins, use {@link types!McpUiResourceMeta.domain `_meta.ui.domain`} to give the app a stable origin that the API server can allowlist. The format is host-specific, so check each host's documentation for its supported format.
+
+
+```ts source="../src/server/index.examples.ts#registerAppResource_withDomain"
+// Computes a stable origin from an MCP server URL for hosting in Claude.
+function computeAppDomainForClaude(mcpServerUrl: string): string {
+ const hash = crypto
+ .createHash("sha256")
+ .update(mcpServerUrl)
+ .digest("hex")
+ .slice(0, 32);
+ return `${hash}.claudemcpcontent.com`;
+}
+
+const APP_DOMAIN = computeAppDomainForClaude("https://example.com/mcp");
+
+registerAppResource(
+ server,
+ "Company Dashboard",
+ "ui://dashboard/view.html",
+ {
+ description: "Internal dashboard with company data",
+ },
+ async () => ({
+ contents: [
+ {
+ uri: "ui://dashboard/view.html",
+ mimeType: RESOURCE_MIME_TYPE,
+ text: dashboardHtml,
+ _meta: {
+ ui: {
+ // CSP: tell browser the app is allowed to make requests
+ csp: {
+ connectDomains: ["https://api.example.com"],
+ },
+ // CORS: give app a stable origin for the API server to allowlist
+ //
+ // (Public APIs that use `Access-Control-Allow-Origin: *` or API
+ // key auth don't need this.)
+ domain: APP_DOMAIN,
+ },
+ },
+ },
+ ],
+ }),
+);
+```
+
+Note that `_meta.ui.csp` and `_meta.ui.domain` are set in the `contents[]` objects returned by the resource read callback, not in `registerAppResource()`'s config object.
+
+> [!NOTE]
+> For full examples that configures CSP, see: [`examples/sheet-music-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) (`connectDomains`) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (`connectDomains` and `resourceDomains`).
diff --git a/docs/favicons/apple-touch-icon.png b/docs/favicons/apple-touch-icon.png
new file mode 100644
index 0000000..2e10abc
Binary files /dev/null and b/docs/favicons/apple-touch-icon.png differ
diff --git a/docs/favicons/favicon-16x16.png b/docs/favicons/favicon-16x16.png
new file mode 100644
index 0000000..1548f7e
Binary files /dev/null and b/docs/favicons/favicon-16x16.png differ
diff --git a/docs/favicons/favicon-32x32.png b/docs/favicons/favicon-32x32.png
new file mode 100644
index 0000000..88f78f6
Binary files /dev/null and b/docs/favicons/favicon-32x32.png differ
diff --git a/docs/favicons/favicon-dark-16x16.png b/docs/favicons/favicon-dark-16x16.png
new file mode 100644
index 0000000..1548f7e
Binary files /dev/null and b/docs/favicons/favicon-dark-16x16.png differ
diff --git a/docs/favicons/favicon-dark-32x32.png b/docs/favicons/favicon-dark-32x32.png
new file mode 100644
index 0000000..b861b12
Binary files /dev/null and b/docs/favicons/favicon-dark-32x32.png differ
diff --git a/docs/favicons/favicon-dark.ico b/docs/favicons/favicon-dark.ico
new file mode 100644
index 0000000..fd1f24d
Binary files /dev/null and b/docs/favicons/favicon-dark.ico differ
diff --git a/docs/favicons/favicon.ico b/docs/favicons/favicon.ico
new file mode 100644
index 0000000..b92e162
Binary files /dev/null and b/docs/favicons/favicon.ico differ
diff --git a/docs/mcp-theme.css b/docs/mcp-theme.css
new file mode 100644
index 0000000..d786e55
--- /dev/null
+++ b/docs/mcp-theme.css
@@ -0,0 +1,216 @@
+/*
+ * MCP Apps custom theme โ aligns TypeDoc styling with modelcontextprotocol.io
+ */
+
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
+
+/* ------------------------------------------------------------------ */
+/* Fonts */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --font-family-text:
+ "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --font-family-code:
+ "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+body {
+ font-family: var(--font-family-text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ------------------------------------------------------------------ */
+/* Light mode colors */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --light-color-background: #ffffff;
+ --light-color-background-secondary: #f9fafb;
+ --light-color-background-navbar: #ffffff;
+ --light-color-accent: #e5e7eb;
+ --light-color-text: #111827;
+ --light-color-text-aside: #6b7280;
+}
+
+/* ------------------------------------------------------------------ */
+/* Dark mode colors */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --dark-color-background: #0f1117;
+ --dark-color-background-secondary: #161b22;
+ --dark-color-background-navbar: #0f1117;
+ --dark-color-accent: #30363d;
+ --dark-color-text: #f0f6fc;
+ --dark-color-text-aside: #8b949e;
+}
+
+/* ------------------------------------------------------------------ */
+/* Header โ taller, frosted glass */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --dim-toolbar-contents-height: 3.5rem;
+}
+
+.tsd-page-toolbar {
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+@media (prefers-color-scheme: light) {
+ .tsd-page-toolbar {
+ background-color: rgba(255, 255, 255, 0.85);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .tsd-page-toolbar {
+ background-color: rgba(15, 17, 23, 0.75);
+ }
+}
+
+:root[data-theme="light"] .tsd-page-toolbar {
+ background-color: rgba(255, 255, 255, 0.85);
+}
+
+:root[data-theme="dark"] .tsd-page-toolbar {
+ background-color: rgba(15, 17, 23, 0.75);
+}
+
+/* ------------------------------------------------------------------ */
+/* Theme-aware logo (rewritten from README by mcpstyle plugin) */
+/* ------------------------------------------------------------------ */
+
+/* Explicit theme selection via TypeDoc switcher โ takes precedence */
+:root[data-theme="light"] .mcp-logo-dark,
+:root[data-theme="dark"] .mcp-logo-light {
+ display: none;
+}
+
+/* "OS" mode (data-theme='os') follows prefers-color-scheme */
+@media (prefers-color-scheme: light) {
+ :root[data-theme="os"] .mcp-logo-dark {
+ display: none;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root[data-theme="os"] .mcp-logo-light {
+ display: none;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Sidebar navigation (matches modelcontextprotocol.io sidebar) */
+/* ------------------------------------------------------------------ */
+
+.tsd-navigation a {
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ border-radius: 0.75rem;
+ color: var(--color-text-aside);
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+}
+
+.tsd-navigation a:hover:not(.current) {
+ background-color: rgba(107, 114, 128, 0.05);
+ color: var(--color-text);
+}
+
+.tsd-navigation a.current {
+ background-color: rgba(107, 114, 128, 0.15);
+ color: var(--color-text);
+ font-weight: 700;
+}
+
+/* Group headers (Documents, Security, API Documentation) */
+.tsd-accordion-summary > h3 {
+ font-weight: 600;
+ font-size: 0.875rem;
+ letter-spacing: 0.01em;
+}
+
+.site-menu {
+ padding: 1.25rem 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ .tsd-navigation a {
+ color: var(--color-text-aside);
+ }
+
+ .tsd-navigation a:hover:not(.current) {
+ background-color: rgba(229, 231, 235, 0.05);
+ color: var(--color-text);
+ }
+
+ .tsd-navigation a.current {
+ background-color: rgba(229, 231, 235, 0.15);
+ color: var(--color-text);
+ font-weight: 700;
+ }
+}
+
+:root[data-theme="dark"] .tsd-navigation a {
+ color: var(--color-text-aside);
+}
+
+:root[data-theme="dark"] .tsd-navigation a:hover:not(.current) {
+ background-color: rgba(229, 231, 235, 0.05);
+ color: var(--color-text);
+}
+
+:root[data-theme="dark"] .tsd-navigation a.current {
+ background-color: rgba(229, 231, 235, 0.15);
+ color: var(--color-text);
+ font-weight: 700;
+}
+
+/* ------------------------------------------------------------------ */
+/* Content area */
+/* ------------------------------------------------------------------ */
+
+.tsd-typography {
+ line-height: 1.6;
+}
+
+.tsd-panel.tsd-typography h1 {
+ margin-top: 0;
+ padding-bottom: 0.4em;
+}
+
+.tsd-panel.tsd-typography h2 {
+ margin-top: 2rem;
+ padding-bottom: 0.3em;
+}
+
+.tsd-panel.tsd-typography h3 {
+ margin-top: 1.75rem;
+}
+
+/* ------------------------------------------------------------------ */
+/* Code blocks */
+/* ------------------------------------------------------------------ */
+
+code,
+pre {
+ font-family: var(--font-family-code);
+ font-size: 1em;
+}
+
+.tsd-typography pre {
+ padding: 1rem 1.25rem;
+ border: 1px solid var(--color-accent);
+}
+
+/* Inline code */
+.tsd-typography code:not(pre code) {
+ padding: 0.15em 0.35em;
+ font-size: 1em;
+}
diff --git a/docs/migrate-to-2.md b/docs/migrate-to-2.md
new file mode 100644
index 0000000..f9341d0
--- /dev/null
+++ b/docs/migrate-to-2.md
@@ -0,0 +1,130 @@
+---
+title: Migrate to v2
+group: Getting Started
+description: Upgrade from ext-apps 1.x to 2.x โ new base MCP SDK peer packages, API changes for Views and hosts, and what stays wire-compatible.
+---
+
+# Migrating from ext-apps 1.x to 2.x
+
+ext-apps 2.x is built on the base MCP TypeScript SDK 2.x, which replaced the
+single `@modelcontextprotocol/sdk` package with `@modelcontextprotocol/client`,
+`server`, `core`, `node` and `express`. The MCP Apps wire protocol did not
+change; the breaking changes are in dependencies and in the TypeScript API.
+
+## Host compatibility
+
+The `ui/*` messages exchanged over the iframe channel are byte-identical to
+1.x. A 2.x View runs in a 1.x host and a 2.x host renders 1.x Views. The only
+host-side deltas are in error responses (see below).
+
+## What stays the same
+
+`App` / `AppBridge` constructors, the `on*` setters, `addEventListener`,
+`callServerTool`, `readServerResource`, `registerAppResource`, the React hooks
+and the `_meta.ui.resourceUri` handling are unchanged. Published `.d.ts` files
+now resolve under `moduleResolution: NodeNext` / `Node16` without workarounds
+(#705).
+
+## Peer dependencies by role
+
+| Role | Install |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| View author | `@modelcontextprotocol/ext-apps`, `@modelcontextprotocol/client@^2.0.0`, `zod@^4.2.0` (+ `react`/`react-dom` for `./react`) |
+| Host author | same as View author |
+| MCP server author | View author packages + `@modelcontextprotocol/server@^2.0.0`; for HTTP transports also `@modelcontextprotocol/node@^2.0.0` (`NodeStreamableHTTPServerTransport`) and `@modelcontextprotocol/express@^2.0.0` (`createMcpExpressApp`, which peers on `express`) |
+| CDN / `*-with-deps` | nothing extra: `./app-with-deps` and `./react-with-deps` bundle client, core and zod (about 25% larger than the 1.x bundles) |
+
+`@modelcontextprotocol/client` is a required peer (`App` and `AppBridge` extend
+its `Protocol` class); `@modelcontextprotocol/core` is a required peer that `client` already depends on, so npm installs it without you listing it;
+`@modelcontextprotocol/server` stays optional and is only needed for the
+`./server` helpers. Node.js 20+ is required.
+
+## Breaking changes
+
+- **Peer packages.** `@modelcontextprotocol/sdk@^1` is replaced by the split
+ 2.x packages above, all at `^2.0.0`. Remove the 1.x package from your
+ project: the two SDKs share no classes or types (a 1.x `McpError` or
+ `Client` cannot be passed to 2.x code), even though they speak the same
+ wire protocol.
+- **zod 3 dropped.** The peer range is `zod@^4.2.0`. Tool schemas must
+ implement Standard JSON Schema (`~standard.jsonSchema`): zod 4.2+, ArkType,
+ Valibot. zod 4.0 and 4.1 do not expose `~standard.jsonSchema` and are
+ rejected too. Raw zod shapes (`{ q: z.string() }`) still work with
+ `registerAppTool` but are deprecated; wrap them with `z.object({...})`.
+- **`App` / `AppBridge` extend `Protocol` from `@modelcontextprotocol/client`.**
+ `ProtocolWithEvents` is gone; use the SDK's `Protocol`. The `AppRequest`,
+ `AppNotification` and `AppResult` unions are kept as deprecated type
+ aliases (nothing in 2.x consumes them) and will be removed in 3.0.
+- **Handler context.** Custom request handlers, `registerAppTool` callbacks
+ (server) and `app.registerTool` callbacks (View) receive the SDK 2.x
+ context: `extra.signal` โ `extra.mcpReq.signal`, `extra.requestId` โ
+ `extra.mcpReq.id`; `extra.sessionId` is unchanged and `extra.authInfo`
+ moved to `extra.http?.authInfo`.
+- **`setRequestHandler` / `setNotificationHandler` take method names.**
+ `app.setRequestHandler(SomeRequestSchema, handler)` becomes
+ `app.setRequestHandler("some/method", { params: SomeParamsSchema }, (params, ctx) => โฆ)`
+ for custom methods (the handler receives the parsed params); the two-argument
+ `setRequestHandler("tools/call", handler)` form exists only for spec-defined
+ method names. The 1.x `(Schema, handler)` form still works on `App` and
+ `AppBridge` as a deprecated overload: it logs a one-time warning, hands the
+ handler the whole `{ method, params }` message as before, and gives request
+ handlers a 1.x-shaped `extra` (`signal`, `requestId`, `sessionId`, `_meta`,
+ `sendRequest`, `sendNotification`, `authInfo`; exported as
+ `LegacyRequestHandlerExtra`). It will be removed in 3.0.
+- **Double-registration guard covers the SDK's own handlers.** Registering a
+ handler for `notifications/progress` or `notifications/cancelled` (which
+ the base `Protocol` already owns) now throws `already registered`, as
+ `ping` and the `on*`-owned methods did in 1.x. Use `addEventListener` or
+ the SDK's progress callbacks instead of replacing these.
+- **Errors.** Remote JSON-RPC errors are `ProtocolError` (numeric `code`);
+ local conditions are `SdkError` with a string `code`: request timeout โ
+ `"REQUEST_TIMEOUT"`, connection closed โ `"CONNECTION_CLOSED"`. Cancelling a
+ request with an `AbortSignal` also rejects with `"REQUEST_TIMEOUT"` (the
+ message is the abort reason). Messages no longer carry the `MCP error N:`
+ prefix.
+ A resource-not-found reply that carries `data.uri` arrives as
+ `ResourceNotFoundError` (a `ProtocolError` subclass, `code` `-32602`, with a
+ `.uri` getter).
+
+## Host-side wire deltas
+
+Observed when a 2.x `AppBridge` answers a View; a 1.x View sees these too.
+
+| Situation | 1.x host | 2.x host |
+| --------------------------------------------------------- | ------------------------------ | --------------------------------------------------- |
+| A handler throws `-32002` (resource not found) | `error.code: -32002` | `error.code: -32602` (the SDK never emits `-32002`) |
+| Invalid params on a `ui/*` request | `-32603` with a zod issue dump | `-32602 Invalid params for : โฆ` |
+| Error message text | `MCP error -32602: โฆ` prefix | plain message |
+| `tools/call` to an unknown tool through a 2.x `McpServer` | `result.isError: true` | JSON-RPC error `-32602` (`callServerTool` rejects) |
+
+## `schema.json`
+
+The published `./schema.json` export is regenerated from the 2.x core schemas:
+
+- `McpUiToolResultNotification.params.structuredContent` is any JSON value (was
+ `type: "object"`).
+- `McpUiToolResultNotification.params._meta` documents
+ `io.modelcontextprotocol/serverInfo` and no longer lists `progressToken` /
+ `related-task` (both still pass through).
+- `McpUiHostContext.toolInfo.tool.outputSchema` is a loose object (only
+ `$schema` is documented); `inputSchema.properties` values are now typed as
+ JSON values.
+- A recursive JSON-value definition (`__schema0`) is added under the `$defs` of
+ `McpUiHostContext`, `McpUiHostContextChangedNotification` and
+ `McpUiInitializeResult`.
+
+## Checklist
+
+1. `npm uninstall @modelcontextprotocol/sdk` and install the packages for your
+ role from the table above.
+2. Replace `sdk/...` imports with the split packages (`sdk/server/mcp.js` โ
+ `@modelcontextprotocol/server`, `sdk/server/streamableHttp.js` โ
+ `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`,
+ `sdk/server/stdio.js` โ `@modelcontextprotocol/server/stdio`, hand-written
+ Express routing โ `createMcpExpressApp` from `@modelcontextprotocol/express`,
+ `sdk/types.js` โ `@modelcontextprotocol/client` or `server` for the types,
+ `@modelcontextprotocol/core` for the zod schemas).
+3. Wrap raw zod shapes with `z.object({...})`.
+4. Update custom handlers to the `extra.mcpReq.*` context and method-keyed
+ `setRequestHandler` calls.
+5. Replace `McpError` / numeric-code checks with `ProtocolError` / `SdkError`.
diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md
index 2845b8d..22d5d9f 100644
--- a/docs/migrate_from_openai_apps.md
+++ b/docs/migrate_from_openai_apps.md
@@ -1,9 +1,22 @@
+---
+title: Migrate OpenAI App
+group: Getting Started
+description: Migrate from the OpenAI Apps SDK to MCP Apps SDK with concept mapping tables, API equivalents, and complete before/after code examples.
+---
+
# Migrating from OpenAI Apps SDK to MCP Apps SDK
-This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@modelcontextprotocol/ext-apps`).
+This reference maps OpenAI Apps SDK concepts to their MCP Apps SDK (`@modelcontextprotocol/ext-apps`) equivalents. Use the tables below for quick lookup during migration, and refer to the code examples for complete before/after comparisons.
+
+This guide covers server-side changes first (metadata, tools, resources), then client-side changes (setup, context, events).
+
+> [!NOTE]
+> Some OpenAI Apps SDK features don't have MCP equivalents yet. These are marked "Not yet implemented" in the tables below.
## Server-Side
+The server-side changes involve updating metadata structure and using helper functions.
+
### Quick Start Comparison
| OpenAI Apps SDK | MCP Apps SDK |
@@ -24,13 +37,13 @@ This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@mod
### Resource Metadata
-| OpenAI | MCP Apps | Notes |
-| ------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------- |
-| `_meta["openai/widgetCSP"]` | `_meta.ui.csp` | `connect_domains` โ `connectDomains`, `resource_domains` โ `resourceDomains`, etc. |
-| โ | `_meta.ui.permissions` | MCP adds: permissions for camera, microphone, geolocation, clipboard |
-| `_meta["openai/widgetDomain"]` | `_meta.ui.domain` | Dedicated sandbox origin |
-| `_meta["openai/widgetPrefersBorder"]` | `_meta.ui.prefersBorder` | Visual boundary preference |
-| `_meta["openai/widgetDescription"]` | โ | Not yet implemented; use `app.updateModelContext()` for dynamic context |
+| OpenAI | MCP Apps | Notes |
+| ------------------------------------- | ------------------------ | ----------------------------------------------------------------------- |
+| `_meta["openai/widgetCSP"]` | `_meta.ui.csp` | See [CSP field mapping](#csp-field-mapping) below |
+| โ | `_meta.ui.permissions` | MCP adds: permissions for camera, microphone, geolocation, clipboard |
+| `_meta["openai/widgetDomain"]` | `_meta.ui.domain` | Dedicated sandbox origin |
+| `_meta["openai/widgetPrefersBorder"]` | `_meta.ui.prefersBorder` | Visual boundary preference |
+| `_meta["openai/widgetDescription"]` | โ | Not yet implemented; use `app.updateModelContext()` for dynamic context |
### Resource MIME Type
@@ -38,12 +51,22 @@ This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@mod
| --------------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `text/html+skybridge` | `text/html;profile=mcp-app` | Auto-set by `registerAppResource()`; use `RESOURCE_MIME_TYPE` constant if manual |
+### CSP Field Mapping
+
+| OpenAI | MCP Apps | Notes |
+| ------------------ | ----------------- | ---------------------------------------------------------- |
+| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
+| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
+| `frame_domains` | `frameDomains` | Origins for nested iframes |
+| `redirect_domains` | โ | OpenAI-only: origins for `openExternal` redirects |
+| โ | `baseUriDomains` | MCP-only: `base-uri` CSP directive |
+
### Server-Side Migration Example
-### Before (OpenAI)
+#### Before (OpenAI)
```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
function createServer() {
@@ -55,10 +78,10 @@ function createServer() {
{
title: "Shopping Cart",
description: "Display the user's shopping cart",
- inputSchema: { userId: z.string() },
+ inputSchema: z.object({ userId: z.string() }),
annotations: { readOnlyHint: true },
_meta: {
- "openai/outputTemplate": "ui://widget/cart.html",
+ "openai/outputTemplate": "ui://view/cart.html",
"openai/toolInvocation/invoking": "Loading cart...",
"openai/toolInvocation/invoked": "Cart ready",
"openai/widgetAccessible": true,
@@ -75,15 +98,22 @@ function createServer() {
// Register UI resource
server.registerResource(
- "Cart Widget",
- "ui://widget/cart.html",
+ "Cart View",
+ "ui://view/cart.html",
{ mimeType: "text/html+skybridge" },
async () => ({
contents: [
{
- uri: "ui://widget/cart.html",
+ uri: "ui://view/cart.html",
mimeType: "text/html+skybridge",
text: getCartHtml(),
+ _meta: {
+ "openai/widgetCSP": {
+ resource_domains: ["https://cdn.example.com"],
+ connect_domains: ["https://api.example.com"],
+ frame_domains: ["https://embed.example.com"],
+ },
+ },
},
],
}),
@@ -93,10 +123,10 @@ function createServer() {
}
```
-### After (MCP Apps)
+#### After (MCP Apps)
```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { McpServer } from "@modelcontextprotocol/server";
import {
registerAppTool,
registerAppResource,
@@ -114,9 +144,9 @@ function createServer() {
{
title: "Shopping Cart",
description: "Display the user's shopping cart",
- inputSchema: { userId: z.string() },
+ inputSchema: z.object({ userId: z.string() }),
annotations: { readOnlyHint: true },
- _meta: { ui: { resourceUri: "ui://widget/cart.html" } },
+ _meta: { ui: { resourceUri: "ui://view/cart.html" } },
},
async (args) => {
const cart = await getCart(args.userId);
@@ -130,15 +160,24 @@ function createServer() {
// Register UI resource
registerAppResource(
server,
- "Cart Widget",
- "ui://widget/cart.html",
+ "Cart View",
+ "ui://view/cart.html",
{ description: "Shopping cart UI" },
async () => ({
contents: [
{
- uri: "ui://widget/cart.html",
+ uri: "ui://view/cart.html",
mimeType: RESOURCE_MIME_TYPE,
text: getCartHtml(),
+ _meta: {
+ ui: {
+ csp: {
+ resourceDomains: ["https://cdn.example.com"],
+ connectDomains: ["https://api.example.com"],
+ frameDomains: ["https://embed.example.com"],
+ },
+ },
+ },
},
],
}),
@@ -152,13 +191,15 @@ function createServer() {
1. **Metadata Structure**: OpenAI uses flat `_meta["openai/..."]` properties; MCP uses nested `_meta.ui.*` structure
2. **Tool Visibility**: OpenAI uses boolean/string (`true`/`"public"`); MCP uses string arrays (`["app", "model"]`)
-3. **CSP Property Names**: snake_case โ camelCase (`connect_domains` โ `connectDomains`)
+3. **CSP Field Names**: snake_case โ camelCase (e.g., `connect_domains` โ `connectDomains`)
4. **App Permissions**: MCP adds `_meta.ui.permissions` for camera, microphone, geolocation, clipboard (not in OpenAI)
5. **Resource MIME Type**: `text/html+skybridge` โ `text/html;profile=mcp-app` (use `RESOURCE_MIME_TYPE` constant)
6. **Helper Functions**: MCP provides `registerAppTool()` and `registerAppResource()` helpers
7. **Not Yet Implemented**: `_meta["openai/toolInvocation/invoking"]`, `_meta["openai/toolInvocation/invoked"]`, and `_meta["openai/widgetDescription"]` don't have MCP equivalents yet
-## Client-side
+## Client-Side
+
+Client-side migration involves replacing the implicit `window.openai` global with an explicit `App` instance.
### Quick Start Comparison
@@ -172,7 +213,7 @@ function createServer() {
| OpenAI | MCP Apps | Notes |
| -------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
-| `window.openai` (auto-available) | `const app = new App({name, version}, {})` | MCP requires explicit instantiation |
+| `window.openai` (auto-available) | `const app = new App({name, version})` | MCP requires explicit instantiation |
| (implicit) | Vanilla: `await app.connect()` / React: `useApp()` | MCP requires async connection; auto-detects OpenAI env |
| โ | `await app.connect(new OpenAITransport())` | Force OpenAI mode (not yet available, see [PR #172](https://github.com/modelcontextprotocol/ext-apps/pull/172)) |
| โ | `await app.connect(new PostMessageTransport(...))` | Force MCP mode explicitly |
@@ -252,16 +293,17 @@ function createServer() {
| ------------------------------------------- | -------- | ------------------- |
| `await window.openai.requestModal(options)` | โ | Not yet implemented |
| `window.openai.requestClose()` | โ | Not yet implemented |
+| `window.openai.setOpenInAppUrl({ href })` | โ | Not yet implemented |
| `window.openai.view` | โ | Not yet mapped |
### Event Handling
-| OpenAI | MCP Apps | Notes |
-| ------------------------------ | ------------------------------------------- | -------------------------------- |
-| Read `window.openai.*` on load | `app.ontoolinput = (params) => {...}` | Register before `connect()` |
-| Read `window.openai.*` on load | `app.ontoolresult = (params) => {...}` | Register before `connect()` |
-| Poll or re-read properties | `app.onhostcontextchanged = (ctx) => {...}` | MCP pushes context changes |
-| โ | `app.onteardown = async () => {...}` | MCP adds: cleanup before unmount |
+| OpenAI | MCP Apps | Notes |
+| --------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ |
+| `window.openai.toolInput` (read on load) | `app.ontoolinput = (params) => {...}` | OpenAI: sync property; MCP: callback (register before `connect()`) |
+| `window.openai.toolOutput` (read on load) | `app.ontoolresult = (params) => {...}` | OpenAI: sync property; MCP: callback (register before `connect()`) |
+| `addEventListener("openai:set_globals", ...)` | `app.onhostcontextchanged = (ctx) => {...}` | Both push updates; MCP uses callback, OpenAI uses DOM event |
+| โ | `app.onteardown = async () => {...}` | MCP adds: cleanup before unmount |
### Logging
@@ -276,7 +318,7 @@ function createServer() {
| โ | `app.getHostVersion()` | Returns `{ name, version }` of host |
| โ | `app.getHostCapabilities()` | Check `serverTools`, `openLinks`, `logging`, etc. |
-### Full Migration Example
+### Client-Side Migration Example
#### Before (OpenAI)
diff --git a/docs/overview.md b/docs/overview.md
new file mode 100644
index 0000000..8eda95d
--- /dev/null
+++ b/docs/overview.md
@@ -0,0 +1,217 @@
+---
+title: Overview
+group: Getting Started
+description: MCP Apps extends the Model Context Protocol to let MCP servers deliver interactive UIs โ charts, forms, dashboards โ rendered securely in iframes inside any compliant host.
+---
+
+# MCP Apps Overview
+
+MCP Apps is an extension to the Model Context Protocol that enables MCP servers to deliver interactive user interfaces to hosts. It defines how servers declare UI resources, how hosts render them securely in iframes, and how the two communicate.
+
+## Why MCP Apps?
+
+MCP allows servers to expose tools and resources to AI assistants, but responses are limited to text and structured data. Many use cases need more:
+
+- **Data visualization** โ Charts, graphs, and dashboards that update as data changes
+- **Rich media** โ Video players, audio waveforms, 3D models
+- **Interactive forms** โ Multi-step wizards, configuration panels, approval workflows
+- **Real-time displays** โ Live logs, progress indicators, streaming content
+
+Before MCP Apps, each host implemented UI support differently. MCP-UI, OpenAI's Apps SDK, and custom implementations all solved similar problems with incompatible approaches. Server developers had to maintain separate adapters for each host, and security models varied.
+
+MCP Apps standardizes this. Servers declare their UIs once; any compliant host can render them. Security is consistent โ sandboxed iframes, auditable communication, declarative CSP. The result is write-once portability with predictable behavior.
+
+## Progressive Enhancement
+
+MCP Apps is designed for graceful degradation. Hosts advertise their UI support when connecting to servers; servers check these capabilities before registering UI-enabled tools. If a host doesn't support MCP Apps, tools still work โ they just return text instead of UI.
+
+This is fundamental: UI is a progressive enhancement, not a requirement. Your server works everywhere; hosts that support UI get a richer experience.
+
+See the [Client<>Server Capability Negotiation](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#clientserver-capability-negotiation) section of the specification.
+
+## Architecture
+
+In MCP Apps, three entities work together:
+
+```mermaid
+flowchart LR
+ subgraph Server ["MCP Server"]
+ T[Tools]
+ R[UI Resources]
+ end
+
+ subgraph Host ["Host (Chat Client)"]
+ B[AppBridge]
+ end
+
+ subgraph View ["View (iframe)"]
+ A[App]
+ end
+
+ Server <-->|MCP Protocol| Host
+ Host <-->|postMessage| View
+```
+
+- **Server** โ A standard MCP server that declares tools and UI resources. The server defines what the UI looks like (HTML) and what tools it exposes.
+- **Host** โ The chat client (e.g., Claude Desktop) that connects to servers, embeds Views in iframes, and proxies communication between them.
+- **View** โ The UI running inside a sandboxed iframe. It receives tool data from the Host and can call server tools or send messages back to the chat.
+
+The View's `App` and the Host's `AppBridge` both subclass the `Protocol` class
+exported by `@modelcontextprotocol/client` for the iframe channel. A separate outer `Client` connects
+the Host to the actual MCP Server. Keeping those two connections separate
+preserves the Apps-only iframe handshake and prevents iframe capabilities from
+leaking into the server connection.
+
+## Lifecycle
+
+```mermaid
+sequenceDiagram
+ participant H as Host
+ participant V as View (iframe)
+ participant S as MCP Server
+
+ Note over H,S: 1. Discovery
+ S-->>H: tools/list (with UI metadata)
+
+ Note over H,V: 2. Initialization
+ H->>H: Render iframe with UI resource
+ V->>H: ui/initialize
+ H-->>V: host context, capabilities
+ V->>H: ui/notifications/initialized
+
+ Note over H,V: 3. Data Delivery
+ H-->>V: ui/notifications/tool-input
+ H-->>V: ui/notifications/tool-result
+
+ Note over H,V: 4. Interactive Phase
+ loop User Interaction
+ V->>H: tools/call
+ H->>S: tools/call
+ S-->>H: result
+ H-->>V: result
+ end
+
+ Note over H,V: 5. Teardown
+ H->>V: ui/resource-teardown
+ V-->>H: acknowledgment
+```
+
+1. **Discovery** โ The Host learns about tools and their UI resources when connecting to the server.
+2. **Initialization** โ When a UI tool is called, the Host renders the iframe. The View sends `ui/initialize` (the only handshake on the iframe channel) and receives host context (theme, capabilities, container dimensions). `ui/initialize` is authoritative for Apps capabilities and identity, and `ui/notifications/initialized` is the public View-ready signal.
+3. **Data delivery** โ The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering). This separation lets servers provide rich data to the UI without bloating the model's context.
+4. **Interactive phase** โ The user interacts with the View. The View can call tools, send messages, or update context.
+5. **Teardown** โ Before unmounting, the Host notifies the View so it can save state or release resources.
+
+See the [Lifecycle](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#lifecycle) section of the specification for the complete sequence diagrams.
+
+## UI Resources
+
+UI resources are HTML templates that servers declare using the `ui://` URI scheme. When a tool with UI metadata is called, the Host fetches the corresponding resource and renders it.
+
+Resources are declared upfront, during tool registration. This design enables:
+
+- **Prefetching** โ Hosts can cache templates before tool execution
+- **Separation of concerns** โ Templates (presentation) are separate from tool results (data)
+- **Review** โ Hosts can inspect UI templates during connection setup
+
+See the [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#ui-resource-format) section of the specification for the full schema.
+
+## Tool-UI Linkage
+
+Tools reference their UI templates through metadata. When a server registers a tool, it includes a `_meta.ui` object pointing to a `ui://` resource:
+
+```json
+"_meta": {
+ "ui": { "resourceUri": "ui://weather/forecast" }
+}
+```
+
+When this tool is called, the Host:
+
+1. Fetches the UI resource
+2. Renders it in a sandboxed iframe
+3. Passes the tool arguments and results to the View
+
+See the [Resource Discovery](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery) section of the specification for details.
+
+## Bidirectional Communication
+
+Views communicate with Hosts using JSON-RPC over [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). From a View, you can:
+
+**Interact with the server:**
+
+- Call server tools (`tools/call`)
+- Read server resources (`resources/read`)
+
+**Interact with the chat:**
+
+- Send messages to the conversation (`ui/message`)
+- Update model context (`ui/update-model-context`)
+
+**Request host actions:**
+
+- Open external links (`ui/open-link`)
+
+See the {@link app!App} class for the View-side API and the [Communication Protocol](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#communication-protocol) section of the specification.
+
+## Tool Visibility
+
+Tools can be visible to the model, the app, or both. By default, tools are visible to both (`visibility: ["model", "app"]`).
+
+App-only tools (`visibility: ["app"]`) are useful for UI interactions that shouldn't clutter the agent's context โ things like refresh buttons, pagination controls, or form submissions. The model never sees these tools; they exist purely for the View to call.
+
+## Host Context
+
+When a View initializes, the Host provides context about the environment it's running in. This includes:
+
+- **Theme** โ Light or dark mode preference
+- **Locale and timezone** โ For formatting dates, numbers, and text
+- **Display mode** โ Whether the View is inline, fullscreen, or picture-in-picture
+- **Container dimensions** โ The available space, which may be fixed by the Host or flexible based on content
+- **Platform** โ Web, desktop, or mobile
+
+Views use this context to adapt their presentation. For example, a chart might use dark colors when the host is in dark mode, or a form might adjust its layout for mobile platforms.
+
+The Host notifies Views when context changes (e.g., the user toggles dark mode), allowing dynamic updates without reloading.
+
+See the [Host Context](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#host-context-in-mcpuiinitializeresult) section of the specification for the full schema.
+
+## Theming
+
+Hosts provide CSS custom properties for colors, typography, and borders. Views use these variables with fallbacks to match the host's visual style:
+
+```css
+.container {
+ background: var(--color-background-primary, #ffffff);
+ color: var(--color-text-primary, #000000);
+}
+```
+
+Theme changes (light/dark toggle) are sent via notifications, allowing Views to update dynamically.
+
+See the [Theming](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#theming) section of the specification for the full list of CSS variables.
+
+## Display Modes
+
+Views can be displayed in different modes:
+
+- **inline** โ Embedded in the chat flow. Best for content that's part of the conversation (charts, previews, forms).
+- **fullscreen** โ Takes over the window. Use for immersive experiences like editors, games, or complex dashboards.
+- **pip** โ Picture-in-picture overlay. Useful for persistent widgets (music player, timer) that should remain visible while the user continues chatting.
+
+Views declare which modes they support; Hosts declare which they can provide. Views can request mode changes, but the Host decides whether to honor them โ the Host always has final say over its own UI.
+
+See the [Display Modes](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#display-modes) section of the specification.
+
+## Security
+
+All Views run in sandboxed iframes with no access to the Host's DOM, cookies, or storage. Communication happens only through [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage), making it auditable.
+
+Servers declare which network domains their UI needs via [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) metadata. Hosts enforce these declarations โ if no domains are declared, no external connections are allowed. This "restrictive by default" approach prevents data exfiltration to undeclared servers.
+
+See the [Security Implications](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#security-implications) section of the specification for the threat model and mitigations.
+
+## Learn More
+
+- [Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) โ Full protocol specification
+- [Quickstart](./quickstart.md) โ Build your first MCP App
diff --git a/docs/patterns.md b/docs/patterns.md
new file mode 100644
index 0000000..884de26
--- /dev/null
+++ b/docs/patterns.md
@@ -0,0 +1,627 @@
+---
+title: Patterns
+group: Getting Started
+description: Common patterns and recipes for building MCP Apps โ polling, chunked data, binary resources, theming, fullscreen, model context, state persistence, and more.
+---
+
+# MCP Apps Patterns
+
+This document covers common patterns and recipes for building MCP Apps.
+
+## Tools that are private to Apps
+
+Set {@link types!McpUiToolMeta.visibility `Tool._meta.ui.visibility`} to `["app"]` to make tools only callable by Apps (hidden from the model). This is useful for UI-driven actions like updating server-side state, polling, or other interactions that shouldn't appear in the model's tool list.
+
+
+```ts source="../src/server/index.examples.ts#registerAppTool_appOnlyVisibility"
+registerAppTool(
+ server,
+ "update-quantity",
+ {
+ description: "Update item quantity in cart",
+ inputSchema: z.object({ itemId: z.string(), quantity: z.number() }),
+ _meta: {
+ ui: {
+ resourceUri: "ui://shop/cart.html",
+ visibility: ["app"],
+ },
+ },
+ },
+ async ({ itemId, quantity }) => {
+ const cart = await updateCartItem(itemId, quantity);
+ return { content: [{ type: "text", text: JSON.stringify(cart) }] };
+ },
+);
+```
+
+> [!NOTE]
+> For full examples that implement this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) and [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server).
+
+## Polling for live data
+
+For real-time dashboards or monitoring views, use an app-only tool (with `visibility: ["app"]`) that the App polls at regular intervals.
+
+**Vanilla JS:**
+
+
+```ts source="./patterns.tsx#pollingVanillaJs"
+let intervalId: number | null = null;
+
+async function poll() {
+ const result = await app.callServerTool({
+ name: "poll-data",
+ arguments: {},
+ });
+ updateUI(result.structuredContent);
+}
+
+function startPolling() {
+ if (intervalId !== null) return;
+ poll();
+ intervalId = window.setInterval(poll, 2000);
+}
+
+function stopPolling() {
+ if (intervalId === null) return;
+ clearInterval(intervalId);
+ intervalId = null;
+}
+
+// Clean up when host tears down the view
+app.onteardown = async () => {
+ stopPolling();
+ return {};
+};
+```
+
+**React:**
+
+
+```tsx source="./patterns.tsx#pollingReact"
+useEffect(() => {
+ if (!app) return;
+ let cancelled = false;
+
+ async function poll() {
+ const result = await app!.callServerTool({
+ name: "poll-data",
+ arguments: {},
+ });
+ if (!cancelled) setData(result.structuredContent);
+ }
+
+ poll();
+ const id = setInterval(poll, 2000);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+}, [app]);
+```
+
+> [!NOTE]
+> For a full example that implements this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server).
+
+## Reading large amounts of data via chunked tool calls
+
+Some host platforms have size limits on tool call responses, so large files (PDFs, images, etc.) cannot be sent in a single response. Use an app-only tool with chunked responses to bypass these limits while keeping the data out of model context.
+
+**Server-side**: Register an app-only tool that returns data in chunks with pagination metadata:
+
+
+```tsx source="./patterns.tsx#chunkedDataServer"
+// Define the chunk response schema
+const DataChunkSchema = z.object({
+ bytes: z.string(), // base64-encoded data
+ offset: z.number(),
+ byteCount: z.number(),
+ totalBytes: z.number(),
+ hasMore: z.boolean(),
+});
+
+const MAX_CHUNK_BYTES = 500 * 1024; // 500KB per chunk
+
+registerAppTool(
+ server,
+ "read_data_bytes",
+ {
+ title: "Read Data Bytes",
+ description: "Load binary data in chunks",
+ inputSchema: z.object({
+ id: z.string().describe("Resource identifier"),
+ offset: z.number().min(0).default(0).describe("Byte offset"),
+ byteCount: z
+ .number()
+ .default(MAX_CHUNK_BYTES)
+ .describe("Bytes to read"),
+ }),
+ outputSchema: DataChunkSchema,
+ // Hidden from model - only callable by the App
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ id, offset, byteCount }): Promise => {
+ const data = await loadData(id); // Your data loading logic
+ const chunk = data.slice(offset, offset + byteCount);
+
+ return {
+ content: [{ type: "text", text: `${chunk.length} bytes at ${offset}` }],
+ structuredContent: {
+ bytes: Buffer.from(chunk).toString("base64"),
+ offset,
+ byteCount: chunk.length,
+ totalBytes: data.length,
+ hasMore: offset + chunk.length < data.length,
+ },
+ };
+ },
+);
+```
+
+**Client-side**: Loop calling the tool until all chunks are received:
+
+
+```tsx source="./patterns.tsx#chunkedDataClient"
+interface DataChunk {
+ bytes: string; // base64
+ offset: number;
+ byteCount: number;
+ totalBytes: number;
+ hasMore: boolean;
+}
+
+async function loadDataInChunks(
+ id: string,
+ onProgress?: (loaded: number, total: number) => void,
+): Promise {
+ const CHUNK_SIZE = 500 * 1024; // 500KB chunks
+ const chunks: Uint8Array[] = [];
+ let offset = 0;
+ let totalBytes = 0;
+ let hasMore = true;
+
+ while (hasMore) {
+ const result = await app.callServerTool({
+ name: "read_data_bytes",
+ arguments: { id, offset, byteCount: CHUNK_SIZE },
+ });
+
+ if (result.isError || !result.structuredContent) {
+ throw new Error("Failed to load data chunk");
+ }
+
+ const chunk = result.structuredContent as unknown as DataChunk;
+ totalBytes = chunk.totalBytes;
+ hasMore = chunk.hasMore;
+
+ // Decode base64 to bytes
+ const binaryString = atob(chunk.bytes);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ chunks.push(bytes);
+
+ offset += chunk.byteCount;
+ onProgress?.(offset, totalBytes);
+ }
+
+ // Combine all chunks into single array
+ const fullData = new Uint8Array(totalBytes);
+ let pos = 0;
+ for (const chunk of chunks) {
+ fullData.set(chunk, pos);
+ pos += chunk.length;
+ }
+
+ return fullData;
+}
+
+// Usage: load data with progress updates
+loadDataInChunks(resourceId, (loaded, total) => {
+ console.log(`Loading: ${Math.round((loaded / total) * 100)}%`);
+}).then((data) => {
+ console.log(`Loaded ${data.length} bytes`);
+});
+```
+
+> [!NOTE]
+> For a full example that implements this pattern, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server).
+
+## Giving errors back to the model
+
+**Server-side**: Tool handler validates inputs and returns `{ isError: true, content: [...] }`. The model receives this error through the normal tool call response.
+
+**Client-side**: If a runtime error occurs (e.g., API failure, permission denied, resource unavailable), use {@link app!App.updateModelContext `updateModelContext`} to inform the model:
+
+
+```ts source="../src/app.examples.ts#App_updateModelContext_reportError"
+try {
+ const _stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ // ... use _stream for transcription
+} catch (err) {
+ // Inform the model that the app is in a degraded state
+ await app.updateModelContext({
+ content: [
+ {
+ type: "text",
+ text: "Error: transcription unavailable",
+ },
+ ],
+ });
+}
+```
+
+## Serving binary blobs via resources
+
+Binary content (e.g., video) can be served via MCP resources as base64-encoded blobs. The server returns the data in the `blob` field of the resource content, and the App fetches it via `resources/read` for use in the browser.
+
+**Server-side**: Register a resource that returns binary data in the `blob` field:
+
+
+```ts source="./patterns.tsx#binaryBlobResourceServer"
+server.registerResource(
+ "Video",
+ new ResourceTemplate("video://{id}", { list: undefined }),
+ {
+ description: "Video data served as base64 blob",
+ mimeType: "video/mp4",
+ },
+ async (uri, { id }): Promise => {
+ // Fetch or load your binary data
+ const idString = Array.isArray(id) ? id[0] : id;
+ const buffer = await getVideoData(idString);
+ const blob = Buffer.from(buffer).toString("base64");
+
+ return { contents: [{ uri: uri.href, mimeType: "video/mp4", blob }] };
+ },
+);
+```
+
+**Client-side**: Fetch the resource and convert the base64 blob to a data URI:
+
+
+```ts source="./patterns.tsx#binaryBlobResourceClient"
+const result = await app.readServerResource({ uri: `video://${videoId}` });
+
+const content = result.contents[0];
+if (!content || !("blob" in content)) {
+ throw new Error("Resource did not contain blob data");
+}
+
+const videoEl = document.querySelector("video")!;
+videoEl.src = `data:${content.mimeType!};base64,${content.blob}`;
+```
+
+> [!NOTE]
+> For a full example that implements this pattern, see: [`examples/video-resource-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/video-resource-server).
+
+## Configuring CSP and CORS
+
+See the dedicated [CSP & CORS](./csp-cors.md) guide in the Security section.
+
+## Adapting to host context (theme, styling, fonts, and safe areas)
+
+The host provides context about its environment via {@link types!McpUiHostContext `McpUiHostContext`}. Use this to adapt your app's appearance and layout:
+
+- **Theme** โ Use `[data-theme="dark"]` selectors or `light-dark()` function for theme-aware styles
+- **CSS variables** โ Use `var(--color-background-primary)`, etc. in your CSS (see {@link types!McpUiStyleVariableKey `McpUiStyleVariableKey`} for a full list)
+- **Fonts** โ Use `var(--font-sans)` or `var(--font-mono)` with fallbacks (e.g., `font-family: var(--font-sans, system-ui, sans-serif)`)
+- **Safe area insets** โ Apply padding to avoid device notches, rounded corners, or system UI overlays
+
+**Vanilla JS:**
+
+
+```tsx source="./patterns.tsx#hostContextVanillaJs"
+function applyHostContext(ctx: McpUiHostContext) {
+ if (ctx.theme) {
+ applyDocumentTheme(ctx.theme);
+ }
+ if (ctx.styles?.variables) {
+ applyHostStyleVariables(ctx.styles.variables);
+ }
+ if (ctx.styles?.css?.fonts) {
+ applyHostFonts(ctx.styles.css.fonts);
+ }
+ if (ctx.safeAreaInsets) {
+ mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
+ mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
+ mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
+ mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
+ }
+}
+
+// Apply when host context changes
+app.onhostcontextchanged = applyHostContext;
+
+// Apply initial context after connecting
+app.connect().then(() => {
+ const ctx = app.getHostContext();
+ if (ctx) {
+ applyHostContext(ctx);
+ }
+});
+```
+
+**React:**
+
+
+```tsx source="./patterns.tsx#hostContextReact"
+function MyApp() {
+ const [hostContext, setHostContext] = useState();
+
+ const { app } = useApp({
+ appInfo: { name: "MyApp", version: "1.0.0" },
+ capabilities: {},
+ onAppCreated: (app) => {
+ app.onhostcontextchanged = (ctx) => {
+ setHostContext((prev) => ({ ...prev, ...ctx }));
+ };
+ },
+ });
+
+ // Set initial host context after connection
+ useEffect(() => {
+ if (app) {
+ setHostContext(app.getHostContext());
+ }
+ }, [app]);
+
+ // Apply styles when host context changes
+ useEffect(() => {
+ if (hostContext?.theme) {
+ applyDocumentTheme(hostContext.theme);
+ }
+ if (hostContext?.styles?.variables) {
+ applyHostStyleVariables(hostContext.styles.variables);
+ }
+ if (hostContext?.styles?.css?.fonts) {
+ applyHostFonts(hostContext.styles.css.fonts);
+ }
+ }, [hostContext]);
+
+ return (
+
+ Styled with host CSS variables, fonts, and safe area insets
+
+ );
+}
+```
+
+> [!NOTE]
+> For full examples that implement this pattern, see: [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react).
+
+## Entering / exiting fullscreen
+
+Toggle fullscreen mode by calling {@link app!App.requestDisplayMode `requestDisplayMode`}:
+
+
+```ts source="../src/app.examples.ts#App_requestDisplayMode_toggle"
+const container = document.getElementById("main")!;
+const ctx = app.getHostContext();
+const newMode = ctx?.displayMode === "inline" ? "fullscreen" : "inline";
+if (ctx?.availableDisplayModes?.includes(newMode)) {
+ const result = await app.requestDisplayMode({ mode: newMode });
+ container.classList.toggle("fullscreen", result.mode === "fullscreen");
+}
+```
+
+Listen for display mode changes via {@link app!App.onhostcontextchanged `onhostcontextchanged`} to update your UI:
+
+
+```ts source="../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode"
+app.onhostcontextchanged = (ctx) => {
+ // Adjust to current display mode
+ if (ctx.displayMode) {
+ const container = document.getElementById("main")!;
+ const isFullscreen = ctx.displayMode === "fullscreen";
+ container.classList.toggle("fullscreen", isFullscreen);
+ }
+
+ // Adjust display mode controls
+ if (ctx.availableDisplayModes) {
+ const fullscreenBtn = document.getElementById("fullscreen-btn")!;
+ const canFullscreen = ctx.availableDisplayModes.includes("fullscreen");
+ fullscreenBtn.style.display = canFullscreen ? "block" : "none";
+ }
+};
+```
+
+In fullscreen mode, remove the container's border radius so content extends to the viewport edges:
+
+```css
+#main {
+ border-radius: var(--border-radius-lg);
+
+ &.fullscreen {
+ border-radius: 0;
+ }
+}
+```
+
+> [!NOTE]
+> For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server), [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server), and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server).
+
+## Passing contextual information from the App to the model
+
+Use {@link app!App.updateModelContext `updateModelContext`} to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing:
+
+
+```ts source="../src/app.examples.ts#App_updateModelContext_appState"
+const markdown = `---
+item-count: ${itemList.length}
+total-cost: ${totalCost}
+currency: ${currency}
+---
+
+User is viewing their shopping cart with ${itemList.length} items selected:
+
+${itemList.map((item) => `- ${item}`).join("\n")}`;
+
+await app.updateModelContext({
+ content: [{ type: "text", text: markdown }],
+});
+```
+
+> [!NOTE]
+> For full examples that implement this pattern, see: [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) and [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server).
+
+## Sending large follow-up messages
+
+When you need to send more data than fits in a message, use {@link app!App.updateModelContext `updateModelContext`} to set the context first, then {@link app!App.sendMessage `sendMessage`} with a brief prompt to trigger a response:
+
+
+```ts source="../src/app.examples.ts#App_sendMessage_withLargeContext"
+const markdown = `---
+word-count: ${fullTranscript.split(/\s+/).length}
+speaker-names: ${speakerNames.join(", ")}
+---
+
+${fullTranscript}`;
+
+// Offload long transcript to model context
+await app.updateModelContext({ content: [{ type: "text", text: markdown }] });
+
+// Send brief trigger message
+await app.sendMessage({
+ role: "user",
+ content: [{ type: "text", text: "Summarize the key points" }],
+});
+```
+
+> [!NOTE]
+> For a full example that implements this pattern, see: [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server).
+
+## Persisting view state
+
+For recoverable view state (e.g., current page in a PDF viewer, camera position in a map), use [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) with a stable identifier provided by the server.
+
+**Server-side**: Tool handler generates a unique `viewUUID` and returns it in `CallToolResult._meta.viewUUID`:
+
+
+```tsx source="./patterns.tsx#persistDataServer"
+// In your tool callback, include viewUUID in the result metadata.
+return {
+ content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }],
+ structuredContent: { url, title, pageCount, initialPage: 1 },
+ _meta: {
+ viewUUID: randomUUID(),
+ },
+};
+```
+
+**Client-side**: Receive the UUID in {@link app!App.ontoolresult `ontoolresult`} and use it as the storage key:
+
+
+```tsx source="./patterns.tsx#persistData"
+// Store the viewUUID received from the server
+let viewUUID: string | undefined;
+
+// Helper to save state to localStorage
+function saveState(state: T): void {
+ if (!viewUUID) return;
+ try {
+ localStorage.setItem(viewUUID, JSON.stringify(state));
+ } catch (err) {
+ console.error("Failed to save view state:", err);
+ }
+}
+
+// Helper to load state from localStorage
+function loadState(): T | null {
+ if (!viewUUID) return null;
+ try {
+ const saved = localStorage.getItem(viewUUID);
+ return saved ? (JSON.parse(saved) as T) : null;
+ } catch (err) {
+ console.error("Failed to load view state:", err);
+ return null;
+ }
+}
+
+// Receive viewUUID from the tool result
+app.ontoolresult = (result) => {
+ viewUUID = result._meta?.viewUUID
+ ? String(result._meta.viewUUID)
+ : undefined;
+
+ // Restore any previously saved state
+ const savedState = loadState<{ currentPage: number }>();
+ if (savedState) {
+ // Apply restored state to your UI...
+ }
+};
+
+// Call saveState() whenever your view state changes
+// e.g., saveState({ currentPage: 5 });
+```
+
+For state that represents user effort (e.g., saved bookmarks, annotations, custom configurations), consider persisting it server-side using [app-only tools](#tools-that-are-private-to-apps) instead. Pass the `viewUUID` to the app-only tool to scope the saved data to that view instance.
+
+> [!NOTE]
+> For full examples using `localStorage`, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) (persists current page) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (persists camera position).
+
+## Pausing computation-heavy views when offscreen
+
+Views with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled offscreen. Use [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to pause expensive operations when the view isn't visible:
+
+
+```tsx source="./patterns.tsx#visibilityBasedPause"
+// Use IntersectionObserver to pause when view scrolls out of view
+const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ animation.play(); // or startPolling(), etc
+ } else {
+ animation.pause(); // or stopPolling(), etc
+ }
+ });
+});
+observer.observe(container);
+
+// Clean up when the host tears down the view
+app.onteardown = async () => {
+ observer.disconnect();
+ animation.pause();
+ return {};
+};
+```
+
+> [!NOTE]
+> For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) and [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server).
+
+## Lowering perceived latency
+
+Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive. This lets you show a loading preview before the complete input is available, such as streaming code into a `
` tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart.
+
+
+```ts source="../src/app.examples.ts#App_ontoolinputpartial_progressiveRendering"
+const codePreview = document.querySelector("#code-preview")!;
+const canvas = document.querySelector("#canvas")!;
+
+app.ontoolinputpartial = (params) => {
+ codePreview.textContent = (params.arguments?.code as string) ?? "";
+ codePreview.style.display = "block";
+ canvas.style.display = "none";
+};
+
+app.ontoolinput = (params) => {
+ codePreview.style.display = "none";
+ canvas.style.display = "block";
+ render(params.arguments?.code as string);
+};
+```
+
+> [!IMPORTANT]
+> Partial arguments are "healed" JSON โ the host closes unclosed brackets/braces to produce valid JSON. This means objects may be incomplete (e.g., the last item in an array may be truncated). Don't rely on partial data for critical operations; use it only for preview UI.
+
+> [!NOTE]
+> For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) and [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server).
diff --git a/docs/patterns.tsx b/docs/patterns.tsx
new file mode 100644
index 0000000..bb9eaa4
--- /dev/null
+++ b/docs/patterns.tsx
@@ -0,0 +1,462 @@
+/**
+ * Type-checked code examples for the patterns documentation.
+ *
+ * These examples are included in {@link ./patterns.md} via `@includeCode` tags.
+ * Each function's region markers define the code snippet that appears in the docs.
+ *
+ * @module
+ */
+
+import { App } from "../src/app.js";
+import {
+ applyDocumentTheme,
+ applyHostFonts,
+ applyHostStyleVariables,
+} from "../src/styles.js";
+import { randomUUID } from "node:crypto";
+import type {
+ CallToolResult,
+ ReadResourceResult,
+} from "@modelcontextprotocol/server";
+import type { McpUiHostContext } from "../src/types.js";
+import { useEffect, useState } from "react";
+import { useApp } from "../src/react/index.js";
+import { registerAppTool } from "../src/server/index.js";
+import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
+import { z } from "zod";
+
+/**
+ * Example: Polling for live data (Vanilla JS)
+ */
+function pollingVanillaJs(app: App, updateUI: (data: unknown) => void) {
+ //#region pollingVanillaJs
+ let intervalId: number | null = null;
+
+ async function poll() {
+ const result = await app.callServerTool({
+ name: "poll-data",
+ arguments: {},
+ });
+ updateUI(result.structuredContent);
+ }
+
+ function startPolling() {
+ if (intervalId !== null) return;
+ poll();
+ intervalId = window.setInterval(poll, 2000);
+ }
+
+ function stopPolling() {
+ if (intervalId === null) return;
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+
+ // Clean up when host tears down the view
+ app.onteardown = async () => {
+ stopPolling();
+ return {};
+ };
+ //#endregion pollingVanillaJs
+}
+
+/**
+ * Example: Polling for live data (React)
+ */
+function pollingReact(
+ app: App | null, // via useApp()
+) {
+ const [data, setData] = useState();
+
+ //#region pollingReact
+ useEffect(() => {
+ if (!app) return;
+ let cancelled = false;
+
+ async function poll() {
+ const result = await app!.callServerTool({
+ name: "poll-data",
+ arguments: {},
+ });
+ if (!cancelled) setData(result.structuredContent);
+ }
+
+ poll();
+ const id = setInterval(poll, 2000);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, [app]);
+ //#endregion pollingReact
+}
+
+/**
+ * Example: Server-side chunked data tool (app-only)
+ */
+function chunkedDataServer(server: McpServer) {
+ //#region chunkedDataServer
+ // Define the chunk response schema
+ const DataChunkSchema = z.object({
+ bytes: z.string(), // base64-encoded data
+ offset: z.number(),
+ byteCount: z.number(),
+ totalBytes: z.number(),
+ hasMore: z.boolean(),
+ });
+
+ const MAX_CHUNK_BYTES = 500 * 1024; // 500KB per chunk
+
+ registerAppTool(
+ server,
+ "read_data_bytes",
+ {
+ title: "Read Data Bytes",
+ description: "Load binary data in chunks",
+ inputSchema: z.object({
+ id: z.string().describe("Resource identifier"),
+ offset: z.number().min(0).default(0).describe("Byte offset"),
+ byteCount: z
+ .number()
+ .default(MAX_CHUNK_BYTES)
+ .describe("Bytes to read"),
+ }),
+ outputSchema: DataChunkSchema,
+ // Hidden from model - only callable by the App
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ id, offset, byteCount }): Promise => {
+ const data = await loadData(id); // Your data loading logic
+ const chunk = data.slice(offset, offset + byteCount);
+
+ return {
+ content: [{ type: "text", text: `${chunk.length} bytes at ${offset}` }],
+ structuredContent: {
+ bytes: Buffer.from(chunk).toString("base64"),
+ offset,
+ byteCount: chunk.length,
+ totalBytes: data.length,
+ hasMore: offset + chunk.length < data.length,
+ },
+ };
+ },
+ );
+ //#endregion chunkedDataServer
+}
+
+// Stub for the example
+declare function loadData(id: string): Promise;
+
+/**
+ * Example: Client-side chunked data loading
+ */
+function chunkedDataClient(app: App, resourceId: string) {
+ //#region chunkedDataClient
+ interface DataChunk {
+ bytes: string; // base64
+ offset: number;
+ byteCount: number;
+ totalBytes: number;
+ hasMore: boolean;
+ }
+
+ async function loadDataInChunks(
+ id: string,
+ onProgress?: (loaded: number, total: number) => void,
+ ): Promise {
+ const CHUNK_SIZE = 500 * 1024; // 500KB chunks
+ const chunks: Uint8Array[] = [];
+ let offset = 0;
+ let totalBytes = 0;
+ let hasMore = true;
+
+ while (hasMore) {
+ const result = await app.callServerTool({
+ name: "read_data_bytes",
+ arguments: { id, offset, byteCount: CHUNK_SIZE },
+ });
+
+ if (result.isError || !result.structuredContent) {
+ throw new Error("Failed to load data chunk");
+ }
+
+ const chunk = result.structuredContent as unknown as DataChunk;
+ totalBytes = chunk.totalBytes;
+ hasMore = chunk.hasMore;
+
+ // Decode base64 to bytes
+ const binaryString = atob(chunk.bytes);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ chunks.push(bytes);
+
+ offset += chunk.byteCount;
+ onProgress?.(offset, totalBytes);
+ }
+
+ // Combine all chunks into single array
+ const fullData = new Uint8Array(totalBytes);
+ let pos = 0;
+ for (const chunk of chunks) {
+ fullData.set(chunk, pos);
+ pos += chunk.length;
+ }
+
+ return fullData;
+ }
+
+ // Usage: load data with progress updates
+ loadDataInChunks(resourceId, (loaded, total) => {
+ console.log(`Loading: ${Math.round((loaded / total) * 100)}%`);
+ }).then((data) => {
+ console.log(`Loaded ${data.length} bytes`);
+ });
+ //#endregion chunkedDataClient
+}
+
+/**
+ * Example: Serving binary blobs via resources (server-side)
+ */
+function binaryBlobResourceServer(
+ server: McpServer,
+ getVideoData: (id: string) => Promise,
+) {
+ //#region binaryBlobResourceServer
+ server.registerResource(
+ "Video",
+ new ResourceTemplate("video://{id}", { list: undefined }),
+ {
+ description: "Video data served as base64 blob",
+ mimeType: "video/mp4",
+ },
+ async (uri, { id }): Promise => {
+ // Fetch or load your binary data
+ const idString = Array.isArray(id) ? id[0] : id;
+ const buffer = await getVideoData(idString);
+ const blob = Buffer.from(buffer).toString("base64");
+
+ return { contents: [{ uri: uri.href, mimeType: "video/mp4", blob }] };
+ },
+ );
+ //#endregion binaryBlobResourceServer
+}
+
+/**
+ * Example: Serving binary blobs via resources (client-side)
+ */
+async function binaryBlobResourceClient(app: App, videoId: string) {
+ //#region binaryBlobResourceClient
+ const result = await app.readServerResource({ uri: `video://${videoId}` });
+
+ const content = result.contents[0];
+ if (!content || !("blob" in content)) {
+ throw new Error("Resource did not contain blob data");
+ }
+
+ const videoEl = document.querySelector("video")!;
+ videoEl.src = `data:${content.mimeType!};base64,${content.blob}`;
+ //#endregion binaryBlobResourceClient
+}
+
+/**
+ * Example: Adapting to host context (theme, CSS variables, fonts, safe areas)
+ */
+function hostContextVanillaJs(app: App, mainEl: HTMLElement) {
+ //#region hostContextVanillaJs
+ function applyHostContext(ctx: McpUiHostContext) {
+ if (ctx.theme) {
+ applyDocumentTheme(ctx.theme);
+ }
+ if (ctx.styles?.variables) {
+ applyHostStyleVariables(ctx.styles.variables);
+ }
+ if (ctx.styles?.css?.fonts) {
+ applyHostFonts(ctx.styles.css.fonts);
+ }
+ if (ctx.safeAreaInsets) {
+ mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
+ mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
+ mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
+ mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
+ }
+ }
+
+ // Apply when host context changes
+ app.onhostcontextchanged = applyHostContext;
+
+ // Apply initial context after connecting
+ app.connect().then(() => {
+ const ctx = app.getHostContext();
+ if (ctx) {
+ applyHostContext(ctx);
+ }
+ });
+ //#endregion hostContextVanillaJs
+}
+
+/**
+ * Example: Adapting to host context with React (CSS variables, theme, fonts, safe areas)
+ */
+function hostContextReact() {
+ //#region hostContextReact
+ function MyApp() {
+ const [hostContext, setHostContext] = useState();
+
+ const { app } = useApp({
+ appInfo: { name: "MyApp", version: "1.0.0" },
+ capabilities: {},
+ onAppCreated: (app) => {
+ app.onhostcontextchanged = (ctx) => {
+ setHostContext((prev) => ({ ...prev, ...ctx }));
+ };
+ },
+ });
+
+ // Set initial host context after connection
+ useEffect(() => {
+ if (app) {
+ setHostContext(app.getHostContext());
+ }
+ }, [app]);
+
+ // Apply styles when host context changes
+ useEffect(() => {
+ if (hostContext?.theme) {
+ applyDocumentTheme(hostContext.theme);
+ }
+ if (hostContext?.styles?.variables) {
+ applyHostStyleVariables(hostContext.styles.variables);
+ }
+ if (hostContext?.styles?.css?.fonts) {
+ applyHostFonts(hostContext.styles.css.fonts);
+ }
+ }, [hostContext]);
+
+ return (
+
+ Styled with host CSS variables, fonts, and safe area insets
+
+ );
+ }
+ //#endregion hostContextReact
+}
+
+/**
+ * Example: Persisting view state (server-side)
+ */
+function persistViewStateServer(url: string, title: string, pageCount: number) {
+ function toolCallback(): CallToolResult {
+ //#region persistDataServer
+ // In your tool callback, include viewUUID in the result metadata.
+ return {
+ content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }],
+ structuredContent: { url, title, pageCount, initialPage: 1 },
+ _meta: {
+ viewUUID: randomUUID(),
+ },
+ };
+ //#endregion persistDataServer
+ }
+}
+
+/**
+ * Example: Persisting view state (client-side)
+ */
+function persistViewState(app: App) {
+ //#region persistData
+ // Store the viewUUID received from the server
+ let viewUUID: string | undefined;
+
+ // Helper to save state to localStorage
+ function saveState(state: T): void {
+ if (!viewUUID) return;
+ try {
+ localStorage.setItem(viewUUID, JSON.stringify(state));
+ } catch (err) {
+ console.error("Failed to save view state:", err);
+ }
+ }
+
+ // Helper to load state from localStorage
+ function loadState(): T | null {
+ if (!viewUUID) return null;
+ try {
+ const saved = localStorage.getItem(viewUUID);
+ return saved ? (JSON.parse(saved) as T) : null;
+ } catch (err) {
+ console.error("Failed to load view state:", err);
+ return null;
+ }
+ }
+
+ // Receive viewUUID from the tool result
+ app.ontoolresult = (result) => {
+ viewUUID = result._meta?.viewUUID
+ ? String(result._meta.viewUUID)
+ : undefined;
+
+ // Restore any previously saved state
+ const savedState = loadState<{ currentPage: number }>();
+ if (savedState) {
+ // Apply restored state to your UI...
+ }
+ };
+
+ // Call saveState() whenever your view state changes
+ // e.g., saveState({ currentPage: 5 });
+ //#endregion persistData
+}
+
+/**
+ * Example: Pausing computation-heavy views when out of view
+ */
+function visibilityBasedPause(
+ app: App,
+ container: HTMLElement,
+ animation: { play: () => void; pause: () => void },
+) {
+ //#region visibilityBasedPause
+ // Use IntersectionObserver to pause when view scrolls out of view
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ animation.play(); // or startPolling(), etc
+ } else {
+ animation.pause(); // or stopPolling(), etc
+ }
+ });
+ });
+ observer.observe(container);
+
+ // Clean up when the host tears down the view
+ app.onteardown = async () => {
+ observer.disconnect();
+ animation.pause();
+ return {};
+ };
+ //#endregion visibilityBasedPause
+}
+
+// Suppress unused variable warnings
+void pollingVanillaJs;
+void pollingReact;
+void chunkedDataServer;
+void chunkedDataClient;
+void binaryBlobResourceServer;
+void binaryBlobResourceClient;
+void hostContextVanillaJs;
+void hostContextReact;
+void persistViewStateServer;
+void persistViewState;
+void visibilityBasedPause;
diff --git a/docs/quickstart-success.png b/docs/quickstart-success.png
new file mode 100644
index 0000000..058fb3f
Binary files /dev/null and b/docs/quickstart-success.png differ
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 9481df3..a021912 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -1,203 +1,369 @@
---
title: Quickstart
+group: Getting Started
+description: Build your first MCP App step by step โ create an MCP server with an interactive View that renders inside Claude Desktop and other MCP hosts.
---
# 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.
+This tutorial walks you through building an MCP Appโa tool with an interactive **View** (a UI that renders inside an iframe) that displays in MCP hosts like Claude Desktop.
+
+> [!TIP]
+> Feel like vibe coding instead? Try the [MCP Apps agent skills](./agent-skills.md).
## 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`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs).
+A simple app that fetches the current server time and displays it in an interactive View. You'll learn the core pattern: **MCP Apps = Tool + UI Resource**. The complete example is available in [`examples/quickstart`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/quickstart).
## Prerequisites
-- Familiarity with MCP concepts, especially [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)
-- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
-- Node.js 18+
+This tutorial assumes you've built an MCP server before and are comfortable with [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources). If not, the [official MCP quickstart](https://modelcontextprotocol.io/docs/develop/build-server) is a good place to start.
-> [!TIP]
-> New to building MCP servers? Start with the [official MCP quickstart guide](https://modelcontextprotocol.io/docs/develop/build-server) to learn the core concepts first.
+We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server.
-## 1. Project Setup
+You'll also need Node.js 20+.
-Create a new directory and initialize:
+## 1. Set up the project
+
+We'll set up a minimal TypeScript project with Vite for bundling.
+
+Start by creating a project directory:
```bash
mkdir my-mcp-app && cd my-mcp-app
-npm init -y
```
-Install dependencies:
+Install the dependencies you'll need:
```bash
-npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
-npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
+npm init -y
+npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 @modelcontextprotocol/node@^2.0.0 @modelcontextprotocol/express@^2.0.0 zod@^4.2.0 express cors
+npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env
```
-Create `tsconfig.json`:
+Configure your [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/package.json):
-```json
+```bash
+npm pkg set type=module
+npm pkg set scripts.build="tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build"
+npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"'
+```
+
+> [!NOTE]
+> Windows `cmd.exe` users will need to convert quotes in the above command: `npm pkg set scripts.start="concurrently --raw ""cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch"" ""tsx watch main.ts"""`.
+
+
+Create tsconfig.json:
+
+
+```json source="../examples/quickstart/tsconfig.json"
{
"compilerOptions": {
- "target": "ES2022",
+ "types": ["node", "vite/client"],
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
"strict": true,
- "esModuleInterop": true,
"skipLibCheck": true,
- "outDir": "dist"
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
},
- "include": ["*.ts", "src/**/*.ts"]
+ "include": ["src", "server.ts", "main.ts"]
}
```
-Create `vite.config.ts` โ this bundles your UI into a single HTML file:
+
-```typescript
-import { defineConfig } from "vite";
+
+Create tsconfig.server.json โ for compiling server-side code:
+
+
+```json source="../examples/quickstart/tsconfig.server.json"
+{
+ "compilerOptions": {
+ "types": ["node"],
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "outDir": "./dist",
+ "rootDir": ".",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["server.ts", "main.ts"]
+}
+```
+
+
+
+
+Create vite.config.ts โ bundles UI into a single HTML file:
+
+
+```ts source="../examples/quickstart/vite.config.ts"
+import { createLogger, defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
+const INPUT = process.env.INPUT;
+if (!INPUT) {
+ throw new Error("INPUT environment variable is not set");
+}
+
+const isDevelopment = process.env.NODE_ENV === "development";
+
+const prefixedLogger = createLogger();
+for (const level of ["info", "warn", "error"] as const) {
+ const fn = prefixedLogger[level];
+ prefixedLogger[level] = (msg, opts) => fn(msg.replace(/^/mg, "[vite] "), opts);
+}
+
export default defineConfig({
+ customLogger: prefixedLogger,
plugins: [viteSingleFile()],
build: {
- outDir: "dist",
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+
rollupOptions: {
- input: process.env.INPUT,
+ input: INPUT,
},
+ outDir: "dist",
+ emptyOutDir: false,
},
});
```
-Add to your `package.json`:
+
-```json
-{
- "type": "module",
- "scripts": {
- "build": "INPUT=mcp-app.html vite build",
- "serve": "npx tsx server.ts"
- }
-}
+Your `my-mcp-app` directory should now contain:
+
+```
+my-mcp-app/
+โโโ package.json
+โโโ tsconfig.json
+โโโ tsconfig.server.json
+โโโ vite.config.ts
```
-> [!NOTE]
-> **Full files:** [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/package.json), [`tsconfig.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/tsconfig.json), [`vite.config.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/vite.config.ts)
+With the project scaffolded, let's write the server code.
-## 2. Create the Server
+## 2. Register the tool and UI resource
MCP Apps use a **two-part registration**:
1. A **tool** that the LLM/host calls
-2. A **resource** that serves the UI HTML
+2. A **resource** that contains the View HTML
-The tool's `_meta` field links them together.
+The tool's `_meta` field links them together via the resource's URI. When an MCP Apps-capable host calls the tool, it will also read the resource and render the View.
-Create `server.ts`:
+Create [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/server.ts), which registers the tool and its UI resource:
-```typescript
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+
+```ts source="../examples/quickstart/server.ts"
import {
- registerAppTool,
registerAppResource,
+ registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import cors from "cors";
-import express from "express";
+import { McpServer } from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
+import { z } from "zod";
-const server = new McpServer({
- name: "My MCP App Server",
- version: "1.0.0",
-});
+const DIST_DIR = path.join(import.meta.dirname, "dist");
-// Two-part registration: tool + resource, tied together by the resource URI.
-const resourceUri = "ui://get-time/mcp-app.html";
-
-// Register a tool with UI metadata. When the host calls this tool, it reads
-// `_meta.ui.resourceUri` to know which resource to fetch and render as an
-// interactive UI.
-registerAppTool(
- server,
- "get-time",
- {
- title: "Get Time",
- description: "Returns the current server time.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
- },
- async () => {
- const time = new Date().toISOString();
- return {
- content: [{ type: "text", text: time }],
- };
- },
-);
-
-// Register the resource, which returns the bundled HTML/JavaScript for the UI.
-registerAppResource(
- server,
- resourceUri,
- resourceUri,
- { mimeType: RESOURCE_MIME_TYPE },
- async () => {
- const html = await fs.readFile(
- path.join(import.meta.dirname, "dist", "mcp-app.html"),
- "utf-8",
- );
- return {
- contents: [
- { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
- ],
- };
- },
-);
-
-// Start an Express server that exposes the MCP endpoint.
-const expressApp = express();
-expressApp.use(cors());
-expressApp.use(express.json());
-
-expressApp.post("/mcp", async (req, res) => {
- const transport = new StreamableHTTPServerTransport({
- sessionIdGenerator: undefined,
- enableJsonResponse: true,
+/**
+ * Creates a new MCP server instance with tools and resources registered.
+ */
+export function createServer(): McpServer {
+ const server = new McpServer({
+ name: "Quickstart MCP App Server",
+ version: "1.0.0",
});
- res.on("close", () => transport.close());
- await server.connect(transport);
- await transport.handleRequest(req, res, req.body);
-});
-expressApp.listen(3001, (err) => {
- if (err) {
- console.error("Error starting server:", err);
- process.exit(1);
+ // Two-part registration: tool + resource, tied together by the resource URI.
+ const resourceUri = "ui://get-time/mcp-app.html";
+
+ // Register a tool with UI metadata. When the host calls this tool, it reads
+ // `_meta.ui.resourceUri` to know which resource to fetch and render as an
+ // interactive UI.
+ registerAppTool(
+ server,
+ "get-time",
+ {
+ title: "Get Time",
+ description: "Returns the current server time.",
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
+ },
+ async () => {
+ const time = new Date().toISOString();
+ return { content: [{ type: "text", text: time }] };
+ },
+ );
+
+ // Register the resource, which returns the bundled HTML/JavaScript for the UI.
+ registerAppResource(
+ server,
+ resourceUri,
+ resourceUri,
+ { mimeType: RESOURCE_MIME_TYPE },
+ async () => {
+ const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
+
+ return {
+ contents: [
+ { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
+ ],
+ };
+ },
+ );
+
+ return server;
+}
+```
+
+
+Create main.ts โ the entry point that starts the server:
+
+
+```ts source="../examples/quickstart/main.ts"
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import cors from "cors";
+import type { Request, Response } from "express";
+import { createServer } from "./server.js";
+
+/**
+ * Starts an MCP server with Streamable HTTP transport in stateless mode.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance per request.
+ */
+export async function startStreamableHTTPServer(
+ createServer: () => McpServer,
+): Promise {
+ const port = parseInt(process.env.PORT ?? "3001", 10);
+
+ const app = createMcpExpressApp({ host: "0.0.0.0" });
+ app.use(cors());
+
+ app.all("/mcp", async (req: Request, res: Response) => {
+ const server = createServer();
+ const transport = new NodeStreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ });
+
+ res.on("close", () => {
+ transport.close().catch(() => {});
+ server.close().catch(() => {});
+ });
+
+ try {
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error("MCP error:", error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: "2.0",
+ error: { code: -32603, message: "Internal server error" },
+ id: null,
+ });
+ }
+ }
+ });
+
+ const httpServer = app.listen(port, (err) => {
+ if (err) {
+ console.error("Failed to start server:", err);
+ process.exit(1);
+ }
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
+ });
+
+ const shutdown = () => {
+ console.log("\nShutting down...");
+ httpServer.close(() => process.exit(0));
+ };
+
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
+async function main() {
+ if (process.argv.includes("--stdio")) {
+ await startStdioServer(createServer);
+ } else {
+ await startStreamableHTTPServer(createServer);
}
- console.log("Server listening on http://localhost:3001/mcp");
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
});
```
-> [!NOTE]
-> **Full file:** [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/server.ts)
+
-Then, verify your server compiles:
+Your `my-mcp-app` directory should now contain:
+
+```
+my-mcp-app/
+โโโ main.ts
+โโโ package.json
+โโโ server.ts
+โโโ tsconfig.json
+โโโ tsconfig.server.json
+โโโ vite.config.ts
+```
+
+Let's verify everything compiles:
```bash
npx tsc --noEmit
```
-No output means success. If you see errors, check for typos in `server.ts`.
+No output means success! If you see errors, check for typos in `server.ts` or `main.ts`.
-## 3. Build the UI
+The server can return the current time when the tool is called. Now let's build the UI to display it.
-Create `mcp-app.html`:
+## 3. Build the View
-```html
+The View consists of an HTML page and a script that connects to the host.
+
+Create [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/mcp-app.html), the HTML for your View:
+
+
+```html source="../examples/quickstart/mcp-app.html"
@@ -214,9 +380,10 @@ Create `mcp-app.html`:
```
-Create `src/mcp-app.ts`:
+Create [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/quickstart/src/mcp-app.ts), which connects to the host and handles user interactions:
-```typescript
+
+```ts source="../examples/quickstart/src/mcp-app.ts"
import { App } from "@modelcontextprotocol/ext-apps";
// Get element references
@@ -226,7 +393,8 @@ 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
+// Handle tool results from the server. Set before `app.connect()` to avoid
+// missing the initial tool result.
app.ontoolresult = (result) => {
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
@@ -234,6 +402,7 @@ app.ontoolresult = (result) => {
// Wire up button click
getTimeBtn.addEventListener("click", async () => {
+ // `app.callServerTool()` lets the UI request fresh data from the server
const result = await app.callServerTool({ name: "get-time", arguments: {} });
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
@@ -243,50 +412,76 @@ getTimeBtn.addEventListener("click", async () => {
app.connect();
```
-> [!NOTE]
-> **Full files:** [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/mcp-app.html), [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
+Your `my-mcp-app` directory should now contain:
-Build the UI:
+```
+my-mcp-app/
+โโโ main.ts
+โโโ mcp-app.html
+โโโ package.json
+โโโ server.ts
+โโโ src/
+โ โโโ mcp-app.ts
+โโโ tsconfig.json
+โโโ tsconfig.server.json
+โโโ vite.config.ts
+```
+
+Now let's build the bundled View:
```bash
npm run build
```
-This produces `dist/mcp-app.html` which contains your bundled app:
+This produces `dist/mcp-app.html`:
```console
-$ ls dist/mcp-app.html
-dist/mcp-app.html
+$ ls dist/
+mcp-app.html
```
-## 4. Test It
+The View will connect to the host, receive the tool result, and display it. Let's see it in action!
-You'll need two terminals.
+## 4. See it in action
-**Terminal 1** โ Build and start your server:
+You'll need two terminal windows.
+
+**Terminal 1** โ Start your server (with watch mode):
```bash
-npm run build && npm run serve
+npm start
+```
+
+You should see:
+
+```console
+MCP server listening on http://localhost:3001/mcp
```
**Terminal 2** โ Run the test host (from the [ext-apps repo](https://github.com/modelcontextprotocol/ext-apps)):
```bash
git clone https://github.com/modelcontextprotocol/ext-apps.git
-cd ext-apps/examples/basic-host
+cd ext-apps
npm install
-npm run start
+cd examples/basic-host
+npm start
```
Open http://localhost:8080 in your browser:
1. Select **get-time** from the "Tool Name" dropdown
2. Click **Call Tool**
-3. Your UI renders in the sandbox below
+3. Your View renders in the sandbox below
4. Click **Get Server Time** โ the current time appears!
+
+
+You've built your first MCP App!
+
## Next Steps
-- **Host communication**: Add [`sendMessage()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), and [`sendOpenLink()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink) to interact with the host โ see [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
+- **Continue learning**: The [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) example builds on this quickstart with host communication, theming, and lifecycle handlers
- **React version**: Compare with [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) for a React-based UI
-- **API reference**: See the full [API documentation](https://modelcontextprotocol.github.io/ext-apps/api/)
+- **Other frameworks**: See also [Vue](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue), [Svelte](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte), [Preact](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact), and [Solid](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) examples
+- **API reference**: See the full [API documentation](https://apps.extensions.modelcontextprotocol.io/api/)
diff --git a/docs/testing-mcp-apps.md b/docs/testing-mcp-apps.md
new file mode 100644
index 0000000..a8eb333
--- /dev/null
+++ b/docs/testing-mcp-apps.md
@@ -0,0 +1,93 @@
+---
+title: Testing MCP Apps
+group: Getting Started
+description: Test MCP Apps locally with the basic-host reference implementation or in production hosts like Claude.ai and VS Code.
+---
+
+# Test Your MCP App
+
+This guide covers two approaches for testing your MCP App: using the `basic-host` reference implementation for local development, or using an MCP Apps-compatible host like Claude\.ai or VS Code.
+
+## Test with basic-host
+
+The [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example in this repository is a reference host implementation that lets you select a tool, call it, and see your App UI rendered in a sandboxed iframe.
+
+**Prerequisites:**
+
+- Node.js installed
+- Your MCP server running locally (e.g., at `http://localhost:3001/mcp`)
+
+**Steps:**
+
+1. Clone the repository and install dependencies:
+
+ ```bash
+ git clone https://github.com/modelcontextprotocol/ext-apps.git
+ cd ext-apps
+ npm install
+ cd examples/basic-host
+ ```
+
+ The root install runs the package build, including type-checking the
+ documentation snippets against the pinned base MCP SDK 2.x packages. Do
+ not bypass a failed build before testing the host.
+
+2. Start basic-host, pointing it to your MCP server:
+
+ ```bash
+ SERVERS='["http://localhost:3001/mcp"]' npm start
+ ```
+
+ To connect to multiple servers, list them in the array:
+
+ ```bash
+ SERVERS='["http://localhost:3001/mcp", "http://localhost:3002/mcp"]' npm start
+ ```
+
+3. Open http://localhost:8080 in your browser.
+
+4. Select your server from the dropdown, then select a tool with UI support.
+
+5. Enter any required tool input as JSON and click "Call Tool" to see your App render.
+
+### Debugging with basic-host
+
+The basic-host UI includes collapsible panels to help you debug your App:
+
+- **Tool Input** โ The JSON input sent to your tool
+- **Tool Result** โ The result returned by your tool
+- **Messages** โ Messages sent by your App to the model
+- **Model Context** โ Context updates sent by your App
+
+For additional observability, open your browser's developer console. Basic-host logs key events with a `[HOST]` prefix, including server connections, tool calls, App initialization, and App-to-host requests.
+
+## Test with an MCP Apps-compatible host
+
+To test your App in a real conversational environment, install your MCP server in a host that supports MCP Apps:
+
+- Claude\.ai
+ - [Remote MCP servers (over HTTP)](https://claude.com/docs/connectors/custom/remote-mcp)
+ - [Local MCP servers (over stdio)](https://claude.com/docs/connectors/custom/desktop-extensions)
+- [VS Code (Insiders)](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
+- [Goose](https://block.github.io/goose/docs/getting-started/using-extensions/)
+
+Once your server is configured, ask the agent to perform a task related to your App-enhanced tool. For example, if you have a weather App, ask the agent "Show me the current weather."
+
+## Expose local servers with `cloudflared`
+
+Remote hosts like Claude\.ai cannot reach `localhost`. To test a local HTTP server with a remote host, use [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/) to create a publicly accessible tunnel:
+
+1. Start your MCP server locally (e.g., at `http://localhost:3001/mcp`).
+
+2. Run `cloudflared` to expose your server:
+
+ ```bash
+ npx cloudflared tunnel --url http://localhost:3001
+ ```
+
+3. Copy the generated URL from the `cloudflared` output (e.g., `https://random-name.trycloudflare.com`).
+
+4. Add that URL as a remote MCP server in your host, appending your MCP endpoint path (e.g., `https://random-name.trycloudflare.com/mcp`).
+
+> [!NOTE]
+> The tunnel URL changes each time you restart `cloudflared`.
diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json
index 9975de4..cefe9d5 100644
--- a/examples/basic-host/package.json
+++ b/examples/basic-host/package.json
@@ -1,7 +1,7 @@
{
"homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host",
"name": "@modelcontextprotocol/ext-apps-basic-host",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"scripts": {
"build": "tsc --noEmit && concurrently \"cross-env INPUT=index.html vite build\" \"cross-env INPUT=sandbox.html vite build\"",
@@ -11,15 +11,15 @@
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\""
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
@@ -29,7 +29,6 @@
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^6.0.0",
- "vite-plugin-singlefile": "^2.3.0",
- "vitest": "^3.2.4"
+ "vite-plugin-singlefile": "^2.3.0"
}
}
diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html
index e0790fb..fb4cd8d 100644
--- a/examples/basic-host/sandbox.html
+++ b/examples/basic-host/sandbox.html
@@ -12,6 +12,8 @@
margin: 0;
height: 100vh;
width: 100vw;
+ /* Transparent background allows parent page to show through */
+ background-color: transparent;
}
body {
display: flex;
@@ -26,6 +28,8 @@
padding: 0px;
overflow: hidden;
flex-grow: 1;
+ /* Inherit color scheme from parent for consistent transparency */
+ color-scheme: inherit;
}
diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts
index 03c3a0b..bc3fbff 100644
--- a/examples/basic-host/serve.ts
+++ b/examples/basic-host/serve.ts
@@ -82,6 +82,8 @@ function buildCspHeader(csp?: McpUiResourceCsp): string {
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
// Fonts: same-origin + data/blob URIs + specified domains
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
+ // Media (audio/video): same-origin + data/blob URIs + specified domains
+ `media-src 'self' data: blob: ${resourceDomains}`.trim(),
// Network requests: same-origin + specified API/tile domains
`connect-src 'self' ${connectDomains}`.trim(),
// Workers: same-origin + blob (dynamic workers) + specified domains
diff --git a/examples/basic-host/src/global.css b/examples/basic-host/src/global.css
index 97cda44..74b5751 100644
--- a/examples/basic-host/src/global.css
+++ b/examples/basic-host/src/global.css
@@ -1,10 +1,38 @@
+:root {
+ color-scheme: light dark;
+
+ /* Light theme (default) */
+ --color-bg: #ffffff;
+ --color-bg-secondary: #f5f5f5;
+ --color-text: #1f2937;
+ --color-text-secondary: #6b7280;
+ --color-border: #e5e7eb;
+ --color-primary: #1e3a5f;
+ --color-primary-hover: #2d4a7c;
+}
+
+[data-theme="dark"] {
+ --color-bg: #1a1a1a;
+ --color-bg-secondary: #2d2d2d;
+ --color-text: #f3f4f6;
+ --color-text-secondary: #9ca3af;
+ --color-border: #404040;
+ --color-primary: #3b82f6;
+ --color-primary-hover: #60a5fa;
+}
+
* {
box-sizing: border-box;
}
html, body {
+ margin: 0;
+ padding: 0;
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ transition: background-color 0.2s, color 0.2s;
}
code {
diff --git a/examples/basic-host/src/host-styles.ts b/examples/basic-host/src/host-styles.ts
new file mode 100644
index 0000000..f6d4887
--- /dev/null
+++ b/examples/basic-host/src/host-styles.ts
@@ -0,0 +1,113 @@
+/**
+ * MCP style variables for the basic-host example.
+ * These are passed to apps via hostContext.styles.variables.
+ */
+import type { McpUiStyles } from "@modelcontextprotocol/ext-apps";
+
+/**
+ * MCP App style variables using light-dark() for theme adaptation.
+ * Apps receive these and can use them as CSS custom properties.
+ */
+export const HOST_STYLE_VARIABLES: McpUiStyles = {
+ // Background colors - using light-dark() for automatic adaptation
+ "--color-background-primary": "light-dark(#ffffff, #1a1a1a)",
+ "--color-background-secondary": "light-dark(#f5f5f5, #2d2d2d)",
+ "--color-background-tertiary": "light-dark(#e5e5e5, #404040)",
+ "--color-background-inverse": "light-dark(#1a1a1a, #ffffff)",
+ "--color-background-ghost": "light-dark(rgba(255,255,255,0), rgba(26,26,26,0))",
+ "--color-background-info": "light-dark(#eff6ff, #1e3a5f)",
+ "--color-background-danger": "light-dark(#fef2f2, #7f1d1d)",
+ "--color-background-success": "light-dark(#f0fdf4, #14532d)",
+ "--color-background-warning": "light-dark(#fefce8, #713f12)",
+ "--color-background-disabled": "light-dark(rgba(255,255,255,0.5), rgba(26,26,26,0.5))",
+
+ // Text colors
+ "--color-text-primary": "light-dark(#1f2937, #f3f4f6)",
+ "--color-text-secondary": "light-dark(#6b7280, #9ca3af)",
+ "--color-text-tertiary": "light-dark(#9ca3af, #6b7280)",
+ "--color-text-inverse": "light-dark(#f3f4f6, #1f2937)",
+ "--color-text-ghost": "light-dark(rgba(107,114,128,0.5), rgba(156,163,175,0.5))",
+ "--color-text-info": "light-dark(#1d4ed8, #60a5fa)",
+ "--color-text-danger": "light-dark(#b91c1c, #f87171)",
+ "--color-text-success": "light-dark(#15803d, #4ade80)",
+ "--color-text-warning": "light-dark(#a16207, #fbbf24)",
+ "--color-text-disabled": "light-dark(rgba(31,41,55,0.5), rgba(243,244,246,0.5))",
+
+ // Border colors
+ "--color-border-primary": "light-dark(#e5e7eb, #404040)",
+ "--color-border-secondary": "light-dark(#d1d5db, #525252)",
+ "--color-border-tertiary": "light-dark(#f3f4f6, #374151)",
+ "--color-border-inverse": "light-dark(rgba(255,255,255,0.3), rgba(0,0,0,0.3))",
+ "--color-border-ghost": "light-dark(rgba(229,231,235,0), rgba(64,64,64,0))",
+ "--color-border-info": "light-dark(#93c5fd, #1e40af)",
+ "--color-border-danger": "light-dark(#fca5a5, #991b1b)",
+ "--color-border-success": "light-dark(#86efac, #166534)",
+ "--color-border-warning": "light-dark(#fde047, #854d0e)",
+ "--color-border-disabled": "light-dark(rgba(229,231,235,0.5), rgba(64,64,64,0.5))",
+
+ // Ring colors (focus)
+ "--color-ring-primary": "light-dark(#3b82f6, #60a5fa)",
+ "--color-ring-secondary": "light-dark(#6b7280, #9ca3af)",
+ "--color-ring-inverse": "light-dark(#ffffff, #1f2937)",
+ "--color-ring-info": "light-dark(#2563eb, #3b82f6)",
+ "--color-ring-danger": "light-dark(#dc2626, #ef4444)",
+ "--color-ring-success": "light-dark(#16a34a, #22c55e)",
+ "--color-ring-warning": "light-dark(#ca8a04, #eab308)",
+
+ // Typography - Family
+ "--font-sans": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
+ "--font-mono": "ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', monospace",
+
+ // Typography - Weight
+ "--font-weight-normal": "400",
+ "--font-weight-medium": "500",
+ "--font-weight-semibold": "600",
+ "--font-weight-bold": "700",
+
+ // Typography - Text Size
+ "--font-text-xs-size": "0.75rem",
+ "--font-text-sm-size": "0.875rem",
+ "--font-text-md-size": "1rem",
+ "--font-text-lg-size": "1.125rem",
+
+ // Typography - Heading Size
+ "--font-heading-xs-size": "0.75rem",
+ "--font-heading-sm-size": "0.875rem",
+ "--font-heading-md-size": "1rem",
+ "--font-heading-lg-size": "1.25rem",
+ "--font-heading-xl-size": "1.5rem",
+ "--font-heading-2xl-size": "1.875rem",
+ "--font-heading-3xl-size": "2.25rem",
+
+ // Typography - Text Line Height
+ "--font-text-xs-line-height": "1.4",
+ "--font-text-sm-line-height": "1.4",
+ "--font-text-md-line-height": "1.5",
+ "--font-text-lg-line-height": "1.5",
+
+ // Typography - Heading Line Height
+ "--font-heading-xs-line-height": "1.4",
+ "--font-heading-sm-line-height": "1.4",
+ "--font-heading-md-line-height": "1.4",
+ "--font-heading-lg-line-height": "1.3",
+ "--font-heading-xl-line-height": "1.25",
+ "--font-heading-2xl-line-height": "1.2",
+ "--font-heading-3xl-line-height": "1.1",
+
+ // Border radius
+ "--border-radius-xs": "2px",
+ "--border-radius-sm": "4px",
+ "--border-radius-md": "6px",
+ "--border-radius-lg": "8px",
+ "--border-radius-xl": "12px",
+ "--border-radius-full": "9999px",
+
+ // Border width
+ "--border-width-regular": "1px",
+
+ // Shadows
+ "--shadow-hairline": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
+ "--shadow-sm": "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
+ "--shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
+ "--shadow-lg": "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)",
+};
diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts
index 9ce9fa6..5f680ff 100644
--- a/examples/basic-host/src/implementation.ts
+++ b/examples/basic-host/src/implementation.ts
@@ -1,7 +1,14 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
-import { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
-import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
+import {
+ Client,
+ SSEClientTransport,
+ StreamableHTTPClientTransport,
+ type CallToolResult,
+ type Resource,
+ type Tool,
+} from "@modelcontextprotocol/client";
+import { getTheme, onThemeChange } from "./theme";
+import { HOST_STYLE_VARIABLES } from "./host-styles";
const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
@@ -19,16 +26,14 @@ export interface ServerInfo {
name: string;
client: Client;
tools: Map;
+ resources: Map;
appHtmlCache: Map;
}
export async function connectToServer(serverUrl: URL): Promise {
- const client = new Client(IMPLEMENTATION);
-
log.info("Connecting to server:", serverUrl.href);
- await client.connect(new StreamableHTTPClientTransport(serverUrl));
- log.info("Connection successful");
+ const client = await connectWithFallback(serverUrl);
const name = client.getServerVersion()?.name ?? serverUrl.href;
@@ -36,7 +41,34 @@ export async function connectToServer(serverUrl: URL): Promise {
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
log.info("Server tools:", Array.from(tools.keys()));
- return { name, client, tools, appHtmlCache: new Map() };
+ // Fetch resources for listing-level _meta.ui (fallback for content-level)
+ const resourcesList = await client.listResources();
+ const resources = new Map(resourcesList.resources.map((r) => [r.uri, r]));
+ log.info("Server resources:", Array.from(resources.keys()));
+
+ return { name, client, tools, resources, appHtmlCache: new Map() };
+}
+
+async function connectWithFallback(serverUrl: URL): Promise {
+ // Try Streamable HTTP first (modern transport)
+ try {
+ const client = new Client(IMPLEMENTATION);
+ await client.connect(new StreamableHTTPClientTransport(serverUrl));
+ log.info("Connected via Streamable HTTP transport");
+ return client;
+ } catch (streamableError) {
+ log.info("Streamable HTTP failed:", streamableError);
+ }
+
+ // Fall back to SSE (deprecated but needed for older servers)
+ try {
+ const client = new Client(IMPLEMENTATION);
+ await client.connect(new SSEClientTransport(serverUrl));
+ log.info("Connected via SSE transport");
+ return client;
+ } catch (sseError) {
+ throw new Error(`Could not connect with any transport. SSE error: ${sseError}`);
+ }
}
@@ -106,14 +138,23 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise {
+ log.info("Theme changed:", newTheme);
+ appBridge.sendHostContextChange({ theme: newTheme });
+ });
+
+ // Per spec, the host SHOULD notify the view when container dimensions
+ // change. A ResizeObserver on the iframe covers window resize, layout
+ // shifts, and the inlineโfullscreen panel toggle (which React applies
+ // a tick after onrequestdisplaymode returns โ sending containerDimensions
+ // alongside displayMode there would race the layout). Height stays
+ // flexible (maxHeight) so the view can keep driving it via sendSizeChanged.
+ const iframeResizeObserver = new ResizeObserver(([entry]) => {
+ const width = Math.round(entry.contentRect.width);
+ if (width > 0) {
+ appBridge.sendHostContextChange({
+ containerDimensions: { width, maxHeight: 6000 },
+ });
+ }
+ });
+ iframeResizeObserver.observe(iframe);
+ // AppBridge inherits Protocol's onclose hook โ chain disposal there.
+ const prevOnclose = appBridge.onclose;
+ appBridge.onclose = () => {
+ iframeResizeObserver.disconnect();
+ prevOnclose?.();
+ };
+
+ // Register all handlers before calling connect(). The view can start
// sending requests immediately after the initialization handshake, so any
// handlers registered after connect() might miss early requests.
diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css
index 75fa1de..0bd09dd 100644
--- a/examples/basic-host/src/index.module.css
+++ b/examples/basic-host/src/index.module.css
@@ -1,8 +1,34 @@
+/* Theme toggle button */
+.themeToggle {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ background-color: var(--color-bg-secondary);
+ font-size: 1.25rem;
+ cursor: pointer;
+ z-index: 1000;
+ transition: background-color 0.2s, border-color 0.2s, transform 0.1s;
+
+ &:hover {
+ background-color: var(--color-border);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+}
+
.callToolPanel, .toolCallInfoPanel {
margin: 0 auto;
padding: 1rem;
- border: 1px solid #ddd;
+ border: 1px solid var(--color-border);
border-radius: 4px;
+ background-color: var(--color-bg-secondary);
* + & {
margin-top: 1rem;
@@ -28,9 +54,11 @@
select,
textarea {
padding: 0.5rem;
- border: 1px solid #ccc;
+ border: 1px solid var(--color-border);
border-radius: 4px;
font-size: inherit;
+ background-color: var(--color-bg);
+ color: var(--color-text);
}
.toolSelect {
@@ -43,7 +71,7 @@
resize: vertical;
&[aria-invalid="true"] {
- background-color: #fdd;
+ background-color: light-dark(#fee2e2, #7f1d1d);
}
}
@@ -53,14 +81,14 @@
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
- background-color: #1e3a5f;
+ background-color: var(--color-primary);
font-size: inherit;
font-weight: 600;
color: white;
cursor: pointer;
&:hover:not(:disabled) {
- background-color: #2d4a7c;
+ background-color: var(--color-primary-hover);
}
&:disabled {
@@ -103,27 +131,27 @@
padding: 0;
border: none;
border-radius: 4px;
- background: #e0e0e0;
+ background: var(--color-border);
font-size: 1.25rem;
line-height: 1;
- color: #666;
+ color: var(--color-text-secondary);
cursor: pointer;
&:hover {
- background: #d0d0d0;
- color: #333;
+ background: var(--color-bg-secondary);
+ color: var(--color-text);
}
}
}
.appIframePanel {
- min-height: 200px;
+ min-height: 100px;
iframe {
width: 100%;
- height: 600px;
+ height: 400px;
box-sizing: border-box;
- border: 3px dashed #888;
+ border: 3px dashed var(--color-border);
border-radius: 4px;
}
@@ -137,7 +165,7 @@
margin: 0;
padding: 1rem;
max-width: none;
- background: white;
+ background: var(--color-bg);
border: none;
border-radius: 0;
display: flex;
@@ -173,29 +201,30 @@
width: 32px;
height: 32px;
padding: 0;
- border: 1px solid #ccc;
+ border: 1px solid var(--color-border);
border-radius: 4px;
- background: #f5f5f5;
+ background: var(--color-bg-secondary);
+ color: var(--color-text);
font-size: 1.25rem;
cursor: pointer;
&:hover {
- background: #e0e0e0;
+ background: var(--color-bg);
}
}
.collapsiblePanel {
margin: 0.5rem 0;
padding: 0.5rem 0.75rem;
- border: 1px solid #e0e0e0;
+ border: 1px solid var(--color-border);
border-radius: 4px;
- background-color: #fafafa;
+ background-color: var(--color-bg-secondary);
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
- background-color: #f0f0f0;
+ background-color: var(--color-bg);
}
}
@@ -207,23 +236,23 @@
.collapsibleLabel {
font-weight: 600;
- color: #555;
+ color: var(--color-text);
}
.collapsibleSize {
- color: #888;
+ color: var(--color-text-secondary);
font-size: 0.75rem;
}
.collapsibleToggle {
margin-left: auto;
- color: #888;
+ color: var(--color-text-secondary);
font-size: 0.75rem;
}
.collapsiblePreview {
margin-top: 0.25rem;
- color: #666;
+ color: var(--color-text-secondary);
font-family: monospace;
font-size: 0.8rem;
white-space: nowrap;
@@ -235,7 +264,8 @@
margin: 0.5rem 0 0;
padding: 0.5rem;
border-radius: 4px;
- background-color: #f5f5f5;
+ background-color: var(--color-bg);
+ color: var(--color-text);
font-family: monospace;
font-size: 0.8rem;
white-space: pre-wrap;
@@ -246,6 +276,6 @@
.error {
padding: 1.5rem;
- background-color: #ddd;
- color: #d00;
+ background-color: var(--color-bg-secondary);
+ color: light-dark(#dc2626, #f87171);
}
diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx
index baaa7bb..00c0d52 100644
--- a/examples/basic-host/src/index.tsx
+++ b/examples/basic-host/src/index.tsx
@@ -1,8 +1,9 @@
import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol/ext-apps/app-bridge";
-import type { Tool } from "@modelcontextprotocol/sdk/types.js";
+import type { Tool } from "@modelcontextprotocol/client";
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation";
+import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme";
import styles from "./index.module.css";
/**
@@ -54,16 +55,39 @@ interface HostProps {
type ToolCallEntry = ToolCallInfo & { id: number };
let nextToolCallId = 0;
-// Parse URL query params for debugging: ?server=name&tool=name&call=true
+// Parse URL query params for debugging: ?server=name&tool=name&call=true&theme=hide
function getQueryParams() {
const params = new URLSearchParams(window.location.search);
return {
server: params.get("server"),
tool: params.get("tool"),
call: params.get("call") === "true",
+ hideThemeToggle: params.get("theme") === "hide",
};
}
+/**
+ * Theme toggle button with light/dark icons.
+ */
+function ThemeToggle() {
+ const [theme, setTheme] = useState(getTheme);
+
+ useEffect(() => {
+ return onThemeChange(setTheme);
+ }, []);
+
+ return (
+
+ );
+}
+
+
function Host({ serversPromise }: HostProps) {
const [toolCalls, setToolCalls] = useState([]);
const [destroyingIds, setDestroyingIds] = useState>(new Set());
@@ -84,6 +108,7 @@ function Host({ serversPromise }: HostProps) {
return (
<>
+ {!queryParams.hideThemeToggle && }
{toolCalls.map((info) => (
{
}
});
-// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
+// Notify the Host that the Sandbox is ready to receive view HTML.
// Use specific origin instead of "*" to ensure only the expected host receives this.
window.parent.postMessage({
jsonrpc: "2.0",
diff --git a/examples/basic-host/src/theme.ts b/examples/basic-host/src/theme.ts
new file mode 100644
index 0000000..882b10a
--- /dev/null
+++ b/examples/basic-host/src/theme.ts
@@ -0,0 +1,64 @@
+/**
+ * Simple theme manager for the basic-host example.
+ * Manages light/dark theme state and notifies listeners.
+ */
+
+export type Theme = "light" | "dark";
+
+type ThemeListener = (theme: Theme) => void;
+
+const listeners = new Set();
+
+// Get initial theme from system preference
+let currentTheme: Theme = window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+
+// Apply theme to document
+function applyTheme(theme: Theme) {
+ document.documentElement.setAttribute("data-theme", theme);
+ document.documentElement.style.colorScheme = theme;
+}
+
+// Initial application
+applyTheme(currentTheme);
+
+/**
+ * Get current theme.
+ */
+export function getTheme(): Theme {
+ return currentTheme;
+}
+
+/**
+ * Set theme and notify all listeners.
+ */
+export function setTheme(theme: Theme): void {
+ if (theme === currentTheme) return;
+ currentTheme = theme;
+ applyTheme(theme);
+ listeners.forEach((listener) => listener(theme));
+}
+
+/**
+ * Toggle between light and dark themes.
+ */
+export function toggleTheme(): Theme {
+ const newTheme = currentTheme === "dark" ? "light" : "dark";
+ setTheme(newTheme);
+ return newTheme;
+}
+
+/**
+ * Subscribe to theme changes.
+ * Returns unsubscribe function.
+ */
+export function onThemeChange(listener: ThemeListener): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+// Also listen for system preference changes
+window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
+ setTheme(e.matches ? "dark" : "light");
+});
diff --git a/examples/basic-host/tsconfig.json b/examples/basic-host/tsconfig.json
index 9d7c3bc..3c143ca 100644
--- a/examples/basic-host/tsconfig.json
+++ b/examples/basic-host/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-preact/README.md b/examples/basic-server-preact/README.md
index 8091ad8..6a75ddb 100644
--- a/examples/basic-server-preact/README.md
+++ b/examples/basic-server-preact/README.md
@@ -5,11 +5,50 @@ An MCP App example with a Preact UI.
> [!TIP]
> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)!
+## MCP Client Configuration
+
+Add to your MCP client configuration (stdio transport):
+
+```json
+{
+ "mcpServers": {
+ "basic-preact": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "--silent",
+ "--registry=https://registry.npmjs.org/",
+ "@modelcontextprotocol/server-basic-preact",
+ "--stdio"
+ ]
+ }
+ }
+}
+```
+
+### Local Development
+
+To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
+
+```json
+{
+ "mcpServers": {
+ "basic-preact": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/basic-server-preact && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
## Overview
- Tool registration with a linked UI resource
-- Preact UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
+- Preact UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -31,4 +70,4 @@ npm run dev
## Build System
-This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
+This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-preact/main.ts b/examples/basic-server-preact/main.ts
index d9a51a2..ffdff52 100644
--- a/examples/basic-server-preact/main.ts
+++ b/examples/basic-server-preact/main.ts
@@ -4,41 +4,30 @@
* Or: node dist/index.js [--stdio]
*/
-/**
- * Shared utilities for running MCP servers with Streamable HTTP transport.
- */
-
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
-export interface ServerOptions {
- port: number;
- name?: string;
-}
-
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
- * @param options - Server configuration options.
*/
-export async function startServer(
+export async function startStreamableHTTPServer(
createServer: () => McpServer,
- options: ServerOptions,
): Promise {
- const { port, name = "MCP Server" } = options;
+ const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -67,7 +56,7 @@ export async function startServer(
console.error("Failed to start server:", err);
process.exit(1);
}
- console.log(`${name} listening on http://localhost:${port}/mcp`);
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
@@ -79,12 +68,22 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
async function main() {
if (process.argv.includes("--stdio")) {
- await createServer().connect(new StdioServerTransport());
+ await startStdioServer(createServer);
} else {
- const port = parseInt(process.env.PORT ?? "3001", 10);
- await startServer(createServer, { port, name: "Basic MCP App Server (Preact)" });
+ await startStreamableHTTPServer(createServer);
}
}
diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json
index bbd31c7..60aa40d 100644
--- a/examples/basic-server-preact/package.json
+++ b/examples/basic-server-preact/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-preact",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Preact",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-preact"
},
"license": "MIT",
@@ -17,23 +17,28 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
- "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"preact": "^10.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"typescript": "^5.9.3",
diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts
index 2d99964..dfec94a 100644
--- a/examples/basic-server-preact/server.ts
+++ b/examples/basic-server-preact/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,8 +32,8 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-preact/src/global.css b/examples/basic-server-preact/src/global.css
index 97cda44..801291f 100644
--- a/examples/basic-server-preact/src/global.css
+++ b/examples/basic-server-preact/src/global.css
@@ -1,12 +1,92 @@
+:root {
+ color-scheme: light dark;
+
+ /*
+ * Fallbacks for host style variables used by this app.
+ * The host may provide these (and many more) via the host context.
+ */
+ --color-text-primary: light-dark(#1f2937, #f3f4f6);
+ --color-text-inverse: light-dark(#f3f4f6, #1f2937);
+ --color-text-info: light-dark(#1d4ed8, #60a5fa);
+ --color-background-primary: light-dark(#ffffff, #1a1a1a);
+ --color-background-inverse: light-dark(#1a1a1a, #ffffff);
+ --color-background-info: light-dark(#eff6ff, #1e3a5f);
+ --color-ring-primary: light-dark(#3b82f6, #60a5fa);
+ --border-radius-md: 6px;
+ --border-width-regular: 1px;
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ --font-weight-normal: 400;
+ --font-weight-bold: 700;
+ --font-text-md-size: 1rem;
+ --font-text-md-line-height: 1.5;
+ --font-heading-3xl-size: 2.25rem;
+ --font-heading-3xl-line-height: 1.1;
+ --font-heading-2xl-size: 1.875rem;
+ --font-heading-2xl-line-height: 1.2;
+ --font-heading-xl-size: 1.5rem;
+ --font-heading-xl-line-height: 1.25;
+ --font-heading-lg-size: 1.25rem;
+ --font-heading-lg-line-height: 1.3;
+ --font-heading-md-size: 1rem;
+ --font-heading-md-line-height: 1.4;
+ --font-heading-sm-size: 0.875rem;
+ --font-heading-sm-line-height: 1.4;
+
+ /* Spacing derived from host typography */
+ --spacing-unit: var(--font-text-md-size);
+ --spacing-xs: calc(var(--spacing-unit) * 0.25);
+ --spacing-sm: calc(var(--spacing-unit) * 0.5);
+ --spacing-md: var(--spacing-unit);
+ --spacing-lg: calc(var(--spacing-unit) * 1.5);
+
+ /* App accent color (customize for your brand) */
+ --color-accent: #2563eb;
+ --color-text-on-accent: #ffffff;
+}
+
* {
box-sizing: border-box;
}
html, body {
- font-family: system-ui, -apple-system, sans-serif;
- font-size: 1rem;
+ font-family: var(--font-sans);
+ font-size: var(--font-text-md-size);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--font-text-md-line-height);
+ color: var(--color-text-primary);
}
-code {
+h1 {
+ font-size: var(--font-heading-3xl-size);
+ line-height: var(--font-heading-3xl-line-height);
+}
+h2 {
+ font-size: var(--font-heading-2xl-size);
+ line-height: var(--font-heading-2xl-line-height);
+}
+h3 {
+ font-size: var(--font-heading-xl-size);
+ line-height: var(--font-heading-xl-line-height);
+}
+h4 {
+ font-size: var(--font-heading-lg-size);
+ line-height: var(--font-heading-lg-line-height);
+}
+h5 {
+ font-size: var(--font-heading-md-size);
+ line-height: var(--font-heading-md-line-height);
+}
+h6 {
+ font-size: var(--font-heading-sm-size);
+ line-height: var(--font-heading-sm-line-height);
+}
+
+code, pre, kbd {
+ font-family: var(--font-mono);
font-size: 1em;
}
+
+b, strong {
+ font-weight: var(--font-weight-bold);
+}
diff --git a/examples/basic-server-preact/src/mcp-app.module.css b/examples/basic-server-preact/src/mcp-app.module.css
index 0c6429a..ae08a8c 100644
--- a/examples/basic-server-preact/src/mcp-app.module.css
+++ b/examples/basic-server-preact/src/mcp-app.module.css
@@ -1,8 +1,4 @@
.main {
- --color-primary: #2563eb;
- --color-primary-hover: #1d4ed8;
- --color-notice-bg: #eff6ff;
-
width: 100%;
max-width: 425px;
box-sizing: border-box;
@@ -13,7 +9,7 @@
}
> * + * {
- margin-top: 1.5rem;
+ margin-top: var(--spacing-lg);
}
}
@@ -25,41 +21,59 @@
}
> * + * {
- margin-top: 0.5rem;
+ margin-top: var(--spacing-sm);
+ }
+
+ /* Server time row: flex layout for consistent mask width in E2E tests */
+ > p {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-xs);
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {
+ display: block;
font-family: inherit;
font-size: inherit;
}
button {
- padding: 0.5rem 1rem;
+ padding: var(--spacing-sm) var(--spacing-md);
border: none;
- border-radius: 6px;
- color: white;
- font-weight: bold;
- background-color: var(--color-primary);
+ border-radius: var(--border-radius-md);
+ color: var(--color-text-on-accent);
+ font-weight: var(--font-weight-bold);
+ background-color: var(--color-accent);
cursor: pointer;
- &:hover,
+ &:hover {
+ background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse));
+ }
+
&:focus-visible {
- background-color: var(--color-primary-hover);
+ outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary);
+ outline-offset: var(--border-width-regular);
}
}
}
.notice {
- padding: 0.5rem 0.75rem;
- color: var(--color-primary);
+ padding: var(--spacing-sm) var(--spacing-md);
+ color: var(--color-text-info);
text-align: center;
font-style: italic;
- background-color: var(--color-notice-bg);
+ background-color: var(--color-background-info);
&::before {
content: "โน๏ธ ";
font-style: normal;
}
}
+
+/* Server time fills remaining width for consistent E2E screenshot masking */
+.serverTime {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/examples/basic-server-preact/src/mcp-app.tsx b/examples/basic-server-preact/src/mcp-app.tsx
index d5b6b60..351a8ec 100644
--- a/examples/basic-server-preact/src/mcp-app.tsx
+++ b/examples/basic-server-preact/src/mcp-app.tsx
@@ -8,7 +8,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { useCallback, useEffect, useState } from "preact/hooks";
import { render } from "preact";
import styles from "./mcp-app.module.css";
@@ -144,7 +144,7 @@ function GetTimeAppInner({ app, toolResult, hostContext }: GetTimeAppInnerProps)
- Server Time:{serverTime}
+ Server Time:{serverTime}
diff --git a/examples/basic-server-preact/tsconfig.json b/examples/basic-server-preact/tsconfig.json
index 32182d9..9bf9389 100644
--- a/examples/basic-server-preact/tsconfig.json
+++ b/examples/basic-server-preact/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-preact/tsconfig.server.json b/examples/basic-server-preact/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-preact/tsconfig.server.json
+++ b/examples/basic-server-preact/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-react/README.md b/examples/basic-server-react/README.md
index d29073d..d2f2e0d 100644
--- a/examples/basic-server-react/README.md
+++ b/examples/basic-server-react/README.md
@@ -7,11 +7,50 @@ An MCP App example with a React UI.
> [!TIP]
> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)!
+## MCP Client Configuration
+
+Add to your MCP client configuration (stdio transport):
+
+```json
+{
+ "mcpServers": {
+ "basic-react": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "--silent",
+ "--registry=https://registry.npmjs.org/",
+ "@modelcontextprotocol/server-basic-react",
+ "--stdio"
+ ]
+ }
+ }
+}
+```
+
+### Local Development
+
+To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
+
+```json
+{
+ "mcpServers": {
+ "basic-react": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/basic-server-react && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
## Overview
- Tool registration with a linked UI resource
-- React UI using the [`useApp()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
+- React UI using the [`useApp()`](https://apps.extensions.modelcontextprotocol.io/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -33,4 +72,4 @@ npm run dev
## Build System
-This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
+This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-react/main.ts b/examples/basic-server-react/main.ts
index 39840a1..21c5e18 100644
--- a/examples/basic-server-react/main.ts
+++ b/examples/basic-server-react/main.ts
@@ -4,37 +4,30 @@
* Or: node dist/index.js [--stdio]
*/
-import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
-export interface ServerOptions {
- port: number;
- name?: string;
-}
-
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
- * @param options - Server configuration options.
*/
-export async function startServer(
+export async function startStreamableHTTPServer(
createServer: () => McpServer,
- options: ServerOptions,
): Promise {
- const { port, name = "MCP Server" } = options;
+ const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -63,7 +56,7 @@ export async function startServer(
console.error("Failed to start server:", err);
process.exit(1);
}
- console.log(`${name} listening on http://localhost:${port}/mcp`);
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
@@ -75,12 +68,22 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
async function main() {
if (process.argv.includes("--stdio")) {
- await createServer().connect(new StdioServerTransport());
+ await startStdioServer(createServer);
} else {
- const port = parseInt(process.env.PORT ?? "3001", 10);
- await startServer(createServer, { port, name: "Basic MCP App Server (React)" });
+ await startStreamableHTTPServer(createServer);
}
}
diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json
index 18ba617..ea51a8a 100644
--- a/examples/basic-server-react/package.json
+++ b/examples/basic-server-react/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-react",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using React",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-react"
},
"license": "MIT",
@@ -27,23 +27,28 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
- "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts
index 416b217..d77c908 100644
--- a/examples/basic-server-react/server.ts
+++ b/examples/basic-server-react/server.ts
@@ -1,8 +1,12 @@
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
@@ -29,8 +33,8 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-react/src/global.css b/examples/basic-server-react/src/global.css
index 97cda44..801291f 100644
--- a/examples/basic-server-react/src/global.css
+++ b/examples/basic-server-react/src/global.css
@@ -1,12 +1,92 @@
+:root {
+ color-scheme: light dark;
+
+ /*
+ * Fallbacks for host style variables used by this app.
+ * The host may provide these (and many more) via the host context.
+ */
+ --color-text-primary: light-dark(#1f2937, #f3f4f6);
+ --color-text-inverse: light-dark(#f3f4f6, #1f2937);
+ --color-text-info: light-dark(#1d4ed8, #60a5fa);
+ --color-background-primary: light-dark(#ffffff, #1a1a1a);
+ --color-background-inverse: light-dark(#1a1a1a, #ffffff);
+ --color-background-info: light-dark(#eff6ff, #1e3a5f);
+ --color-ring-primary: light-dark(#3b82f6, #60a5fa);
+ --border-radius-md: 6px;
+ --border-width-regular: 1px;
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ --font-weight-normal: 400;
+ --font-weight-bold: 700;
+ --font-text-md-size: 1rem;
+ --font-text-md-line-height: 1.5;
+ --font-heading-3xl-size: 2.25rem;
+ --font-heading-3xl-line-height: 1.1;
+ --font-heading-2xl-size: 1.875rem;
+ --font-heading-2xl-line-height: 1.2;
+ --font-heading-xl-size: 1.5rem;
+ --font-heading-xl-line-height: 1.25;
+ --font-heading-lg-size: 1.25rem;
+ --font-heading-lg-line-height: 1.3;
+ --font-heading-md-size: 1rem;
+ --font-heading-md-line-height: 1.4;
+ --font-heading-sm-size: 0.875rem;
+ --font-heading-sm-line-height: 1.4;
+
+ /* Spacing derived from host typography */
+ --spacing-unit: var(--font-text-md-size);
+ --spacing-xs: calc(var(--spacing-unit) * 0.25);
+ --spacing-sm: calc(var(--spacing-unit) * 0.5);
+ --spacing-md: var(--spacing-unit);
+ --spacing-lg: calc(var(--spacing-unit) * 1.5);
+
+ /* App accent color (customize for your brand) */
+ --color-accent: #2563eb;
+ --color-text-on-accent: #ffffff;
+}
+
* {
box-sizing: border-box;
}
html, body {
- font-family: system-ui, -apple-system, sans-serif;
- font-size: 1rem;
+ font-family: var(--font-sans);
+ font-size: var(--font-text-md-size);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--font-text-md-line-height);
+ color: var(--color-text-primary);
}
-code {
+h1 {
+ font-size: var(--font-heading-3xl-size);
+ line-height: var(--font-heading-3xl-line-height);
+}
+h2 {
+ font-size: var(--font-heading-2xl-size);
+ line-height: var(--font-heading-2xl-line-height);
+}
+h3 {
+ font-size: var(--font-heading-xl-size);
+ line-height: var(--font-heading-xl-line-height);
+}
+h4 {
+ font-size: var(--font-heading-lg-size);
+ line-height: var(--font-heading-lg-line-height);
+}
+h5 {
+ font-size: var(--font-heading-md-size);
+ line-height: var(--font-heading-md-line-height);
+}
+h6 {
+ font-size: var(--font-heading-sm-size);
+ line-height: var(--font-heading-sm-line-height);
+}
+
+code, pre, kbd {
+ font-family: var(--font-mono);
font-size: 1em;
}
+
+b, strong {
+ font-weight: var(--font-weight-bold);
+}
diff --git a/examples/basic-server-react/src/mcp-app.module.css b/examples/basic-server-react/src/mcp-app.module.css
index 0c6429a..ae08a8c 100644
--- a/examples/basic-server-react/src/mcp-app.module.css
+++ b/examples/basic-server-react/src/mcp-app.module.css
@@ -1,8 +1,4 @@
.main {
- --color-primary: #2563eb;
- --color-primary-hover: #1d4ed8;
- --color-notice-bg: #eff6ff;
-
width: 100%;
max-width: 425px;
box-sizing: border-box;
@@ -13,7 +9,7 @@
}
> * + * {
- margin-top: 1.5rem;
+ margin-top: var(--spacing-lg);
}
}
@@ -25,41 +21,59 @@
}
> * + * {
- margin-top: 0.5rem;
+ margin-top: var(--spacing-sm);
+ }
+
+ /* Server time row: flex layout for consistent mask width in E2E tests */
+ > p {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-xs);
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {
+ display: block;
font-family: inherit;
font-size: inherit;
}
button {
- padding: 0.5rem 1rem;
+ padding: var(--spacing-sm) var(--spacing-md);
border: none;
- border-radius: 6px;
- color: white;
- font-weight: bold;
- background-color: var(--color-primary);
+ border-radius: var(--border-radius-md);
+ color: var(--color-text-on-accent);
+ font-weight: var(--font-weight-bold);
+ background-color: var(--color-accent);
cursor: pointer;
- &:hover,
+ &:hover {
+ background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse));
+ }
+
&:focus-visible {
- background-color: var(--color-primary-hover);
+ outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary);
+ outline-offset: var(--border-width-regular);
}
}
}
.notice {
- padding: 0.5rem 0.75rem;
- color: var(--color-primary);
+ padding: var(--spacing-sm) var(--spacing-md);
+ color: var(--color-text-info);
text-align: center;
font-style: italic;
- background-color: var(--color-notice-bg);
+ background-color: var(--color-background-info);
&::before {
content: "โน๏ธ ";
font-style: normal;
}
}
+
+/* Server time fills remaining width for consistent E2E screenshot masking */
+.serverTime {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/examples/basic-server-react/src/mcp-app.tsx b/examples/basic-server-react/src/mcp-app.tsx
index a42fce7..2b5fbef 100644
--- a/examples/basic-server-react/src/mcp-app.tsx
+++ b/examples/basic-server-react/src/mcp-app.tsx
@@ -3,7 +3,7 @@
*/
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { StrictMode, useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import styles from "./mcp-app.module.css";
@@ -131,7 +131,7 @@ function GetTimeAppInner({ app, toolResult, hostContext }: GetTimeAppInnerProps)
- Server Time:{serverTime}
+ Server Time:{serverTime}
diff --git a/examples/basic-server-react/tsconfig.json b/examples/basic-server-react/tsconfig.json
index fc3c210..3ecbdad 100644
--- a/examples/basic-server-react/tsconfig.json
+++ b/examples/basic-server-react/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-react/tsconfig.server.json b/examples/basic-server-react/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-react/tsconfig.server.json
+++ b/examples/basic-server-react/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-solid/README.md b/examples/basic-server-solid/README.md
index e0e7584..43811a2 100644
--- a/examples/basic-server-solid/README.md
+++ b/examples/basic-server-solid/README.md
@@ -5,11 +5,50 @@ An MCP App example with a Solid UI.
> [!TIP]
> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)!
+## MCP Client Configuration
+
+Add to your MCP client configuration (stdio transport):
+
+```json
+{
+ "mcpServers": {
+ "basic-solid": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "--silent",
+ "--registry=https://registry.npmjs.org/",
+ "@modelcontextprotocol/server-basic-solid",
+ "--stdio"
+ ]
+ }
+ }
+}
+```
+
+### Local Development
+
+To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
+
+```json
+{
+ "mcpServers": {
+ "basic-solid": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/basic-server-solid && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
## Overview
- Tool registration with a linked UI resource
-- Solid UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
+- Solid UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -31,4 +70,4 @@ npm run dev
## Build System
-This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
+This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-solid/main.ts b/examples/basic-server-solid/main.ts
index 2335348..095ef8d 100644
--- a/examples/basic-server-solid/main.ts
+++ b/examples/basic-server-solid/main.ts
@@ -4,41 +4,30 @@
* Or: node dist/index.js [--stdio]
*/
-/**
- * Shared utilities for running MCP servers with Streamable HTTP transport.
- */
-
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
-export interface ServerOptions {
- port: number;
- name?: string;
-}
-
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
- * @param options - Server configuration options.
*/
-export async function startServer(
+export async function startStreamableHTTPServer(
createServer: () => McpServer,
- options: ServerOptions,
): Promise {
- const { port, name = "MCP Server" } = options;
+ const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -67,7 +56,7 @@ export async function startServer(
console.error("Failed to start server:", err);
process.exit(1);
}
- console.log(`${name} listening on http://localhost:${port}/mcp`);
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
@@ -79,12 +68,22 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
async function main() {
if (process.argv.includes("--stdio")) {
- await createServer().connect(new StdioServerTransport());
+ await startStdioServer(createServer);
} else {
- const port = parseInt(process.env.PORT ?? "3001", 10);
- await startServer(createServer, { port, name: "Basic MCP App Server (Solid)" });
+ await startStreamableHTTPServer(createServer);
}
}
diff --git a/examples/basic-server-solid/package.json b/examples/basic-server-solid/package.json
index 25331c5..b3ca909 100644
--- a/examples/basic-server-solid/package.json
+++ b/examples/basic-server-solid/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-solid",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Solid",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-solid"
},
"license": "MIT",
@@ -17,28 +17,33 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
- "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "solid-js": "^1.9.0",
- "zod": "^4.1.13"
+ "solid-js": "1.9.10",
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
- "vite-plugin-solid": "^2.0.0"
+ "vite-plugin-solid": "^2.11.12"
},
"types": "dist/server.d.ts",
"exports": {
diff --git a/examples/basic-server-solid/server.ts b/examples/basic-server-solid/server.ts
index 33f76a1..addb522 100644
--- a/examples/basic-server-solid/server.ts
+++ b/examples/basic-server-solid/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,8 +32,8 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-solid/src/global.css b/examples/basic-server-solid/src/global.css
index 97cda44..801291f 100644
--- a/examples/basic-server-solid/src/global.css
+++ b/examples/basic-server-solid/src/global.css
@@ -1,12 +1,92 @@
+:root {
+ color-scheme: light dark;
+
+ /*
+ * Fallbacks for host style variables used by this app.
+ * The host may provide these (and many more) via the host context.
+ */
+ --color-text-primary: light-dark(#1f2937, #f3f4f6);
+ --color-text-inverse: light-dark(#f3f4f6, #1f2937);
+ --color-text-info: light-dark(#1d4ed8, #60a5fa);
+ --color-background-primary: light-dark(#ffffff, #1a1a1a);
+ --color-background-inverse: light-dark(#1a1a1a, #ffffff);
+ --color-background-info: light-dark(#eff6ff, #1e3a5f);
+ --color-ring-primary: light-dark(#3b82f6, #60a5fa);
+ --border-radius-md: 6px;
+ --border-width-regular: 1px;
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ --font-weight-normal: 400;
+ --font-weight-bold: 700;
+ --font-text-md-size: 1rem;
+ --font-text-md-line-height: 1.5;
+ --font-heading-3xl-size: 2.25rem;
+ --font-heading-3xl-line-height: 1.1;
+ --font-heading-2xl-size: 1.875rem;
+ --font-heading-2xl-line-height: 1.2;
+ --font-heading-xl-size: 1.5rem;
+ --font-heading-xl-line-height: 1.25;
+ --font-heading-lg-size: 1.25rem;
+ --font-heading-lg-line-height: 1.3;
+ --font-heading-md-size: 1rem;
+ --font-heading-md-line-height: 1.4;
+ --font-heading-sm-size: 0.875rem;
+ --font-heading-sm-line-height: 1.4;
+
+ /* Spacing derived from host typography */
+ --spacing-unit: var(--font-text-md-size);
+ --spacing-xs: calc(var(--spacing-unit) * 0.25);
+ --spacing-sm: calc(var(--spacing-unit) * 0.5);
+ --spacing-md: var(--spacing-unit);
+ --spacing-lg: calc(var(--spacing-unit) * 1.5);
+
+ /* App accent color (customize for your brand) */
+ --color-accent: #2563eb;
+ --color-text-on-accent: #ffffff;
+}
+
* {
box-sizing: border-box;
}
html, body {
- font-family: system-ui, -apple-system, sans-serif;
- font-size: 1rem;
+ font-family: var(--font-sans);
+ font-size: var(--font-text-md-size);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--font-text-md-line-height);
+ color: var(--color-text-primary);
}
-code {
+h1 {
+ font-size: var(--font-heading-3xl-size);
+ line-height: var(--font-heading-3xl-line-height);
+}
+h2 {
+ font-size: var(--font-heading-2xl-size);
+ line-height: var(--font-heading-2xl-line-height);
+}
+h3 {
+ font-size: var(--font-heading-xl-size);
+ line-height: var(--font-heading-xl-line-height);
+}
+h4 {
+ font-size: var(--font-heading-lg-size);
+ line-height: var(--font-heading-lg-line-height);
+}
+h5 {
+ font-size: var(--font-heading-md-size);
+ line-height: var(--font-heading-md-line-height);
+}
+h6 {
+ font-size: var(--font-heading-sm-size);
+ line-height: var(--font-heading-sm-line-height);
+}
+
+code, pre, kbd {
+ font-family: var(--font-mono);
font-size: 1em;
}
+
+b, strong {
+ font-weight: var(--font-weight-bold);
+}
diff --git a/examples/basic-server-solid/src/mcp-app.module.css b/examples/basic-server-solid/src/mcp-app.module.css
index 0c6429a..ae08a8c 100644
--- a/examples/basic-server-solid/src/mcp-app.module.css
+++ b/examples/basic-server-solid/src/mcp-app.module.css
@@ -1,8 +1,4 @@
.main {
- --color-primary: #2563eb;
- --color-primary-hover: #1d4ed8;
- --color-notice-bg: #eff6ff;
-
width: 100%;
max-width: 425px;
box-sizing: border-box;
@@ -13,7 +9,7 @@
}
> * + * {
- margin-top: 1.5rem;
+ margin-top: var(--spacing-lg);
}
}
@@ -25,41 +21,59 @@
}
> * + * {
- margin-top: 0.5rem;
+ margin-top: var(--spacing-sm);
+ }
+
+ /* Server time row: flex layout for consistent mask width in E2E tests */
+ > p {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-xs);
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {
+ display: block;
font-family: inherit;
font-size: inherit;
}
button {
- padding: 0.5rem 1rem;
+ padding: var(--spacing-sm) var(--spacing-md);
border: none;
- border-radius: 6px;
- color: white;
- font-weight: bold;
- background-color: var(--color-primary);
+ border-radius: var(--border-radius-md);
+ color: var(--color-text-on-accent);
+ font-weight: var(--font-weight-bold);
+ background-color: var(--color-accent);
cursor: pointer;
- &:hover,
+ &:hover {
+ background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse));
+ }
+
&:focus-visible {
- background-color: var(--color-primary-hover);
+ outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary);
+ outline-offset: var(--border-width-regular);
}
}
}
.notice {
- padding: 0.5rem 0.75rem;
- color: var(--color-primary);
+ padding: var(--spacing-sm) var(--spacing-md);
+ color: var(--color-text-info);
text-align: center;
font-style: italic;
- background-color: var(--color-notice-bg);
+ background-color: var(--color-background-info);
&::before {
content: "โน๏ธ ";
font-style: normal;
}
}
+
+/* Server time fills remaining width for consistent E2E screenshot masking */
+.serverTime {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/examples/basic-server-solid/src/mcp-app.tsx b/examples/basic-server-solid/src/mcp-app.tsx
index fa054e6..2d78b7b 100644
--- a/examples/basic-server-solid/src/mcp-app.tsx
+++ b/examples/basic-server-solid/src/mcp-app.tsx
@@ -8,7 +8,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import { createEffect, createSignal, onMount, Show } from "solid-js";
import { render } from "solid-js/web";
import styles from "./mcp-app.module.css";
@@ -149,7 +149,7 @@ function GetTimeAppInner(props: GetTimeAppInnerProps) {
- Server Time:{serverTime()}
+ Server Time:{serverTime()}
diff --git a/examples/basic-server-solid/tsconfig.json b/examples/basic-server-solid/tsconfig.json
index 84f0efe..bb13a01 100644
--- a/examples/basic-server-solid/tsconfig.json
+++ b/examples/basic-server-solid/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-solid/tsconfig.server.json b/examples/basic-server-solid/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-solid/tsconfig.server.json
+++ b/examples/basic-server-solid/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-svelte/README.md b/examples/basic-server-svelte/README.md
index 22505c8..ec6a0d2 100644
--- a/examples/basic-server-svelte/README.md
+++ b/examples/basic-server-svelte/README.md
@@ -5,11 +5,50 @@ An MCP App example with a Svelte 5 UI using runes for reactivity.
> [!TIP]
> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)!
+## MCP Client Configuration
+
+Add to your MCP client configuration (stdio transport):
+
+```json
+{
+ "mcpServers": {
+ "basic-svelte": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "--silent",
+ "--registry=https://registry.npmjs.org/",
+ "@modelcontextprotocol/server-basic-svelte",
+ "--stdio"
+ ]
+ }
+ }
+}
+```
+
+### Local Development
+
+To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
+
+```json
+{
+ "mcpServers": {
+ "basic-svelte": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/basic-server-svelte && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
## Overview
- Tool registration with a linked UI resource
-- Svelte 5 UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
+- Svelte 5 UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -31,4 +70,4 @@ npm run dev
## Build System
-This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
+This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-svelte/main.ts b/examples/basic-server-svelte/main.ts
index 762a40e..734ec0c 100644
--- a/examples/basic-server-svelte/main.ts
+++ b/examples/basic-server-svelte/main.ts
@@ -4,41 +4,30 @@
* Or: node dist/index.js [--stdio]
*/
-/**
- * Shared utilities for running MCP servers with Streamable HTTP transport.
- */
-
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
-export interface ServerOptions {
- port: number;
- name?: string;
-}
-
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
- * @param options - Server configuration options.
*/
-export async function startServer(
+export async function startStreamableHTTPServer(
createServer: () => McpServer,
- options: ServerOptions,
): Promise {
- const { port, name = "MCP Server" } = options;
+ const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -67,7 +56,7 @@ export async function startServer(
console.error("Failed to start server:", err);
process.exit(1);
}
- console.log(`${name} listening on http://localhost:${port}/mcp`);
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
@@ -79,12 +68,22 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
async function main() {
if (process.argv.includes("--stdio")) {
- await createServer().connect(new StdioServerTransport());
+ await startStdioServer(createServer);
} else {
- const port = parseInt(process.env.PORT ?? "3001", 10);
- await startServer(createServer, { port, name: "Basic MCP App Server (Svelte)" });
+ await startStreamableHTTPServer(createServer);
}
}
diff --git a/examples/basic-server-svelte/package.json b/examples/basic-server-svelte/package.json
index 4bfb9cb..19fe4c9 100644
--- a/examples/basic-server-svelte/package.json
+++ b/examples/basic-server-svelte/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-svelte",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Svelte",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-svelte"
},
"license": "MIT",
@@ -17,23 +17,28 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
- "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"svelte": "^5.0.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"typescript": "^5.9.3",
diff --git a/examples/basic-server-svelte/server.ts b/examples/basic-server-svelte/server.ts
index dd2dba6..9414285 100644
--- a/examples/basic-server-svelte/server.ts
+++ b/examples/basic-server-svelte/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,8 +32,8 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-svelte/src/App.svelte b/examples/basic-server-svelte/src/App.svelte
index 31fe972..cabcc31 100644
--- a/examples/basic-server-svelte/src/App.svelte
+++ b/examples/basic-server-svelte/src/App.svelte
@@ -7,7 +7,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
function extractTime(result: CallToolResult): string {
const { text } = result.content?.find((c) => c.type === "text")!;
@@ -111,7 +111,7 @@ async function handleOpenLink() {
Watch activity in the DevTools console!
-
Server Time:{serverTime}
+
Server Time:{serverTime}
@@ -133,10 +133,6 @@ async function handleOpenLink() {
diff --git a/examples/basic-server-svelte/src/global.css b/examples/basic-server-svelte/src/global.css
index 97cda44..801291f 100644
--- a/examples/basic-server-svelte/src/global.css
+++ b/examples/basic-server-svelte/src/global.css
@@ -1,12 +1,92 @@
+:root {
+ color-scheme: light dark;
+
+ /*
+ * Fallbacks for host style variables used by this app.
+ * The host may provide these (and many more) via the host context.
+ */
+ --color-text-primary: light-dark(#1f2937, #f3f4f6);
+ --color-text-inverse: light-dark(#f3f4f6, #1f2937);
+ --color-text-info: light-dark(#1d4ed8, #60a5fa);
+ --color-background-primary: light-dark(#ffffff, #1a1a1a);
+ --color-background-inverse: light-dark(#1a1a1a, #ffffff);
+ --color-background-info: light-dark(#eff6ff, #1e3a5f);
+ --color-ring-primary: light-dark(#3b82f6, #60a5fa);
+ --border-radius-md: 6px;
+ --border-width-regular: 1px;
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ --font-weight-normal: 400;
+ --font-weight-bold: 700;
+ --font-text-md-size: 1rem;
+ --font-text-md-line-height: 1.5;
+ --font-heading-3xl-size: 2.25rem;
+ --font-heading-3xl-line-height: 1.1;
+ --font-heading-2xl-size: 1.875rem;
+ --font-heading-2xl-line-height: 1.2;
+ --font-heading-xl-size: 1.5rem;
+ --font-heading-xl-line-height: 1.25;
+ --font-heading-lg-size: 1.25rem;
+ --font-heading-lg-line-height: 1.3;
+ --font-heading-md-size: 1rem;
+ --font-heading-md-line-height: 1.4;
+ --font-heading-sm-size: 0.875rem;
+ --font-heading-sm-line-height: 1.4;
+
+ /* Spacing derived from host typography */
+ --spacing-unit: var(--font-text-md-size);
+ --spacing-xs: calc(var(--spacing-unit) * 0.25);
+ --spacing-sm: calc(var(--spacing-unit) * 0.5);
+ --spacing-md: var(--spacing-unit);
+ --spacing-lg: calc(var(--spacing-unit) * 1.5);
+
+ /* App accent color (customize for your brand) */
+ --color-accent: #2563eb;
+ --color-text-on-accent: #ffffff;
+}
+
* {
box-sizing: border-box;
}
html, body {
- font-family: system-ui, -apple-system, sans-serif;
- font-size: 1rem;
+ font-family: var(--font-sans);
+ font-size: var(--font-text-md-size);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--font-text-md-line-height);
+ color: var(--color-text-primary);
}
-code {
+h1 {
+ font-size: var(--font-heading-3xl-size);
+ line-height: var(--font-heading-3xl-line-height);
+}
+h2 {
+ font-size: var(--font-heading-2xl-size);
+ line-height: var(--font-heading-2xl-line-height);
+}
+h3 {
+ font-size: var(--font-heading-xl-size);
+ line-height: var(--font-heading-xl-line-height);
+}
+h4 {
+ font-size: var(--font-heading-lg-size);
+ line-height: var(--font-heading-lg-line-height);
+}
+h5 {
+ font-size: var(--font-heading-md-size);
+ line-height: var(--font-heading-md-line-height);
+}
+h6 {
+ font-size: var(--font-heading-sm-size);
+ line-height: var(--font-heading-sm-line-height);
+}
+
+code, pre, kbd {
+ font-family: var(--font-mono);
font-size: 1em;
}
+
+b, strong {
+ font-weight: var(--font-weight-bold);
+}
diff --git a/examples/basic-server-svelte/tsconfig.json b/examples/basic-server-svelte/tsconfig.json
index 5edb66a..4fd3cd7 100644
--- a/examples/basic-server-svelte/tsconfig.json
+++ b/examples/basic-server-svelte/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-svelte/tsconfig.server.json b/examples/basic-server-svelte/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-svelte/tsconfig.server.json
+++ b/examples/basic-server-svelte/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md
index 41e23e3..6445d8c 100644
--- a/examples/basic-server-vanillajs/README.md
+++ b/examples/basic-server-vanillajs/README.md
@@ -5,12 +5,51 @@ An MCP App example with a vanilla JavaScript UI (no framework).
> [!TIP]
> Looking for a React-based example? See [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react)!
+## MCP Client Configuration
+
+Add to your MCP client configuration (stdio transport):
+
+```json
+{
+ "mcpServers": {
+ "basic-vanillajs": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "--silent",
+ "--registry=https://registry.npmjs.org/",
+ "@modelcontextprotocol/server-basic-vanillajs",
+ "--stdio"
+ ]
+ }
+ }
+}
+```
+
+### Local Development
+
+To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
+
+```json
+{
+ "mcpServers": {
+ "basic-vanillajs": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/basic-server-vanillajs && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
## Overview
- Tool registration with a linked UI resource
-- Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
-- Theme integration via [`applyDocumentTheme()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/app.applyHostFonts.html)
+- Vanilla JS UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class directly
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
+- Theme integration via [`applyDocumentTheme()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyDocumentTheme.html), [`applyHostStyleVariables()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyHostStyleVariables.html), and [`applyHostFonts()`](https://apps.extensions.modelcontextprotocol.io/api/functions/app.applyHostFonts.html)
## Key Files
@@ -32,4 +71,4 @@ npm run dev
## Build System
-This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
+This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-vanillajs/main.ts b/examples/basic-server-vanillajs/main.ts
index d53d53b..4de7331 100644
--- a/examples/basic-server-vanillajs/main.ts
+++ b/examples/basic-server-vanillajs/main.ts
@@ -4,41 +4,30 @@
* Or: node dist/index.js [--stdio]
*/
-/**
- * Shared utilities for running MCP servers with Streamable HTTP transport.
- */
-
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
-export interface ServerOptions {
- port: number;
- name?: string;
-}
-
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
- * @param options - Server configuration options.
*/
-export async function startServer(
+export async function startStreamableHTTPServer(
createServer: () => McpServer,
- options: ServerOptions,
): Promise {
- const { port, name = "MCP Server" } = options;
+ const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -67,7 +56,7 @@ export async function startServer(
console.error("Failed to start server:", err);
process.exit(1);
}
- console.log(`${name} listening on http://localhost:${port}/mcp`);
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
@@ -79,12 +68,22 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
async function main() {
if (process.argv.includes("--stdio")) {
- await createServer().connect(new StdioServerTransport());
+ await startStdioServer(createServer);
} else {
- const port = parseInt(process.env.PORT ?? "3102", 10);
- await startServer(createServer, { port, name: "Basic MCP App Server (Vanilla JS)" });
+ await startStreamableHTTPServer(createServer);
}
}
diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json
index 35047e5..ebfebbb 100644
--- a/examples/basic-server-vanillajs/package.json
+++ b/examples/basic-server-vanillajs/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-vanillajs",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using vanilla JavaScript",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-vanillajs"
},
"license": "MIT",
@@ -17,21 +17,26 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
- "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"typescript": "^5.9.3",
diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts
index 07e82c2..693d861 100644
--- a/examples/basic-server-vanillajs/server.ts
+++ b/examples/basic-server-vanillajs/server.ts
@@ -1,6 +1,9 @@
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
@@ -29,11 +32,11 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
+ inputSchema: z.object({}),
outputSchema: z.object({
time: z.string(),
}),
- _meta: { ui: { resourceUri } },
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-vanillajs/src/global.css b/examples/basic-server-vanillajs/src/global.css
index 97cda44..801291f 100644
--- a/examples/basic-server-vanillajs/src/global.css
+++ b/examples/basic-server-vanillajs/src/global.css
@@ -1,12 +1,92 @@
+:root {
+ color-scheme: light dark;
+
+ /*
+ * Fallbacks for host style variables used by this app.
+ * The host may provide these (and many more) via the host context.
+ */
+ --color-text-primary: light-dark(#1f2937, #f3f4f6);
+ --color-text-inverse: light-dark(#f3f4f6, #1f2937);
+ --color-text-info: light-dark(#1d4ed8, #60a5fa);
+ --color-background-primary: light-dark(#ffffff, #1a1a1a);
+ --color-background-inverse: light-dark(#1a1a1a, #ffffff);
+ --color-background-info: light-dark(#eff6ff, #1e3a5f);
+ --color-ring-primary: light-dark(#3b82f6, #60a5fa);
+ --border-radius-md: 6px;
+ --border-width-regular: 1px;
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ --font-weight-normal: 400;
+ --font-weight-bold: 700;
+ --font-text-md-size: 1rem;
+ --font-text-md-line-height: 1.5;
+ --font-heading-3xl-size: 2.25rem;
+ --font-heading-3xl-line-height: 1.1;
+ --font-heading-2xl-size: 1.875rem;
+ --font-heading-2xl-line-height: 1.2;
+ --font-heading-xl-size: 1.5rem;
+ --font-heading-xl-line-height: 1.25;
+ --font-heading-lg-size: 1.25rem;
+ --font-heading-lg-line-height: 1.3;
+ --font-heading-md-size: 1rem;
+ --font-heading-md-line-height: 1.4;
+ --font-heading-sm-size: 0.875rem;
+ --font-heading-sm-line-height: 1.4;
+
+ /* Spacing derived from host typography */
+ --spacing-unit: var(--font-text-md-size);
+ --spacing-xs: calc(var(--spacing-unit) * 0.25);
+ --spacing-sm: calc(var(--spacing-unit) * 0.5);
+ --spacing-md: var(--spacing-unit);
+ --spacing-lg: calc(var(--spacing-unit) * 1.5);
+
+ /* App accent color (customize for your brand) */
+ --color-accent: #2563eb;
+ --color-text-on-accent: #ffffff;
+}
+
* {
box-sizing: border-box;
}
html, body {
- font-family: system-ui, -apple-system, sans-serif;
- font-size: 1rem;
+ font-family: var(--font-sans);
+ font-size: var(--font-text-md-size);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--font-text-md-line-height);
+ color: var(--color-text-primary);
}
-code {
+h1 {
+ font-size: var(--font-heading-3xl-size);
+ line-height: var(--font-heading-3xl-line-height);
+}
+h2 {
+ font-size: var(--font-heading-2xl-size);
+ line-height: var(--font-heading-2xl-line-height);
+}
+h3 {
+ font-size: var(--font-heading-xl-size);
+ line-height: var(--font-heading-xl-line-height);
+}
+h4 {
+ font-size: var(--font-heading-lg-size);
+ line-height: var(--font-heading-lg-line-height);
+}
+h5 {
+ font-size: var(--font-heading-md-size);
+ line-height: var(--font-heading-md-line-height);
+}
+h6 {
+ font-size: var(--font-heading-sm-size);
+ line-height: var(--font-heading-sm-line-height);
+}
+
+code, pre, kbd {
+ font-family: var(--font-mono);
font-size: 1em;
}
+
+b, strong {
+ font-weight: var(--font-weight-bold);
+}
diff --git a/examples/basic-server-vanillajs/src/mcp-app.css b/examples/basic-server-vanillajs/src/mcp-app.css
index 0c6429a..b23bd5e 100644
--- a/examples/basic-server-vanillajs/src/mcp-app.css
+++ b/examples/basic-server-vanillajs/src/mcp-app.css
@@ -1,8 +1,4 @@
.main {
- --color-primary: #2563eb;
- --color-primary-hover: #1d4ed8;
- --color-notice-bg: #eff6ff;
-
width: 100%;
max-width: 425px;
box-sizing: border-box;
@@ -13,7 +9,7 @@
}
> * + * {
- margin-top: 1.5rem;
+ margin-top: var(--spacing-lg);
}
}
@@ -25,41 +21,59 @@
}
> * + * {
- margin-top: 0.5rem;
+ margin-top: var(--spacing-sm);
+ }
+
+ /* Server time row: flex layout for consistent mask width in E2E tests */
+ > p {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-xs);
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {
+ display: block;
font-family: inherit;
font-size: inherit;
}
button {
- padding: 0.5rem 1rem;
+ padding: var(--spacing-sm) var(--spacing-md);
border: none;
- border-radius: 6px;
- color: white;
- font-weight: bold;
- background-color: var(--color-primary);
+ border-radius: var(--border-radius-md);
+ color: var(--color-text-on-accent);
+ font-weight: var(--font-weight-bold);
+ background-color: var(--color-accent);
cursor: pointer;
- &:hover,
+ &:hover {
+ background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse));
+ }
+
&:focus-visible {
- background-color: var(--color-primary-hover);
+ outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary);
+ outline-offset: var(--border-width-regular);
}
}
}
.notice {
- padding: 0.5rem 0.75rem;
- color: var(--color-primary);
+ padding: var(--spacing-sm) var(--spacing-md);
+ color: var(--color-text-info);
text-align: center;
font-style: italic;
- background-color: var(--color-notice-bg);
+ background-color: var(--color-background-info);
&::before {
content: "โน๏ธ ";
font-style: normal;
}
}
+
+/* Server time fills remaining width for consistent E2E screenshot masking */
+#server-time {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts
index c3168c2..6b5084a 100644
--- a/examples/basic-server-vanillajs/src/mcp-app.ts
+++ b/examples/basic-server-vanillajs/src/mcp-app.ts
@@ -8,7 +8,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
import "./global.css";
import "./mcp-app.css";
diff --git a/examples/basic-server-vanillajs/tsconfig.json b/examples/basic-server-vanillajs/tsconfig.json
index 535267b..b028ff5 100644
--- a/examples/basic-server-vanillajs/tsconfig.json
+++ b/examples/basic-server-vanillajs/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/basic-server-vanillajs/tsconfig.server.json b/examples/basic-server-vanillajs/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/basic-server-vanillajs/tsconfig.server.json
+++ b/examples/basic-server-vanillajs/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/basic-server-vue/README.md b/examples/basic-server-vue/README.md
index 2090973..c484f5b 100644
--- a/examples/basic-server-vue/README.md
+++ b/examples/basic-server-vue/README.md
@@ -5,11 +5,50 @@ An MCP App example with a Vue 3 UI using the Composition API.
> [!TIP]
> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)!
+## MCP Client Configuration
+
+Add to your MCP client configuration (stdio transport):
+
+```json
+{
+ "mcpServers": {
+ "basic-vue": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "--silent",
+ "--registry=https://registry.npmjs.org/",
+ "@modelcontextprotocol/server-basic-vue",
+ "--stdio"
+ ]
+ }
+ }
+}
+```
+
+### Local Development
+
+To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path):
+
+```json
+{
+ "mcpServers": {
+ "basic-vue": {
+ "command": "bash",
+ "args": [
+ "-c",
+ "cd ~/code/ext-apps/examples/basic-server-vue && npm run build >&2 && node dist/index.js --stdio"
+ ]
+ }
+ }
+}
+```
+
## Overview
- Tool registration with a linked UI resource
-- Vue 3 UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class
-- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink)
+- Vue 3 UI using the [`App`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html) class
+- App communication APIs: [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool), [`sendMessage`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendmessage), [`sendLog`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#sendlog), [`openLink`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#openlink)
## Key Files
@@ -31,4 +70,4 @@ npm run dev
## Build System
-This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
+This example bundles into a single HTML file using Vite with `vite-plugin-singlefile` โ see [`vite.config.ts`](vite.config.ts). This allows all UI content to be served as a single MCP resource. Alternatively, MCP apps can load external resources by defining [`_meta.ui.csp.resourceDomains`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceCsp.html#resourcedomains) in the UI resource metadata.
diff --git a/examples/basic-server-vue/main.ts b/examples/basic-server-vue/main.ts
index 0304600..d774269 100644
--- a/examples/basic-server-vue/main.ts
+++ b/examples/basic-server-vue/main.ts
@@ -4,41 +4,30 @@
* Or: node dist/index.js [--stdio]
*/
-/**
- * Shared utilities for running MCP servers with Streamable HTTP transport.
- */
-
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { createMcpExpressApp } from "@modelcontextprotocol/express";
+import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
+import type { McpServer } from "@modelcontextprotocol/server";
+import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
-export interface ServerOptions {
- port: number;
- name?: string;
-}
-
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
- * @param options - Server configuration options.
*/
-export async function startServer(
+export async function startStreamableHTTPServer(
createServer: () => McpServer,
- options: ServerOptions,
): Promise {
- const { port, name = "MCP Server" } = options;
+ const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
- const transport = new StreamableHTTPServerTransport({
+ const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
@@ -67,7 +56,7 @@ export async function startServer(
console.error("Failed to start server:", err);
process.exit(1);
}
- console.log(`${name} listening on http://localhost:${port}/mcp`);
+ console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
@@ -79,12 +68,22 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
+/**
+ * Starts an MCP server with stdio transport.
+ *
+ * @param createServer - Factory function that creates a new McpServer instance.
+ */
+export async function startStdioServer(
+ createServer: () => McpServer,
+): Promise {
+ await createServer().connect(new StdioServerTransport());
+}
+
async function main() {
if (process.argv.includes("--stdio")) {
- await createServer().connect(new StdioServerTransport());
+ await startStdioServer(createServer);
} else {
- const port = parseInt(process.env.PORT ?? "3001", 10);
- await startServer(createServer, { port, name: "Basic MCP App Server (Vue)" });
+ await startStreamableHTTPServer(createServer);
}
}
diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json
index 1dad261..858dd89 100644
--- a/examples/basic-server-vue/package.json
+++ b/examples/basic-server-vue/package.json
@@ -1,11 +1,11 @@
{
"name": "@modelcontextprotocol/server-basic-vue",
- "version": "0.4.1",
+ "version": "2.0.0",
"type": "module",
"description": "Basic MCP App Server example using Vue",
"repository": {
"type": "git",
- "url": "https://github.com/modelcontextprotocol/ext-apps",
+ "url": "git+https://github.com/modelcontextprotocol/ext-apps.git",
"directory": "examples/basic-server-vue"
},
"license": "MIT",
@@ -17,22 +17,27 @@
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
+ "serve:stdio": "bun main.ts --stdio",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
- "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
+ "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio",
+ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
- "@modelcontextprotocol/ext-apps": "^0.4.1",
- "@modelcontextprotocol/sdk": "^1.24.0",
+ "@modelcontextprotocol/client": "2.0.0",
+ "@modelcontextprotocol/express": "2.0.0",
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
+ "@modelcontextprotocol/node": "2.0.0",
+ "@modelcontextprotocol/server": "2.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"vue": "^3.5.0",
- "zod": "^4.1.13"
+ "zod": "^4.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
- "@types/node": "^22.0.0",
+ "@types/node": "22.10.0",
"@vitejs/plugin-vue": "^5.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts
index 73cb54d..9adb911 100644
--- a/examples/basic-server-vue/server.ts
+++ b/examples/basic-server-vue/server.ts
@@ -1,8 +1,12 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import {
+ McpServer,
+ type CallToolResult,
+ type ReadResourceResult,
+} from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
+import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@@ -28,8 +32,8 @@ export function createServer(): McpServer {
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
- inputSchema: {},
- _meta: { ui: { resourceUri } },
+ inputSchema: z.object({}),
+ _meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async (): Promise => {
const time = new Date().toISOString();
diff --git a/examples/basic-server-vue/src/App.vue b/examples/basic-server-vue/src/App.vue
index bfffaee..a45c262 100644
--- a/examples/basic-server-vue/src/App.vue
+++ b/examples/basic-server-vue/src/App.vue
@@ -7,7 +7,7 @@ import {
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { CallToolResult } from "@modelcontextprotocol/client";
function extractTime(result: CallToolResult): string {
const { text } = result.content?.find((c) => c.type === "text")!;
@@ -118,7 +118,7 @@ async function handleOpenLink() {
Download file via EmbeddedResource or ResourceLink
+
+
+
+
+
+ )}
);
}
diff --git a/examples/integration-server/tsconfig.json b/examples/integration-server/tsconfig.json
index fc3c210..3ecbdad 100644
--- a/examples/integration-server/tsconfig.json
+++ b/examples/integration-server/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node", "vite/client"],
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
diff --git a/examples/integration-server/tsconfig.server.json b/examples/integration-server/tsconfig.server.json
index 05ddd8e..27eb28f 100644
--- a/examples/integration-server/tsconfig.server.json
+++ b/examples/integration-server/tsconfig.server.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
diff --git a/examples/lazy-auth-server/README.md b/examples/lazy-auth-server/README.md
new file mode 100644
index 0000000..f2e7545
--- /dev/null
+++ b/examples/lazy-auth-server/README.md
@@ -0,0 +1,105 @@
+# Example: Lazy Auth Server
+
+An MCP App example demonstrating **lazy (on-demand) auth**: the server connects and lists tools without any authentication, and only asks for OAuth when a _protected_ tool is actually called โ by answering `401` with a `WWW-Authenticate` header. A public MCP App renders an "Auth me" button; clicking it calls a protected tool via [`callServerTool`](https://apps.extensions.modelcontextprotocol.io/api/classes/app.App.html#callservertool). The host sees the 401, runs the OAuth flow, retries, and the result renders inline.
+
+The embedded OAuth authorization server is a deliberately minimal mock (HS256 JWTs, stateless auth codes, auto-approve consent page) so the whole flow runs from a single process with no external dependencies. It is **not** a production authorization server.
+
+## Tools
+
+| Tool | Auth | Description |
+| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------- |
+| `show_auth_button` | public | Renders buttons: "Auth me" (calls `get_secret`), "Revoke token" (calls `revoke_auth_token`) |
+| `get_secret` | **protected** | Returns secret data (requires Bearer token) |
+| `revoke_auth_token` | **protected** | Revokes the caller's **entire auth session** (access + refresh token) โ forces full re-auth |
+| `elicit_url` | public | URL elicitation via `elicitInput` (blocks until the elicitation completes) |
+| `elicit_by_error` | public | URL elicitation via the `-32042` (`UrlElicitationRequired`) error; succeeds on retry after completion |
+
+## Getting Started
+
+```bash
+npm install
+npm start
+# โ MCP endpoint at http://localhost:3097/mcp
+```
+
+To test with a remote MCP host, expose the server through a public tunnel (see [Testing MCP Apps](../../docs/testing-mcp-apps.md)) and set `PUBLIC_URL` to the tunnel URL so OAuth metadata and callback URLs use it:
+
+```bash
+PUBLIC_URL=https:// npm start
+```
+
+This example is HTTP-only (no stdio mode): the lazy-auth flow relies on HTTP status codes and OAuth endpoints.
+
+## Environment Variables
+
+| Var | Required | Description |
+| --------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `JWT_SECRET` | recommended | 32+ byte secret for HS256 signing (`openssl rand -hex 32`). A dev-only default is used if unset. |
+| `PORT` | no | Local port (default 3097) |
+| `PUBLIC_URL` | non-local | Public base URL of the server (e.g. your tunnel URL). Required for non-localhost deployments; client-supplied `Host` headers are only trusted for loopback hosts |
+| `ACCESS_TOKEN_TTL_SECONDS` | no | Default access-token lifetime (default **30**, short on purpose so you can watch the host's refresh flow kick in) |
+| `REFRESH_TOKEN_TTL_SECONDS` | no | Default refresh-token lifetime (default **300**). Between the access and refresh TTLs, calls succeed via silent refresh; past it, a full re-auth is required |
+| `REACTIVE_AUTH_ONLY` | no | Set `1` to remove auth metadata from the root `/.well-known/oauth-*` paths so hosts can't discover auth preemptively โ discovery then only happens via the 401 |
+
+### Per-connection token lifetimes
+
+The short defaults are great for watching the refresh flow, but slow or automated clients may want tokens that survive a whole session. Any client can request a different access-token lifetime by connecting to a TTL-scoped MCP endpoint path (capped at 24 hours):
+
+```
+https:///ttl/3600/mcp โ tokens for this connection live 1 hour
+```
+
+This works through [RFC 8707 resource indicators](https://www.rfc-editor.org/rfc/rfc8707): MCP hosts send the MCP server URL as the `resource` parameter in OAuth authorization and token requests, and this server issues tokens for that grant with the lifetime encoded in the path (refresh tokens are extended to at least match). The TTL is a _path_ segment rather than a query param because hosts canonicalize resource indicators and strip query strings. Each TTL endpoint also enforces its value as a maximum token age, so connecting to a path with a _lower_ TTL than a token's issued lifetime forces the refresh flow. To exercise the full **re-auth** flow, call the `revoke_auth_token` tool.
+
+## Mounting under a base path
+
+`createApp()` can also be mounted inside another Express app, so an existing server can host this example at a sub-path of its own origin:
+
+```ts
+import { createApp } from "@modelcontextprotocol/server-lazy-auth";
+
+hostApp.use("/lazy-auth", createApp());
+// โ MCP endpoint at https:///lazy-auth/mcp
+```
+
+All advertised URLs (OAuth metadata, `WWW-Authenticate` `resource_metadata`, PRM `resource`, elicitation callbacks) include the mount path automatically, derived from Express's `req.baseUrl`. When `PUBLIC_URL` is set, it must include the mount path (e.g. `https://example.com/lazy-auth`).
+
+One thing the mounted app cannot do for itself: [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414#section-3) / [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) put well-known discovery documents at the _root_ of the origin with the path inserted after the well-known prefix (`/.well-known/oauth-authorization-server/lazy-auth`), and MCP SDK clients only try that insertion form. The host app must rewrite those root paths into the mount before its other routes:
+
+```ts
+hostApp.use((req, _res, next) => {
+ const m = req.url.match(
+ /^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)\/lazy-auth(\/.*)?$/,
+ );
+ if (m) req.url = `/lazy-auth/.well-known/${m[1]}${m[2] ?? ""}`;
+ next();
+});
+```
+
+Rewriting into the mount (rather than calling the sub-app directly) keeps `req.baseUrl` โ and therefore every advertised URL โ consistent.
+
+## How It Works
+
+1. **Connect without auth** โ `initialize`, `tools/list`, and public tool calls succeed with no `Authorization` header.
+2. **Protected tool โ 401** โ when `get_secret` or `revoke_auth_token` is called without a (valid) Bearer token, the server responds `401` with `WWW-Authenticate: Bearer resource_metadata="โฆ/auth/prm"`.
+3. **Discovery** โ the host follows `resource_metadata` to the protected-resource metadata (RFC 9728), which points at the authorization server metadata (RFC 8414).
+4. **OAuth flow** โ the host runs the authorization-code + PKCE flow against the mock `/authorize` and `/token` endpoints (a small consent page keeps the popup visible).
+5. **Retry** โ the host retries the tool call with the Bearer token and the secret renders inline in the app.
+6. **Refresh + revocation** โ access tokens expire after 30 seconds and refresh tokens after 5 minutes by default, so all three states are easy to observe: direct success (<30s), silent refresh (30sโ5min), and full re-auth (>5min). Connections can request different lifetimes via the `/ttl//mcp` endpoint path (see [Per-connection token lifetimes](#per-connection-token-lifetimes)), and `revoke_auth_token` invalidates the whole session immediately.
+
+The two `elicit_*` tools demonstrate the complementary pattern of [URL elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation), where the server asks the user to open a URL (e.g. to complete sign-in) either by blocking inside the tool call (`elicit_url`) or by failing with the `-32042` error and succeeding on retry (`elicit_by_error`).
+
+## Architecture
+
+- **Stateless auth codes** โ grant details are encoded _inside_ the authorization code as a 5-minute JWT, so nothing needs to be stored between requests.
+- **Short-lived tokens** โ access tokens default to a **30 second** TTL and refresh tokens to **5 minutes**: first `get_secret` succeeds โ wait >30s โ next call 401s โ host refreshes โ retry succeeds โ wait >5min โ full re-auth. Per-connection overrides via the `/ttl//mcp` endpoint path.
+- **HS256** โ a single shared secret; no key-pair persistence.
+- **Per-request MCP server** โ each `/mcp` request gets a fresh `McpServer` + `StreamableHTTPServerTransport` (stateless, no session IDs).
+- **Session revocation** โ all tokens from one OAuth session share a `sid` claim; `revoke_auth_token` adds the sid to an in-memory revocation list checked by both token verification and the refresh grant.
+
+## Key Files
+
+- [`server.ts`](server.ts) - OAuth endpoints, discovery metadata, and the MCP server with public + protected tools
+- [`main.ts`](main.ts) - HTTP entry point
+- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - Public app with the "Auth me" button
+- [`secret-app.html`](secret-app.html) / [`src/secret-app.ts`](src/secret-app.ts) - Protected app rendered for `get_secret`
diff --git a/examples/lazy-auth-server/main.ts b/examples/lazy-auth-server/main.ts
new file mode 100644
index 0000000..a96f34c
--- /dev/null
+++ b/examples/lazy-auth-server/main.ts
@@ -0,0 +1,42 @@
+/**
+ * Entry point for running the Lazy Auth demo MCP server.
+ * Run with: npx mcp-server-lazy-auth
+ * Or: node dist/index.js
+ *
+ * This example is HTTP-only (no stdio mode): the lazy-auth flow it demonstrates
+ * relies on HTTP status codes (401 + WWW-Authenticate) and OAuth endpoints.
+ *
+ * To test with a remote MCP host, expose the server through a public tunnel
+ * and set PUBLIC_URL to the tunnel URL so OAuth metadata and callback URLs
+ * use it (see docs/testing-mcp-apps.md in the repository root).
+ */
+import { createApp, PORT } from "./server.js";
+
+async function main() {
+ const app = createApp();
+
+ const httpServer = app.listen(PORT, (err) => {
+ if (err) {
+ console.error("Failed to start server:", err);
+ process.exit(1);
+ }
+ console.log(
+ `Lazy Auth demo MCP server listening on http://localhost:${PORT}/mcp`,
+ );
+ console.log(
+ ` Tools: show_auth_button, get_secret [PROTECTED], revoke_auth_token [PROTECTED], elicit_url, elicit_by_error`,
+ );
+ });
+
+ const shutdown = () => {
+ console.log("\nShutting down...");
+ httpServer.close(() => process.exit(0));
+ };
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/examples/lazy-auth-server/mcp-app.html b/examples/lazy-auth-server/mcp-app.html
new file mode 100644
index 0000000..7040f15
--- /dev/null
+++ b/examples/lazy-auth-server/mcp-app.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Lazy Auth Demo
+
+
+
+
Public App
+
+ No auth was needed to render this view. Click below to invoke the
+ protected
+ get_secret tool โ the host will run the OAuth flow on 401
+ and retry.
+