diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb41938..6848e4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,21 @@ jobs: grep -q "Ready" /tmp/mcpb-smoke.log working-directory: examples/pdf-server + dependency-isolation: + name: Dependency isolation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + + - run: npm ci + + - run: npm run test:dependency-isolation + e2e: runs-on: ubuntu-latest steps: @@ -132,8 +147,10 @@ jobs: - run: npm ci + # Headless runs without a channel use chromium-headless-shell, so the + # full Chromium build (about 165 MB) would only be downloaded, never run. - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + run: npx playwright install --with-deps --only-shell chromium - name: Run E2E tests run: npx playwright test --reporter=list @@ -165,9 +182,14 @@ jobs: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y nodejs + # The checkout lives on the Windows drive, which WSL reaches over 9P; + # writing node_modules there is what makes this job slow (npm ci alone + # took about 3 minutes). Copy the tree onto the distro's ext4 filesystem + # first, as Microsoft's WSL guidance recommends for anything I/O heavy. - name: Build and test in WSL shell: wsl-bash {0} run: | + cp -r "$(pwd)" ~/ext-apps && cd ~/ext-apps npm ci npm run build npm run examples:build diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 0667bbd..ed36c15 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -112,6 +112,7 @@ jobs: - cohort-heatmap-server - customer-segmentation-server - debug-server + - lazy-auth-server - map-server - pdf-server - scenario-modeler-server diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f1d3ac..478c5b8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,6 +34,7 @@ jobs: ./examples/cohort-heatmap-server \ ./examples/customer-segmentation-server \ ./examples/debug-server \ + ./examples/lazy-auth-server \ ./examples/map-server \ ./examples/pdf-server \ ./examples/scenario-modeler-server \ diff --git a/AGENTS.md b/AGENTS.md index 6bfc8cd..219b947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,8 +60,8 @@ rm -fR package-lock.json node_modules && \ ### Key Source Files -- `src/app.ts` - `App` class extends MCP Protocol, handles guest initialization, tool calls, messaging -- `src/app-bridge.ts` - `AppBridge` class for hosts, proxies MCP requests, sends tool input/results to guests +- `src/app.ts` - `App` subclasses `Protocol` from `@modelcontextprotocol/client`, handles View initialization, tool calls, and messaging +- `src/app-bridge.ts` - `AppBridge` subclasses `Protocol` from `@modelcontextprotocol/client` for the iframe channel and proxies through a separate outer `Client` - `src/server/index.ts` - Helpers for MCP servers to register tools/resources with UI metadata - `src/types.ts` - Protocol types re-exported from `spec.types.ts` and Zod schemas from `generated/schema.ts` (auto-generated during build) - `src/message-transport.ts` - `PostMessageTransport` for iframe communication @@ -70,12 +70,12 @@ rm -fR package-lock.json node_modules && \ ### Protocol Flow ``` -View (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP Server +View (App/Protocol) <--PostMessageTransport--> Host (AppBridge/Protocol) <--separate MCP Client--> MCP Server ``` 1. Host creates iframe with view HTML 2. View creates `App` instance and calls `connect()` with `PostMessageTransport` -3. View sends `ui/initialize` request, receives host capabilities and context +3. View sends `ui/initialize` (the only handshake on the iframe channel) and receives authoritative Apps capabilities and context 4. Host sends `sendToolInput()` with tool arguments after initialization 5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()` 6. Host sends `sendToolResult()` when tool execution completes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a3102b..e28dcd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -502,7 +502,7 @@ Alternatively, use the [workflow dispatch](https://github.com/modelcontextprotoc ## Code of Conduct -This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it before contributing. +This project follows our [Code of Conduct](https://github.com/modelcontextprotocol/.github/blob/main/CODE_OF_CONDUCT.md). Please review it before contributing. ## Reporting Issues @@ -573,7 +573,7 @@ To release a patch for an older version: ### Testing Pre-releases -Every commit and PR automatically publishes a preview package via [pkg-pr-new](https://github.com/pkg-pr-new/pkg-pr-new). Check the PR comments or workflow logs for the install command. +Every commit and PR automatically publishes a preview package via [pkg-pr-new](https://github.com/stackblitz-labs/pkg.pr.new). Check the PR comments or workflow logs for the install command. --- diff --git a/README.md b/README.md index 7348e15..ab4004d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

Why · - Quickstart + Quickstart · API Docs · @@ -89,6 +89,8 @@ it will guide you through the rest. Goose Postman MCPJam + mcp-use + Alpic Playground

> [!NOTE] @@ -120,12 +122,36 @@ resources: ## Getting Started +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). + +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 ``` +For an MCP server, add the server package (and, for HTTP transports, the Node +and Express adapters): + +```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) +[Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/quickstart.html) to build your first MCP App. ## Using the SDK @@ -139,7 +165,7 @@ UI metadata. | `@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.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) @@ -246,7 +272,7 @@ point your client at a local build: ## Resources -- [Quickstart Guide](https://apps.extensions.modelcontextprotocol.io/api/documents/Quickstart.html) +- [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)) diff --git a/build.bun.ts b/build.bun.ts index a129d5d..61361e6 100644 --- a/build.bun.ts +++ b/build.bun.ts @@ -1,6 +1,9 @@ #!/usr/bin/env bun import { $ } from "bun"; -import { cpSync, mkdirSync } from "node:fs"; +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`; @@ -31,9 +34,15 @@ function buildJs( }); } -// zod is a peerDependency — keep it external so consumers share a single -// zod instance (instanceof ZodError / schema.extend() break with duplicate copies). -const PEER_EXTERNALS = ["@modelcontextprotocol/sdk", "zod"]; +// 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", { diff --git a/docs/authorization.md b/docs/authorization.md index 59fc26c..df7adc6 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -150,7 +150,7 @@ registerAppTool( "get_account_balance", { description: "Get account balance", - inputSchema: { accountId: z.string() }, + inputSchema: z.object({ accountId: z.string() }), }, async ({ accountId }) => { if (!authInfo) { 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 d91281b..22d5d9f 100644 --- a/docs/migrate_from_openai_apps.md +++ b/docs/migrate_from_openai_apps.md @@ -66,7 +66,7 @@ The server-side changes involve updating metadata structure and using helper fun #### Before (OpenAI) ```typescript -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; function createServer() { @@ -78,7 +78,7 @@ 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://view/cart.html", @@ -126,7 +126,7 @@ function createServer() { #### After (MCP Apps) ```typescript -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerAppTool, registerAppResource, @@ -144,7 +144,7 @@ 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://view/cart.html" } }, }, diff --git a/docs/overview.md b/docs/overview.md index 28f5b23..8eda95d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -56,7 +56,11 @@ flowchart LR - **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 acts as an MCP client, the Host acts as a proxy, and the Server is a standard MCP server. +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 @@ -93,7 +97,7 @@ sequenceDiagram ``` 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` and receives host context (theme, capabilities, container dimensions). This handshake ensures the View is ready before receiving data. +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. diff --git a/docs/patterns.md b/docs/patterns.md index 87a6d82..884de26 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -19,7 +19,7 @@ registerAppTool( "update-quantity", { description: "Update item quantity in cart", - inputSchema: { itemId: z.string(), quantity: z.number() }, + inputSchema: z.object({ itemId: z.string(), quantity: z.number() }), _meta: { ui: { resourceUri: "ui://shop/cart.html", @@ -127,14 +127,14 @@ registerAppTool( { title: "Read Data Bytes", description: "Load binary data in chunks", - inputSchema: { + 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"] } }, @@ -281,10 +281,7 @@ server.registerResource( ```ts source="./patterns.tsx#binaryBlobResourceClient" -const result = await app.request( - { method: "resources/read", params: { uri: `video://${videoId}` } }, - ReadResourceResultSchema, -); +const result = await app.readServerResource({ uri: `video://${videoId}` }); const content = result.contents[0]; if (!content || !("blob" in content)) { diff --git a/docs/patterns.tsx b/docs/patterns.tsx index 6b87692..bb9eaa4 100644 --- a/docs/patterns.tsx +++ b/docs/patterns.tsx @@ -17,16 +17,12 @@ import { randomUUID } from "node:crypto"; import type { CallToolResult, ReadResourceResult, -} from "@modelcontextprotocol/sdk/types.js"; -import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js"; +} 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/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server"; import { z } from "zod"; /** @@ -117,14 +113,14 @@ function chunkedDataServer(server: McpServer) { { title: "Read Data Bytes", description: "Load binary data in chunks", - inputSchema: { + 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"] } }, @@ -252,10 +248,7 @@ function binaryBlobResourceServer( */ async function binaryBlobResourceClient(app: App, videoId: string) { //#region binaryBlobResourceClient - const result = await app.request( - { method: "resources/read", params: { uri: `video://${videoId}` } }, - ReadResourceResultSchema, - ); + const result = await app.readServerResource({ uri: `video://${videoId}` }); const content = result.contents[0]; if (!content || !("blob" in content)) { diff --git a/docs/quickstart.md b/docs/quickstart.md index 3e00796..a021912 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -21,7 +21,7 @@ This tutorial assumes you've built an MCP server before and are comfortable with We'll use the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) to build the server. -You'll also need Node.js 18+. +You'll also need Node.js 20+. ## 1. Set up the project @@ -37,7 +37,7 @@ Install the dependencies you'll need: ```bash npm init -y -npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors +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 ``` @@ -59,6 +59,7 @@ npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development IN ```json source="../examples/quickstart/tsconfig.json" { "compilerOptions": { + "types": ["node", "vite/client"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", @@ -87,6 +88,7 @@ npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development IN ```json source="../examples/quickstart/tsconfig.server.json" { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", @@ -178,9 +180,10 @@ import { registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import fs from "node:fs/promises"; import path from "node:path"; +import { z } from "zod"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -205,7 +208,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async () => { @@ -240,10 +243,10 @@ export function createServer(): McpServer { ```ts source="../examples/quickstart/main.ts" -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"; @@ -263,7 +266,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/docs/testing-mcp-apps.md b/docs/testing-mcp-apps.md index df1396c..a8eb333 100644 --- a/docs/testing-mcp-apps.md +++ b/docs/testing-mcp-apps.md @@ -28,6 +28,10 @@ The [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/ex 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 diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index 8045f36..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": "1.7.0", + "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,11 +11,11 @@ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", @@ -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/src/implementation.ts b/examples/basic-host/src/implementation.ts index 31e3698..5f680ff 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,8 +1,12 @@ 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 { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { CallToolResult, Resource, 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"; diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 3d488a7..00c0d52 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,5 +1,5 @@ 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"; 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/main.ts b/examples/basic-server-preact/main.ts index 7642632..ffdff52 100644 --- a/examples/basic-server-preact/main.ts +++ b/examples/basic-server-preact/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json index dbe0e07..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": "1.7.0", + "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", @@ -24,12 +24,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts index bf14ea5..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,7 +32,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-preact/src/mcp-app.tsx b/examples/basic-server-preact/src/mcp-app.tsx index a8bdc68..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"; 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/main.ts b/examples/basic-server-react/main.ts index ec187b6..21c5e18 100644 --- a/examples/basic-server-react/main.ts +++ b/examples/basic-server-react/main.ts @@ -4,10 +4,10 @@ * 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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json index 63d7656..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": "1.7.0", + "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", @@ -34,13 +34,16 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts index 23f6dda..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,7 +33,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-react/src/mcp-app.tsx b/examples/basic-server-react/src/mcp-app.tsx index 6bb5540..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"; 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/main.ts b/examples/basic-server-solid/main.ts index c8d9de2..095ef8d 100644 --- a/examples/basic-server-solid/main.ts +++ b/examples/basic-server-solid/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-solid/package.json b/examples/basic-server-solid/package.json index b39d041..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": "1.7.0", + "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", @@ -24,12 +24,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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.10", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -40,7 +43,7 @@ "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 2ed2d93..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,7 +32,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-solid/src/mcp-app.tsx b/examples/basic-server-solid/src/mcp-app.tsx index 66d47f3..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"; 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/main.ts b/examples/basic-server-svelte/main.ts index 6c50a25..734ec0c 100644 --- a/examples/basic-server-svelte/main.ts +++ b/examples/basic-server-svelte/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-svelte/package.json b/examples/basic-server-svelte/package.json index 46c38e6..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": "1.7.0", + "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", @@ -24,12 +24,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/basic-server-svelte/server.ts b/examples/basic-server-svelte/server.ts index da603ca..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,7 +32,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-svelte/src/App.svelte b/examples/basic-server-svelte/src/App.svelte index fde128d..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")!; 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/main.ts b/examples/basic-server-vanillajs/main.ts index 286fa34..4de7331 100644 --- a/examples/basic-server-vanillajs/main.ts +++ b/examples/basic-server-vanillajs/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json index d41a72e..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": "1.7.0", + "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", @@ -24,11 +24,14 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index 5b2daf7..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,7 +32,7 @@ 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(), }), 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/main.ts b/examples/basic-server-vue/main.ts index 669a071..d774269 100644 --- a/examples/basic-server-vue/main.ts +++ b/examples/basic-server-vue/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json index 4e8022e..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": "1.7.0", + "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", @@ -24,12 +24,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts index 5aa72de..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,7 +32,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async (): Promise => { diff --git a/examples/basic-server-vue/src/App.vue b/examples/basic-server-vue/src/App.vue index 196ad64..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")!; diff --git a/examples/basic-server-vue/tsconfig.json b/examples/basic-server-vue/tsconfig.json index 5edb66a..4fd3cd7 100644 --- a/examples/basic-server-vue/tsconfig.json +++ b/examples/basic-server-vue/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-vue/tsconfig.server.json b/examples/basic-server-vue/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/basic-server-vue/tsconfig.server.json +++ b/examples/basic-server-vue/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/budget-allocator-server/main.ts b/examples/budget-allocator-server/main.ts index cc488f0..02c03cb 100644 --- a/examples/budget-allocator-server/main.ts +++ b/examples/budget-allocator-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json index d8d0e3c..6e41622 100644 --- a/examples/budget-allocator-server/package.json +++ b/examples/budget-allocator-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-budget-allocator", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Budget allocator MCP App Server with interactive visualization", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/budget-allocator-server" }, "license": "MIT", @@ -26,12 +26,14 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index 04fba11..01f5dbf 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -4,11 +4,11 @@ * Provides budget configuration, 24 months of historical allocation data, * and industry benchmarks by company stage. */ -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"; @@ -270,7 +270,7 @@ export function createServer(): McpServer { title: "Get Budget Data", description: "Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget is interactive and exposes tools for reading/modifying allocations, adjusting budgets, and comparing against industry benchmarks.", - inputSchema: {}, + inputSchema: z.object({}), outputSchema: BudgetDataResponseSchema, _meta: { ui: { resourceUri } }, }, diff --git a/examples/budget-allocator-server/tsconfig.json b/examples/budget-allocator-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/budget-allocator-server/tsconfig.json +++ b/examples/budget-allocator-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/budget-allocator-server/tsconfig.server.json b/examples/budget-allocator-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/budget-allocator-server/tsconfig.server.json +++ b/examples/budget-allocator-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/cohort-heatmap-server/main.ts b/examples/cohort-heatmap-server/main.ts index d59903b..8b33603 100644 --- a/examples/cohort-heatmap-server/main.ts +++ b/examples/cohort-heatmap-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json index a42ba0b..7e2d2e2 100644 --- a/examples/cohort-heatmap-server/package.json +++ b/examples/cohort-heatmap-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-cohort-heatmap", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Cohort heatmap MCP App Server for retention analysis", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/cohort-heatmap-server" }, "license": "MIT", @@ -26,13 +26,15 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts index 6d07144..129e2dc 100644 --- a/examples/cohort-heatmap-server/server.ts +++ b/examples/cohort-heatmap-server/server.ts @@ -1,5 +1,7 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import { + McpServer, + type ReadResourceResult, +} from "@modelcontextprotocol/server"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -178,8 +180,8 @@ export function createServer(): McpServer { title: "Get Cohort Retention Data", description: "Returns cohort retention heatmap data showing customer retention over time by signup month", - inputSchema: GetCohortDataInputSchema.shape, - outputSchema: CohortDataSchema.shape, + inputSchema: GetCohortDataInputSchema, + outputSchema: CohortDataSchema, _meta: { ui: { resourceUri } }, }, async ({ metric, periodType, cohortCount, maxPeriods }) => { diff --git a/examples/cohort-heatmap-server/tsconfig.json b/examples/cohort-heatmap-server/tsconfig.json index fc3c210..3ecbdad 100644 --- a/examples/cohort-heatmap-server/tsconfig.json +++ b/examples/cohort-heatmap-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/cohort-heatmap-server/tsconfig.server.json b/examples/cohort-heatmap-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/cohort-heatmap-server/tsconfig.server.json +++ b/examples/cohort-heatmap-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/customer-segmentation-server/main.ts b/examples/customer-segmentation-server/main.ts index 130076c..c788757 100644 --- a/examples/customer-segmentation-server/main.ts +++ b/examples/customer-segmentation-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json index 46c8037..bac3b89 100644 --- a/examples/customer-segmentation-server/package.json +++ b/examples/customer-segmentation-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-customer-segmentation", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Customer segmentation MCP App Server with filtering", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/customer-segmentation-server" }, "license": "MIT", @@ -26,12 +26,14 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts index 244415d..7cc1c1f 100644 --- a/examples/customer-segmentation-server/server.ts +++ b/examples/customer-segmentation-server/server.ts @@ -1,8 +1,8 @@ -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"; @@ -100,8 +100,8 @@ export function createServer(): McpServer { title: "Get Customer Data", description: "Returns customer data with segment information for visualization. Optionally filter by segment.", - inputSchema: GetCustomerDataInputSchema.shape, - outputSchema: GetCustomerDataOutputSchema.shape, + inputSchema: GetCustomerDataInputSchema, + outputSchema: GetCustomerDataOutputSchema, _meta: { ui: { resourceUri } }, }, async ({ segment }): Promise => { diff --git a/examples/customer-segmentation-server/tsconfig.json b/examples/customer-segmentation-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/customer-segmentation-server/tsconfig.json +++ b/examples/customer-segmentation-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/customer-segmentation-server/tsconfig.server.json b/examples/customer-segmentation-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/customer-segmentation-server/tsconfig.server.json +++ b/examples/customer-segmentation-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/debug-server/main.ts b/examples/debug-server/main.ts index 3c89a97..9fb0825 100644 --- a/examples/debug-server/main.ts +++ b/examples/debug-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/debug-server/package.json b/examples/debug-server/package.json index b0a7d54..e28f4d3 100644 --- a/examples/debug-server/package.json +++ b/examples/debug-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-debug", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Debug MCP App Server for testing all SDK capabilities", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/debug-server" }, "license": "MIT", @@ -34,9 +34,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.1.13" + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/debug-server/server.ts b/examples/debug-server/server.ts index fd3452a..068f94d 100644 --- a/examples/debug-server/server.ts +++ b/examples/debug-server/server.ts @@ -3,11 +3,11 @@ import { 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 { appendFileSync } from "node:fs"; import path from "node:path"; diff --git a/examples/debug-server/tsconfig.json b/examples/debug-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/debug-server/tsconfig.json +++ b/examples/debug-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/debug-server/tsconfig.server.json b/examples/debug-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/debug-server/tsconfig.server.json +++ b/examples/debug-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/integration-server/main.ts b/examples/integration-server/main.ts index c45787e..9a42842 100644 --- a/examples/integration-server/main.ts +++ b/examples/integration-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json index f1173ed..7e04f4a 100644 --- a/examples/integration-server/package.json +++ b/examples/integration-server/package.json @@ -1,6 +1,6 @@ { "name": "integration-server", - "version": "1.7.0", + "version": "2.0.0", "private": true, "type": "module", "scripts": { @@ -15,13 +15,16 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts index e44689d..346f4f7 100644 --- a/examples/integration-server/server.ts +++ b/examples/integration-server/server.ts @@ -3,11 +3,11 @@ import { 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"; @@ -33,7 +33,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time.", - inputSchema: {}, + inputSchema: z.object({}), outputSchema: z.object({ time: z.string(), }), @@ -72,7 +72,7 @@ export function createServer(): McpServer { ); // Sample downloadable resource — used to demo ResourceLink in ui/download-file - server.resource( + server.registerResource( SAMPLE_DOWNLOAD_URI, SAMPLE_DOWNLOAD_URI, { diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index 7f0b18c..281a356 100644 --- a/examples/integration-server/src/mcp-app.tsx +++ b/examples/integration-server/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"; 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. +

+
+ + + +
+
+
+ + + diff --git a/examples/lazy-auth-server/package.json b/examples/lazy-auth-server/package.json new file mode 100644 index 0000000..dbd4601 --- /dev/null +++ b/examples/lazy-auth-server/package.json @@ -0,0 +1,53 @@ +{ + "name": "@modelcontextprotocol/server-lazy-auth", + "version": "2.0.0", + "type": "module", + "description": "MCP App example demonstrating lazy (on-demand) OAuth: public tools work unauthenticated, protected tools return 401 + WWW-Authenticate so the host runs the OAuth flow only when needed", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", + "directory": "examples/lazy-auth-server" + }, + "license": "MIT", + "main": "dist/server.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && cross-env INPUT=secret-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": "concurrently \"cross-env INPUT=mcp-app.html vite build --watch\" \"cross-env INPUT=secret-app.html vite build --watch\"", + "serve": "bun --watch main.ts", + "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\"", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "jose": "^6.0.0", + "zod": "^4.2.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.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" + }, + "types": "dist/server.d.ts", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "bin": { + "mcp-server-lazy-auth": "dist/index.js" + } +} diff --git a/examples/lazy-auth-server/secret-app.html b/examples/lazy-auth-server/secret-app.html new file mode 100644 index 0000000..b2ab00b --- /dev/null +++ b/examples/lazy-auth-server/secret-app.html @@ -0,0 +1,16 @@ + + + + + + + Lazy Auth Demo — Secret + + +
+

Protected App

+
Waiting for secret data…
+
+ + + diff --git a/examples/lazy-auth-server/server.ts b/examples/lazy-auth-server/server.ts new file mode 100644 index 0000000..5240008 --- /dev/null +++ b/examples/lazy-auth-server/server.ts @@ -0,0 +1,1134 @@ +/** + * Lazy Auth demo MCP server. + * + * Demonstrates "lazy" (on-demand) OAuth for MCP servers and Apps: + * + * - The server connects without authentication: `initialize`, `tools/list`, + * and public tool calls all succeed with no Bearer token. + * - Protected tools (`get_secret`, `revoke_auth_token`) return 401 with a + * `WWW-Authenticate` header pointing at protected-resource metadata, so the + * host only runs the OAuth flow when a protected tool is actually called. + * - The `show_auth_button` MCP App is public; clicking its button calls the + * protected `get_secret` tool via `callServerTool`. The host sees the 401, + * runs OAuth, retries, and the result renders inline. + * + * The embedded OAuth authorization server is intentionally minimal and + * stateless (HS256 JWTs, auth codes encoded as short-lived JWTs) so the demo + * can run anywhere with a single env var. It is NOT a production AS. + */ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; +import { + McpServer, + UrlElicitationRequiredError, + type CallToolResult, + type ReadResourceResult, +} from "@modelcontextprotocol/server"; +import cors from "cors"; +import express, { type Express, type Request, type Response } from "express"; +import { SignJWT, jwtVerify } from "jose"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; + +// Works both from source (server.ts) and compiled (dist/server.js). Derived +// from import.meta.url rather than import.meta.filename/dirname, which are +// undefined in some module-VM contexts (e.g. importing this package from +// jest). +const SERVER_FILE = fileURLToPath(import.meta.url); +const DIST_DIR = SERVER_FILE.endsWith(".ts") + ? path.join(path.dirname(SERVER_FILE), "dist") + : path.dirname(SERVER_FILE); + +// ─── Config ────────────────────────────────────────────────────────────────── + +export const PORT = parseInt(process.env.PORT ?? "3097", 10); +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET ?? + "dev-insecure-secret-do-not-use-in-production-" + "x".repeat(20), +); +const PROTECTED_TOOLS = new Set(["get_secret", "revoke_auth_token"]); +// Token lifetimes. Defaults are deliberately short so the host's refresh flow +// (30s–5min after auth) and full re-auth (>5min) are easy to observe in a demo. +// Slow or automated clients can request longer-lived tokens per connection by +// connecting to a TTL-scoped endpoint path (/ttl//mcp) — see +// ttlFromResource() and the README. +const ACCESS_TOKEN_TTL_SECONDS = parseInt( + process.env.ACCESS_TOKEN_TTL_SECONDS ?? "30", + 10, +); +const REFRESH_TOKEN_TTL_SECONDS = parseInt( + process.env.REFRESH_TOKEN_TTL_SECONDS ?? "300", + 10, +); +// Cap on per-connection `?token_ttl_s=` requests. +const MAX_TOKEN_TTL_SECONDS = 86400; +// When true: root well-known PRM returns *stub* metadata WITHOUT authorization_servers, +// and root well-known AS is 404. Hosts probing well-known on connect see "resource has +// PRM but no auth configured" → connect without OAuth. Real PRM (with authorization_servers) +// is only at /auth/prm, reachable via WWW-Authenticate on 401. Default false = standard. +const REACTIVE_AUTH_ONLY = process.env.REACTIVE_AUTH_ONLY === "1"; + +/** Hostname is localhost / a loopback address (URL-style, so IPv6 keeps its brackets). */ +function isLoopbackHostname(hostname: string): boolean { + return ( + ["localhost", "127.0.0.1", "[::1]"].includes(hostname) || + hostname.endsWith(".localhost") + ); +} + +/** + * Resolve the public base URL for this deployment. + * + * Precedence: the PUBLIC_URL env var, then the request's Host header (loopback + * hosts only), then http://localhost:PORT. Host / X-Forwarded-* headers naming + * non-loopback hosts are deliberately NOT trusted — a client could spoof them + * to make OAuth metadata and callback URLs point at a host it controls. + * Non-localhost deployments (e.g. behind a public tunnel) must set PUBLIC_URL + * explicitly. + */ +function resolvePublicUrl(req?: Request): URL { + // Mount path when this app is mounted inside another Express app + // (e.g. app.use("/lazy-auth", createApp())). Empty when standalone. + // PUBLIC_URL, when set, must already include any mount path. + const basePath = req?.baseUrl ?? ""; + const envUrl = process.env.PUBLIC_URL; + if (envUrl) return new URL(envUrl.endsWith("/") ? envUrl : envUrl + "/"); + const host = req?.headers.host; + if (host) { + try { + const url = new URL(`http://${host}${basePath}/`); + if (isLoopbackHostname(url.hostname)) return url; + } catch { + // Malformed Host header → fall through to the localhost default. + } + } + return new URL(`http://localhost:${PORT}${basePath}/`); +} + +/** Public base URL as a string with no trailing slash (may include a base path). */ +function publicBaseHref(req?: Request): string { + const href = resolvePublicUrl(req).href; + return href.endsWith("/") ? href.slice(0, -1) : href; +} + +/** OAuth issuer. In REACTIVE_AUTH_ONLY mode, uses a /auth subpath so root well-known 404s. + * Otherwise uses the public base URL (origin + any mount path). */ +const ISSUER_SUFFIX = REACTIVE_AUTH_ONLY ? "/auth" : ""; +function resolveIssuer(req?: Request): string { + return publicBaseHref(req) + ISSUER_SUFFIX; +} + +// ─── Mock OAuth (HS256, stateless codes) ───────────────────────────────────── + +interface CodePayload { + client_id: string; + redirect_uri: string; + code_challenge?: string; + code_challenge_method?: string; + scope?: string; + /** Requested access-token TTL, from `?token_ttl_s=` on the resource indicator. */ + token_ttl_s?: number; +} + +/** Validate + cap a raw TTL value (from a path segment or query param). */ +function parseTtlSeconds(raw: string | undefined): number | undefined { + const ttl = parseInt(raw ?? "", 10); + return Number.isFinite(ttl) && ttl > 0 + ? Math.min(ttl, MAX_TOKEN_TTL_SECONDS) + : undefined; +} + +/** + * Extract the requested access-token TTL from a resource indicator (RFC 8707). + * + * MCP hosts send `resource=` — the URL the client connected + * to — in authorization and token requests. Clients opt into a non-default + * token lifetime by connecting to a TTL-scoped MCP endpoint path: + * + * /ttl//mcp (e.g. /ttl/3600/mcp for 1-hour tokens) + * + * The TTL lives in the PATH because hosts canonicalize resource indicators and + * strip query strings (verified against real hosts), so `?token_ttl_s=` query + * params would never reach this server's OAuth endpoints. The query param is + * still honored as a fallback for clients that preserve it. + */ +function ttlFromResource(resource: string | undefined): number | undefined { + if (!resource) return undefined; + try { + const url = new URL(resource); + const pathMatch = url.pathname.match(/\/ttl\/(\d+)\/mcp$/); + return ( + parseTtlSeconds(pathMatch?.[1]) ?? + parseTtlSeconds(url.searchParams.get("token_ttl_s") ?? undefined) + ); + } catch { + return undefined; + } +} + +interface AuthInfo { + token: string; + sub: string; + sid: string; +} + +// ─── Session revocation ─────────────────────────────────────────────────────── +// +// Each OAuth session (authorize → code → tokens) gets a single `sid`; all access & +// refresh tokens in that session carry it. Revoking the sid invalidates both → host +// must do a full re-OAuth (refresh grant also fails). +// +// Storage is an in-memory Map, which is fine for a single-process demo server. If +// you deploy this to a multi-instance/serverless environment, back it with a +// shared store instead. + +const revokedSids = new Map(); // sid → unix GC time +// Revoked-sid entries need only outlive the longest token TTL; add small clock-skew grace. +const REVOCATION_GC_TTL_SECONDS = + Math.max( + ACCESS_TOKEN_TTL_SECONDS, + REFRESH_TOKEN_TTL_SECONDS, + MAX_TOKEN_TTL_SECONDS, + ) + 10; + +function revokeSession(sid: string): void { + revokedSids.set( + sid, + Math.floor(Date.now() / 1000) + REVOCATION_GC_TTL_SECONDS, + ); + const now = Math.floor(Date.now() / 1000); + for (const [k, gc] of revokedSids) if (gc < now) revokedSids.delete(k); +} + +function isSessionRevoked(sid: string | undefined): boolean { + if (!sid) return false; + return revokedSids.has(sid); +} + +async function signAccessToken( + sub: string, + scope: string, + sid: string, + issuer: string, + audience: string, + ttlSeconds: number = ACCESS_TOKEN_TTL_SECONDS, +): Promise { + return new SignJWT({ sub, scope, sid }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime(`${ttlSeconds}s`) + .sign(JWT_SECRET); +} + +async function signRefreshToken( + sub: string, + scope: string, + sid: string, + issuer: string, + ttlSeconds: number = REFRESH_TOKEN_TTL_SECONDS, + accessTtlSeconds?: number, +): Promise { + // access_ttl_s carries the grant's requested access-token TTL forward so + // refreshed access tokens keep the same lifetime as the original. + return new SignJWT({ + sub, + scope, + sid, + typ: "refresh", + ...(accessTtlSeconds !== undefined + ? { access_ttl_s: accessTtlSeconds } + : {}), + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(issuer) + .setExpirationTime(`${ttlSeconds}s`) + .sign(JWT_SECRET); +} + +async function verifyRefreshToken( + token: string, + issuer: string, +): Promise< + | { sub: string; scope: string; sid: string; accessTtlSeconds?: number } + | undefined +> { + try { + const { payload } = await jwtVerify(token, JWT_SECRET, { issuer }); + if (payload.typ !== "refresh") return undefined; + const sid = payload.sid as string | undefined; + if (isSessionRevoked(sid)) return undefined; // session revoked → refresh fails + return { + sub: payload.sub ?? "", + scope: (payload.scope as string) ?? "", + sid: sid ?? "", + accessTtlSeconds: + typeof payload.access_ttl_s === "number" + ? payload.access_ttl_s + : undefined, + }; + } catch { + return undefined; + } +} + +/** Encode grant details inside the code itself (5min expiry) — no server storage. */ +async function signAuthCode( + payload: CodePayload, + issuer: string, +): Promise { + return new SignJWT({ ...payload, typ: "code" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setJti(crypto.randomUUID()) + .setIssuer(issuer) + .setExpirationTime("5m") + .sign(JWT_SECRET); +} + +// Authorization codes are single-use (RFC 6749 §4.1.2): remember redeemed code +// IDs until the code's own 5-minute expiry makes replay impossible anyway. +const redeemedCodeJtis = new Map(); // jti → unix GC time + +async function verifyAuthCode( + code: string, + issuer: string, +): Promise { + try { + const { payload } = await jwtVerify(code, JWT_SECRET, { issuer }); + if (payload.typ !== "code" || !payload.jti) return undefined; + const now = Math.floor(Date.now() / 1000); + for (const [k, gc] of redeemedCodeJtis) + if (gc < now) redeemedCodeJtis.delete(k); + if (redeemedCodeJtis.has(payload.jti)) return undefined; // already redeemed + redeemedCodeJtis.set(payload.jti, now + 5 * 60 + 10); + return payload as unknown as CodePayload; + } catch { + return undefined; + } +} + +async function verifyAccessToken( + token: string, + issuer: string, + maxAgeSeconds?: number, +): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET, { issuer }); + if (payload.typ === "code" || payload.typ === "refresh") return undefined; // only plain access tokens + const sid = payload.sid as string | undefined; + if (isSessionRevoked(sid)) return undefined; // session revoked + // Per-connection TTL override (?token_ttl_s= on the MCP URL): treat tokens + // older than maxAgeSeconds as expired even though their exp claim is still + // in the future. Lets one deployment serve both long-TTL (default) and + // short-TTL (refresh-flow testing) connections. + if (maxAgeSeconds !== undefined && typeof payload.iat === "number") { + const ageSeconds = Math.floor(Date.now() / 1000) - payload.iat; + if (ageSeconds > maxAgeSeconds) return undefined; + } + return { token, sub: payload.sub ?? "", sid: sid ?? "" }; + } catch { + return undefined; + } +} + +function escapeHtml(s: string) { + return s.replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ]!, + ); +} + +/** + * This demo AS has no client registry, so it can't check redirect URIs against + * pre-registered values. At minimum, only allow https targets (or http on + * loopback for local development hosts) so the consent page never links to, + * or redirects the authorization code to, an unexpected scheme. + */ +function isAllowedRedirectUri(redirectUri: string): boolean { + try { + const url = new URL(redirectUri); + if (url.protocol === "https:") return true; + return url.protocol === "http:" && isLoopbackHostname(url.hostname); + } catch { + return false; + } +} + +async function handleAuthorize(req: Request, res: Response) { + const { + client_id, + redirect_uri, + state, + code_challenge, + code_challenge_method, + scope, + approved, + resource, + } = req.query as Record; + if (!redirect_uri) { + res.status(400).json({ + error: "invalid_request", + error_description: "Missing redirect_uri", + }); + return; + } + if (!isAllowedRedirectUri(redirect_uri)) { + res.status(400).json({ + error: "invalid_request", + error_description: "redirect_uri must use https (or http on localhost)", + }); + return; + } + // PKCE is mandatory (the MCP auth spec requires it of clients, and this AS + // only advertises S256). Rejecting up front keeps stolen-code attacks out of + // the demo even though it has no real data to protect. + if (!code_challenge || code_challenge_method !== "S256") { + res.status(400).json({ + error: "invalid_request", + error_description: "PKCE with S256 code_challenge is required", + }); + return; + } + const issuer = resolveIssuer(req); + + if (approved !== "1") { + // Show consent page. Keeps the OAuth popup visible so users can see the flow. + const approveUrl = new URL(publicBaseHref(req) + "/authorize"); + for (const [k, v] of Object.entries(req.query)) + if (v) approveUrl.searchParams.set(k, String(v)); + approveUrl.searchParams.set("approved", "1"); + const denyUrl = new URL(redirect_uri); + denyUrl.searchParams.set("error", "access_denied"); + if (state) denyUrl.searchParams.set("state", state); + res.type("text/html").send(/*html*/ ` +Authorize + + +
+

🔑 Mock Authorization

+

An application is requesting access:

+
Client
${escapeHtml(client_id ?? "(none)")}
+
Scope
${escapeHtml(scope ?? "(default)")}
+
Redirect
${escapeHtml(redirect_uri)}
+ +
+ +`); + return; + } + + // Per-grant token TTL, requested via `?token_ttl_s=` on the resource + // indicator (the MCP URL the host is authorizing for). Encoded into the + // auth code so /token can issue tokens with the requested lifetime. + const tokenTtl = ttlFromResource(resource); + const code = await signAuthCode( + { + client_id, + redirect_uri, + code_challenge, + code_challenge_method, + scope, + ...(tokenTtl !== undefined ? { token_ttl_s: tokenTtl } : {}), + }, + issuer, + ); + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set("code", code); + if (state) redirectUrl.searchParams.set("state", state); + console.log( + `[auth] issued code for client_id=${client_id}` + + (tokenTtl !== undefined ? ` (token_ttl_s=${tokenTtl})` : ""), + ); + res.redirect(redirectUrl.href); +} + +async function handleToken(req: Request, res: Response) { + const { grant_type, code, code_verifier, refresh_token, resource } = req.body; + const issuer = resolveIssuer(req); + const audience = resolvePublicUrl(req).href; + + if (grant_type === "refresh_token") { + const refreshClaims = await verifyRefreshToken(refresh_token, issuer); + if (!refreshClaims) { + console.log(`[auth] refresh rejected (invalid/revoked)`); + res.status(400).json({ + error: "invalid_grant", + error_description: "Refresh token invalid or revoked", + }); + return; + } + // Refreshed access tokens keep the grant's requested TTL: prefer the + // resource indicator on this request, else the TTL carried in the refresh + // token, else the default. + const accessTtl = + ttlFromResource(resource) ?? + refreshClaims.accessTtlSeconds ?? + ACCESS_TOKEN_TTL_SECONDS; + const access_token = await signAccessToken( + refreshClaims.sub, + refreshClaims.scope, + refreshClaims.sid, + issuer, + audience, + accessTtl, + ); + console.log( + `[auth] refreshed access token (sid=${refreshClaims.sid}, ttl=${accessTtl}s)`, + ); + res.json({ + access_token, + token_type: "Bearer", + expires_in: accessTtl, + scope: refreshClaims.scope, + refresh_token, + }); + return; + } + if (grant_type !== "authorization_code") { + res.status(400).json({ error: "unsupported_grant_type" }); + return; + } + const stored = await verifyAuthCode(code, issuer); + if (!stored) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid or expired code", + }); + return; + } + // PKCE verification (challenges are always present — /authorize requires them). + if (!code_verifier) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Missing code_verifier", + }); + return; + } + const hash = crypto + .createHash("sha256") + .update(code_verifier) + .digest("base64url"); + if (hash !== stored.code_challenge) { + res.status(400).json({ + error: "invalid_grant", + error_description: "PKCE verification failed", + }); + return; + } + // RFC 6749 §4.1.3 redirect_uri binding: if the client includes redirect_uri + // in the token request it must match the one from the authorization request. + // (OAuth 2.1 clients may omit it and rely on PKCE, which is enforced above.) + if (req.body.redirect_uri && req.body.redirect_uri !== stored.redirect_uri) { + res.status(400).json({ + error: "invalid_grant", + error_description: "redirect_uri does not match authorization request", + }); + return; + } + const scope = stored.scope ?? "read:secret"; + const sid = crypto.randomBytes(16).toString("hex"); // new session per authorization_code grant + // Per-grant TTL: prefer the resource indicator on this request (RFC 8707), + // else the TTL encoded in the auth code at /authorize, else the default. + // Refresh tokens must outlive the access tokens they can mint. + const accessTtl = + ttlFromResource(resource) ?? stored.token_ttl_s ?? ACCESS_TOKEN_TTL_SECONDS; + const refreshTtl = Math.max(REFRESH_TOKEN_TTL_SECONDS, accessTtl); + const access_token = await signAccessToken( + "mock-user-123", + scope, + sid, + issuer, + audience, + accessTtl, + ); + const refresh = await signRefreshToken( + "mock-user-123", + scope, + sid, + issuer, + refreshTtl, + accessTtl, + ); + console.log( + `[auth] exchanged code → token (client_id=${stored.client_id}, scope=${scope}, sid=${sid}, access_ttl=${accessTtl}s, refresh_ttl=${refreshTtl}s)`, + ); + res.json({ + access_token, + token_type: "Bearer", + expires_in: accessTtl, + scope, + refresh_token: refresh, + }); +} + +// ─── Elicitation state (module-level — survives across SHTTP requests) ────── +// +// Streamable HTTP creates a fresh McpServer per request, so state must live at +// module scope. This is fine for a single-process demo server; back it with a +// shared store if you deploy multiple instances. + +/** elicitationIds the user has completed via /elicitation-callback */ +const completedElicitations = new Set(); +/** elicit_by_error: remember the last-issued eid so retries can check it */ +let lastIssuedElicitationId: string | undefined; + +// ─── MCP Server ────────────────────────────────────────────────────────────── + +/** + * Creates a new MCP server instance with tools and resources registered. + * + * @param authInfo - Verified Bearer token info, if the request carried one. + * @param req - The inbound HTTP request (used to resolve the public URL). + */ +export function createServer(authInfo?: AuthInfo, req?: Request): McpServer { + const server = new McpServer({ name: "Lazy Auth Demo", version: "1.0.0" }); + + const buttonUri = "ui://lazy-auth/mcp-app.html"; + registerAppTool( + server, + "show_auth_button", + { + title: "Show Auth Button", + description: + "Public tool: shows a button that triggers the protected get_secret tool.", + inputSchema: z.object({}), + _meta: { ui: { resourceUri: buttonUri } }, + }, + async (): Promise => ({ + content: [{ type: "text", text: "ok" }], + }), + ); + registerAppResource( + server, + buttonUri, + buttonUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: buttonUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + const secretUri = "ui://lazy-auth/secret-app.html"; + registerAppTool( + server, + "get_secret", + { + title: "Get Secret", + description: + "Protected tool: returns secret data. Requires authentication.", + inputSchema: z.object({}), + _meta: { ui: { resourceUri: secretUri } }, + }, + async (): Promise => { + if (!authInfo) { + return { + isError: true, + content: [{ type: "text", text: "Authentication required." }], + }; + } + const secret = { + subject: authInfo.sub, + secret: "the-answer-is-42", + issuedAt: new Date().toISOString(), + }; + return { + content: [{ type: "text", text: JSON.stringify(secret, null, 2) }], + }; + }, + ); + registerAppResource( + server, + secretUri, + secretUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "secret-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: secretUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + server.registerTool( + "revoke_auth_token", + { + title: "Revoke Auth Token", + description: + "Protected tool: revokes the caller's entire auth session (access + refresh token).", + inputSchema: z.object({}), + }, + async (): Promise => { + if (!authInfo) { + return { + isError: true, + content: [{ type: "text", text: "Authentication required." }], + }; + } + revokeSession(authInfo.sid); + console.log( + `[auth] revoked session sid=${authInfo.sid} (total revoked sessions: ${revokedSids.size})`, + ); + return { + content: [ + { + type: "text", + text: `Session revoked (sid: ${authInfo.sid}). Access + refresh token invalid → full re-auth required.`, + }, + ], + }; + }, + ); + + // ── Elicitation test tools ──────────────────────────────────────────────── + // Public tools — no auth. Used to exercise a host's URL-elicitation flow + // end-to-end over Streamable HTTP. + + const base = publicBaseHref(req); + const callbackUrl = (eid: string) => + `${base}/elicitation-callback?id=${encodeURIComponent(eid)}`; + + server.registerTool( + "elicit_url", + { + title: "Elicit URL (session-callback)", + description: + "URL elicitation via elicitInput. Blocks until the client accepts; sends ElicitCompleteNotification before returning.", + inputSchema: z.object({}), + }, + async (_args, extra): Promise => { + const eid = crypto.randomUUID(); + // Defer notifier creation — checks client capabilities which throws if + // client doesn't advertise elicitation. Create lazily after elicitInput + // succeeds (implies client supports it). + console.log(`[elicit_url] eid=${eid} — blocking on elicitInput`); + const result = await server.server.elicitInput( + { + mode: "url", + url: callbackUrl(eid), + message: "Please open this URL to continue.", + elicitationId: eid, + }, + { relatedRequestId: extra.mcpReq.id, timeout: 300_000 }, + ); + // Send completion notification before returning — lands on the same SSE + // stream as this tool response. For Streamable HTTP this is the ONLY + // window where the server can push a notification (stream closes right + // after). The notification is optional per spec — skip if the client + // doesn't support it. + try { + const notifier = server.server.createElicitationCompletionNotifier(eid); + await notifier(); + console.log( + `[elicit_url] eid=${eid} — notified, result=${result.action}`, + ); + } catch (e) { + console.log( + `[elicit_url] eid=${eid} — notify skipped: ${e instanceof Error ? e.message : e}, result=${result.action}`, + ); + } + return { + content: [ + { type: "text", text: `elicit_url: ${JSON.stringify(result)}` }, + ], + }; + }, + ); + + server.registerTool( + "elicit_by_error", + { + title: "Elicit by -32042 Error", + description: + "Throws UrlElicitationRequiredError (-32042). Client must open the URL, then retry. On retry, if the last-issued elicitationId was completed, sends ElicitCompleteNotification and succeeds.", + inputSchema: z.object({}), + }, + async (): Promise => { + // Retry path: check if the user completed the elicitation we last issued. + // Module-level state survives across Streamable HTTP requests in a single + // process. Checking ANY completed eid (not just last-issued) would be more + // forgiving, but last-issued is the stricter real-world pattern hosts must + // handle. + if ( + lastIssuedElicitationId && + completedElicitations.has(lastIssuedElicitationId) + ) { + const eid = lastIssuedElicitationId; + completedElicitations.delete(eid); + lastIssuedElicitationId = undefined; + // Send notification DURING this (retry) tool call — only window where + // the Streamable HTTP stream is open. The notification is optional per + // spec — createElicitationCompletionNotifier throws if the client + // doesn't advertise the capability, but the tool still succeeds. + try { + const notifier = + server.server.createElicitationCompletionNotifier(eid); + await notifier(); + console.log( + `[elicit_by_error] eid=${eid} — completed on retry, notified`, + ); + } catch (e) { + console.log( + `[elicit_by_error] eid=${eid} — completed on retry, notify skipped: ${e instanceof Error ? e.message : e}`, + ); + } + return { + content: [ + { + type: "text", + text: `Auth completed (${eid}). Tool succeeded on retry.`, + }, + ], + }; + } + const eid = crypto.randomUUID(); + lastIssuedElicitationId = eid; + console.log(`[elicit_by_error] eid=${eid} — throwing -32042`); + throw new UrlElicitationRequiredError([ + { + mode: "url", + url: callbackUrl(eid), + message: + "This tool requires authentication. Please open the link to continue.", + elicitationId: eid, + }, + ]); + }, + ); + + return server; +} + +// ─── Express app ───────────────────────────────────────────────────────────── + +/** + * Creates the Express app: OAuth endpoints, discovery metadata, and the /mcp + * endpoint with lazy (401-triggered) auth on protected tools. + */ +export function createApp(): Express { + const app = express(); + // Wildcard CORS is deliberate: browser-based MCP hosts connect to this demo + // from arbitrary origins and must read WWW-Authenticate to drive the lazy + // auth flow. There are no cookies or ambient credentials to protect — all + // auth is an explicit Bearer header, and /token is a credential-less PKCE + // exchange — so cross-origin reads expose nothing a direct request wouldn't. + app.use(cors({ exposedHeaders: ["WWW-Authenticate"] })); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // Request tracing: log every inbound request (incl. query params) so we can + // see what hosts probe during connect and what they send through OAuth. + app.use((req, _res, next) => { + const ua = (req.headers["user-agent"] ?? "").slice(0, 80); + const auth = req.headers.authorization ? "[Bearer]" : "[no-auth]"; + const query = Object.keys(req.query).length + ? ` query=${JSON.stringify(req.query)}` + : ""; + console.log(`[req] ${req.method} ${req.path} ${auth}${query} ua=${ua}`); + next(); + }); + + // OAuth endpoints + app.get("/authorize", handleAuthorize); + app.post("/token", handleToken); + + // OAuth discovery metadata. In REACTIVE_AUTH_ONLY mode, PRM + AS are kept off the + // ROOT /.well-known paths so MCP hosts can't proactively probe them and trigger + // preemptive auth during connection — discovery is purely reactive: + // 401 → WWW-Authenticate → PRM → AS → OAuth flow. + const PRM_PATH = "/auth/prm"; + + function buildAsMetadata(req: Request) { + const base = publicBaseHref(req); + return { + issuer: resolveIssuer(req), // subpath issuer → well-known at /.well-known/.../auth + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["read:secret"], + client_id_metadata_document_supported: true, + }; + } + + // AS metadata: serve at the issuer's well-known location. + // - REACTIVE_AUTH_ONLY: only at /.well-known/.../auth (subpath); root 404s. + // - Default: also at root /.well-known/... (standard; some hosts probe this on connect). + if (ISSUER_SUFFIX) { + // Per RFC 8414 §3: issuer /auth → /.well-known/oauth-authorization-server/auth + app.get( + `/.well-known/oauth-authorization-server${ISSUER_SUFFIX}`, + (req, res) => res.json(buildAsMetadata(req)), + ); + // RFC 8615 style (well-known between host and path): + app.get( + `${ISSUER_SUFFIX}/.well-known/oauth-authorization-server`, + (req, res) => res.json(buildAsMetadata(req)), + ); + } + if (!REACTIVE_AUTH_ONLY) { + // Default mode: full AS metadata at root well-known (standard). + app.get("/.well-known/oauth-authorization-server", (req, res) => + res.json(buildAsMetadata(req)), + ); + } + + // PRM: full version at custom path (referenced via WWW-Authenticate on 401). + function buildPrm(req: Request, includeAuth: boolean, resourcePath = "/mcp") { + const base = publicBaseHref(req); + return { + resource: `${base}${resourcePath}`, + ...(includeAuth + ? { + authorization_servers: [resolveIssuer(req)], + scopes_supported: ["read:secret"], + bearer_methods_supported: ["header"], + } + : {}), + }; + } + /** Resource path for a TTL-scoped MCP endpoint, validating the TTL segment. */ + function ttlResourcePath(rawTtl: string): string | undefined { + const ttl = parseTtlSeconds(rawTtl); + return ttl !== undefined ? `/ttl/${ttl}/mcp` : undefined; + } + app.get(PRM_PATH, (req, res) => res.json(buildPrm(req, true))); + app.get(`${PRM_PATH}/ttl/:ttl`, (req, res) => { + const resourcePath = ttlResourcePath(String(req.params.ttl)); + if (!resourcePath) { + res.status(404).json({ error: "invalid_ttl" }); + return; + } + res.json(buildPrm(req, true, resourcePath)); + }); + // Root + path-dependent well-known PRM: in REACTIVE mode these 404 so hosts don't + // interpret their presence as "server has OAuth". In default mode, full PRM. + if (!REACTIVE_AUTH_ONLY) { + app.get("/.well-known/oauth-protected-resource", (req, res) => + res.json(buildPrm(req, true)), + ); + app.get("/.well-known/oauth-protected-resource/mcp", (req, res) => + res.json(buildPrm(req, true)), + ); + // Well-known PRM for TTL-scoped MCP endpoints (RFC 9728 derives this URL + // from the resource path, so each TTL path has its own). + app.get( + "/.well-known/oauth-protected-resource/ttl/:ttl/mcp", + (req, res) => { + const resourcePath = ttlResourcePath(String(req.params.ttl)); + if (!resourcePath) { + res.status(404).json({ error: "invalid_ttl" }); + return; + } + res.json(buildPrm(req, true, resourcePath)); + }, + ); + } + + // /register: some MCP hosts fall through to OAuth dynamic client registration (RFC 7591) + // when all well-known probes 404. We don't support it — return a proper OAuth error so + // the host concludes "auth not available here" and connects without OAuth, instead of + // treating a generic 404 as a connection failure. + app.post("/register", (_req, res) => { + res.status(400).json({ + error: "invalid_request", + error_description: + "Dynamic client registration is not supported. This server uses per-tool auth triggered via WWW-Authenticate on 401.", + }); + }); + + // MCP endpoint handler, shared by the default and TTL-scoped paths. + // pathTtl (from /ttl//mcp) doubles as the acceptance window for + // Bearer tokens: tokens older than it are rejected even if their issued + // lifetime was longer. + async function handleMcp( + req: Request, + res: Response, + pathTtl: number | undefined, + ) { + const base = publicBaseHref(req); + const resourceMetadataUrl = + pathTtl !== undefined + ? `${base}${PRM_PATH}/ttl/${pathTtl}` + : `${base}${PRM_PATH}`; + + const body = req.body; + const messages = Array.isArray(body) ? body : body ? [body] : []; + const needsAuth = messages.some( + (msg: { method?: string; params?: { name?: string } }) => + msg?.method === "tools/call" && + PROTECTED_TOOLS.has(msg.params?.name ?? ""), + ); + + // Acceptance window: the path TTL, else the `?token_ttl_s=` query param + // (fallback for clients that preserve query strings). + const tokenMaxAgeSeconds = + pathTtl ?? parseTtlSeconds(String(req.query.token_ttl_s ?? "")); + + let authInfo: AuthInfo | undefined; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + authInfo = await verifyAccessToken( + authHeader.slice(7), + resolveIssuer(req), + tokenMaxAgeSeconds, + ); + if (!authInfo && needsAuth) { + console.log(`[mcp] 401: invalid token for protected tool`); + res + .status(401) + .set( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="The access token is invalid", resource_metadata="${resourceMetadataUrl}"`, + ) + .json({ + error: "invalid_token", + error_description: "The access token is invalid", + }); + return; + } + } else if (needsAuth) { + console.log(`[mcp] 401: no token for protected tool`); + res + .status(401) + .set( + "WWW-Authenticate", + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ) + .json({ + error: "invalid_token", + error_description: "Authorization required", + }); + return; + } + + const server = createServer(authInfo, req); + 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, + }); + } + } + } + + // Default MCP endpoint (default token TTLs). + app.all("/mcp", (req: Request, res: Response) => + handleMcp(req, res, undefined), + ); + // TTL-scoped MCP endpoint: tokens for connections made through this path are + // issued with (and limited to) the requested lifetime, e.g. /ttl/3600/mcp. + app.all("/ttl/:ttl/mcp", (req: Request, res: Response) => { + const pathTtl = parseTtlSeconds(String(req.params.ttl)); + if (pathTtl === undefined) { + res.status(404).json({ error: "invalid_ttl" }); + return; + } + return handleMcp(req, res, pathTtl); + }); + + // Elicitation callback — marks an eid as completed. Module-level state; + // the next elicit_by_error retry reads it. Minimal confirmation page so + // the popup shows something. + app.get("/elicitation-callback", (req, res) => { + const id = String(req.query.id ?? ""); + if (!id) { + res.status(400).type("text/plain").send("missing ?id="); + return; + } + completedElicitations.add(id); + console.log( + `[callback] eid=${id} completed (total: ${completedElicitations.size})`, + ); + res + .type("text/html") + .send( + `Done` + + `` + + `

✓ Elicitation completed

` + + `

${escapeHtml(id)}

` + + `

You can close this tab. The tool will succeed on the next retry.

`, + ); + }); + + // Simple landing page + app.get("/", (req, res) => { + const base = publicBaseHref(req); + res + .type("text/plain") + .send( + `Lazy Auth Demo — MCP server\n\n` + + ` MCP endpoint: ${base}/mcp\n` + + ` ${base}/ttl//mcp (custom token TTL, e.g. /ttl/3600/mcp)\n` + + ` AS metadata: ${base}/.well-known/oauth-authorization-server${ISSUER_SUFFIX}\n` + + ` PRM metadata: ${base}${PRM_PATH}\n\n` + + `Tools:\n` + + ` - show_auth_button (public)\n` + + ` - get_secret (protected, requires Bearer token)\n` + + ` - revoke_auth_token (protected, revokes caller's auth session)\n` + + ` - elicit_url (public, URL elicitation via elicitInput)\n` + + ` - elicit_by_error (public, URL elicitation via -32042 error)\n`, + ); + }); + + return app; +} diff --git a/examples/lazy-auth-server/src/global.css b/examples/lazy-auth-server/src/global.css new file mode 100644 index 0000000..54a9860 --- /dev/null +++ b/examples/lazy-auth-server/src/global.css @@ -0,0 +1,100 @@ +: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: 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); +} + +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/lazy-auth-server/src/mcp-app.css b/examples/lazy-auth-server/src/mcp-app.css new file mode 100644 index 0000000..800b224 --- /dev/null +++ b/examples/lazy-auth-server/src/mcp-app.css @@ -0,0 +1,82 @@ +.main { + width: 100%; + max-width: 480px; + box-sizing: border-box; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: var(--spacing-md); + } +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + /* Keep a long press on or near the buttons from starting text selection + or raising the iOS callout menu instead of a click. */ + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +button { + /* Match the consent page's 56px tap targets (server.ts .btn). */ + min-height: 56px; + min-width: 56px; + padding: var(--spacing-sm) var(--spacing-md); + border: none; + 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; + /* Suppress the double-tap-to-zoom gesture so a tap resolves to a click + immediately instead of waiting to see if a second tap follows. */ + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:active:not(:disabled) { + filter: brightness(0.85); + } + + &:hover:not(:disabled) { + background-color: color-mix( + in srgb, + var(--color-accent) 85%, + var(--color-background-inverse) + ); + } + + &:focus-visible { + outline: calc(var(--border-width-regular) * 2) solid + var(--color-ring-primary); + outline-offset: var(--border-width-regular); + } +} + +.result { + padding: var(--spacing-sm) var(--spacing-md); + border: var(--border-width-regular) solid + color-mix(in srgb, var(--color-text-primary) 25%, transparent); + border-radius: var(--border-radius-md); + font-family: var(--font-mono); + font-size: 0.875em; + white-space: pre-wrap; + word-break: break-word; +} + +.error { + color: var(--color-text-danger, light-dark(#b91c1c, #f87171)); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/examples/lazy-auth-server/src/mcp-app.ts b/examples/lazy-auth-server/src/mcp-app.ts new file mode 100644 index 0000000..f959d71 --- /dev/null +++ b/examples/lazy-auth-server/src/mcp-app.ts @@ -0,0 +1,126 @@ +/** + * @file Public app view: buttons that call the protected tools. + * + * The view itself renders without auth. Clicking "Auth me" calls the protected + * `get_secret` tool via `callServerTool`; the host receives a 401 from the + * server, runs the OAuth flow, retries the call, and the result renders here. + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import "./global.css"; +import "./mcp-app.css"; + +const mainEl = document.querySelector(".main") as HTMLElement; +const authBtn = document.getElementById("auth-btn") as HTMLButtonElement; +const revokeBtn = document.getElementById("revoke-btn") as HTMLButtonElement; +const fullscreenBtn = document.getElementById( + "fullscreen-btn", +) as HTMLButtonElement; +const outputEl = document.getElementById("output")!; + +function handleHostContextChanged(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`; + } +} + +const app = new App( + { name: "Lazy Auth Demo", version: "1.0.0" }, + { availableDisplayModes: ["inline", "fullscreen"] }, +); + +// The host echoes the result of the originating `show_auth_button` call; nothing to render. +app.ontoolresult = () => {}; +app.onerror = console.error; +app.onhostcontextchanged = handleHostContextChanged; + +function renderOutput(text: string, isError: boolean) { + const div = document.createElement("div"); + div.className = isError ? "error" : "result"; + div.textContent = text; + outputEl.replaceChildren(div); +} + +/** + * Calls a (protected) server tool. The interesting part happens on the host: + * the server answers 401 + WWW-Authenticate, the host runs the OAuth flow, + * then retries the call and returns the result here. + * + * The default MCP request timeout is 60s; the OAuth flow involves a human + * approving a consent page, which can easily take longer. Use a generous + * timeout so the pending call survives the sign-in round trip. + */ +const AUTH_FLOW_TIMEOUT_MS = 5 * 60_000; + +async function callTool(name: string) { + outputEl.replaceChildren(); + try { + console.info(`Calling ${name} tool...`); + const result = await app.callServerTool( + { name, arguments: {} }, + { timeout: AUTH_FLOW_TIMEOUT_MS }, + ); + const text = + result.content?.find((c) => c.type === "text")?.text ?? "(no content)"; + renderOutput(text, result.isError === true); + } catch (e) { + renderOutput(String(e instanceof Error ? e.message : e), true); + } +} + +authBtn.addEventListener("click", async () => { + authBtn.disabled = true; + try { + await callTool("get_secret"); + } finally { + authBtn.disabled = false; + } +}); + +revokeBtn.addEventListener("click", async () => { + revokeBtn.disabled = true; + try { + await callTool("revoke_auth_token"); + } finally { + revokeBtn.disabled = false; + } +}); + +let displayMode = "inline"; +fullscreenBtn.addEventListener("click", async () => { + fullscreenBtn.disabled = true; + try { + const next = displayMode === "fullscreen" ? "inline" : "fullscreen"; + const { mode } = await app.requestDisplayMode({ mode: next }); + displayMode = mode; + } catch (e) { + console.warn("Display mode not supported:", e); + } finally { + fullscreenBtn.disabled = false; + } +}); + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + displayMode = ctx.displayMode ?? "inline"; + } +}); diff --git a/examples/lazy-auth-server/src/secret-app.ts b/examples/lazy-auth-server/src/secret-app.ts new file mode 100644 index 0000000..aeb6857 --- /dev/null +++ b/examples/lazy-auth-server/src/secret-app.ts @@ -0,0 +1,57 @@ +/** + * @file Protected app view: renders the result of the `get_secret` tool. + * + * This view is only reached after the host has completed the OAuth flow + * (the tool call that produces it requires a Bearer token). + */ +import { + App, + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import "./global.css"; +import "./mcp-app.css"; + +const mainEl = document.querySelector(".main") as HTMLElement; +const outputEl = document.getElementById("output")!; + +function handleHostContextChanged(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`; + } +} + +const app = new App({ name: "Lazy Auth Demo (Secret)", version: "1.0.0" }); + +app.onerror = console.error; +app.onhostcontextchanged = handleHostContextChanged; + +app.ontoolresult = (result) => { + const text = + result.content?.find((c) => c.type === "text")?.text ?? "(no content)"; + const div = document.createElement("div"); + div.className = result.isError ? "error" : "result"; + div.textContent = text; + outputEl.replaceChildren(div); +}; + +app.connect().then(() => { + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + } +}); diff --git a/examples/lazy-auth-server/tsconfig.json b/examples/lazy-auth-server/tsconfig.json new file mode 100644 index 0000000..d6a42ce --- /dev/null +++ b/examples/lazy-auth-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "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, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts", "main.ts"] +} diff --git a/examples/lazy-auth-server/tsconfig.server.json b/examples/lazy-auth-server/tsconfig.server.json new file mode 100644 index 0000000..27eb28f --- /dev/null +++ b/examples/lazy-auth-server/tsconfig.server.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "types": ["node"], + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts"] +} diff --git a/examples/lazy-auth-server/vite.config.ts b/examples/lazy-auth-server/vite.config.ts new file mode 100644 index 0000000..6ff6d99 --- /dev/null +++ b/examples/lazy-auth-server/vite.config.ts @@ -0,0 +1,24 @@ +import { 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"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/examples/map-server/README.md b/examples/map-server/README.md index 4c4de5f..e1e044a 100644 --- a/examples/map-server/README.md +++ b/examples/map-server/README.md @@ -122,7 +122,7 @@ Vanilla TypeScript app that: - [`server.ts`](server.ts) - MCP server with geocode and show-map tools - [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - CesiumJS globe UI -- [`server-utils.ts`](server-utils.ts) - HTTP server utilities +- [`main.ts`](main.ts) - HTTP and stdio server utilities ## Notes diff --git a/examples/map-server/main.ts b/examples/map-server/main.ts index f91f57b..8038394 100644 --- a/examples/map-server/main.ts +++ b/examples/map-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/map-server/package.json b/examples/map-server/package.json index d39648b..cc4585f 100644 --- a/examples/map-server/package.json +++ b/examples/map-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-map", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "MCP App Server example with CesiumJS 3D globe and geocoding", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/map-server" }, "license": "MIT", @@ -26,11 +26,14 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index 10125a6..5767d46 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -5,11 +5,11 @@ * - geocode: Search for places using OpenStreetMap Nominatim * - show-map: Display an interactive 3D globe at a given location */ -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"; @@ -148,7 +148,7 @@ export function createServer(): McpServer { title: "Show Map", description: "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget is interactive and exposes tools for navigation (fly to locations) and querying the current view.", - inputSchema: { + inputSchema: z.object({ west: z .number() .optional() @@ -173,7 +173,7 @@ export function createServer(): McpServer { .string() .optional() .describe("Optional label to display on the map"), - }, + }), _meta: { ui: { resourceUri } }, }, async ({ west, south, east, north, label }): Promise => ({ @@ -196,13 +196,13 @@ export function createServer(): McpServer { title: "Geocode", description: "Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.", - inputSchema: { + inputSchema: z.object({ query: z .string() .describe( "Place name or address to search for (e.g., 'Paris', 'Golden Gate Bridge', '1600 Pennsylvania Ave')", ), - }, + }), }, async ({ query }): Promise => { try { diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 39d7c2a..d3090c7 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -6,7 +6,7 @@ * a navigate-to tool for the host to control navigation. */ import { App } from "@modelcontextprotocol/ext-apps"; -import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js"; +import type { ContentBlock } from "@modelcontextprotocol/client"; import { z } from "zod"; // TypeScript declaration for Cesium loaded from CDN diff --git a/examples/map-server/tsconfig.json b/examples/map-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/map-server/tsconfig.json +++ b/examples/map-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/map-server/tsconfig.server.json b/examples/map-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/map-server/tsconfig.server.json +++ b/examples/map-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/pdf-server/build-mcpb.mjs b/examples/pdf-server/build-mcpb.mjs index c1c3469..2917334 100644 --- a/examples/pdf-server/build-mcpb.mjs +++ b/examples/pdf-server/build-mcpb.mjs @@ -43,6 +43,21 @@ writeFileSync( path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), ); +// The bundle must ship the ext-apps build from this checkout, not whatever +// the registry has: pack the repo root and point the staged manifest at it. +const repoRoot = path.resolve(here, "..", ".."); +const packed = JSON.parse( + execSync( + "npm pack --json --ignore-scripts --pack-destination " + + JSON.stringify(stage), + { + cwd: repoRoot, + encoding: "utf8", + }, + ).replace(/^[^[]*/, ""), +); +pkg.dependencies["@modelcontextprotocol/ext-apps"] = + "file:./" + packed[0].filename; writeFileSync(path.join(stage, "package.json"), JSON.stringify(pkg, null, 2)); const run = (cmd) => execSync(cmd, { cwd: stage, stdio: "inherit" }); diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index 1ff848a..bb7b57f 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -6,10 +6,10 @@ import fs from "node:fs"; import path from "node:path"; -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 { @@ -39,7 +39,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json index 57af047..c8d9cf8 100644 --- a/examples/pdf-server/package.json +++ b/examples/pdf-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-pdf", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "MCP server for loading and extracting text from PDF files with chunked pagination and interactive viewer", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/pdf-server" }, "license": "MIT", @@ -25,13 +25,16 @@ "build:mcpb": "npm run build && node build-mcpb.mjs" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@cantoo/pdf-lib": "^2.6.5", + "@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", - "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.0.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts index a2f64ee..0b2a5eb 100644 --- a/examples/pdf-server/server.test.ts +++ b/examples/pdf-server/server.test.ts @@ -2,11 +2,16 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { PDFDocument } from "@cantoo/pdf-lib"; +import { makeRandomJpeg } from "../../tests/helpers/range-counting-server"; import { createPdfCache, createServer, + extractFormSchema, + PdfCacheRangeTransport, + MAX_CHUNK_BYTES, validateUrl, isAncestorDir, allowedLocalFiles, @@ -71,6 +76,72 @@ describe("PDF Cache with Timeouts", () => { }); }); + describe("byte-cap LRU eviction", () => { + const tenBytes = new Uint8Array(10); + + async function fill(cache: PdfCache, url: string): Promise { + const m = spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(tenBytes, { status: 200 }), + ); + try { + await cache.readPdfRange(url, 0, 1024); + } finally { + m.mockRestore(); + } + } + + it("evicts least-recently-used entry when total exceeds cap", async () => { + const cache = createPdfCache(25); + try { + await fill(cache, "https://arxiv.org/pdf/a"); + await fill(cache, "https://arxiv.org/pdf/b"); + expect(cache.getCacheSize()).toBe(2); + + // Touch A so B becomes least-recently-used + await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1); + + // Inserting C (10B) pushes total to 30 > 25 → evict LRU (B) + await fill(cache, "https://arxiv.org/pdf/c"); + expect(cache.getCacheSize()).toBe(2); + + // A and C still served from cache; B re-fetches + const m = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(tenBytes, { status: 200 }), + ); + try { + await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1); + await cache.readPdfRange("https://arxiv.org/pdf/c", 0, 1); + expect(m).toHaveBeenCalledTimes(0); + await cache.readPdfRange("https://arxiv.org/pdf/b", 0, 1); + expect(m).toHaveBeenCalledTimes(1); + } finally { + m.mockRestore(); + } + } finally { + cache.clearCache(); + } + }); + + it("evicts multiple entries if a single insert exceeds the cap", async () => { + const cache = createPdfCache(25); + try { + await fill(cache, "https://arxiv.org/pdf/a"); + await fill(cache, "https://arxiv.org/pdf/b"); + const big = spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(new Uint8Array(20), { status: 200 }), + ); + try { + await cache.readPdfRange("https://arxiv.org/pdf/big", 0, 1024); + } finally { + big.mockRestore(); + } + expect(cache.getCacheSize()).toBe(1); + } finally { + cache.clearCache(); + } + }); + }); + describe("readPdfRange caching behavior", () => { const testUrl = "https://arxiv.org/pdf/test-pdf"; const testData = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header @@ -223,6 +294,266 @@ describe("PDF Cache with Timeouts", () => { // through manual testing or E2E tests. }); +describe("PdfCacheRangeTransport", () => { + it("accumulates ranges larger than MAX_CHUNK_BYTES into one onDataRange call", async () => { + const big = MAX_CHUNK_BYTES * 2 + 100; + const reads: Array<[number, number]> = []; + const t = new PdfCacheRangeTransport("u", big, async (_u, off, n) => { + reads.push([off, n]); + return { + data: new Uint8Array(Math.min(n, MAX_CHUNK_BYTES)), + totalBytes: big, + }; + }); + const delivered: Array<[number, number]> = []; + t.addRangeListener((begin: number, chunk: Uint8Array) => + delivered.push([begin, chunk.length]), + ); + t.requestDataRange(0, big); + await new Promise((r) => setTimeout(r, 10)); + // pdf.js's reader is keyed by the original begin and removed after one + // delivery, so deliver() must call onDataRange exactly once with the + // accumulated buffer — multiple calls would throw inside pdfjs. + expect(delivered).toEqual([[0, big]]); + expect(reads).toEqual([ + [0, MAX_CHUNK_BYTES], + [MAX_CHUNK_BYTES, MAX_CHUNK_BYTES], + [MAX_CHUNK_BYTES * 2, 100], + ]); + }); + + it("rejects .failed when a range fetch errors instead of hanging", async () => { + const t = new PdfCacheRangeTransport("u", 1000, async () => { + throw new Error("network down"); + }); + t.requestDataRange(0, 100); + await expect( + Promise.race([ + t.failed, + new Promise((r) => setTimeout(() => r("timeout"), 200)), + ]), + ).rejects.toThrow("network down"); + }); + + it("rejects .failed on zero-length response (would otherwise spin)", async () => { + const t = new PdfCacheRangeTransport("u", 1000, async () => ({ + data: new Uint8Array(0), + totalBytes: 1000, + })); + t.requestDataRange(0, 100); + await expect(t.failed).rejects.toThrow(/empty range/); + }); + + it("getDocument resolves on a >1MB PDF when readPdfRange clamps to MAX_CHUNK_BYTES", async () => { + // pdfjs coalesces adjacent missing chunks into one requestDataRange that + // can exceed MAX_CHUNK_BYTES. deliver() must accumulate clamped reads and + // hand pdfjs a single onDataRange(begin, fullBuffer). This test fails if + // deliver() either truncates or calls onDataRange more than once per + // requestDataRange (pdf.mjs _onReceiveData matches by exact begin). + const d = await PDFDocument.create(); + const img = await d.embedJpg(makeRandomJpeg(1_100_000)); + const page = d.addPage([612, 792]); + page.drawImage(img, { x: 36, y: 36, width: 540, height: 720 }); + const bytes = await d.save(); + expect(bytes.length).toBeGreaterThan(2 * MAX_CHUNK_BYTES); + + const readClamped: PdfCache["readPdfRange"] = async (_u, off, n) => { + const len = Math.min(n, MAX_CHUNK_BYTES, bytes.length - off); + return { data: bytes.slice(off, off + len), totalBytes: bytes.length }; + }; + // Record the spans pdfjs actually requests so the test fails fast if it + // never asks for >MAX_CHUNK_BYTES (i.e. can't go vacuously green). + const spans: number[] = []; + class RecordingTransport extends PdfCacheRangeTransport { + override requestDataRange(begin: number, end: number): void { + spans.push(end - begin); + super.requestDataRange(begin, end); + } + } + const transport = new RecordingTransport( + "mem://big", + bytes.length, + readClamped, + ); + + const orHang = (p: Promise, what: string): Promise => + Promise.race([ + p, + transport.failed, + new Promise((_, rej) => + setTimeout(() => rej(new Error(`${what} hung`)), 5000), + ), + ]); + + const doc = await orHang( + getDocument({ + range: transport, + length: bytes.length, + disableAutoFetch: true, + disableStream: true, + rangeChunkSize: 64 * 1024, + }).promise, + "getDocument", + ); + const p1 = await orHang(doc.getPage(1), "getPage"); + // getPage() alone doesn't decode the image XObject; getOperatorList() does, + // which is what triggers the >512KB coalesced range request. + await orHang(p1.getOperatorList(), "getOperatorList"); + expect(Math.max(...spans)).toBeGreaterThan(MAX_CHUNK_BYTES); + doc.destroy(); + }); +}); + +describe("display_pdf transport-error handling", () => { + it("returns (does not hang) when range fetches fail mid-load", async () => { + // First fetch = the 1-byte size probe → 206 with Content-Range so + // display_pdf gets totalBytes. Every subsequent fetch (made by + // PdfCacheRangeTransport via readPdfRange) rejects, which must surface + // through transport.failed → orFail() → outer catch, not hang. + let calls = 0; + const mockFetch = spyOn(globalThis, "fetch").mockImplementation( + async () => { + if (calls++ === 0) { + return new Response(new Uint8Array(1), { + status: 206, + headers: { "Content-Range": "bytes 0-0/50000" }, + }); + } + throw new Error("network down"); + }, + ); + + const server = createServer(); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + try { + const result = await Promise.race([ + client.callTool({ + name: "display_pdf", + arguments: { url: "https://arxiv.org/pdf/err-test" }, + }), + new Promise((_, rej) => + setTimeout( + () => rej(new Error("display_pdf hung on transport error")), + 3000, + ), + ), + ]); + expect(result.isError).toBeFalsy(); + const sc = result.structuredContent as { formFields?: unknown }; + expect(sc.formFields).toBeUndefined(); + expect(calls).toBeGreaterThan(1); + } finally { + mockFetch.mockRestore(); + await client.close(); + await server.close(); + } + }); +}); + +describe("extractFormSchema field-tree handling", () => { + async function schemaFor(bytes: Uint8Array) { + const doc = await getDocument({ data: bytes }).promise; + try { + const fo = (await doc.getFieldObjects()) as Parameters< + typeof extractFormSchema + >[1]; + return await extractFormSchema(doc, fo); + } finally { + doc.destroy(); + } + } + + it("handles pdf-lib separated field/widget structure", async () => { + const d = await PDFDocument.create(); + const form = d.getForm(); + d.addPage([612, 792]); + form + .createTextField("alpha") + .addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 }); + form + .createCheckBox("agree") + .addToPage(d.getPage(0), { x: 50, y: 660, width: 20, height: 20 }); + form + .createDropdown("choice") + .addToPage(d.getPage(0), { x: 50, y: 620, width: 100, height: 20 }); + + const schema = await schemaFor(await d.save()); + expect(schema).not.toBeNull(); + expect(schema!.properties.alpha).toEqual({ + type: "string", + title: "alpha", + }); + expect(schema!.properties.agree).toEqual({ + type: "boolean", + title: "agree", + }); + expect(schema!.properties.choice.type).toBe("string"); + }); + + it("handles fields with multiple widgets across pages", async () => { + const d = await PDFDocument.create(); + const form = d.getForm(); + d.addPage([612, 792]); + d.addPage([612, 792]); + const tf = form.createTextField("shared"); + tf.addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 }); + tf.addToPage(d.getPage(1), { x: 50, y: 700, width: 200, height: 20 }); + + const schema = await schemaFor(await d.save()); + expect(schema?.properties.shared).toEqual({ + type: "string", + title: "shared", + }); + }); + + it("skips container nodes and finds leaf fields (W-9 style)", async () => { + const bytes = fs.readFileSync( + path.join(__dirname, "../../tests/helpers/assets/fw9.pdf"), + ); + const doc = await getDocument({ data: new Uint8Array(bytes) }).promise; + try { + const fo = (await doc.getFieldObjects()) as Parameters< + typeof extractFormSchema + >[1]; + // Container nodes (no leaf type) should not crash extraction + expect(fo!["topmostSubform[0]"]).toBeDefined(); + // Schema is null for W-9 (mechanical names), but extraction must not throw + const schema = await extractFormSchema(doc, fo); + expect(schema).toBeNull(); + } finally { + doc.destroy(); + } + }); + + it("returns null when no AcroForm present", async () => { + const d = await PDFDocument.create(); + d.addPage([612, 792]); + const schema = await schemaFor(await d.save()); + expect(schema).toBeNull(); + }); +}); + +describe("validateUrl loopback HTTP allow (PDF_SERVER_ALLOW_LOOPBACK_HTTP)", () => { + it("rejects http://127.0.0.1 by default", () => { + expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(false); + }); + + it("accepts http://127.0.0.1 only when the env gate is set, and never non-loopback http", () => { + const prev = process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP; + process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = "1"; + try { + expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(true); + expect(validateUrl("http://169.254.169.254/").valid).toBe(false); + } finally { + if (prev === undefined) delete process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP; + else process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = prev; + } + }); +}); + describe("validateUrl with MCP roots (allowedLocalDirs)", () => { const savedFiles = new Set(allowedLocalFiles); const savedDirs = new Set(allowedLocalDirs); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 80a5499..e67cd47 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -13,27 +13,30 @@ import { randomUUID } from "crypto"; import fs from "node:fs"; import path from "node:path"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { - RootsListChangedNotificationSchema, + McpServer, type CallToolResult, + type ElicitResult, + type PrimitiveSchemaDefinition, type ReadResourceResult, -} from "@modelcontextprotocol/sdk/types.js"; + type Server, +} from "@modelcontextprotocol/server"; // Stub DOMMatrix/ImageData/Path2D before pdfjs-dist loads — its legacy // build instantiates DOMMatrix at module scope and the @napi-rs/canvas // polyfill is unreliable under npx. See ./pdfjs-polyfill.ts for details. import "./pdfjs-polyfill.js"; import { getDocument, + PDFDataRangeTransport, VerbosityLevel, version as PDFJS_VERSION, } from "pdfjs-dist/legacy/build/pdf.mjs"; +import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api.js"; /** * PDF Standard-14 fonts from CDN. Used by both server and viewer so we @@ -60,10 +63,6 @@ class FetchStandardFontDataFactory { return new Uint8Array(await res.arrayBuffer()); } } -import type { - PrimitiveSchemaDefinition, - ElicitResult, -} from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; // ============================================================================= @@ -83,6 +82,9 @@ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds /** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */ export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB +/** Max total bytes across all cache entries; oldest evicted first when exceeded. */ +export const CACHE_MAX_TOTAL_BYTES = 256 * 1024 * 1024; // 256MB + /** Allowed local file paths (CLI args + file roots — read access). */ export const allowedLocalFiles = new Set(); @@ -364,9 +366,6 @@ export const viewSourcePaths = new Map(); /** Valid form field names per viewer UUID (populated during display_pdf) */ const viewFieldNames = new Map>(); -/** Detailed form field info per viewer UUID (populated during display_pdf) */ -const viewFieldInfo = new Map(); - /** * Active fs.watch per view. Only created for local files when interact is * enabled (stdio). Watcher is re-established on `rename` events to survive @@ -383,7 +382,7 @@ const viewFileWatches = new Map(); /** * Per-view heartbeat. THIS is what the sweep iterates — not commandQueues. * - * Why not commandQueues: display_pdf populates viewFieldNames/viewFieldInfo/ + * Why not commandQueues: display_pdf populates viewFieldNames/ * viewFileWatches but never touches commandQueues (only enqueueCommand does, * and it's triply gated). And dequeueCommands deletes the entry on every poll, * so even when it exists the sweep's TTL window is ~200ms wide. Net effect: @@ -405,7 +404,6 @@ function pruneStaleQueues(): void { viewLastActivity.delete(uuid); commandQueues.delete(uuid); viewFieldNames.delete(uuid); - viewFieldInfo.delete(uuid); viewsPolled.delete(uuid); viewSourcePaths.delete(uuid); stopFileWatch(uuid); @@ -640,10 +638,19 @@ export function validateUrl(url: string): { // Remote URL - require HTTPS try { const parsed = new URL(url); - if (parsed.protocol !== "https:") { - return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` }; + if (parsed.protocol === "https:") return { valid: true }; + // Loopback HTTP is opt-in (test fixtures, local dev). Off by default so a + // remotely-deployed server can't be made to probe its own internal ports. + if ( + process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP && + parsed.protocol === "http:" && + (parsed.hostname === "127.0.0.1" || + parsed.hostname === "localhost" || + parsed.hostname === "[::1]") + ) { + return { valid: true }; } - return { valid: true }; + return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` }; } catch { return { valid: false, error: `Invalid URL: ${url}` }; } @@ -695,8 +702,11 @@ export interface PdfCache { * - CACHE_INACTIVITY_TIMEOUT_MS of no access (resets on each access) * - CACHE_MAX_LIFETIME_MS from creation (absolute timeout) */ -export function createPdfCache(): PdfCache { +export function createPdfCache( + maxTotalBytes: number = CACHE_MAX_TOTAL_BYTES, +): PdfCache { const cache = new Map(); + let totalBytes = 0; /** Delete a cache entry and clear its timers */ function deleteCacheEntry(url: string): void { @@ -704,6 +714,7 @@ export function createPdfCache(): PdfCache { if (entry) { clearTimeout(entry.inactivityTimer); clearTimeout(entry.maxLifetimeTimer); + totalBytes -= entry.data.length; cache.delete(url); } } @@ -719,6 +730,10 @@ export function createPdfCache(): PdfCache { deleteCacheEntry(url); }, CACHE_INACTIVITY_TIMEOUT_MS); + // Move to end of insertion order so size-cap eviction is LRU. + cache.delete(url); + cache.set(url, entry); + return entry.data; } @@ -727,6 +742,12 @@ export function createPdfCache(): PdfCache { // Clear any existing entry first deleteCacheEntry(url); + // Evict least-recently-used entries until under the byte cap. + for (const oldest of cache.keys()) { + if (totalBytes + data.length <= maxTotalBytes) break; + deleteCacheEntry(oldest); + } + const entry: CacheEntry = { data, createdAt: Date.now(), @@ -739,6 +760,7 @@ export function createPdfCache(): PdfCache { }; cache.set(url, entry); + totalBytes += data.length; } /** Slice a cached or freshly-fetched full body to the requested range. */ @@ -863,6 +885,61 @@ export function createPdfCache(): PdfCache { }; } +/** + * pdf.js range transport backed by {@link PdfCache.readPdfRange}. Lets + * getDocument() fetch only the byte ranges it needs (xref, /AcroForm dict) + * instead of the whole file. With disableAutoFetch, a PDF without form + * fields is opened with ~5% of bytes fetched. + * + * pdf.js has no upstream error channel on PDFDataRangeTransport (its + * `abort()` is a no-op stub it calls *on* us, not the other way). Callers + * must `Promise.race` their pdf.js awaits against {@link failed}, which + * rejects on the first fetch error. + */ +export class PdfCacheRangeTransport extends PDFDataRangeTransport { + /** Rejects on the first range-fetch error; never resolves. */ + readonly failed: Promise; + private fail!: (e: unknown) => void; + + constructor( + private url: string, + length: number, + private readPdfRange: PdfCache["readPdfRange"], + ) { + super(length, null); + this.failed = new Promise((_, reject) => { + this.fail = reject; + }); + // Don't crash the process if no one is racing yet. + this.failed.catch(() => {}); + } + + override requestDataRange(begin: number, end: number): void { + void this.deliver(begin, end).catch((e) => this.fail(e)); + } + + /** + * pdf.js coalesces adjacent missing chunks into one unbounded request, but + * readPdfRange clamps each call to MAX_CHUNK_BYTES. Its reader is keyed by + * the original `begin` and removed after one delivery, so we must accumulate + * slices and call onDataRange exactly once with the full buffer. + */ + private async deliver(begin: number, end: number): Promise { + const buf = new Uint8Array(end - begin); + let off = 0; + while (off < buf.length) { + const want = Math.min(buf.length - off, MAX_CHUNK_BYTES); + const { data } = await this.readPdfRange(this.url, begin + off, want); + if (data.length === 0) { + throw new Error(`empty range at ${begin + off} for ${this.url}`); + } + buf.set(data.subarray(0, Math.min(data.length, buf.length - off)), off); + off += data.length; + } + this.onDataRange(begin, buf); + } +} + // ============================================================================= // MCP Roots // ============================================================================= @@ -938,135 +1015,142 @@ interface FormFieldInfo { options?: string[]; } +/** + * Open `url` via {@link PdfCacheRangeTransport} and return form metadata. + * Uses `disableAutoFetch` so PDFs without an AcroForm are probed with only + * the trailer/xref/catalog (~5-25% of bytes); PDFs with forms still walk + * every page via {@link extractFormFieldInfo} but those are typically small. + * All errors (including range-fetch failures surfaced via + * {@link PdfCacheRangeTransport.failed}) resolve to empty results. + */ +async function probeFormFields( + url: string, + totalBytes: number, + readPdfRange: PdfCache["readPdfRange"], +): Promise<{ + formSchema: Awaited>; + fieldInfo: FormFieldInfo[]; +}> { + // Assigned sequentially below so a throw in extractFormFieldInfo (no per-page + // guard, unlike extractFormSchema) doesn't discard an already-computed schema. + let formSchema: Awaited> = null; + let fieldInfo: FormFieldInfo[] = []; + try { + const transport = new PdfCacheRangeTransport(url, totalBytes, readPdfRange); + const orFail = (p: Promise): Promise => + Promise.race([p, transport.failed]); + const pdfDoc = await orFail( + getDocument({ + range: transport, + length: totalBytes, + disableAutoFetch: true, + disableStream: true, + rangeChunkSize: 64 * 1024, + standardFontDataUrl: STANDARD_FONT_DATA_URL, + StandardFontDataFactory: FetchStandardFontDataFactory, + verbosity: VerbosityLevel.ERRORS, + }).promise, + ); + try { + const fieldObjects = (await orFail(pdfDoc.getFieldObjects())) as Record< + string, + PdfJsFieldObject[] + > | null; + if (fieldObjects && Object.keys(fieldObjects).length > 0) { + formSchema = await orFail(extractFormSchema(pdfDoc, fieldObjects)); + fieldInfo = await orFail(extractFormFieldInfo(pdfDoc)); + } + } finally { + pdfDoc.destroy(); + } + } catch { + // Non-fatal — return whatever was assigned before the throw. + } + return { formSchema, fieldInfo }; +} + /** * Extract detailed form field info (name, type, page, bounding box, label) * from a PDF. Bounding boxes are converted to model coordinates (top-left origin). */ async function extractFormFieldInfo( - url: string, - readRange: ( - url: string, - offset: number, - byteCount: number, - ) => Promise<{ data: Uint8Array; totalBytes: number }>, + pdfDoc: PDFDocumentProxy, ): Promise { - const { totalBytes } = await readRange(url, 0, 1); - const { data } = await readRange(url, 0, totalBytes); - - const loadingTask = getDocument({ - data, - standardFontDataUrl: STANDARD_FONT_DATA_URL, - StandardFontDataFactory: FetchStandardFontDataFactory, - // We only introspect form fields (never render) — silence residual - // warnings like "Unimplemented border style: inset". - verbosity: VerbosityLevel.ERRORS, - }); - const pdfDoc = await loadingTask.promise; - const fields: FormFieldInfo[] = []; - try { - for (let i = 1; i <= pdfDoc.numPages; i++) { - const page = await pdfDoc.getPage(i); - const pageHeight = page.getViewport({ scale: 1.0 }).height; - const annotations = await page.getAnnotations(); - for (const ann of annotations) { - // Only include form widgets (annotationType 20) - if (ann.annotationType !== 20) continue; - if (!ann.rect) continue; + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + const pageHeight = page.getViewport({ scale: 1.0 }).height; + const annotations = await page.getAnnotations(); + for (const ann of annotations) { + // Only include form widgets (annotationType 20) + if (ann.annotationType !== 20) continue; + if (!ann.rect) continue; - const fieldName = ann.fieldName || ""; - const fieldType = ann.fieldType || "unknown"; + const fieldName = ann.fieldName || ""; + const fieldType = ann.fieldType || "unknown"; - // PDF rect is [x1, y1, x2, y2] in bottom-left origin - const x1 = Math.min(ann.rect[0], ann.rect[2]); - const y1 = Math.min(ann.rect[1], ann.rect[3]); - const x2 = Math.max(ann.rect[0], ann.rect[2]); - const y2 = Math.max(ann.rect[1], ann.rect[3]); - const width = x2 - x1; - const height = y2 - y1; + // PDF rect is [x1, y1, x2, y2] in bottom-left origin + const x1 = Math.min(ann.rect[0], ann.rect[2]); + const y1 = Math.min(ann.rect[1], ann.rect[3]); + const x2 = Math.max(ann.rect[0], ann.rect[2]); + const y2 = Math.max(ann.rect[1], ann.rect[3]); + const width = x2 - x1; + const height = y2 - y1; - // Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height - const modelY = pageHeight - y2; + // Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height + const modelY = pageHeight - y2; - // Choice widgets (combo/listbox) carry `options` as - // [{exportValue, displayValue}]. Expose export values — that's - // what fill_form needs. - let options: string[] | undefined; - if (Array.isArray(ann.options) && ann.options.length > 0) { - options = ann.options - .map((o: { exportValue?: string }) => o?.exportValue) - .filter((v: unknown): v is string => typeof v === "string"); - } - - fields.push({ - name: fieldName, - type: fieldType, - page: i, - x: Math.round(x1), - y: Math.round(modelY), - width: Math.round(width), - height: Math.round(height), - ...(ann.alternativeText ? { label: ann.alternativeText } : undefined), - // Radio: buttonValue is the per-widget export value — the only - // thing distinguishing three `size [Btn]` lines from each other. - ...(ann.radioButton && ann.buttonValue != null - ? { exportValue: String(ann.buttonValue) } - : undefined), - ...(options?.length ? { options } : undefined), - }); + // Choice widgets (combo/listbox) carry `options` as + // [{exportValue, displayValue}]. Expose export values — that's + // what fill_form needs. + let options: string[] | undefined; + if (Array.isArray(ann.options) && ann.options.length > 0) { + options = ann.options + .map((o: { exportValue?: string }) => o?.exportValue) + .filter((v: unknown): v is string => typeof v === "string"); } + + fields.push({ + name: fieldName, + type: fieldType, + page: i, + x: Math.round(x1), + y: Math.round(modelY), + width: Math.round(width), + height: Math.round(height), + ...(ann.alternativeText ? { label: ann.alternativeText } : undefined), + // Radio: buttonValue is the per-widget export value — the only + // thing distinguishing three `size [Btn]` lines from each other. + ...(ann.radioButton && ann.buttonValue != null + ? { exportValue: String(ann.buttonValue) } + : undefined), + ...(options?.length ? { options } : undefined), + }); } - } finally { - pdfDoc.destroy(); } return fields; } -async function extractFormSchema( - url: string, - readRange: ( - url: string, - offset: number, - byteCount: number, - ) => Promise<{ data: Uint8Array; totalBytes: number }>, +export async function extractFormSchema( + pdfDoc: PDFDocumentProxy, + fieldObjects: Record | null, ): Promise<{ type: "object"; properties: Record; required?: string[]; } | null> { - // Read full PDF bytes - const { totalBytes } = await readRange(url, 0, 1); - const { data } = await readRange(url, 0, totalBytes); - - const loadingTask = getDocument({ - data, - standardFontDataUrl: STANDARD_FONT_DATA_URL, - StandardFontDataFactory: FetchStandardFontDataFactory, - // We only introspect form fields (never render) — silence residual - // warnings like "Unimplemented border style: inset". - verbosity: VerbosityLevel.ERRORS, - }); - const pdfDoc = await loadingTask.promise; - - let fieldObjects: Record | null; - try { - fieldObjects = (await pdfDoc.getFieldObjects()) as Record< - string, - PdfJsFieldObject[] - > | null; - } catch { - pdfDoc.destroy(); - return null; - } if (!fieldObjects || Object.keys(fieldObjects).length === 0) { - pdfDoc.destroy(); return null; } const properties: Record = {}; for (const [name, fields] of Object.entries(fieldObjects)) { - const field = fields[0]; // first widget determines the type + // pdfjs returns the full field-tree array: for separated structures + // (pdf-lib) the typed widget is at [1+] behind a container at [0]; for + // merged/leaf entries (W-9, most authoring tools) it's at [0]. Pick the + // first entry that actually has a field type. + const field = fields.find((f) => f.type) ?? fields[0]; if (!field.editable) continue; switch (field.type) { @@ -1131,7 +1215,6 @@ async function extractFormSchema( return /[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name); }); - pdfDoc.destroy(); if (Object.keys(properties).length === 0) return null; if (hasMechanicalNames) return null; @@ -1178,6 +1261,12 @@ export interface CreateServerOptions { debug?: boolean; } +// Module-level singletons so they survive across createServer() calls — in +// stateless HTTP deployments a fresh server is created per request, and +// per-instance caches are discarded immediately. +const sharedPdfCache = createPdfCache(); +let cachedAppHtml: string | undefined; + export function createServer(options: CreateServerOptions = {}): McpServer { const { enableInteract = false, useClientRoots = false } = options; const debug = options.debug ?? false; @@ -1190,21 +1279,22 @@ export function createServer(options: CreateServerOptions = {}): McpServer { refreshRoots(server.server); }; server.server.setNotificationHandler( - RootsListChangedNotificationSchema, + "notifications/roots/list_changed", async () => { await refreshRoots(server.server); }, ); } - // Create session-local cache (isolated per server instance) - const { readPdfRange } = createPdfCache(); + const { readPdfRange } = sharedPdfCache; // Tool: list_pdfs - List available PDFs - server.tool( + server.registerTool( "list_pdfs", - "List available PDFs that can be displayed", - {}, + { + description: "List available PDFs that can be displayed", + inputSchema: z.object({}), + }, async (): Promise => { const seen = new Set(); const localFiles: string[] = []; @@ -1286,7 +1376,7 @@ export function createServer(options: CreateServerOptions = {}): McpServer { title: "Read PDF Bytes", description: "Read a range of bytes from a PDF (max 512KB per request). The model should NOT call this tool directly.", - inputSchema: { + inputSchema: z.object({ url: z.string().describe("PDF URL or local file path"), offset: z.number().min(0).default(0).describe("Byte offset"), byteCount: z @@ -1295,7 +1385,7 @@ export function createServer(options: CreateServerOptions = {}): McpServer { .max(MAX_CHUNK_BYTES) .default(MAX_CHUNK_BYTES) .describe("Bytes to read"), - }, + }), outputSchema: z.object({ url: z.string(), bytes: z.string().describe("Base64 encoded bytes"), @@ -1382,7 +1472,7 @@ Returns a viewUUID in structuredContent. Pass it to \`interact\`: Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL. Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`, - inputSchema: { + inputSchema: z.object({ url: z .string() .default(DEFAULT_PDF) @@ -1398,7 +1488,7 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before "If true and the PDF has form fields, prompt the user to fill them before displaying", ), }), - }, + }), outputSchema: z.object({ viewUUID: z .string() @@ -1456,7 +1546,7 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before const { totalBytes } = await readPdfRange(normalized, 0, 1); const uuid = randomUUID(); // Start the heartbeat now so the sweep can clean up viewFieldNames/ - // viewFieldInfo/viewFileWatches even if no interact calls ever happen. + // viewFileWatches even if no interact calls ever happen. if (!disableInteract) touchView(uuid); // Check writability (governs save button; see isWritablePath doc). @@ -1484,33 +1574,19 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before } } - // Extract form field schema (used for elicitation and field name validation) - let formSchema: Awaited> = null; - try { - formSchema = await extractFormSchema(normalized, readPdfRange); - } catch { - // Non-fatal — PDF may not have form fields - } + const { formSchema, fieldInfo } = await probeFormFields( + normalized, + totalBytes, + readPdfRange, + ); if (formSchema) { viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties))); } - - // Extract detailed form field info (page, bounding box, label) - let fieldInfo: FormFieldInfo[] = []; - try { - fieldInfo = await extractFormFieldInfo(normalized, readPdfRange); - if (fieldInfo.length > 0) { - viewFieldInfo.set(uuid, fieldInfo); - // Also populate viewFieldNames from field info if not already set - if (!viewFieldNames.has(uuid)) { - viewFieldNames.set( - uuid, - new Set(fieldInfo.map((f) => f.name).filter(Boolean)), - ); - } - } - } catch { - // Non-fatal + if (fieldInfo.length > 0 && !viewFieldNames.has(uuid)) { + viewFieldNames.set( + uuid, + new Set(fieldInfo.map((f) => f.name).filter(Boolean)), + ); } // Elicit form field values if requested and client supports it @@ -2349,7 +2425,7 @@ Example — add a signature image and a stamp, then screenshot to verify: **FORMS** — fill_form: fill fields with \`fields\` array of {name, value}. **SAVE** — save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`).`, - inputSchema: { + inputSchema: z.object({ viewUUID: z .string() .describe( @@ -2446,7 +2522,7 @@ Example — add a signature image and a stamp, then screenshot to verify: .describe( "Array of commands to execute sequentially. More efficient than separate calls. Tip: end with get_pages+getScreenshots to verify changes.", ), - }, + }), }, async ( { @@ -2523,7 +2599,7 @@ Example — add a signature image and a stamp, then screenshot to verify: const result = await processInteractCommand( uuid, commandList[i], - extra.signal, + extra.mcpReq.signal, ); if (result.isError) { const errText = result.content @@ -2580,7 +2656,7 @@ Example — add a signature image and a stamp, then screenshot to verify: title: "Submit Page Data", description: "Submit rendered page data for a get_pages request (used by viewer). The model should NOT call this tool directly.", - inputSchema: { + inputSchema: z.object({ requestId: z .string() .describe("The request ID from the get_pages command"), @@ -2593,7 +2669,7 @@ Example — add a signature image and a stamp, then screenshot to verify: }), ) .describe("Page data entries"), - }, + }), _meta: { ui: { visibility: ["app"] } }, }, async ({ requestId, pages }): Promise => { @@ -2623,7 +2699,7 @@ Example — add a signature image and a stamp, then screenshot to verify: title: "Submit Save Data", description: "Submit annotated PDF bytes for a save_as request (used by viewer). The model should NOT call this tool directly.", - inputSchema: { + inputSchema: z.object({ requestId: z .string() .describe("The request ID from the save_as command"), @@ -2632,7 +2708,7 @@ Example — add a signature image and a stamp, then screenshot to verify: .string() .optional() .describe("Error message if the viewer failed to build bytes"), - }, + }), _meta: { ui: { visibility: ["app"] } }, }, async ({ requestId, data, error }): Promise => { @@ -2662,7 +2738,7 @@ Example — add a signature image and a stamp, then screenshot to verify: title: "Submit Viewer State", description: "Submit a viewer-state snapshot for a get_viewer_state request (used by viewer). The model should NOT call this tool directly.", - inputSchema: { + inputSchema: z.object({ requestId: z .string() .describe("The request ID from the get_viewer_state command"), @@ -2674,7 +2750,7 @@ Example — add a signature image and a stamp, then screenshot to verify: .string() .optional() .describe("Error message if the viewer failed to read state"), - }, + }), _meta: { ui: { visibility: ["app"] } }, }, async ({ requestId, state, error }): Promise => { @@ -2704,9 +2780,9 @@ Example — add a signature image and a stamp, then screenshot to verify: title: "Poll PDF Commands", description: "Poll for pending commands for a PDF viewer. The model should NOT call this tool directly.", - inputSchema: { + inputSchema: z.object({ viewUUID: z.string().describe("The viewUUID of the PDF viewer"), - }, + }), _meta: { ui: { visibility: ["app"] } }, }, async ({ viewUUID: uuid }): Promise => { @@ -2751,10 +2827,10 @@ Example — add a signature image and a stamp, then screenshot to verify: title: "Save PDF", description: "Save annotated PDF bytes back to a local file. The model should NOT call this tool directly — use interact with action: save_as instead.", - inputSchema: { + inputSchema: z.object({ url: z.string().describe("Original PDF URL or local file path"), data: z.string().describe("Base64-encoded PDF bytes"), - }, + }), outputSchema: z.object({ filePath: z.string(), mtimeMs: z.number(), @@ -2833,10 +2909,10 @@ Example — add a signature image and a stamp, then screenshot to verify: RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise => { - const html = await fs.promises.readFile( + const html = (cachedAppHtml ??= await fs.promises.readFile( path.join(DIST_DIR, "mcp-app.html"), "utf-8", - ); + )); return { contents: [ { diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 500879a..45e0c74 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -12,8 +12,10 @@ import { applyDocumentTheme, applyHostStyleVariables, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js"; +import type { + CallToolResult, + ContentBlock, +} from "@modelcontextprotocol/client"; import { z } from "zod"; import * as pdfjsLib from "pdfjs-dist"; import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist"; @@ -150,6 +152,11 @@ const imageCache = new Map(); /** Annotations imported from the PDF file (baseline for diff computation). */ let pdfBaselineAnnotations: PdfAnnotationDef[] = []; +/** Pages whose native annotations have already been imported into the baseline. */ +const baselineScannedPages = new Set(); +/** Native-annotation ids the user deleted (from restored localStorage diff) — + * the lazy per-page scan must NOT re-add these to annotationMap. */ +const restoredRemovedIds = new Set(); // Dirty flag — tracks unsaved local changes let isDirty = false; @@ -2679,52 +2686,60 @@ function annotationStorageKey(): string | null { } /** - * Import annotations from the loaded PDF to establish the baseline. - * These are the annotations that exist in the PDF file itself. + * Import one page's native annotations into the baseline. Called lazily from + * renderPage() so we don't walk every page (and pull most of the file via + * range requests) before the user sees anything. Idempotent per page. */ -async function loadBaselineAnnotations( - doc: pdfjsLib.PDFDocumentProxy, -): Promise { - pdfBaselineAnnotations = []; - for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { +function scanPageBaselineAnnotations( + pageNum: number, + annotations: unknown[], +): void { + if (baselineScannedPages.has(pageNum)) return; + baselineScannedPages.add(pageNum); + let imported = 0; + for (let i = 0; i < annotations.length; i++) { + // Isolate each annotation: a malformed one must not bubble up to the + // caller's form-layer try in renderPage() (which would skip + // AnnotationLayer.render and hide form widgets for the whole page). try { - const page = await doc.getPage(pageNum); - const annotations = await page.getAnnotations(); - for (let i = 0; i < annotations.length; i++) { - const ann = annotations[i]; - const def = importPdfjsAnnotation(ann, pageNum, i); - if (def) { - pdfBaselineAnnotations.push(def); - // Add to annotationMap if not already present (from localStorage restore) - if (!annotationMap.has(def.id)) { - annotationMap.set(def.id, { def, elements: [] }); - } - } else if (ann.annotationType !== 20) { - // Widget (type 20) is expected to be skipped; anything else we - // don't import will still be painted by page.render() onto the - // canvas as unselectable pixels. Log so we can diagnose - // "ghost annotations" (visible but not in panel, not clickable). - log.info( - `[WARN] Baseline: skipped PDF annotation on page ${pageNum}`, - `type=${ann.annotationType}`, - `subtype=${ann.subtype ?? "?"}`, - `name=${ann.name ?? "?"}`, - `rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`, - ); + const ann = annotations[i] as { + annotationType?: number; + subtype?: string; + name?: string; + rect?: number[]; + }; + const def = importPdfjsAnnotation(ann, pageNum, i); + if (def) { + pdfBaselineAnnotations.push(def); + imported++; + if (!annotationMap.has(def.id) && !restoredRemovedIds.has(def.id)) { + annotationMap.set(def.id, { def, elements: [] }); } + } else if (ann.annotationType !== 20) { + // Widget (type 20) is expected to be skipped; anything else we + // don't import will still be painted by page.render() onto the + // canvas as unselectable pixels. Log so we can diagnose + // "ghost annotations" (visible but not in panel, not clickable). + log.info( + `[WARN] Baseline: skipped PDF annotation on page ${pageNum}`, + `type=${ann.annotationType}`, + `subtype=${ann.subtype ?? "?"}`, + `name=${ann.name ?? "?"}`, + `rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`, + ); } } catch (err) { - // Log the error — a thrown import for one annotation silently - // drops the REST of that page's annotations too. - log.info( - `[WARN] Baseline: page ${pageNum} annotation import failed:`, - err, - ); + log.info(`Baseline: page ${pageNum} annotation import failed`, err); + } + } + if (imported > 0) { + try { + updateAnnotationsBadge(); + renderAnnotationPanel(); + } catch (err) { + log.info(`Baseline: page ${pageNum} panel update failed`, err); } } - log.info( - `Loaded ${pdfBaselineAnnotations.length} baseline annotations from PDF`, - ); } function persistAnnotations(): void { @@ -2740,6 +2755,20 @@ function persistAnnotations(): void { pdfBaselineFormValues, ); + // computeDiff only sees baseline ids from pages we've already scanned. + // Carry forward restored tombstones for unvisited pages so the first + // persist after restore doesn't drop them. Once every page is scanned the + // baseline is complete and computeDiff is authoritative on its own — + // dropping the carry-forward then also stops a stale id (no longer in the + // file) from pinning dirty=true forever. + if (baselineScannedPages.size < totalPages) { + for (const id of restoredRemovedIds) { + if (!annotationMap.has(id) && !diff.removed.includes(id)) { + diff.removed.push(id); + } + } + } + // Dirty tracks whether there are unsaved changes. Undoing back to baseline // yields an empty diff → clean again → save button disables. if (!isRestoring) setDirty(!isDiffEmpty(diff)); @@ -2765,11 +2794,11 @@ function restoreAnnotations(): void { const diff = deserializeDiff(raw); // Merge baseline + diff. The loop below is add-only, so we MUST also - // delete: loadBaselineAnnotations() runs between the two restore calls - // and re-seeds annotationMap with every baseline id — including the - // ones in diff.removed. Without this, the zombie survives the restore, - // and the next persistAnnotations() sees it in currentIds → computeDiff - // produces removed=[] → the deletion is permanently lost from storage. + // delete: the per-page baseline scan re-seeds annotationMap with every + // native id it encounters — including ones in diff.removed. Without the + // deletes here AND the restoredRemovedIds tombstones below, the zombie + // survives, and the next persistAnnotations() sees it in currentIds → + // computeDiff produces removed=[] → the deletion is permanently lost. const merged = mergeAnnotations(pdfBaselineAnnotations, diff); for (const def of merged) { if (!annotationMap.has(def.id)) { @@ -2778,6 +2807,9 @@ function restoreAnnotations(): void { } for (const id of diff.removed) { annotationMap.delete(id); + // Tombstone so the lazy per-page baseline scan (which runs AFTER this + // restore) doesn't resurrect it. + restoredRemovedIds.add(id); } // Restore form fields @@ -2869,6 +2901,14 @@ async function buildFieldNameMap( // getFieldObjects may fail on some PDFs } + // No AcroForm → nothing to map. Skip the per-page widget walk so form-free + // PDFs (the common large case) don't pull every page after first paint. + // getFieldObjects() itself only reads the catalog/AcroForm dict via range + // transport, so this gate is cheap. + if (!cachedFieldObjects || Object.keys(cachedFieldObjects).length === 0) { + return false; + } + // Scan every page's widget annotations to collect the CORRECT storage keys, // plus labels, pages, positions, AND fieldValue (what the widget renders // — which can differ from getFieldObjects().value if the PDF is internally @@ -3074,11 +3114,19 @@ async function getAnnotatedPdfBytes(): Promise { } // Baseline annotations the user deleted: strip their refs from /Annots so - // they don't reappear on reload. Ids without a recoverable ref (page-index - // fallback) can't be removed by-ref and are skipped. - const removedRefs = pdfBaselineAnnotations - .filter((a) => !annotationMap.has(a.id)) - .map((a) => parseAnnotationRef(a.id)) + // they don't reappear on reload. Include restored tombstones for pages we + // haven't scanned yet — those ids aren't in pdfBaselineAnnotations but the + // ref is still parseable from the id string. Ids without a recoverable ref + // (page-index fallback) can't be removed by-ref and are skipped. + const removedIds = new Set(); + for (const a of pdfBaselineAnnotations) { + if (!annotationMap.has(a.id)) removedIds.add(a.id); + } + for (const id of restoredRemovedIds) { + if (!annotationMap.has(id)) removedIds.add(id); + } + const removedRefs = [...removedIds] + .map(parseAnnotationRef) .filter((r): r is NonNullable => r !== null); // Only write fields that actually changed vs. what's already in the PDF. @@ -3362,6 +3410,9 @@ async function renderPage() { formLayerEl.style.setProperty("--total-scale-factor", `${scale}`); try { const annotations = await page.getAnnotations(); + // Lazy baseline import — piggyback on the annotations we just fetched + // for this page instead of walking all pages upfront. + scanPageBaselineAnnotations(pageToRender, annotations); if (annotations.length > 0) { const linkService = { getDestinationHash: () => "#", @@ -4406,6 +4457,8 @@ async function reloadPdf(): Promise { undoStack.length = 0; redoStack.length = 0; pdfBaselineAnnotations = []; + baselineScannedPages.clear(); + restoredRemovedIds.clear(); pdfBaselineFormValues.clear(); pageTextCache.clear(); pageTextItemsCache.clear(); @@ -4449,11 +4502,11 @@ async function reloadPdf(): Promise { log.info("PDF reloaded:", totalPages, "pages,", totalBytes, "bytes"); showViewer(); - // Render immediately — annotation/form scans below are O(numPages) and - // do NOT block the canvas. See same pattern in the initial-load path. + // Render immediately — baseline-annotation scan now happens per-page + // inside renderPage(); buildFieldNameMap below early-returns when no + // AcroForm is present. See same pattern in the initial-load path. await renderPage(); - await loadBaselineAnnotations(document); const seeded = await buildFieldNameMap(document); syncFormValuesToStorage(); if (seeded) await renderPage(); @@ -4509,6 +4562,11 @@ async function loadPdfProgressively(urlToLoad: string): Promise<{ const loadingTask = pdfjsLib.getDocument({ range: transport, standardFontDataUrl: STANDARD_FONT_DATA_URL, + // Only fetch ranges renderPage()/getFieldObjects() actually ask for. + // Without these pdfjs background-prefetches the whole file regardless of + // the per-page lazy scans below. + disableAutoFetch: true, + disableStream: true, }); try { @@ -4673,12 +4731,13 @@ app.ontoolresult = async (result: CallToolResult) => { scale = fitScale; log.info("Initial fit scale:", scale); } - await renderPage(); - - // Import annotations from the PDF to establish baseline - await loadBaselineAnnotations(document); - // Restore any persisted user diff + // Restore any persisted user diff BEFORE first render so the per-page + // baseline scan inside renderPage() can honour the removed-id tombstones + // and not resurrect annotations the user deleted last session. + // restoreAnnotations is sync (localStorage read) so first paint is not + // delayed. restoreAnnotations(); + await renderPage(); // Build field name → annotation ID mapping for form filling const seeded = await buildFieldNameMap(document); diff --git a/examples/pdf-server/src/pdf-annotations.test.ts b/examples/pdf-server/src/pdf-annotations.test.ts index 38ef27b..40e63ff 100644 --- a/examples/pdf-server/src/pdf-annotations.test.ts +++ b/examples/pdf-server/src/pdf-annotations.test.ts @@ -18,7 +18,13 @@ import { type PdfAnnotationDef, type AnnotationDiff, } from "./pdf-annotations"; -import { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber } from "pdf-lib"; +import { + PDFDocument, + PDFDict, + PDFName, + PDFArray, + PDFNumber, +} from "@cantoo/pdf-lib"; // ============================================================================= // Diff Model @@ -367,6 +373,92 @@ describe("computeDiff", () => { current.map((a) => a.id).sort(), ); }); + + // Backs the post-computeDiff union step in mcp-app.ts persistAnnotations / + // getAnnotatedPdfBytes: with the lazy per-page baseline scan, computeDiff + // alone cannot produce `removed` for natives on pages not yet visited, so + // callers must union restoredRemovedIds. These tests pin the contract those + // call sites depend on. + describe("partial baseline (lazy-scan tombstone preservation)", () => { + const tombstoned = "pdf-5-0"; + const userNote: PdfAnnotationDef = { + type: "note", + id: "u1", + page: 1, + x: 10, + y: 10, + content: "unrelated edit", + }; + + /** Mirrors the union loop in mcp-app.ts persistAnnotations. */ + function unionRestored( + diff: AnnotationDiff, + restored: Iterable, + currentIds: Set, + ): void { + for (const id of restored) { + if (!currentIds.has(id) && !diff.removed.includes(id)) { + diff.removed.push(id); + } + } + } + + it("computeDiff alone drops tombstones for unscanned pages; the union step preserves them", () => { + const baseline: PdfAnnotationDef[] = []; // page with the native not yet scanned + const current = [userNote]; + const diff = computeDiff(baseline, current, new Map()); + expect(diff.removed).toEqual([]); // proves the union step is load-bearing + + unionRestored(diff, [tombstoned], new Set(current.map((a) => a.id))); + + expect(diff.removed).toEqual([tombstoned]); + expect(isDiffEmpty(diff)).toBe(false); + expect(deserializeDiff(serializeDiff(diff)).removed).toEqual([ + tombstoned, + ]); + }); + + it("a tombstone the user re-added is excluded from the union", () => { + const reAdded: PdfAnnotationDef = { + type: "note", + id: tombstoned, + page: 3, + x: 0, + y: 0, + content: "back", + }; + const current = [userNote, reAdded]; + const diff = computeDiff([], current, new Map()); + unionRestored(diff, [tombstoned], new Set(current.map((a) => a.id))); + expect(diff.removed).toEqual([]); + }); + + it("union does not duplicate ids once the page is scanned and computeDiff produces them", () => { + const native: PdfAnnotationDef = { + type: "note", + id: tombstoned, + page: 3, + x: 0, + y: 0, + content: "native", + }; + const diff = computeDiff([native], [userNote], new Map()); + expect(diff.removed).toEqual([tombstoned]); + unionRestored(diff, [tombstoned], new Set([userNote.id])); + expect(diff.removed).toEqual([tombstoned]); + }); + + it("removedRefs from restored tombstones parse for buildAnnotatedPdfBytes; non-ref ids are skipped", () => { + const restored = new Set(["pdf-5-0", "pdf-12R", "pdf-2-idx-7"]); + const removedRefs = [...restored] + .map(parseAnnotationRef) + .filter((r): r is NonNullable => r !== null); + expect(removedRefs).toEqual([ + { objectNumber: 5, generationNumber: 0 }, + { objectNumber: 12, generationNumber: 0 }, + ]); + }); + }); }); // ============================================================================= @@ -988,16 +1080,16 @@ describe("buildAnnotatedPdfBytes", () => { // Check /C (stroke color) = [1, 0, 0] for #ff0000 const cArr = annotDict.get(PDFName.of("C")) as PDFArray; expect(cArr).toBeDefined(); - expect((cArr.get(0) as PDFNumber).value()).toBe(1); // r - expect((cArr.get(1) as PDFNumber).value()).toBe(0); // g - expect((cArr.get(2) as PDFNumber).value()).toBe(0); // b + expect((cArr.get(0) as PDFNumber).asNumber()).toBe(1); // r + expect((cArr.get(1) as PDFNumber).asNumber()).toBe(0); // g + expect((cArr.get(2) as PDFNumber).asNumber()).toBe(0); // b // Check /IC (fill color) = [0, 1, 0] for #00ff00 const icArr = annotDict.get(PDFName.of("IC")) as PDFArray; expect(icArr).toBeDefined(); - expect((icArr.get(0) as PDFNumber).value()).toBe(0); // r - expect((icArr.get(1) as PDFNumber).value()).toBe(1); // g - expect((icArr.get(2) as PDFNumber).value()).toBe(0); // b + expect((icArr.get(0) as PDFNumber).asNumber()).toBe(0); // r + expect((icArr.get(1) as PDFNumber).asNumber()).toBe(1); // g + expect((icArr.get(2) as PDFNumber).asNumber()).toBe(0); // b }); it("adds freetext annotation to PDF", async () => { @@ -1073,9 +1165,9 @@ describe("buildAnnotatedPdfBytes", () => { // Check /C color const cArr = annotDict.get(PDFName.of("C")) as PDFArray; expect(cArr).toBeDefined(); - expect((cArr.get(0) as PDFNumber).value()).toBe(0); - expect((cArr.get(1) as PDFNumber).value()).toBe(1); - expect((cArr.get(2) as PDFNumber).value()).toBe(0); + expect((cArr.get(0) as PDFNumber).asNumber()).toBe(0); + expect((cArr.get(1) as PDFNumber).asNumber()).toBe(1); + expect((cArr.get(2) as PDFNumber).asNumber()).toBe(0); // Check appearance stream exists and contains the color const ap = annotDict.get(PDFName.of("AP")) as PDFDict; diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index 73b9b8d..201fe8d 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -24,7 +24,7 @@ import { PDFOptionList, PDFRadioGroup, type PDFForm, -} from "pdf-lib"; +} from "@cantoo/pdf-lib"; // ============================================================================= // Types diff --git a/examples/pdf-server/tsconfig.json b/examples/pdf-server/tsconfig.json index 0501827..2fe7e62 100644 --- a/examples/pdf-server/tsconfig.json +++ b/examples/pdf-server/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node", "vite/client", "bun"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", diff --git a/examples/pdf-server/tsconfig.server.json b/examples/pdf-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/pdf-server/tsconfig.server.json +++ b/examples/pdf-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/qr-server/package.json b/examples/qr-server/package.json index f74e671..425728e 100644 --- a/examples/qr-server/package.json +++ b/examples/qr-server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-qr", - "version": "1.7.0", + "version": "2.0.0", "private": true, "scripts": { "start": "uv run server.py", @@ -8,7 +8,6 @@ "build": "echo 'No build step needed for Python server'" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/ext-apps": "^2.0.0" } } diff --git a/examples/qr-server/server.py b/examples/qr-server/server.py index d282d1a..c1c302e 100755 --- a/examples/qr-server/server.py +++ b/examples/qr-server/server.py @@ -26,7 +26,7 @@ VIEW_URI = "ui://qr-server/view.html" HOST = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 for Docker compatibility PORT = int(os.environ.get("PORT", "3001")) -mcp = FastMCP("QR Code Server", stateless_http=True) +mcp = FastMCP("QR Code Server", stateless_http=True, host=HOST, port=PORT) # Embedded View HTML for self-contained usage (uv run or unbundled) EMBEDDED_VIEW_HTML = """ diff --git a/examples/quickstart/main.ts b/examples/quickstart/main.ts index 06895a4..7f370a8 100644 --- a/examples/quickstart/main.ts +++ b/examples/quickstart/main.ts @@ -1,7 +1,7 @@ -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"; @@ -21,7 +21,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json index 036b66e..57f8444 100644 --- a/examples/quickstart/package.json +++ b/examples/quickstart/package.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/quickstart", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "private": true, "description": "Quickstart MCP App Server example", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/quickstart" }, "license": "MIT", @@ -15,10 +15,13 @@ "start": "concurrently --raw \"cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch\" \"tsx watch main.ts\"" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" + "express": "^5.1.0", + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/quickstart/server.ts b/examples/quickstart/server.ts index eb3c3b1..611d0f0 100644 --- a/examples/quickstart/server.ts +++ b/examples/quickstart/server.ts @@ -3,9 +3,10 @@ import { registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import fs from "node:fs/promises"; import path from "node:path"; +import { z } from "zod"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -30,7 +31,7 @@ export function createServer(): McpServer { { title: "Get Time", description: "Returns the current server time.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async () => { diff --git a/examples/quickstart/tsconfig.json b/examples/quickstart/tsconfig.json index 6c553b5..d6a42ce 100644 --- a/examples/quickstart/tsconfig.json +++ b/examples/quickstart/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node", "vite/client"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", diff --git a/examples/quickstart/tsconfig.server.json b/examples/quickstart/tsconfig.server.json index 7e65f5f..72552e2 100644 --- a/examples/quickstart/tsconfig.server.json +++ b/examples/quickstart/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/say-server/package.json b/examples/say-server/package.json index 283c4d5..cc12249 100644 --- a/examples/say-server/package.json +++ b/examples/say-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-say", - "version": "1.7.0", + "version": "2.0.0", "private": true, "description": "Streaming TTS MCP App Server with karaoke-style text highlighting", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/say-server" }, "license": "MIT", @@ -15,7 +15,6 @@ "build": "echo 'No build step needed for Python server'" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/ext-apps": "^2.0.0" } } diff --git a/examples/scenario-modeler-server/main.ts b/examples/scenario-modeler-server/main.ts index dbd3ab6..cc20e63 100644 --- a/examples/scenario-modeler-server/main.ts +++ b/examples/scenario-modeler-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json index 2d626cf..e6f37c0 100644 --- a/examples/scenario-modeler-server/package.json +++ b/examples/scenario-modeler-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-scenario-modeler", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Financial scenario modeling MCP App Server", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/scenario-modeler-server" }, "license": "MIT", @@ -26,14 +26,16 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.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", diff --git a/examples/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts index b59851c..c5fa05d 100644 --- a/examples/scenario-modeler-server/server.ts +++ b/examples/scenario-modeler-server/server.ts @@ -3,11 +3,11 @@ import { registerAppResource, registerAppTool, } 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"; @@ -305,8 +305,8 @@ export function createServer(): McpServer { title: "Get Scenario Data", description: "Returns SaaS scenario templates and optionally computes custom projections for given inputs", - inputSchema: GetScenarioDataInputSchema.shape, - outputSchema: GetScenarioDataOutputSchema.shape, + inputSchema: GetScenarioDataInputSchema, + outputSchema: GetScenarioDataOutputSchema, _meta: { ui: { resourceUri } }, }, async (args: { diff --git a/examples/scenario-modeler-server/tsconfig.json b/examples/scenario-modeler-server/tsconfig.json index fc3c210..3ecbdad 100644 --- a/examples/scenario-modeler-server/tsconfig.json +++ b/examples/scenario-modeler-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/scenario-modeler-server/tsconfig.server.json b/examples/scenario-modeler-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/scenario-modeler-server/tsconfig.server.json +++ b/examples/scenario-modeler-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/shadertoy-server/main.ts b/examples/shadertoy-server/main.ts index ac8f6ad..7e388f0 100644 --- a/examples/shadertoy-server/main.ts +++ b/examples/shadertoy-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/shadertoy-server/package.json b/examples/shadertoy-server/package.json index 4924d63..aec56c6 100644 --- a/examples/shadertoy-server/package.json +++ b/examples/shadertoy-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-shadertoy", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "MCP App Server example for rendering ShaderToy-compatible GLSL shaders", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/shadertoy-server" }, "license": "MIT", @@ -24,11 +24,13 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/shadertoy-server/server.ts b/examples/shadertoy-server/server.ts index 5238c40..50c27bc 100644 --- a/examples/shadertoy-server/server.ts +++ b/examples/shadertoy-server/server.ts @@ -3,11 +3,11 @@ import { 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"; diff --git a/examples/shadertoy-server/tsconfig.json b/examples/shadertoy-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/shadertoy-server/tsconfig.json +++ b/examples/shadertoy-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/shadertoy-server/tsconfig.server.json b/examples/shadertoy-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/shadertoy-server/tsconfig.server.json +++ b/examples/shadertoy-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/sheet-music-server/main.ts b/examples/sheet-music-server/main.ts index 8b143a7..d7bf3a0 100644 --- a/examples/sheet-music-server/main.ts +++ b/examples/sheet-music-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/sheet-music-server/package.json b/examples/sheet-music-server/package.json index b5f2370..85ca6bb 100644 --- a/examples/sheet-music-server/package.json +++ b/examples/sheet-music-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-sheet-music", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "MCP App Server for rendering and playing sheet music from ABC notation", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/sheet-music-server" }, "license": "MIT", @@ -24,12 +24,14 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "abcjs": "^6.4.4", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/sheet-music-server/server.ts b/examples/sheet-music-server/server.ts index cfd319b..be7214a 100644 --- a/examples/sheet-music-server/server.ts +++ b/examples/sheet-music-server/server.ts @@ -1,8 +1,8 @@ -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"; diff --git a/examples/sheet-music-server/tsconfig.json b/examples/sheet-music-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/sheet-music-server/tsconfig.json +++ b/examples/sheet-music-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/sheet-music-server/tsconfig.server.json b/examples/sheet-music-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/sheet-music-server/tsconfig.server.json +++ b/examples/sheet-music-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/system-monitor-server/main.ts b/examples/system-monitor-server/main.ts index 365a560..c55c799 100644 --- a/examples/system-monitor-server/main.ts +++ b/examples/system-monitor-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/system-monitor-server/package.json b/examples/system-monitor-server/package.json index 05243db..2dc3ef1 100644 --- a/examples/system-monitor-server/package.json +++ b/examples/system-monitor-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-system-monitor", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "System monitor MCP App Server with real-time stats", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/system-monitor-server" }, "license": "MIT", @@ -26,13 +26,15 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "systeminformation": "^5.31.5", - "zod": "^4.1.13" + "systeminformation": "^5.31.6", + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/system-monitor-server/server.ts b/examples/system-monitor-server/server.ts index 8fac5f2..a17bcf6 100644 --- a/examples/system-monitor-server/server.ts +++ b/examples/system-monitor-server/server.ts @@ -3,11 +3,11 @@ import { registerAppResource, registerAppTool, } 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 os from "node:os"; import path from "node:path"; @@ -135,8 +135,8 @@ export function createServer(): McpServer { title: "Get System Info", description: "Returns system information, including hostname, platform, CPU info, and memory.", - inputSchema: {}, - outputSchema: SystemInfoSchema.shape, + inputSchema: z.object({}), + outputSchema: SystemInfoSchema, _meta: { ui: { resourceUri } }, }, (): CallToolResult => { @@ -156,8 +156,8 @@ export function createServer(): McpServer { title: "Poll System Stats", description: "Returns dynamic system metrics for polling: per-core CPU timing, memory usage, and uptime. App-only.", - inputSchema: {}, - outputSchema: PollStatsSchema.shape, + inputSchema: z.object({}), + outputSchema: PollStatsSchema, _meta: { ui: { visibility: ["app"] } }, }, async (): Promise => { diff --git a/examples/system-monitor-server/tsconfig.json b/examples/system-monitor-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/system-monitor-server/tsconfig.json +++ b/examples/system-monitor-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/system-monitor-server/tsconfig.server.json b/examples/system-monitor-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/system-monitor-server/tsconfig.server.json +++ b/examples/system-monitor-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/threejs-server/main.ts b/examples/threejs-server/main.ts index 13a2084..ef16e26 100644 --- a/examples/threejs-server/main.ts +++ b/examples/threejs-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/threejs-server/package.json b/examples/threejs-server/package.json index 0c2d5f5..68864d9 100644 --- a/examples/threejs-server/package.json +++ b/examples/threejs-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-threejs", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Three.js 3D visualization MCP App Server", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/threejs-server" }, "license": "MIT", @@ -26,14 +26,17 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", "three": "^0.181.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/threejs-server/server.ts b/examples/threejs-server/server.ts index ae320df..0a13c19 100644 --- a/examples/threejs-server/server.ts +++ b/examples/threejs-server/server.ts @@ -8,8 +8,10 @@ import { registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import { + McpServer, + type ReadResourceResult, +} from "@modelcontextprotocol/server"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -161,7 +163,7 @@ export function createServer(): McpServer { title: "Show Three.js Scene", description: "Render an interactive 3D scene with custom Three.js code. Supports transparent backgrounds (alpha: true) for seamless host UI integration. Available globals: THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, height.", - inputSchema: { + inputSchema: z.object({ code: z .string() .default(DEFAULT_THREEJS_CODE) @@ -172,7 +174,7 @@ export function createServer(): McpServer { .positive() .default(400) .describe("Height in pixels"), - }, + }), outputSchema: z.object({ success: z.boolean(), }), @@ -192,7 +194,7 @@ export function createServer(): McpServer { { title: "Learn Three.js", description: "Get documentation and examples for using the Three.js View", - inputSchema: {}, + inputSchema: z.object({}), }, async () => { return { diff --git a/examples/threejs-server/src/mcp-app-wrapper.tsx b/examples/threejs-server/src/mcp-app-wrapper.tsx index 34194b1..44fee0c 100644 --- a/examples/threejs-server/src/mcp-app-wrapper.tsx +++ b/examples/threejs-server/src/mcp-app-wrapper.tsx @@ -6,7 +6,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, useState, useCallback, useEffect, useRef } from "react"; import { createRoot } from "react-dom/client"; import { z } from "zod"; diff --git a/examples/threejs-server/tsconfig.json b/examples/threejs-server/tsconfig.json index fc3c210..3ecbdad 100644 --- a/examples/threejs-server/tsconfig.json +++ b/examples/threejs-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/threejs-server/tsconfig.server.json b/examples/threejs-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/threejs-server/tsconfig.server.json +++ b/examples/threejs-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/transcript-server/README.md b/examples/transcript-server/README.md index 3b3fa42..559ffad 100644 --- a/examples/transcript-server/README.md +++ b/examples/transcript-server/README.md @@ -56,7 +56,7 @@ To test local modifications, use this configuration (replace `~/code/ext-apps` w ### Prerequisites -- Node.js 18+ +- Node.js 20+ - Chrome, Edge, or Safari (Web Speech API support) ### Installation diff --git a/examples/transcript-server/main.ts b/examples/transcript-server/main.ts index 68db8dc..e86ad7c 100644 --- a/examples/transcript-server/main.ts +++ b/examples/transcript-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -25,7 +25,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/transcript-server/package.json b/examples/transcript-server/package.json index f0daa38..46a8f98 100644 --- a/examples/transcript-server/package.json +++ b/examples/transcript-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-transcript", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "MCP App Server for live speech transcription", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/transcript-server" }, "license": "MIT", @@ -24,11 +24,13 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/transcript-server/server.ts b/examples/transcript-server/server.ts index 56bf88d..863eb29 100644 --- a/examples/transcript-server/server.ts +++ b/examples/transcript-server/server.ts @@ -1,8 +1,8 @@ -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 { @@ -10,6 +10,7 @@ import { 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") @@ -33,7 +34,7 @@ export function createServer(): McpServer { title: "Transcribe Speech", description: "Opens a live speech transcription interface using the Web Speech API.", - inputSchema: {}, + inputSchema: z.object({}), _meta: { ui: { resourceUri } }, }, async (): Promise => { diff --git a/examples/transcript-server/tsconfig.json b/examples/transcript-server/tsconfig.json index 535267b..00e4801 100644 --- a/examples/transcript-server/tsconfig.json +++ b/examples/transcript-server/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node", "vite/client", "dom-speech-recognition"], "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", diff --git a/examples/transcript-server/tsconfig.server.json b/examples/transcript-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/transcript-server/tsconfig.server.json +++ b/examples/transcript-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/video-resource-server/main.ts b/examples/video-resource-server/main.ts index 082fa5a..1983373 100644 --- a/examples/video-resource-server/main.ts +++ b/examples/video-resource-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/video-resource-server/package.json b/examples/video-resource-server/package.json index 9e70d24..4a7a61f 100644 --- a/examples/video-resource-server/package.json +++ b/examples/video-resource-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-video-resource", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "MCP App Server demonstrating video resources served as base64 blobs", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/video-resource-server" }, "license": "MIT", @@ -24,11 +24,14 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", diff --git a/examples/video-resource-server/server.ts b/examples/video-resource-server/server.ts index 2cb317e..8ae6240 100644 --- a/examples/video-resource-server/server.ts +++ b/examples/video-resource-server/server.ts @@ -12,11 +12,9 @@ import { import { McpServer, ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - CallToolResult, - ReadResourceResult, -} from "@modelcontextprotocol/sdk/types.js"; + type CallToolResult, + type ReadResourceResult, +} from "@modelcontextprotocol/server"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -135,14 +133,14 @@ Available videos: ${Object.entries(VIDEO_LIBRARY) .map(([id, v]) => `- ${id}: ${v.description}`) .join("\n")}`, - inputSchema: { + inputSchema: z.object({ videoId: z .enum(Object.keys(VIDEO_LIBRARY) as [string, ...string[]]) .default("bunny-1mb") .describe( `Video ID to play. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`, ), - }, + }), outputSchema: z.object({ videoUri: z.string(), description: z.string(), diff --git a/examples/video-resource-server/src/mcp-app.ts b/examples/video-resource-server/src/mcp-app.ts index 88ed913..2a3f8f9 100644 --- a/examples/video-resource-server/src/mcp-app.ts +++ b/examples/video-resource-server/src/mcp-app.ts @@ -5,7 +5,7 @@ * The video is served as a base64 blob and converted to a data URI for playback. */ import { App, 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/video-resource-server/tsconfig.json b/examples/video-resource-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/video-resource-server/tsconfig.json +++ b/examples/video-resource-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/video-resource-server/tsconfig.server.json b/examples/video-resource-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/video-resource-server/tsconfig.server.json +++ b/examples/video-resource-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/examples/wiki-explorer-server/main.ts b/examples/wiki-explorer-server/main.ts index cc7b3a2..bfeeffb 100644 --- a/examples/wiki-explorer-server/main.ts +++ b/examples/wiki-explorer-server/main.ts @@ -4,10 +4,10 @@ * Or: node dist/index.js [--stdio] */ -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"; @@ -27,7 +27,7 @@ export async function startStreamableHTTPServer( app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); diff --git a/examples/wiki-explorer-server/package.json b/examples/wiki-explorer-server/package.json index a6a8f19..52304cf 100644 --- a/examples/wiki-explorer-server/package.json +++ b/examples/wiki-explorer-server/package.json @@ -1,11 +1,11 @@ { "name": "@modelcontextprotocol/server-wiki-explorer", - "version": "1.7.0", + "version": "2.0.0", "type": "module", "description": "Wikipedia link explorer MCP App Server with graph visualization", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps", + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git", "directory": "examples/wiki-explorer-server" }, "license": "MIT", @@ -26,12 +26,15 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts index 59d5e18..8bc4eb7 100644 --- a/examples/wiki-explorer-server/server.ts +++ b/examples/wiki-explorer-server/server.ts @@ -3,11 +3,11 @@ import { registerAppResource, registerAppTool, } 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 * as cheerio from "cheerio"; import fs from "node:fs/promises"; import path from "node:path"; diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 291292d..9833d9c 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -2,7 +2,7 @@ * Wiki Explorer - Force-directed graph visualization of Wikipedia link networks */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/client"; import { z } from "zod"; import { forceCenter, diff --git a/examples/wiki-explorer-server/tsconfig.json b/examples/wiki-explorer-server/tsconfig.json index 535267b..b028ff5 100644 --- a/examples/wiki-explorer-server/tsconfig.json +++ b/examples/wiki-explorer-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/wiki-explorer-server/tsconfig.server.json b/examples/wiki-explorer-server/tsconfig.server.json index 05ddd8e..27eb28f 100644 --- a/examples/wiki-explorer-server/tsconfig.server.json +++ b/examples/wiki-explorer-server/tsconfig.server.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node"], "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", diff --git a/package-lock.json b/package-lock.json index c0c0c47..0189ca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/ext-apps", - "version": "1.7.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/ext-apps", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "workspaces": [ "examples/*" @@ -16,7 +16,10 @@ }, "devDependencies": { "@boneskull/typedoc-plugin-mermaid": "^0.2.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/server": "2.0.0", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", "@types/node": "20.19.27", @@ -31,6 +34,7 @@ "electron-to-chromium": "1.5.267", "esbuild": "^0.25.12", "express": "^5.1.0", + "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5", "husky": "^9.1.7", "nodemon": "^3.1.0", "playwright": "1.57.0", @@ -38,24 +42,29 @@ "prettier": "^3.6.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typedoc": "^0.28.14", "typedoc-github-theme": "^0.4.0", "typescript": "^5.9.3", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.2.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, "react": { "optional": true }, @@ -66,13 +75,13 @@ }, "examples/basic-host": { "name": "@modelcontextprotocol/ext-apps-basic-host", - "version": "1.7.0", + "version": "2.0.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", @@ -86,8 +95,7 @@ "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" } }, "examples/basic-host/node_modules/@types/node": { @@ -109,15 +117,18 @@ }, "examples/basic-server-preact": { "name": "@modelcontextprotocol/server-basic-preact", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-server-basic-preact": "dist/index.js" @@ -153,16 +164,19 @@ }, "examples/basic-server-react": { "name": "@modelcontextprotocol/server-basic-react", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-server-basic-react": "dist/index.js" @@ -200,15 +214,18 @@ }, "examples/basic-server-solid": { "name": "@modelcontextprotocol/server-basic-solid", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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.10", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-server-basic-solid": "dist/index.js" @@ -222,7 +239,7 @@ "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" } }, "examples/basic-server-solid/node_modules/@types/node": { @@ -244,15 +261,18 @@ }, "examples/basic-server-svelte": { "name": "@modelcontextprotocol/server-basic-svelte", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-server-basic-svelte": "dist/index.js" @@ -288,14 +308,17 @@ }, "examples/basic-server-vanillajs": { "name": "@modelcontextprotocol/server-basic-vanillajs", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-server-basic-vanillajs": "dist/index.js" @@ -330,15 +353,18 @@ }, "examples/basic-server-vue": { "name": "@modelcontextprotocol/server-basic-vue", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-server-basic-vue": "dist/index.js" @@ -374,15 +400,17 @@ }, "examples/budget-allocator-server": { "name": "@modelcontextprotocol/server-budget-allocator", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-budget-allocator-server": "dist/index.js" @@ -417,16 +445,18 @@ }, "examples/cohort-heatmap-server": { "name": "@modelcontextprotocol/server-cohort-heatmap", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-cohort-heatmap-server": "dist/index.js" @@ -464,15 +494,17 @@ }, "examples/customer-segmentation-server": { "name": "@modelcontextprotocol/server-customer-segmentation", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-customer-segmentation-server": "dist/index.js" @@ -507,12 +539,14 @@ }, "examples/debug-server": { "name": "@modelcontextprotocol/server-debug", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.1.13" + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "zod": "^4.2.0" }, "bin": { "mcp-server-debug": "dist/index.js" @@ -548,15 +582,18 @@ "license": "MIT" }, "examples/integration-server": { - "version": "1.7.0", + "version": "2.0.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-integration-server": "dist/index.js" @@ -591,16 +628,63 @@ "dev": true, "license": "MIT" }, - "examples/map-server": { - "name": "@modelcontextprotocol/server-map", - "version": "1.7.0", + "examples/lazy-auth-server": { + "name": "@modelcontextprotocol/server-lazy-auth", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" + "jose": "^6.0.0", + "zod": "^4.2.0" + }, + "bin": { + "mcp-server-lazy-auth": "dist/index.js" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.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" + } + }, + "examples/lazy-auth-server/node_modules/@types/node": { + "version": "22.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", + "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "examples/lazy-auth-server/node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "examples/map-server": { + "name": "@modelcontextprotocol/server-map", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@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.2.0" }, "bin": { "mcp-map-server": "dist/index.js" @@ -635,16 +719,19 @@ }, "examples/pdf-server": { "name": "@modelcontextprotocol/server-pdf", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@cantoo/pdf-lib": "^2.6.5", + "@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", - "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.0.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-pdf-server": "dist/index.js" @@ -679,21 +766,23 @@ }, "examples/qr-server": { "name": "@modelcontextprotocol/server-qr", - "version": "1.7.0", + "version": "2.0.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/ext-apps": "^2.0.0" } }, "examples/quickstart": { "name": "@modelcontextprotocol/quickstart", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" + "express": "^5.1.0", + "zod": "^4.2.0" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -719,26 +808,27 @@ }, "examples/say-server": { "name": "@modelcontextprotocol/server-say", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/ext-apps": "^2.0.0" } }, "examples/scenario-modeler-server": { "name": "@modelcontextprotocol/server-scenario-modeler", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.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" }, "bin": { "mcp-scenario-modeler-server": "dist/index.js" @@ -776,14 +866,16 @@ }, "examples/shadertoy-server": { "name": "@modelcontextprotocol/server-shadertoy", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-shadertoy-server": "dist/index.js" @@ -818,15 +910,17 @@ }, "examples/sheet-music-server": { "name": "@modelcontextprotocol/server-sheet-music", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "abcjs": "^6.4.4", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-sheet-music-server": "dist/index.js" @@ -861,16 +955,18 @@ }, "examples/system-monitor-server": { "name": "@modelcontextprotocol/server-system-monitor", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", - "systeminformation": "^5.31.5", - "zod": "^4.1.13" + "systeminformation": "^5.31.6", + "zod": "^4.2.0" }, "bin": { "mcp-system-monitor-server": "dist/index.js" @@ -896,32 +992,6 @@ "undici-types": "~6.20.0" } }, - "examples/system-monitor-server/node_modules/systeminformation": { - "version": "5.31.5", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", - "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32", - "freebsd", - "openbsd", - "netbsd", - "sunos", - "android" - ], - "bin": { - "systeminformation": "lib/cli.js" - }, - "engines": { - "node": ">=8.0.0" - }, - "funding": { - "type": "Buy me a coffee", - "url": "https://www.buymeacoffee.com/systeminfo" - } - }, "examples/system-monitor-server/node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -931,17 +1001,20 @@ }, "examples/threejs-server": { "name": "@modelcontextprotocol/server-threejs", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", "three": "^0.181.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-threejs-server": "dist/index.js" @@ -980,14 +1053,16 @@ }, "examples/transcript-server": { "name": "@modelcontextprotocol/server-transcript", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-transcript-server": "dist/index.js" @@ -1023,14 +1098,17 @@ }, "examples/video-resource-server": { "name": "@modelcontextprotocol/server-video-resource", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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" }, "bin": { "mcp-video-resource-server": "dist/index.js" @@ -1065,15 +1143,18 @@ }, "examples/wiki-explorer-server": { "name": "@modelcontextprotocol/server-wiki-explorer", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.29.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", "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^5.1.0", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "bin": { "mcp-wiki-explorer-server": "dist/index.js" @@ -1108,13 +1189,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1123,9 +1204,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -1133,21 +1214,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1174,14 +1255,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1204,14 +1285,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1231,9 +1312,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -1241,29 +1322,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1283,27 +1364,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -1311,26 +1392,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1424,33 +1505,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1458,13 +1539,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1489,6 +1570,21 @@ } } }, + "node_modules/@cantoo/pdf-lib": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.5.tgz", + "integrity": "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "color": "^4.2.3", + "crypto-js": "^4.2.0", + "node-html-better-parser": ">=1.4.0", + "pako": "^1.0.11", + "tslib": ">=2" + } + }, "node_modules/@clack/core": { "version": "1.0.0-alpha.4", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0-alpha.4.tgz", @@ -1520,9 +1616,9 @@ "license": "Apache-2.0" }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, @@ -2006,9 +2102,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { @@ -2016,9 +2112,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -2029,19 +2125,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -2052,19 +2148,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -2079,9 +2195,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -2096,9 +2212,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -2113,9 +2229,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -2130,9 +2246,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -2147,9 +2263,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -2164,9 +2280,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -2181,9 +2297,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -2198,9 +2314,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -2215,9 +2331,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -2232,9 +2348,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -2245,19 +2361,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -2268,19 +2384,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -2291,19 +2407,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -2314,19 +2430,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -2337,19 +2453,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -2360,19 +2476,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -2383,19 +2499,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -2406,39 +2522,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -2449,16 +2582,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -2469,16 +2602,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -2489,7 +2622,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2546,64 +2679,93 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, - "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.0.tgz", - "integrity": "sha512-d5vGKBhWkRoa3xlKOynF8kd+sTSY2D3QSjTMCs46/ddOUOSn5e/E0SaShixFm7H7mNlj4uY0RuU0jAsPM/0qwA==", - "hasInstallScript": true, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", - "workspaces": [ - "examples/*" - ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/express": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/express/-/express-2.0.0.tgz", + "integrity": "sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ==", + "license": "MIT", + "dependencies": { + "cors": "^2.8.5" + }, + "engines": { + "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "@modelcontextprotocol/server": "^2.0.0", + "express": "^4.18.0 || ^5.0.0" } }, + "node_modules/@modelcontextprotocol/ext-apps": { + "resolved": "", + "link": true + }, "node_modules/@modelcontextprotocol/ext-apps-basic-host": { "resolved": "examples/basic-host", "link": true }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/quickstart": { "resolved": "examples/quickstart", "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2637,6 +2799,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/server-basic-preact": { "resolved": "examples/basic-server-preact", "link": true @@ -2677,6 +2852,10 @@ "resolved": "examples/debug-server", "link": true }, + "node_modules/@modelcontextprotocol/server-lazy-auth": { + "resolved": "examples/lazy-auth-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-map": { "resolved": "examples/map-server", "link": true @@ -3006,9 +3185,9 @@ } }, "node_modules/@oclif/core/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3038,6 +3217,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3051,6 +3231,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3064,6 +3245,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3077,6 +3259,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3090,6 +3273,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3103,6 +3287,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3116,6 +3301,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3129,6 +3315,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3142,6 +3329,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3169,6 +3357,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3182,6 +3371,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3341,6 +3531,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3354,6 +3545,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3423,6 +3615,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3548,6 +3741,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3603,6 +3797,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3644,6 +3839,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3706,9 +3902,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", - "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -3827,17 +4023,6 @@ "bun-types": "1.3.6" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3858,13 +4043,6 @@ "@types/node": "*" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/dom-speech-recognition": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.7.tgz", @@ -4028,19 +4206,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@typescript/vfs": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.2.tgz", @@ -4089,131 +4254,6 @@ "vue": "^3.2.25" } }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vue/compiler-core": { "version": "3.5.27", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", @@ -4367,9 +4407,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4379,9 +4419,10 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4398,6 +4439,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -4496,16 +4538,6 @@ "node": ">= 0.4" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4523,9 +4555,9 @@ } }, "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.40.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.3.tgz", - "integrity": "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w==", + "version": "0.40.6", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.6.tgz", + "integrity": "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4563,17 +4595,17 @@ } }, "node_modules/babel-preset-solid": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.10.tgz", - "integrity": "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz", + "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jsx-dom-expressions": "^0.40.3" + "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", - "solid-js": "^1.9.10" + "solid-js": "^1.9.12" }, "peerDependenciesMeta": { "solid-js": { @@ -4589,13 +4621,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bezier-js": { @@ -4623,20 +4658,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -4646,6 +4681,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4669,9 +4717,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4693,9 +4741,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -4713,11 +4761,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4726,6 +4774,34 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/browserslist/node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/browserslist/node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "dev": true, + "license": "ISC" + }, "node_modules/bun": { "version": "1.3.10", "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz", @@ -4780,16 +4856,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4853,23 +4919,6 @@ "node": ">=12" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4912,16 +4961,6 @@ "pnpm": ">=8" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/cheerio": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", @@ -5115,11 +5154,23 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5132,9 +5183,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5150,15 +5210,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -5266,6 +5326,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -5552,16 +5618,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5592,9 +5648,9 @@ } }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/dom-serializer": { @@ -5767,13 +5823,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5864,13 +5913,20 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", - "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, "node_modules/estree-walker": { @@ -5916,16 +5972,6 @@ "node": ">=18.0.0" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5970,12 +6016,14 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5987,16 +6035,49 @@ "express": ">= 4.11" } }, + "node_modules/ext-apps-v1": { + "name": "@modelcontextprotocol/ext-apps", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, "funding": [ { "type": "github", @@ -6027,9 +6108,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -6330,9 +6411,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6342,7 +6423,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", - "dev": true, "license": "MIT" }, "node_modules/htmlparser2": { @@ -6472,9 +6552,10 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -6489,6 +6570,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6665,12 +6752,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/json5": { @@ -6730,10 +6819,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -6918,13 +7017,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6952,15 +7044,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -7101,9 +7203,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7127,6 +7229,15 @@ "node": ">= 0.6" } }, + "node_modules/node-html-better-parser": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz", + "integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==", + "license": "MIT", + "dependencies": { + "html-entities": "^2.3.2" + } + }, "node_modules/node-html-parser": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", @@ -7139,11 +7250,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nodemon": { "version": "3.1.11", @@ -7360,41 +7474,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/pdf-lib": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", - "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", - "license": "MIT", - "dependencies": { - "@pdf-lib/standard-fonts": "^1.0.0", - "@pdf-lib/upng": "^1.0.1", - "pako": "^1.0.11", - "tslib": "^1.11.1" - } - }, - "node_modules/pdf-lib/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/pdfjs-dist": { "version": "5.4.530", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", @@ -7483,9 +7562,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7502,7 +7581,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7567,9 +7646,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7679,6 +7758,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7802,9 +7882,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7887,48 +7967,53 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -7953,9 +8038,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -8037,13 +8122,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8067,6 +8145,15 @@ "kolorist": "^1.6.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -8185,13 +8272,6 @@ "node": ">=16" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8201,13 +8281,6 @@ "node": ">= 0.8" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8272,26 +8345,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -8309,23 +8362,23 @@ } }, "node_modules/svelte": { - "version": "5.55.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", - "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", + "version": "5.56.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.1.tgz", + "integrity": "sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.4", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -8335,6 +8388,32 @@ "node": ">=18" } }, + "node_modules/systeminformation": { + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/text-camel-case": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/text-camel-case/-/text-camel-case-1.2.9.tgz", @@ -8553,13 +8632,6 @@ "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", "license": "MIT" }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -8567,13 +8639,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8622,36 +8687,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8751,7 +8786,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsutils": { @@ -9295,17 +9329,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typedoc": { @@ -9346,9 +9397,9 @@ } }, "node_modules/typedoc/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -9400,9 +9451,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -9465,9 +9516,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -9539,29 +9590,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-plugin-singlefile": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", @@ -9580,9 +9608,9 @@ } }, "node_modules/vite-plugin-solid": { - "version": "2.11.10", - "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.10.tgz", - "integrity": "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==", + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.12.tgz", + "integrity": "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==", "dev": true, "license": "MIT", "dependencies": { @@ -9596,7 +9624,7 @@ "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@testing-library/jest-dom": { @@ -9605,9 +9633,9 @@ } }, "node_modules/vite-prerender-plugin": { - "version": "0.5.12", - "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.12.tgz", - "integrity": "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", "dev": true, "license": "MIT", "dependencies": { @@ -9619,7 +9647,7 @@ "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { - "vite": "5.x || 6.x || 7.x" + "vite": "5.x || 6.x || 7.x || 8.x" } }, "node_modules/vite/node_modules/fdir": { @@ -9673,92 +9701,6 @@ } } }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vue": { "version": "3.5.27", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", @@ -9817,23 +9759,6 @@ "node": ">= 8" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -9979,12 +9904,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index 5072e5b..c86efa9 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "@modelcontextprotocol/ext-apps", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/ext-apps" + "url": "git+https://github.com/modelcontextprotocol/ext-apps.git" }, "homepage": "https://github.com/modelcontextprotocol/ext-apps", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "description": "MCP Apps SDK — Enable MCP servers to display interactive user interfaces in conversational clients.", "type": "module", @@ -54,6 +54,7 @@ "prepack": "npm run build", "build:all": "npm run examples:build", "test": "bun test src examples", + "test:dependency-isolation": "npm run build && node scripts/check-dependency-isolation.mjs", "test:e2e": "playwright test", "test:e2e:update": "playwright test --update-snapshots", "test:e2e:ui": "playwright test --ui", @@ -75,9 +76,13 @@ "update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/" }, "author": "Olivier Chafik", + "//interop": "The @modelcontextprotocol/sdk 1.x and ext-apps-v1 devDependencies exist only for src/interop-v1.test.ts, which runs the published 1.7.5 package against this build. The library does not use them.", "devDependencies": { "@boneskull/typedoc-plugin-mermaid": "^0.2.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/server": "2.0.0", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", "@types/node": "20.19.27", @@ -92,6 +97,7 @@ "electron-to-chromium": "1.5.267", "esbuild": "^0.25.12", "express": "^5.1.0", + "ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5", "husky": "^9.1.7", "nodemon": "^3.1.0", "playwright": "1.57.0", @@ -99,21 +105,26 @@ "prettier": "^3.6.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typedoc": "^0.28.14", "typedoc-github-theme": "^0.4.0", "typescript": "^5.9.3", - "zod": "^4.1.13" + "zod": "^4.2.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.2.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, "react": { "optional": true }, diff --git a/playwright.config.ts b/playwright.config.ts index 6f6cb65..ecf1e13 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -47,6 +47,10 @@ export default defineConfig({ env: { ...process.env, EXAMPLE: process.env.EXAMPLE ?? "", + // Let pdf-server fetch from the http://127.0.0.1 range-counting fixture + // (validateUrl rejects loopback HTTP unless this is set). Scoped to this + // server's check only — does not touch Node's TLS verification. + PDF_SERVER_ALLOW_LOOPBACK_HTTP: "1", }, }, // Snapshot configuration diff --git a/plugins/mcp-apps/skills/add-app-to-server/SKILL.md b/plugins/mcp-apps/skills/add-app-to-server/SKILL.md index 02d09a4..5a473f0 100644 --- a/plugins/mcp-apps/skills/add-app-to-server/SKILL.md +++ b/plugins/mcp-apps/skills/add-app-to-server/SKILL.md @@ -77,13 +77,28 @@ Before writing any code, analyze the server's existing tools and determine which ## Step 2: Add Dependencies ```bash -npm install @modelcontextprotocol/ext-apps +npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0 npm install -D vite vite-plugin-singlefile ``` -Plus framework-specific dependencies if needed (e.g., `react`, `react-dom`, `@vitejs/plugin-react` for React). +Plus framework-specific dependencies if needed (e.g., `react`, `react-dom`, `@vitejs/plugin-react` for React), and `@modelcontextprotocol/node` / `@modelcontextprotocol/express` if the server uses HTTP transports. -Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory. +ext-apps 2.x requires the split base MCP SDK packages at `^2.0.0` +(`@modelcontextprotocol/core` comes in transitively). Existing servers that +still import `@modelcontextprotocol/sdk` v1 must migrate those imports and +handler schemas before adding the App integration: + +| SDK v1 (`@modelcontextprotocol/sdk`) | SDK v2 | +|---|---| +| `sdk/server/mcp.js` (`McpServer`) | `@modelcontextprotocol/server` | +| `sdk/server/streamableHttp.js` (`StreamableHTTPServerTransport`) | `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node` | +| Express wiring by hand | `createMcpExpressApp` from `@modelcontextprotocol/express` | +| `sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` | +| `sdk/types.js` types (`CallToolResult`, …) | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `sdk/types.js` zod schemas (`CallToolResultSchema`, …) | `@modelcontextprotocol/core` | +| Raw zod shapes: `inputSchema: { q: z.string() }` | `inputSchema: z.object({ q: z.string() })` | +| `extra.signal` in tool callbacks | `extra.mcpReq.signal` | +| `setRequestHandler(SomeRequestSchema, handler)` | `setRequestHandler("some/method", { params: ParamsSchema }, handler)` (the 2-arg form is only for spec methods such as `"tools/call"`) | ## Step 3: Set Up the Build Pipeline @@ -146,10 +161,14 @@ Transform plain MCP tools into App tools with UI. **Before** (plain MCP tool): ```typescript -server.tool("my-tool", { param: z.string() }, async (args) => { - const data = await fetchData(args.param); - return { content: [{ type: "text", text: JSON.stringify(data) }] }; -}); +server.registerTool( + "my-tool", + { inputSchema: z.object({ param: z.string() }) }, + async ({ param }) => { + const data = await fetchData(param); + return { content: [{ type: "text", text: JSON.stringify(data) }] }; + }, +); ``` **After** (App tool with UI): @@ -158,17 +177,22 @@ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@model const resourceUri = "ui://my-tool/mcp-app.html"; -registerAppTool(server, "my-tool", { - description: "Shows data with an interactive UI", - inputSchema: { param: z.string() }, - _meta: { ui: { resourceUri } }, -}, async (args) => { - const data = await fetchData(args.param); - return { - content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts - structuredContent: { data }, // structured data for the UI - }; -}); +registerAppTool( + server, + "my-tool", + { + description: "Shows data with an interactive UI", + inputSchema: z.object({ param: z.string() }), + _meta: { ui: { resourceUri } }, + }, + async ({ param }) => { + const data = await fetchData(param); + return { + content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts + structuredContent: { data }, // structured data for the UI + }; + }, +); ``` Key guidance: @@ -187,17 +211,21 @@ import path from "node:path"; const resourceUri = "ui://my-tool/mcp-app.html"; -registerAppResource(server, { - uri: resourceUri, - name: "My Tool UI", - mimeType: RESOURCE_MIME_TYPE, -}, async () => { - const html = await fs.readFile( - path.resolve(import.meta.dirname, "dist", "mcp-app.html"), - "utf-8", - ); - return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }; -}); +registerAppResource( + server, + "My Tool UI", + resourceUri, + {}, + async () => { + const html = await fs.readFile( + path.resolve(import.meta.dirname, "dist", "mcp-app.html"), + "utf-8", + ); + return { + contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }], + }; + }, +); ``` If multiple tools share the same UI, they can reference the same `resourceUri` and the same resource registration. @@ -271,25 +299,32 @@ registerAppTool(server, "poll-data", { }); ``` -The UI calls these via `app.callServerTool("poll-data", {})`. +The UI calls these via +`app.callServerTool({ name: "poll-data", arguments: {} })`. ### CSP Configuration If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains: ```typescript -registerAppResource(server, { - uri: resourceUri, - name: "My Tool UI", - mimeType: RESOURCE_MIME_TYPE, - _meta: { - ui: { - connectDomains: ["api.example.com"], // fetch/XHR targets - resourceDomains: ["cdn.example.com"], // scripts, styles, images - frameDomains: ["embed.example.com"], // nested iframes +registerAppResource(server, "My Tool UI", resourceUri, {}, async () => ({ + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: ["https://api.example.com"], // fetch/XHR targets + resourceDomains: ["https://cdn.example.com"], // scripts, styles, images + frameDomains: ["https://embed.example.com"], // nested iframes + }, + }, + }, }, - }, -}, async () => { /* ... */ }); + ], +})); ``` ### Streaming Partial Input @@ -326,7 +361,14 @@ server.server.oninitialized = () => { }, appToolHandler); } else { // Text-only client — register plain tool - server.tool("my-tool", "Shows data", { param: z.string() }, plainToolHandler); + server.registerTool( + "my-tool", + { + description: "Shows data", + inputSchema: z.object({ param: z.string() }), + }, + plainToolHandler, + ); } }; ``` diff --git a/plugins/mcp-apps/skills/convert-web-app/SKILL.md b/plugins/mcp-apps/skills/convert-web-app/SKILL.md index 5db2d94..8fb7fa0 100644 --- a/plugins/mcp-apps/skills/convert-web-app/SKILL.md +++ b/plugins/mcp-apps/skills/convert-web-app/SKILL.md @@ -115,19 +115,22 @@ Create a new MCP server with tool and resource registration. This wraps the exis ### Dependencies ```bash -npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod +npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0 npm install -D tsx vite vite-plugin-singlefile ``` -Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory. +Use `npm install` so the package manager resolves versions; ext-apps 2.x needs +the split base MCP SDK packages at `^2.0.0` (`@modelcontextprotocol/core` comes +in transitively). Do not add the legacy `@modelcontextprotocol/sdk` v1 package +or substitute unpublished local packages. ### Server Code Create `server.ts`: ```typescript -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; import fs from "node:fs/promises"; import path from "node:path"; @@ -140,7 +143,9 @@ const resourceUri = "ui://my-app/mcp-app.html"; // Register the tool — inputSchema maps to the app's data sources registerAppTool(server, "show-app", { description: "Displays the app with the given parameters", - inputSchema: { query: z.string().describe("The search query") }, + inputSchema: z.object({ + query: z.string().describe("The search query"), + }), _meta: { ui: { resourceUri } }, }, async (args) => { // Process args server-side if needed @@ -151,19 +156,29 @@ registerAppTool(server, "show-app", { }); // Register the HTML resource -registerAppResource(server, { - uri: resourceUri, - name: "My App UI", - mimeType: RESOURCE_MIME_TYPE, - // Add CSP domains from Step 2 if needed: - // _meta: { ui: { connectDomains: ["api.example.com"], resourceDomains: ["cdn.example.com"] } }, -}, async () => { - const html = await fs.readFile( - path.resolve(import.meta.dirname, "dist", "mcp-app.html"), - "utf-8", - ); - return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }; -}); +registerAppResource( + server, + "My App UI", + resourceUri, + {}, + async () => { + const html = await fs.readFile( + path.resolve(import.meta.dirname, "dist", "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + // Add CSP domains from Step 2 if needed: + // _meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } } }, + }, + ], + }; + }, +); // Start the server const transport = new StdioServerTransport(); @@ -310,7 +325,10 @@ const data = await fetch("/api/data").then(r => r.json()); // After (hybrid): async function fetchData(): Promise { if (isMcpApp) { - const result = await app.callServerTool("fetch-data", {}); + const result = await app.callServerTool({ + name: "fetch-data", + arguments: {}, + }); return result.structuredContent; } return fetch("/api/data").then(r => r.json()); @@ -437,7 +455,8 @@ registerAppTool(server, "refresh-data", { }); ``` -The UI calls these via `app.callServerTool("refresh-data", {})`. +The UI calls these via +`app.callServerTool({ name: "refresh-data", arguments: {} })`. ### Streaming Partial Input diff --git a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md index 5bd1219..d73dc90 100644 --- a/plugins/mcp-apps/skills/create-mcp-app/SKILL.md +++ b/plugins/mcp-apps/skills/create-mcp-app/SKILL.md @@ -110,11 +110,14 @@ See `/tmp/mcp-ext-apps/docs/patterns.md` for detailed recipes: **Always** use `npm install` to add dependencies rather than manually writing version numbers: ```bash -npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod express cors +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 concurrently cross-env @types/node @types/express @types/cors ``` -This lets npm resolve the latest compatible versions. **Never** specify version numbers from memory. +ext-apps 2.x requires the split base MCP SDK packages (`@modelcontextprotocol/client`, +`server`, `node`, `express`) at `^2.0.0`; `@modelcontextprotocol/core` comes in +transitively. Do not add the legacy `@modelcontextprotocol/sdk` v1 package or +substitute unpublished local packages. ### TypeScript Server Execution diff --git a/scripts/check-dependency-isolation.mjs b/scripts/check-dependency-isolation.mjs new file mode 100644 index 0000000..3809224 --- /dev/null +++ b/scripts/check-dependency-isolation.mjs @@ -0,0 +1,209 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const packageJson = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), +); +const client = "@modelcontextprotocol/client"; +const server = "@modelcontextprotocol/server"; + +// App and AppBridge extend the client package's Protocol class, so client is +// a required peer for every consumer. The server helpers are only needed by +// server authors, so server stays optional: View-only consumers must not have +// it installed or bundled. +for (const role of [client, server]) { + if (!packageJson.peerDependencies?.[role]) { + throw new Error(`${role} must remain a peer dependency`); + } +} +if (packageJson.peerDependenciesMeta?.[client]?.optional) { + throw new Error(`${client} must be a required peer dependency`); +} +if (packageJson.peerDependenciesMeta?.[server]?.optional !== true) { + throw new Error(`${server} must be an optional peer dependency`); +} + +/** + * Exact version for a synthetic consumer dependency. Read from + * devDependencies so the consumers exercise the same SDK version the + * repository tests against; the check would silently drift if a range were + * allowed here. + */ +function exactDevDependency(name) { + const version = packageJson.devDependencies?.[name]; + if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version ?? "")) { + throw new Error( + `devDependencies["${name}"] must be an exact version, got ${JSON.stringify(version)}`, + ); + } + return version; +} + +/** Exact version of a package as installed in this repository's node_modules. */ +function installedVersion(name) { + return JSON.parse( + readFileSync(join(root, "node_modules", name, "package.json"), "utf8"), + ).version; +} + +const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-")); +try { + // Minimal environment: nothing inherited from the caller's npm config + // (registry overrides, auth tokens, npm_config_* set by an outer `npm run`) + // can leak into the synthetic consumers. + const npmEnvironment = { + PATH: process.env.PATH ?? process.env.Path, + HOME: process.env.HOME ?? process.env.USERPROFILE, + npm_config_cache: join(temporaryRoot, "npm-cache"), + }; + const packDestination = join(temporaryRoot, "pack"); + mkdirSync(packDestination); + // `npm pack` runs the package's `prepare` script even with --ignore-scripts + // (pacote's directory fetcher), and its output can pollute stdout, so do not + // rely on `--json`: locate the tarball on disk instead. + execFileSync( + "npm", + ["pack", "--ignore-scripts", "--pack-destination", packDestination], + { cwd: root, stdio: "pipe", env: npmEnvironment }, + ); + const tarballs = readdirSync(packDestination).filter((name) => + name.endsWith(".tgz"), + ); + if (tarballs.length !== 1) { + throw new Error(`expected exactly one tarball, found ${tarballs}`); + } + const tarball = join(packDestination, tarballs[0]); + + const consumers = [ + { + // View / host author: ext-apps + client (+ react for the hooks entry). + name: "app-only", + dependencies: { + "@types/node": exactDevDependency("@types/node"), + "@types/react": installedVersion("@types/react"), + [client]: exactDevDependency(client), + "@modelcontextprotocol/ext-apps": `file:${tarball}`, + react: installedVersion("react"), + }, + absent: server, + // server must be neither installed (it is an optional peer) nor bundled. + mustNotInstall: true, + entry: + 'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; console.log(App, AppBridge, useApp);', + }, + { + // Server author: ext-apps + server. npm auto-installs client as a + // required peer (its types back the shared wire types), but the server + // entry must not pull it into a runtime bundle. + name: "server-only", + dependencies: { + "@types/node": exactDevDependency("@types/node"), + "@modelcontextprotocol/ext-apps": `file:${tarball}`, + [server]: exactDevDependency(server), + }, + absent: client, + mustNotInstall: false, + entry: + 'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);', + }, + ]; + + for (const consumer of consumers) { + const directory = join(temporaryRoot, consumer.name); + mkdirSync(directory); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ + private: true, + type: "module", + dependencies: consumer.dependencies, + }), + ); + execFileSync( + "npm", + [ + "install", + "--ignore-scripts", + "--package-lock=false", + "--no-audit", + "--no-fund", + ], + { cwd: directory, stdio: "inherit", env: npmEnvironment }, + ); + + if (consumer.mustNotInstall) { + const absentPath = join( + directory, + "node_modules", + ...consumer.absent.split("/"), + "package.json", + ); + try { + readFileSync(absentPath); + throw new Error( + `${consumer.name} unexpectedly installed ${consumer.absent}`, + ); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + + writeFileSync(join(directory, "entry.ts"), consumer.entry); + writeFileSync( + join(directory, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + jsx: "react-jsx", + lib: ["ES2020", "DOM"], + module: "ESNext", + moduleResolution: "bundler", + noEmit: true, + skipLibCheck: false, + strict: true, + target: "ES2020", + }, + include: ["entry.ts"], + }), + ); + execFileSync( + process.execPath, + [join(root, "node_modules", "typescript", "bin", "tsc")], + { cwd: directory, stdio: "inherit" }, + ); + + const metafile = join(directory, "bundle-meta.json"); + execFileSync( + join(root, "node_modules", ".bin", "esbuild"), + [ + "entry.ts", + "--bundle", + "--platform=browser", + "--outfile=bundle.js", + `--metafile=${metafile}`, + ], + { cwd: directory, stdio: "inherit" }, + ); + const bundleInputs = Object.keys( + JSON.parse(readFileSync(metafile, "utf8")).inputs, + ).join("\n"); + if (bundleInputs.includes(`/node_modules/${consumer.absent}/`)) { + throw new Error( + `${consumer.name} unexpectedly bundled ${consumer.absent}`, + ); + } + } +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} + +console.log("Role peer dependency isolation checks passed."); diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts index 109f352..0ff9f04 100644 --- a/scripts/generate-schemas.ts +++ b/scripts/generate-schemas.ts @@ -12,7 +12,7 @@ * * ts-to-zod generates `import { z } from "zod"`. We rewrite this to * `import { z } from "zod/v4"` because the generated schemas compose with - * schemas imported from `@modelcontextprotocol/sdk/types.js`, which the SDK + * schemas imported from `@modelcontextprotocol/core`, which the SDK * constructs via `zod/v4`. Mixing schemas from the v3 and v4 APIs at runtime * fails with errors like `keyValidator._parse is not a function` (v3 internals * calling into v4 objects, or vice versa). The `zod/v4` subpath is exported by @@ -22,10 +22,12 @@ * * **Problem**: ts-to-zod cannot resolve types imported from external packages. * When it encounters types like `ContentBlock`, `CallToolResult`, `Implementation`, - * `RequestId`, and `Tool` from `@modelcontextprotocol/sdk`, it generates `z.any()` - * as a placeholder. + * `RequestId`, and `Tool` (imported by spec.types.ts from + * `@modelcontextprotocol/client`), it generates `z.any()` as a placeholder. * - * **Solution**: Import the schemas from MCP SDK and remove the z.any() placeholders. + * **Solution**: Import the corresponding Zod schemas from + * `@modelcontextprotocol/core` (the role-neutral package both client and + * server re-export) and remove the z.any() placeholders. * * ### 3. Index Signatures (`z.record().and()` → `z.object().passthrough()`) * @@ -193,7 +195,7 @@ function postProcess(content: string): string { `import { z } from "zod/v4"; import { ${mcpImports}, -} from "@modelcontextprotocol/sdk/types.js";`, +} from "@modelcontextprotocol/core";`, ); // 2. Remove z.any() placeholders for external types (now imported from MCP SDK) diff --git a/scripts/typedoc-plugin-base-sdk-links.mjs b/scripts/typedoc-plugin-base-sdk-links.mjs new file mode 100644 index 0000000..6098918 --- /dev/null +++ b/scripts/typedoc-plugin-base-sdk-links.mjs @@ -0,0 +1,16 @@ +/** + * Resolve module-local links inherited from the base MCP SDK declarations. + * + * TypeDoc's externalSymbolLinkMappings handles package-qualified symbols, but + * the base SDK writes this particular reference as `index.inputRequired`. + */ + +const BASE_SDK_DOCS = "https://ts.sdk.modelcontextprotocol.io/v2/"; + +/** @param {import("typedoc").Application} app */ +export function load(app) { + app.converter.addUnknownSymbolResolver((ref) => { + const name = ref.symbolReference?.path?.map((part) => part.path).join("."); + if (name === "index.inputRequired") return BASE_SDK_DOCS; + }); +} diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 1a14d3f..f2523f2 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -289,6 +289,7 @@ When `_meta.ui` is present on **both**, the content-item value takes precedence. style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; + object-src 'none'; connect-src 'none'; ``` @@ -547,8 +548,8 @@ When the View sends an `ui/initialize` request to the Host, it MUST include its ```typescript interface McpUiAppCapabilities { - /** Experimental features (structure TBD). */ - experimental?: {}; + /** Experimental features keyed by identifier. */ + experimental?: Record; /** App exposes MCP-style tools that the host can call. */ tools?: { /** App supports tools/list_changed notifications. */ @@ -659,9 +660,25 @@ Example: They describe the features and capabilities that the Host supports. ```typescript +/** Content block modalities that the host supports for a given capability. */ +interface SupportedContentBlockModalities { + /** Host supports text content blocks. */ + text?: {}; + /** Host supports image content blocks. */ + image?: {}; + /** Host supports audio content blocks. */ + audio?: {}; + /** Host supports resource content blocks. */ + resource?: {}; + /** Host supports resource link content blocks. */ + resourceLink?: {}; + /** Host supports structured content. */ + structuredContent?: {}; +} + interface HostCapabilities { - /** Experimental features (structure TBD). */ - experimental?: {}; + /** Experimental features keyed by identifier. */ + experimental?: Record; /** Host supports opening external URLs. */ openLinks?: {}; /** Host supports file downloads via ui/download-file. */ @@ -678,14 +695,6 @@ interface HostCapabilities { }; /** Host accepts log messages. */ logging?: {}; - /** - * Host supports LLM sampling (sampling/createMessage) from the view. - * Mirrors MCP ClientCapabilities.sampling so hosts can pass it through. - */ - sampling?: { - /** Host supports tool use via `tools` and `toolChoice` params (SEP-1577). */ - tools?: {}; - }; /** Sandbox configuration applied by the host. */ sandbox?: { /** Permissions granted by the host (camera, microphone, geolocation, clipboard-write). */ @@ -707,6 +716,21 @@ interface HostCapabilities { baseUriDomains?: string[]; }; }; + /** + * Host accepts context updates (ui/update-model-context) to be included in + * the model's context for future turns. + */ + updateModelContext?: SupportedContentBlockModalities; + /** Host supports receiving content messages (ui/message) from the view. */ + message?: SupportedContentBlockModalities; + /** + * Host supports LLM sampling (sampling/createMessage) from the view. + * Mirrors MCP ClientCapabilities.sampling so hosts can pass it through. + */ + sampling?: { + /** Host supports tool use via `tools` and `toolChoice` params (SEP-1577). */ + tools?: {}; + }; } ``` diff --git a/src/app-bridge.examples.ts b/src/app-bridge.examples.ts index d5dc5bc..47ad623 100644 --- a/src/app-bridge.examples.ts +++ b/src/app-bridge.examples.ts @@ -7,17 +7,11 @@ * @module */ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { - CallToolResult, - CallToolResultSchema, +import { Client, type Transport } from "@modelcontextprotocol/client"; +import type { CreateMessageRequest, CreateMessageResult, - ListResourcesResultSchema, - ReadResourceResultSchema, - ListPromptsResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import { AppBridge, PostMessageTransport } from "./app-bridge.js"; import type { McpUiDisplayMode } from "./types.js"; @@ -223,8 +217,7 @@ function AppBridge_oncalltool_forwardToServer( bridge.oncalltool = async (params, extra) => { return mcpClient.request( { method: "tools/call", params }, - CallToolResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; //#endregion AppBridge_oncalltool_forwardToServer @@ -245,7 +238,9 @@ function AppBridge_oncreatesamplingmessage_forwardToLlm( //#region AppBridge_oncreatesamplingmessage_forwardToLlm bridge.oncreatesamplingmessage = async (params, extra) => { // Apply rate limiting, user approval, cost controls here - return await myLlmProvider.complete(params, { signal: extra.signal }); + return await myLlmProvider.complete(params, { + signal: extra.mcpReq.signal, + }); }; //#endregion AppBridge_oncreatesamplingmessage_forwardToLlm } @@ -261,8 +256,7 @@ function AppBridge_onlistresources_returnResources( bridge.onlistresources = async (params, extra) => { return mcpClient.request( { method: "resources/list", params }, - ListResourcesResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; //#endregion AppBridge_onlistresources_returnResources @@ -279,8 +273,7 @@ function AppBridge_onreadresource_returnResource( bridge.onreadresource = async (params, extra) => { return mcpClient.request( { method: "resources/read", params }, - ReadResourceResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; //#endregion AppBridge_onreadresource_returnResource @@ -297,8 +290,7 @@ function AppBridge_onlistprompts_returnPrompts( bridge.onlistprompts = async (params, extra) => { return mcpClient.request( { method: "prompts/list", params }, - ListPromptsResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; //#endregion AppBridge_onlistprompts_returnPrompts @@ -444,10 +436,10 @@ async function AppBridge_sendToolResult_afterExecution( args: Record, ) { //#region AppBridge_sendToolResult_afterExecution - const result = await mcpClient.request( - { method: "tools/call", params: { name: "get_weather", arguments: args } }, - CallToolResultSchema, - ); + const result = await mcpClient.request({ + method: "tools/call", + params: { name: "get_weather", arguments: args }, + }); bridge.sendToolResult(result); //#endregion AppBridge_sendToolResult_afterExecution } diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 38f7d30..a87b0e7 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -1,28 +1,19 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { - EmptyResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - PromptListChangedNotificationSchema, - ReadResourceResultSchema, - ResourceListChangedNotificationSchema, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Server, type ServerCapabilities } from "@modelcontextprotocol/server"; import { z } from "zod/v4"; -import { App } from "./app"; -import { LATEST_PROTOCOL_VERSION } from "./types"; +import { App } from "./app.js"; +import { LATEST_PROTOCOL_VERSION } from "./types.js"; import { AppBridge, + buildAllowAttribute, getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly, + McpUiOpenLinkResultSchema, type McpUiHostCapabilities, -} from "./app-bridge"; +} from "./app-bridge.js"; /** Wait for pending microtasks to complete */ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -33,17 +24,27 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); */ function createMockClient( serverCapabilities: ServerCapabilities = {}, -): Pick { +): Pick< + Client, + | "getServerCapabilities" + | "request" + | "notification" + | "setNotificationHandler" +> { return { getServerCapabilities: () => serverCapabilities, request: async () => ({}) as never, notification: async () => {}, + setNotificationHandler: () => {}, }; } const testHostInfo = { name: "TestHost", version: "1.0.0" }; const testAppInfo = { name: "TestApp", version: "1.0.0" }; const testHostCapabilities: McpUiHostCapabilities = { + experimental: { + "com.example/host-extension": { version: 1 }, + }, openLinks: {}, serverTools: {}, logging: {}, @@ -84,6 +85,53 @@ describe("App <-> AppBridge integration", () => { expect(initializedFired).toBe(true); }); + it("sends only the Apps handshake on the wire and gates the Apps-ready callback", async () => { + const methods: string[] = []; + let releaseAppsInitialized!: () => void; + let reachedAppsInitialized!: () => void; + const appsInitializedGate = new Promise((resolve) => { + releaseAppsInitialized = resolve; + }); + const reachedGate = new Promise((resolve) => { + reachedAppsInitialized = resolve; + }); + const originalSend = appTransport.send.bind(appTransport); + appTransport.send = async (message, options) => { + if ("method" in message) methods.push(message.method); + if ( + "method" in message && + message.method === "ui/notifications/initialized" + ) { + reachedAppsInitialized(); + await appsInitializedGate; + } + return originalSend(message, options); + }; + + let singularCalls = 0; + let listenerCalls = 0; + bridge.oninitialized = () => singularCalls++; + bridge.addEventListener("initialized", () => listenerCalls++); + + await bridge.connect(bridgeTransport); + const connecting = app.connect(appTransport); + await reachedGate; + + expect(methods).toEqual([ + "ui/initialize", + "ui/notifications/initialized", + ]); + expect(bridge.getAppVersion()).toEqual(testAppInfo); + expect(singularCalls).toBe(0); + expect(listenerCalls).toBe(0); + + releaseAppsInitialized(); + await connecting; + + expect(singularCalls).toBe(1); + expect(listenerCalls).toBe(1); + }); + it("App receives host info and capabilities after connect", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -93,10 +141,16 @@ describe("App <-> AppBridge integration", () => { const hostCaps = app.getHostCapabilities(); expect(hostCaps).toEqual(testHostCapabilities); + expect(bridge.getCapabilities()).toEqual(testHostCapabilities); }); it("Bridge receives app info and capabilities after initialization", async () => { - const appCapabilities = { tools: { listChanged: true } }; + const appCapabilities = { + experimental: { + "com.example/app-extension": { version: 1 }, + }, + tools: { listChanged: true }, + }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); await bridge.connect(bridgeTransport); @@ -613,6 +667,30 @@ describe("App <-> AppBridge integration", () => { expect(result.isError).toBe(true); }); + + it("rejects invalid params for a custom ui request", async () => { + bridge.onopenlink = async () => ({}); + await app.connect(appTransport); + + await expect( + app.request( + { + method: "ui/open-link", + params: { url: 42 }, + }, + McpUiOpenLinkResultSchema, + ), + ).rejects.toThrow(); + }); + + it("rejects invalid results from a custom ui handler", async () => { + bridge.onopenlink = async () => ({ isError: "yes" }) as never; + await app.connect(appTransport); + + await expect( + app.openLink({ url: "https://example.com" }), + ).rejects.toThrow(); + }); }); describe("deprecated method aliases", () => { @@ -682,10 +760,7 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // Bridge can send ping via the protocol's request method - const result = await bridge.request( - { method: "ping", params: {} }, - EmptyResultSchema, - ); + const result = await bridge.request({ method: "ping", params: {} }); expect(result).toEqual({}); }); @@ -800,7 +875,7 @@ describe("App <-> AppBridge integration", () => { }); await bridge.callTool({ name: "noargs", arguments: {} }); expect(receivedExtra).toBeDefined(); - expect(receivedExtra.signal).toBeInstanceOf(AbortSignal); + expect(receivedExtra.mcpReq.signal).toBeInstanceOf(AbortSignal); }); it("isError result skips output schema validation", async () => { @@ -1476,40 +1551,9 @@ describe("App <-> AppBridge integration", () => { ).rejects.toThrow(/Invalid input for tool translate/); }); - it("falls back to z.toJSONSchema for zod schemas lacking ~standard.jsonSchema (zod v3.25.x)", async () => { - // zod v3.25 implements ~standard.validate but not ~standard.jsonSchema. - // Simulate by stripping jsonSchema from a real zod schema. - const v4Schema = z.object({ q: z.string() }); - const zod3LikeSchema = Object.assign(Object.create(v4Schema), { - "~standard": { - version: 1 as const, - vendor: "zod", - validate: v4Schema["~standard"].validate, - types: undefined as - | undefined - | { - readonly input: { q: string }; - readonly output: { q: string }; - }, - // no jsonSchema - }, - }); - + it("rejects listTools when a tool schema does not implement Standard JSON Schema", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); - app.registerTool( - "search", - { inputSchema: zod3LikeSchema }, - async ({ q }: { q: string }) => ({ - content: [{ type: "text" as const, text: q }], - }), - ); - await app.connect(appTransport); - - const list = await bridge.listTools({}); - expect(list.tools[0].inputSchema.properties).toHaveProperty("q"); - - // Non-zod schema without jsonSchema → listTools rejects with guidance. app.registerTool( "broken", { @@ -1523,6 +1567,8 @@ describe("App <-> AppBridge integration", () => { }, async () => ({ content: [] }), ); + await app.connect(appTransport); + expect(bridge.listTools({})).rejects.toThrow( /does not implement Standard JSON Schema/, ); @@ -2182,10 +2228,10 @@ describe("App <-> AppBridge integration", () => { await app.connect(appTransport); // App sends resources/list request via the protocol's request method - const result = await app.request( - { method: "resources/list", params: requestParams }, - ListResourcesResultSchema, - ); + const result = await app.request({ + method: "resources/list", + params: requestParams, + }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2226,10 +2272,10 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await app.request( - { method: "resources/read", params: requestParams }, - ReadResourceResultSchema, - ); + const result = await app.request({ + method: "resources/read", + params: requestParams, + }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2277,10 +2323,10 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await app.request( - { method: "resources/templates/list", params: requestParams }, - ListResourceTemplatesResultSchema, - ); + const result = await app.request({ + method: "resources/templates/list", + params: requestParams, + }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2300,10 +2346,10 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - const result = await app.request( - { method: "prompts/list", params: requestParams }, - ListPromptsResultSchema, - ); + const result = await app.request({ + method: "prompts/list", + params: requestParams, + }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); @@ -2312,9 +2358,13 @@ describe("App <-> AppBridge integration", () => { it("sendToolListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; - app.setNotificationHandler(ToolListChangedNotificationSchema, (n) => { - receivedNotifications.push(n.params); - }); + bridge.oncalltool = async () => ({ content: [] }); + app.setNotificationHandler( + "notifications/tools/list_changed", + (notification) => { + receivedNotifications.push(notification.params); + }, + ); await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -2327,9 +2377,13 @@ describe("App <-> AppBridge integration", () => { it("sendResourceListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; - app.setNotificationHandler(ResourceListChangedNotificationSchema, (n) => { - receivedNotifications.push(n.params); - }); + bridge.onlistresources = async () => ({ resources: [] }); + app.setNotificationHandler( + "notifications/resources/list_changed", + (notification) => { + receivedNotifications.push(notification.params); + }, + ); await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -2342,9 +2396,13 @@ describe("App <-> AppBridge integration", () => { it("sendPromptListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; - app.setNotificationHandler(PromptListChangedNotificationSchema, (n) => { - receivedNotifications.push(n.params); - }); + bridge.onlistprompts = async () => ({ prompts: [] }); + app.setNotificationHandler( + "notifications/prompts/list_changed", + (notification) => { + receivedNotifications.push(notification.params); + }, + ); await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -2357,6 +2415,184 @@ describe("App <-> AppBridge integration", () => { }); }); +describe("AppBridge outer v2 Client proxy", () => { + let outerClient: Client; + let outerServer: Server; + let outerClientTransport: InMemoryTransport; + let outerServerTransport: InMemoryTransport; + let outerInitializeCount: number; + + beforeEach(async () => { + [outerClientTransport, outerServerTransport] = + InMemoryTransport.createLinkedPair(); + outerInitializeCount = 0; + const originalSend = outerClientTransport.send.bind(outerClientTransport); + outerClientTransport.send = async (message, options) => { + if ("method" in message && message.method === "initialize") { + outerInitializeCount++; + } + return originalSend(message, options); + }; + + outerServer = new Server( + { name: "ActualServer", version: "1.0.0" }, + { + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + prompts: { listChanged: true }, + }, + supportedProtocolVersions: ["2025-11-25"], + }, + ); + outerServer.setRequestHandler("tools/call", async (request) => ({ + content: [ + { + type: "text", + text: `outer:${request.params.name}`, + }, + ], + })); + + outerClient = new Client( + { name: "HostOuterClient", version: "1.0.0" }, + { + capabilities: {}, + versionNegotiation: { mode: "legacy" }, + supportedProtocolVersions: ["2025-11-25"], + }, + ); + await outerServer.connect(outerServerTransport); + await outerClient.connect(outerClientTransport); + }); + + afterEach(async () => { + await outerClient.close().catch(() => {}); + await outerServer.close().catch(() => {}); + }); + + it("proxies through the outer Client without reconnecting it", async () => { + const bridge = new AppBridge( + outerClient, + testHostInfo, + testHostCapabilities, + ); + const app = new App(testAppInfo, {}, { autoResize: false }); + let [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + expect( + await app.callServerTool({ name: "first", arguments: {} }), + ).toMatchObject({ + content: [{ type: "text", text: "outer:first" }], + }); + expect(outerInitializeCount).toBe(1); + + await app.close(); + await bridge.close(); + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect( + await app.callServerTool({ name: "second", arguments: {} }), + ).toMatchObject({ + content: [{ type: "text", text: "outer:second" }], + }); + expect(outerInitializeCount).toBe(1); + expect(outerClient.getServerVersion()).toMatchObject({ + name: "ActualServer", + version: "1.0.0", + }); + + await app.close(); + await bridge.close(); + }); + + it("propagates View cancellation to the actual outer Server", async () => { + let started!: () => void; + let aborted!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const abortedPromise = new Promise((resolve) => { + aborted = resolve; + }); + outerServer.setRequestHandler("tools/call", async (_request, context) => { + started(); + await new Promise((resolve) => { + context.mcpReq.signal.addEventListener( + "abort", + () => { + aborted(); + resolve(); + }, + { once: true }, + ); + }); + return { content: [] }; + }); + + const bridge = new AppBridge( + outerClient, + testHostInfo, + testHostCapabilities, + ); + const app = new App(testAppInfo, {}, { autoResize: false }); + const [appTransport, bridgeTransport] = + InMemoryTransport.createLinkedPair(); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const controller = new AbortController(); + const result = app.callServerTool( + { name: "cancel-me", arguments: {} }, + { signal: controller.signal }, + ); + await startedPromise; + controller.abort("test cancellation"); + await abortedPromise; + await expect(result).rejects.toThrow(); + + await app.close(); + await bridge.close(); + }); + + it("forwards outer list-changed notifications to the App", async () => { + const bridge = new AppBridge( + outerClient, + testHostInfo, + testHostCapabilities, + ); + const app = new App(testAppInfo, {}, { autoResize: false }); + const [appTransport, bridgeTransport] = + InMemoryTransport.createLinkedPair(); + const received: string[] = []; + app.setNotificationHandler("notifications/tools/list_changed", () => { + received.push("tools"); + }); + app.setNotificationHandler("notifications/resources/list_changed", () => { + received.push("resources"); + }); + app.setNotificationHandler("notifications/prompts/list_changed", () => { + received.push("prompts"); + }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + await outerServer.sendToolListChanged(); + await outerServer.sendResourceListChanged(); + await outerServer.sendPromptListChanged(); + await flush(); + + expect(received).toEqual(["tools", "resources", "prompts"]); + + await app.close(); + await bridge.close(); + }); +}); + describe("getToolUiResourceUri", () => { describe("new nested format (_meta.ui.resourceUri)", () => { it("extracts resourceUri from _meta.ui.resourceUri", () => { @@ -2712,7 +2948,7 @@ describe("isToolVisibilityAppOnly", () => { expect(a).toEqual([]); }); - it("App.onEventDispatch merges hostcontext before listeners fire", async () => { + it("App merges hostcontext before listeners fire", async () => { let seen: unknown; app.addEventListener("hostcontextchanged", () => { seen = app.getHostContext(); @@ -2757,32 +2993,140 @@ describe("isToolVisibilityAppOnly", () => { testHostInfo, testHostCapabilities, ); - bridge2.setRequestHandler( - // @ts-expect-error — exercising throw path with raw schema - { shape: { method: { value: "test/method" } } }, - () => ({}), + const params = z.object({}); + bridge2.setRequestHandler("test/method", { params }, () => ({})); + expect(() => { + bridge2.setRequestHandler("test/method", { params }, () => ({})); + }).toThrow(/already registered/); + }); + + it("direct setRequestHandler cannot silently replace an on* host handler", () => { + const bridge2 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, ); + bridge2.onopenlink = async () => ({}); expect(() => { bridge2.setRequestHandler( - // @ts-expect-error — exercising throw path with raw schema - { shape: { method: { value: "test/method" } } }, + "ui/open-link", + { params: z.object({}) }, () => ({}), ); }).toThrow(/already registered/); }); it("direct setNotificationHandler throws for event-mapped methods", () => { - const app2 = new App(testAppInfo, {}, { autoResize: false }); - app2.addEventListener("toolinput", () => {}); + const bridge2 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + bridge2.onsizechange = () => {}; expect(() => { - app2.setNotificationHandler( - // @ts-expect-error — exercising throw path with raw schema - { - shape: { method: { value: "ui/notifications/tool-input" } }, - }, + bridge2.setNotificationHandler( + "ui/notifications/size-changed", + { params: z.object({}) }, () => {}, ); }).toThrow(/already registered/); }); + + it("removeRequestHandler releases the method so an on* setter can re-register", () => { + const bridge2 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + bridge2.removeRequestHandler("tools/call"); + expect(() => { + bridge2.setRequestHandler("tools/call", async () => ({ content: [] })); + }).not.toThrow(); + expect(() => { + bridge2.oncalltool = async () => ({ content: [] }); + }).not.toThrow(); + }); + + it("oncreatesamplingmessage has a getter, replace semantics, and a replace warning", () => { + const bridge2 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + expect(bridge2.oncreatesamplingmessage).toBeUndefined(); + const first = async () => ({ + role: "assistant" as const, + content: { type: "text" as const, text: "" }, + model: "m", + }); + bridge2.oncreatesamplingmessage = first; + expect(bridge2.oncreatesamplingmessage).toBe(first); + + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(() => { + bridge2.oncreatesamplingmessage = first; + }).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("oncreatesamplingmessage handler replaced"), + ); + } finally { + warn.mockRestore(); + } + + bridge2.oncreatesamplingmessage = undefined; + expect(bridge2.oncreatesamplingmessage).toBeUndefined(); + }); + }); +}); + +describe("buildAllowAttribute", () => { + describe("returns empty string", () => { + it("when permissions is undefined", () => { + expect(buildAllowAttribute(undefined)).toBe(""); + }); + + it("when permissions object is empty", () => { + expect(buildAllowAttribute({})).toBe(""); + }); + }); + + describe("returns a single permission directive", () => { + it("when only camera is set", () => { + expect(buildAllowAttribute({ camera: {} })).toBe("camera"); + }); + + it("when only microphone is set", () => { + expect(buildAllowAttribute({ microphone: {} })).toBe("microphone"); + }); + + it("when only geolocation is set", () => { + expect(buildAllowAttribute({ geolocation: {} })).toBe("geolocation"); + }); + + it("when only clipboardWrite is set, maps to clipboard-write", () => { + expect(buildAllowAttribute({ clipboardWrite: {} })).toBe( + "clipboard-write", + ); + }); + }); + + describe("returns multiple directives joined with '; '", () => { + it("when camera and microphone are set", () => { + expect(buildAllowAttribute({ camera: {}, microphone: {} })).toBe( + "camera; microphone", + ); + }); + + it("when all permissions are set", () => { + expect( + buildAllowAttribute({ + camera: {}, + microphone: {}, + geolocation: {}, + clipboardWrite: {}, + }), + ).toBe("camera; microphone; geolocation; clipboard-write"); + }); }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 23383c4..1ac4db4 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1,58 +1,53 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { - CallToolRequest, - CallToolRequestSchema, - CallToolResult, - CallToolResultSchema, - CreateMessageRequest, - CreateMessageRequestSchema, - CreateMessageResult, - CreateMessageResultWithTools, - EmptyResult, - Implementation, - ListPromptsRequest, - ListPromptsRequestSchema, - ListPromptsResult, - ListPromptsResultSchema, - ListResourcesRequest, - ListResourcesRequestSchema, - ListResourcesResult, - ListResourcesResultSchema, - ListResourceTemplatesRequest, - ListResourceTemplatesRequestSchema, - ListResourceTemplatesResult, - ListResourceTemplatesResultSchema, - ListToolsRequest, - ListToolsRequestSchema, - ListToolsResultSchema, - LoggingMessageNotification, - LoggingMessageNotificationSchema, - PingRequest, - PingRequestSchema, - PromptListChangedNotification, - PromptListChangedNotificationSchema, - ReadResourceRequest, - ReadResourceRequestSchema, - ReadResourceResult, - ReadResourceResultSchema, - ResourceListChangedNotification, - ResourceListChangedNotificationSchema, - Tool, - ToolListChangedNotification, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; import { Protocol, - ProtocolOptions, - RequestOptions, -} from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { ProtocolWithEvents } from "./events"; + type BaseContext, + type Client, + type ProtocolOptions, + type CallToolRequest, + type CallToolResult, + type CreateMessageRequest, + type CreateMessageResult, + type CreateMessageResultWithTools, + type EmptyResult, + type Implementation, + type JSONRPCRequest, + type ListPromptsRequest, + type ListPromptsResult, + type ListResourcesRequest, + type ListResourcesResult, + type ListResourceTemplatesRequest, + type ListResourceTemplatesResult, + type ListToolsRequest, + type LoggingMessageNotification, + type PingRequest, + type PromptListChangedNotification, + type ReadResourceRequest, + type ReadResourceResult, + type RequestOptions, + type ResourceListChangedNotification, + type Result, + type Tool, + type ToolListChangedNotification, + type Transport, +} from "@modelcontextprotocol/client"; +import { + EmptyResultSchema, + LoggingMessageNotificationSchema, +} from "@modelcontextprotocol/core"; +import { EventDispatcher, MethodRegistry } from "./events.js"; +import { + toModernArgs, + type LegacyNotificationHandlerSetter, + type LegacyRequestHandlerSetter, +} from "./legacy-handlers.js"; +import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; + +type MethodSchema = ZodObject<{ + method: ZodLiteral; + params: ZodType; +}>; import { - type AppNotification, - type AppRequest, - type AppResult, type McpUiSandboxResourceReadyNotification, type McpUiSizeChangedNotification, type McpUiToolCancelledNotification, @@ -71,15 +66,19 @@ import { McpUiInitializeRequest, McpUiInitializeRequestSchema, McpUiInitializeResult, + McpUiInitializeResultSchema, McpUiMessageRequest, McpUiMessageRequestSchema, McpUiMessageResult, + McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkRequestSchema, McpUiOpenLinkResult, + McpUiOpenLinkResultSchema, McpUiDownloadFileRequest, McpUiDownloadFileRequestSchema, McpUiDownloadFileResult, + McpUiDownloadFileResultSchema, McpUiResourceTeardownRequest, McpUiResourceTeardownResultSchema, McpUiRequestTeardownNotification, @@ -90,13 +89,16 @@ import { McpUiRequestDisplayModeRequest, McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResult, + McpUiRequestDisplayModeResultSchema, McpUiResourcePermissions, McpUiToolMeta, -} from "./types"; -export * from "./types"; -export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app"; -import { RESOURCE_URI_META_KEY } from "./app"; -export { PostMessageTransport } from "./message-transport"; +} from "./types.js"; +export * from "./types.js"; +export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./constants.js"; +import { RESOURCE_URI_META_KEY } from "./constants.js"; + +type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void; +export { PostMessageTransport } from "./message-transport.js"; /** * Extract UI resource URI from tool metadata. @@ -203,10 +205,10 @@ export function buildAllowAttribute( * * @property hostContext - Optional initial host context to provide to the view * - * @see `ProtocolOptions` from @modelcontextprotocol/sdk for available options + * @see `ProtocolOptions` from @modelcontextprotocol/client for available options * @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure */ -export type HostOptions = ProtocolOptions & { +export type HostOptions = Omit & { hostContext?: McpUiHostContext; }; @@ -221,15 +223,12 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION]; /** * Extra metadata passed to request handlers. * - * This type represents the additional context provided by the `Protocol` class - * when handling requests, including abort signals and session information. - * It is extracted from the MCP SDK's request handler signature. + * This type represents the additional context (`BaseContext`) provided by the + * base MCP SDK `Protocol` when handling requests. * * @internal */ -type RequestHandlerExtra = Parameters< - Parameters[1] ->[1]; +type RequestHandlerExtra = BaseContext; /** * Maps DOM-style event names to their notification `params` types. @@ -249,11 +248,11 @@ export type AppBridgeEventMap = { /** * Host-side bridge for communicating with a single View ({@link app!App `App`}). * - * `AppBridge` extends the MCP SDK's `Protocol` class and acts as a proxy between - * the host application and a view running in an iframe. When an MCP client - * is provided to the constructor, it automatically forwards MCP server capabilities - * (tools, resources, prompts) to the view. It also handles the initialization - * handshake. + * `AppBridge` extends the base MCP SDK's public `Protocol` class and acts as a + * proxy between the host application and a view running in an iframe. When a + * separate MCP client is provided to the constructor, it automatically forwards + * MCP server capabilities (tools, resources, prompts) to the view. It also + * handles the Apps initialization handshake. * * ## Architecture * @@ -303,47 +302,125 @@ export type AppBridgeEventMap = { * await bridge.connect(transport); * ``` */ -export class AppBridge extends ProtocolWithEvents< - AppRequest, - AppNotification, - AppResult, - AppBridgeEventMap -> { +export class AppBridge extends Protocol { private _appCapabilities?: McpUiAppCapabilities; private _hostContext: McpUiHostContext = {}; private _appInfo?: Implementation; private _initializedReceived = false; + private readonly _registeredEvents = new Set(); + private readonly _events = new EventDispatcher(); + private readonly _methods = new MethodRegistry(); + + // ── Handler registration with double-set protection ───────────────── + // + // The base SDK `Protocol` silently replaces an existing handler. The four + // overrides below restore the v1 behaviour: a direct `setRequestHandler` / + // `setNotificationHandler` for a method that already has a handler throws, + // so a stray registration cannot silently replace a host's `on*` handler + // (for example the URL allow-listing in `onopenlink`) or disconnect + // `addEventListener` listeners. They are arrow-function class fields rather + // than prototype methods so that `Protocol`'s constructor — which registers + // its own ping/cancelled/progress handlers before our fields initialize — + // hits the base implementation and skips tracking. /** - * Wrap every handler registered via `replaceRequestHandler` with a check - * that the View has sent `ui/notifications/initialized`. Warns (never - * throws) so lenient hosts keep working while still surfacing the - * misordering that leaves strict hosts with a permanently hidden iframe. - * `ui/initialize` and `ping` use `setRequestHandler` directly and are - * intentionally exempt. + * Registers a request handler. Throws if a handler for the same method has + * already been registered — use the `on*` setter for replace semantics. + * + * @throws {Error} if a handler for this method is already registered. + */ + override setRequestHandler: LegacyRequestHandlerSetter< + Protocol["setRequestHandler"] + > = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("request", args) ?? args; + this._methods.claim(method as string, "setRequestHandler"); + (super.setRequestHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + /** + * Registers a notification handler. Throws if a handler for the same method + * has already been registered — use the `on*` setter (replace semantics) or + * `addEventListener` (multi-listener) for mapped events. + * + * @throws {Error} if a handler for this method is already registered. + */ + override setNotificationHandler: LegacyNotificationHandlerSetter< + Protocol["setNotificationHandler"] + > = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("notification", args) ?? args; + this._methods.claim(method as string, "setNotificationHandler"); + (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + override removeRequestHandler: Protocol["removeRequestHandler"] = + (method: string) => { + this._methods.release(method); + super.removeRequestHandler(method); + }; + + override removeNotificationHandler: Protocol["removeNotificationHandler"] = + (method: string) => { + this._methods.release(method); + super.removeNotificationHandler(method); + }; + + /** + * Register a request handler with replace semantics, bypassing the + * double-set protection of {@link setRequestHandler `setRequestHandler`}. + * Used by the `on*` request-handler setters. + */ + protected replaceRequestHandler: Protocol["setRequestHandler"] = + (method: string, ...rest: unknown[]) => { + this._methods.replace(method); + (super.setRequestHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + /** + * The base MCP SDK calls this hook for every standard and custom request + * handler registration. AppBridge uses it to preserve the existing warning + * when a View calls host methods before `ui/notifications/initialized`, + * without adding overload-forwarding adapters around `setRequestHandler`. + * `ui/initialize` and `ping` are exempt because they are valid before the + * Apps-ready gate. This wraps registered callbacks, not JSON-RPC dispatch. * * @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149} */ - private _baseReplaceRequestHandler = this.replaceRequestHandler; - protected override replaceRequestHandler: Protocol< - AppRequest, - AppNotification, - AppResult - >["setRequestHandler"] = (schema, handler) => { - this._baseReplaceRequestHandler(schema, (request, extra) => { + protected override _wrapHandler( + method: string, + handler: (request: JSONRPCRequest, context: BaseContext) => Promise, + ): (request: JSONRPCRequest, context: BaseContext) => Promise { + const wrapped = super._wrapHandler(method, handler); + if (method === "ui/initialize" || method === "ping") { + return wrapped; + } + return async (request, context) => { if (!this._initializedReceived) { console.warn( - `[ext-apps] AppBridge received '${request.method}' before ` + + `[ext-apps] AppBridge received '${method}' before ` + `ui/notifications/initialized. The View is calling host ` + `methods before completing the handshake; it should await ` + `app.connect() first.`, ); } - return handler(request, extra); - }); - }; + return wrapped(request, context); + }; + } - protected readonly eventSchemas = { + private readonly eventSchemas: { + [K in keyof AppBridgeEventMap]: MethodSchema; + } = { sizechange: McpUiSizeChangedNotificationSchema, sandboxready: McpUiSandboxProxyReadyNotificationSchema, initialized: McpUiInitializedNotificationSchema, @@ -351,6 +428,72 @@ export class AppBridge extends ProtocolWithEvents< loggingmessage: LoggingMessageNotificationSchema, }; + protected setEventHandler( + event: K, + handler: ((params: AppBridgeEventMap[K]) => void) | undefined, + ): void { + this._ensureEventSlot(event); + this._events.setHandler(event, handler); + } + + protected getEventHandler( + event: K, + ): ((params: AppBridgeEventMap[K]) => void) | undefined { + return this._events.getHandler(event); + } + + addEventListener( + event: K, + handler: (params: AppBridgeEventMap[K]) => void, + ): void { + this._ensureEventSlot(event); + this._events.addEventListener(event, handler); + } + + removeEventListener( + event: K, + handler: (params: AppBridgeEventMap[K]) => void, + ): void { + this._events.removeEventListener(event, handler); + } + + private _ensureEventSlot(event: K): void { + if (this._registeredEvents.has(event)) return; + const schema = this.eventSchemas[event]; + if (!schema) throw new Error(`Unknown event: ${String(event)}`); + + const dispatch = (params: AppBridgeEventMap[K]) => { + if (event === "initialized") this._initializedReceived = true; + this._events.dispatch(event, params); + }; + + if (event === "loggingmessage") { + this.setNotificationHandler("notifications/message", (notification) => { + dispatch(notification.params as AppBridgeEventMap[K]); + }); + } else { + this.setNotificationHandler( + schema.shape.method.value, + { params: schema.shape.params }, + (params) => dispatch(params as AppBridgeEventMap[K]), + ); + } + this._registeredEvents.add(event); + } + + protected warnIfRequestHandlerReplaced( + name: string, + previous: unknown, + next: unknown, + ): void { + if (previous && next) { + console.warn( + `[MCP Apps] ${name} handler replaced. ` + + `Previous handler will no longer be called.`, + ); + } + } + /** * Create a new AppBridge instance. * @@ -359,8 +502,8 @@ export class AppBridge extends ProtocolWithEvents< * between the View and the server. When `null`, you must register handlers * manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters. * @param _hostInfo - Host application identification (name and version) - * @param _capabilities - Features and capabilities the host supports - * @param options - Configuration options (inherited from Protocol) + * @param _hostCapabilities - Features and capabilities the host supports + * @param options - Configuration options (`ProtocolOptions` from the base MCP SDK plus `hostContext`) * * @example With MCP client (automatic forwarding) * ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient" @@ -387,22 +530,27 @@ export class AppBridge extends ProtocolWithEvents< constructor( private _client: Client | null, private _hostInfo: Implementation, - private _capabilities: McpUiHostCapabilities, + private _hostCapabilities: McpUiHostCapabilities, options?: HostOptions, ) { super(options); + this._methods.replace("notifications/cancelled"); + this._methods.replace("notifications/progress"); - this.addEventListener("initialized", () => { - this._initializedReceived = true; - }); + this._ensureEventSlot("initialized"); this._hostContext = options?.hostContext || {}; - this.setRequestHandler(McpUiInitializeRequestSchema, (request) => - this._oninitialize(request), + this.replaceRequestHandler( + "ui/initialize", + { + params: McpUiInitializeRequestSchema.shape.params, + result: McpUiInitializeResultSchema, + }, + (params) => this._onAppsInitialize(params), ); - this.setRequestHandler(PingRequestSchema, (request, extra) => { + this.replaceRequestHandler("ping", (request, extra) => { this.onping?.(request.params, extra); return {}; }); @@ -410,14 +558,47 @@ export class AppBridge extends ProtocolWithEvents< // Default handler for requestDisplayMode - returns current mode from host context. // Hosts can override this by setting bridge.onrequestdisplaymode = ... this.replaceRequestHandler( - McpUiRequestDisplayModeRequestSchema, - (request) => { + "ui/request-display-mode", + { + params: McpUiRequestDisplayModeRequestSchema.shape.params, + result: McpUiRequestDisplayModeResultSchema, + }, + () => { const currentMode = this._hostContext.displayMode ?? "inline"; return { mode: currentMode }; }, ); } + /** @internal */ + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + + /** + * Verify that the view supports the capability required for the given request method. + * @internal + */ + protected assertCapabilityForMethod(_method: string): void { + // Host-to-view requests are not capability-gated. + } + + /** + * Verify that a request handler is registered and supported for the given method. + * @internal + */ + protected assertRequestHandlerCapability(_method: string): void { + // Host handler registration is not capability-gated. + } + + /** + * Verify that the host supports the capability required for the given notification method. + * @internal + */ + protected assertNotificationCapability(_method: string): void { + // Host notifications are not capability-gated. + } + /** * Get the view's capabilities discovered during initialization. * @@ -541,8 +722,7 @@ export class AppBridge extends ProtocolWithEvents< * ```typescript * bridge.onsandboxready = async () => { * const resource = await mcpClient.request( - * { method: "resources/read", params: { uri: "ui://my-app" } }, - * ReadResourceResultSchema + * { method: "resources/read", params: { uri: "ui://my-app" } } * ); * * bridge.sendSandboxResourceReady({ @@ -589,13 +769,13 @@ export class AppBridge extends ProtocolWithEvents< * @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ get oninitialized(): - | ((params: McpUiInitializedNotification["params"]) => void) + | ((params?: McpUiInitializedNotification["params"]) => void) | undefined { return this.getEventHandler("initialized"); } set oninitialized( callback: - | ((params: McpUiInitializedNotification["params"]) => void) + | ((params?: McpUiInitializedNotification["params"]) => void) | undefined, ) { this.setEventHandler("initialized", callback); @@ -653,10 +833,14 @@ export class AppBridge extends ProtocolWithEvents< this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback); this._onmessage = callback; this.replaceRequestHandler( - McpUiMessageRequestSchema, - async (request, extra) => { + "ui/message", + { + params: McpUiMessageRequestSchema.shape.params, + result: McpUiMessageResultSchema, + }, + async (params, extra) => { if (!this._onmessage) throw new Error("No onmessage handler set"); - return this._onmessage(request.params, extra); + return this._onmessage(params, extra); }, ); } @@ -722,10 +906,14 @@ export class AppBridge extends ProtocolWithEvents< this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback); this._onopenlink = callback; this.replaceRequestHandler( - McpUiOpenLinkRequestSchema, - async (request, extra) => { + "ui/open-link", + { + params: McpUiOpenLinkRequestSchema.shape.params, + result: McpUiOpenLinkResultSchema, + }, + async (params, extra) => { if (!this._onopenlink) throw new Error("No onopenlink handler set"); - return this._onopenlink(request.params, extra); + return this._onopenlink(params, extra); }, ); } @@ -794,11 +982,15 @@ export class AppBridge extends ProtocolWithEvents< ); this._ondownloadfile = callback; this.replaceRequestHandler( - McpUiDownloadFileRequestSchema, - async (request, extra) => { + "ui/download-file", + { + params: McpUiDownloadFileRequestSchema.shape.params, + result: McpUiDownloadFileResultSchema, + }, + async (params, extra) => { if (!this._ondownloadfile) throw new Error("No ondownloadfile handler set"); - return this._ondownloadfile(request.params, extra); + return this._ondownloadfile(params, extra); }, ); } @@ -894,11 +1086,15 @@ export class AppBridge extends ProtocolWithEvents< ); this._onrequestdisplaymode = callback; this.replaceRequestHandler( - McpUiRequestDisplayModeRequestSchema, - async (request, extra) => { + "ui/request-display-mode", + { + params: McpUiRequestDisplayModeRequestSchema.shape.params, + result: McpUiRequestDisplayModeResultSchema, + }, + async (params, extra) => { if (!this._onrequestdisplaymode) throw new Error("No onrequestdisplaymode handler set"); - return this._onrequestdisplaymode(request.params, extra); + return this._onrequestdisplaymode(params, extra); }, ); } @@ -991,11 +1187,15 @@ export class AppBridge extends ProtocolWithEvents< ); this._onupdatemodelcontext = callback; this.replaceRequestHandler( - McpUiUpdateModelContextRequestSchema, - async (request, extra) => { + "ui/update-model-context", + { + params: McpUiUpdateModelContextRequestSchema.shape.params, + result: EmptyResultSchema, + }, + async (params, extra) => { if (!this._onupdatemodelcontext) throw new Error("No onupdatemodelcontext handler set"); - return this._onupdatemodelcontext(request.params, extra); + return this._onupdatemodelcontext(params, extra); }, ); } @@ -1017,14 +1217,13 @@ export class AppBridge extends ProtocolWithEvents< * bridge.oncalltool = async (params, extra) => { * return mcpClient.request( * { method: "tools/call", params }, - * CallToolResultSchema, - * { signal: extra.signal }, + * { signal: extra.mcpReq.signal }, * ); * }; * ``` * - * @see `CallToolRequest` from @modelcontextprotocol/sdk for the request type - * @see `CallToolResult` from @modelcontextprotocol/sdk for the result type + * @see `CallToolRequest` from @modelcontextprotocol/client for the request type + * @see `CallToolResult` from @modelcontextprotocol/client for the result type */ private _oncalltool?: ( params: CallToolRequest["params"], @@ -1043,13 +1242,10 @@ export class AppBridge extends ProtocolWithEvents< ) { this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); this._oncalltool = callback; - this.replaceRequestHandler( - CallToolRequestSchema, - async (request, extra) => { - if (!this._oncalltool) throw new Error("No oncalltool handler set"); - return this._oncalltool(request.params, extra); - }, - ); + this.replaceRequestHandler("tools/call", async (request, extra) => { + if (!this._oncalltool) throw new Error("No oncalltool handler set"); + return this._oncalltool(request.params, extra); + }); } /** @@ -1074,23 +1270,43 @@ export class AppBridge extends ProtocolWithEvents< * ```ts source="./app-bridge.examples.ts#AppBridge_oncreatesamplingmessage_forwardToLlm" * bridge.oncreatesamplingmessage = async (params, extra) => { * // Apply rate limiting, user approval, cost controls here - * return await myLlmProvider.complete(params, { signal: extra.signal }); + * return await myLlmProvider.complete(params, { + * signal: extra.mcpReq.signal, + * }); * }; * ``` * - * @see `CreateMessageRequest` from @modelcontextprotocol/sdk for the request type - * @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/sdk for result types + * @see `CreateMessageRequest` from @modelcontextprotocol/client for the request type + * @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client for result types */ + private _oncreatesamplingmessage?: ( + params: CreateMessageRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get oncreatesamplingmessage() { + return this._oncreatesamplingmessage; + } set oncreatesamplingmessage( - callback: ( - params: CreateMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: CreateMessageRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( - CreateMessageRequestSchema, + this.warnIfRequestHandlerReplaced( + "oncreatesamplingmessage", + this._oncreatesamplingmessage, + callback, + ); + this._oncreatesamplingmessage = callback; + this.replaceRequestHandler( + "sampling/createMessage", async (request, extra) => { - return callback(request.params, extra); + if (!this._oncreatesamplingmessage) { + throw new Error("No oncreatesamplingmessage handler set"); + } + return this._oncreatesamplingmessage(request.params, extra); }, ); } @@ -1107,12 +1323,12 @@ export class AppBridge extends ProtocolWithEvents< * @example * ```typescript * // In your MCP client notification handler: - * mcpClient.setNotificationHandler(ToolListChangedNotificationSchema, () => { + * mcpClient.setNotificationHandler("notifications/tools/list_changed", () => { * bridge.sendToolListChanged(); * }); * ``` * - * @see `ToolListChangedNotification` from @modelcontextprotocol/sdk for the notification type + * @see `ToolListChangedNotification` from @modelcontextprotocol/client for the notification type */ sendToolListChanged(params: ToolListChangedNotification["params"] = {}) { return this.notification({ @@ -1138,14 +1354,13 @@ export class AppBridge extends ProtocolWithEvents< * bridge.onlistresources = async (params, extra) => { * return mcpClient.request( * { method: "resources/list", params }, - * ListResourcesResultSchema, - * { signal: extra.signal }, + * { signal: extra.mcpReq.signal }, * ); * }; * ``` * - * @see `ListResourcesRequest` from @modelcontextprotocol/sdk for the request type - * @see `ListResourcesResult` from @modelcontextprotocol/sdk for the result type + * @see `ListResourcesRequest` from @modelcontextprotocol/client for the request type + * @see `ListResourcesResult` from @modelcontextprotocol/client for the result type */ private _onlistresources?: ( params: ListResourcesRequest["params"], @@ -1168,14 +1383,11 @@ export class AppBridge extends ProtocolWithEvents< callback, ); this._onlistresources = callback; - this.replaceRequestHandler( - ListResourcesRequestSchema, - async (request, extra) => { - if (!this._onlistresources) - throw new Error("No onlistresources handler set"); - return this._onlistresources(request.params, extra); - }, - ); + this.replaceRequestHandler("resources/list", async (request, extra) => { + if (!this._onlistresources) + throw new Error("No onlistresources handler set"); + return this._onlistresources(request.params, extra); + }); } /** @@ -1195,14 +1407,13 @@ export class AppBridge extends ProtocolWithEvents< * bridge.onlistresourcetemplates = async (params, extra) => { * return mcpClient.request( * { method: "resources/templates/list", params }, - * ListResourceTemplatesResultSchema, - * { signal: extra.signal } + * { signal: extra.mcpReq.signal } * ); * }; * ``` * - * @see `ListResourceTemplatesRequest` from @modelcontextprotocol/sdk for the request type - * @see `ListResourceTemplatesResult` from @modelcontextprotocol/sdk for the result type + * @see `ListResourceTemplatesRequest` from @modelcontextprotocol/client for the request type + * @see `ListResourceTemplatesResult` from @modelcontextprotocol/client for the result type */ private _onlistresourcetemplates?: ( params: ListResourceTemplatesRequest["params"], @@ -1226,7 +1437,7 @@ export class AppBridge extends ProtocolWithEvents< ); this._onlistresourcetemplates = callback; this.replaceRequestHandler( - ListResourceTemplatesRequestSchema, + "resources/templates/list", async (request, extra) => { if (!this._onlistresourcetemplates) throw new Error("No onlistresourcetemplates handler set"); @@ -1252,14 +1463,13 @@ export class AppBridge extends ProtocolWithEvents< * bridge.onreadresource = async (params, extra) => { * return mcpClient.request( * { method: "resources/read", params }, - * ReadResourceResultSchema, - * { signal: extra.signal }, + * { signal: extra.mcpReq.signal }, * ); * }; * ``` * - * @see `ReadResourceRequest` from @modelcontextprotocol/sdk for the request type - * @see `ReadResourceResult` from @modelcontextprotocol/sdk for the result type + * @see `ReadResourceRequest` from @modelcontextprotocol/client for the request type + * @see `ReadResourceResult` from @modelcontextprotocol/client for the result type */ private _onreadresource?: ( params: ReadResourceRequest["params"], @@ -1282,14 +1492,11 @@ export class AppBridge extends ProtocolWithEvents< callback, ); this._onreadresource = callback; - this.replaceRequestHandler( - ReadResourceRequestSchema, - async (request, extra) => { - if (!this._onreadresource) - throw new Error("No onreadresource handler set"); - return this._onreadresource(request.params, extra); - }, - ); + this.replaceRequestHandler("resources/read", async (request, extra) => { + if (!this._onreadresource) + throw new Error("No onreadresource handler set"); + return this._onreadresource(request.params, extra); + }); } /** @@ -1304,12 +1511,12 @@ export class AppBridge extends ProtocolWithEvents< * @example * ```typescript * // In your MCP client notification handler: - * mcpClient.setNotificationHandler(ResourceListChangedNotificationSchema, () => { + * mcpClient.setNotificationHandler("notifications/resources/list_changed", () => { * bridge.sendResourceListChanged(); * }); * ``` * - * @see `ResourceListChangedNotification` from @modelcontextprotocol/sdk for the notification type + * @see `ResourceListChangedNotification` from @modelcontextprotocol/client for the notification type */ sendResourceListChanged( params: ResourceListChangedNotification["params"] = {}, @@ -1337,14 +1544,13 @@ export class AppBridge extends ProtocolWithEvents< * bridge.onlistprompts = async (params, extra) => { * return mcpClient.request( * { method: "prompts/list", params }, - * ListPromptsResultSchema, - * { signal: extra.signal }, + * { signal: extra.mcpReq.signal }, * ); * }; * ``` * - * @see `ListPromptsRequest` from @modelcontextprotocol/sdk for the request type - * @see `ListPromptsResult` from @modelcontextprotocol/sdk for the result type + * @see `ListPromptsRequest` from @modelcontextprotocol/client for the request type + * @see `ListPromptsResult` from @modelcontextprotocol/client for the result type */ private _onlistprompts?: ( params: ListPromptsRequest["params"], @@ -1367,14 +1573,10 @@ export class AppBridge extends ProtocolWithEvents< callback, ); this._onlistprompts = callback; - this.replaceRequestHandler( - ListPromptsRequestSchema, - async (request, extra) => { - if (!this._onlistprompts) - throw new Error("No onlistprompts handler set"); - return this._onlistprompts(request.params, extra); - }, - ); + this.replaceRequestHandler("prompts/list", async (request, extra) => { + if (!this._onlistprompts) throw new Error("No onlistprompts handler set"); + return this._onlistprompts(request.params, extra); + }); } /** @@ -1389,12 +1591,12 @@ export class AppBridge extends ProtocolWithEvents< * @example * ```typescript * // In your MCP client notification handler: - * mcpClient.setNotificationHandler(PromptListChangedNotificationSchema, () => { + * mcpClient.setNotificationHandler("notifications/prompts/list_changed", () => { * bridge.sendPromptListChanged(); * }); * ``` * - * @see `PromptListChangedNotification` from @modelcontextprotocol/sdk for the notification type + * @see `PromptListChangedNotification` from @modelcontextprotocol/client for the notification type */ sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) { return this.notification({ @@ -1403,46 +1605,6 @@ export class AppBridge extends ProtocolWithEvents< }); } - /** - * Verify that the guest supports the capability required for the given request method. - * @internal - */ - assertCapabilityForMethod(method: AppRequest["method"]): void { - // TODO - } - - /** - * Verify that a request handler is registered and supported for the given method. - * @internal - */ - assertRequestHandlerCapability(method: AppRequest["method"]): void { - // TODO - } - - /** - * Verify that the host supports the capability required for the given notification method. - * @internal - */ - assertNotificationCapability(method: AppNotification["method"]): void { - // TODO - } - - /** - * Verify that task creation is supported for the given request method. - * @internal - */ - protected assertTaskCapability(_method: string): void { - throw new Error("Tasks are not supported in MCP Apps"); - } - - /** - * Verify that task handler is supported for the given method. - * @internal - */ - protected assertTaskHandlerCapability(_method: string): void { - throw new Error("Task handlers are not supported in MCP Apps"); - } - /** * Get the host capabilities passed to the constructor. * @@ -1451,17 +1613,17 @@ export class AppBridge extends ProtocolWithEvents< * @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure */ getCapabilities(): McpUiHostCapabilities { - return this._capabilities; + return this._hostCapabilities; } /** * Handle the ui/initialize request from the guest. * @internal */ - private async _oninitialize( - request: McpUiInitializeRequest, + private async _onAppsInitialize( + params: McpUiInitializeRequest["params"], ): Promise { - const requestedVersion = request.params.protocolVersion; + const requestedVersion = params.protocolVersion; if (this._appInfo !== undefined) { console.warn( @@ -1472,8 +1634,8 @@ export class AppBridge extends ProtocolWithEvents< ); } - this._appCapabilities = request.params.appCapabilities; - this._appInfo = request.params.appInfo; + this._appCapabilities = params.appCapabilities; + this._appInfo = params.appInfo; const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes( requestedVersion, @@ -1636,10 +1798,10 @@ export class AppBridge extends ProtocolWithEvents< * * @example * ```ts source="./app-bridge.examples.ts#AppBridge_sendToolResult_afterExecution" - * const result = await mcpClient.request( - * { method: "tools/call", params: { name: "get_weather", arguments: args } }, - * CallToolResultSchema, - * ); + * const result = await mcpClient.request({ + * method: "tools/call", + * params: { name: "get_weather", arguments: args }, + * }); * bridge.sendToolResult(result); * ``` * @@ -1765,11 +1927,7 @@ export class AppBridge extends ProtocolWithEvents< * @returns Promise resolving to the tool call result */ callTool(params: CallToolRequest["params"], options?: RequestOptions) { - return this.request( - { method: "tools/call", params }, - CallToolResultSchema, - options, - ); + return this.request({ method: "tools/call", params }, options); } /** @@ -1782,11 +1940,7 @@ export class AppBridge extends ProtocolWithEvents< * @returns Promise resolving to the list of tools */ listTools(params: ListToolsRequest["params"], options?: RequestOptions) { - return this.request( - { method: "tools/list", params }, - ListToolsResultSchema, - options, - ); + return this.request({ method: "tools/list", params }, options); } /** @@ -1862,13 +2016,12 @@ export class AppBridge extends ProtocolWithEvents< this.oncalltool = async (params, extra) => { return this._client!.request( { method: "tools/call", params }, - CallToolResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; if (serverCapabilities.tools.listChanged) { this._client.setNotificationHandler( - ToolListChangedNotificationSchema, + "notifications/tools/list_changed", (n) => this.sendToolListChanged(n.params), ); } @@ -1877,27 +2030,24 @@ export class AppBridge extends ProtocolWithEvents< this.onlistresources = async (params, extra) => { return this._client!.request( { method: "resources/list", params }, - ListResourcesResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; this.onlistresourcetemplates = async (params, extra) => { return this._client!.request( { method: "resources/templates/list", params }, - ListResourceTemplatesResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; this.onreadresource = async (params, extra) => { return this._client!.request( { method: "resources/read", params }, - ReadResourceResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; if (serverCapabilities.resources.listChanged) { this._client.setNotificationHandler( - ResourceListChangedNotificationSchema, + "notifications/resources/list_changed", (n) => this.sendResourceListChanged(n.params), ); } @@ -1906,23 +2056,21 @@ export class AppBridge extends ProtocolWithEvents< this.onlistprompts = async (params, extra) => { return this._client!.request( { method: "prompts/list", params }, - ListPromptsResultSchema, - { signal: extra.signal }, + { signal: extra.mcpReq.signal }, ); }; if (serverCapabilities.prompts.listChanged) { this._client.setNotificationHandler( - PromptListChangedNotificationSchema, + "notifications/prompts/list_changed", (n) => this.sendPromptListChanged(n.params), ); } } } - // MCP-UI specific handlers are registered by the host component - // after the proxy is created. The standard MCP initialization - // (via oninitialized callback set in constructor) handles the ready signal. - + // Protocol.connect only wires the transport — no MCP handshake happens on + // the iframe channel. The View drives ui/initialize; + // ui/notifications/initialized remains the public View-ready signal. return super.connect(transport); } } diff --git a/src/app.examples.ts b/src/app.examples.ts index 81bdd44..7d475ef 100644 --- a/src/app.examples.ts +++ b/src/app.examples.ts @@ -7,11 +7,8 @@ * @module */ -import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import type { - McpServer, - ToolCallback, -} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { SamplingMessage, Tool } from "@modelcontextprotocol/client"; +import type { McpServer, ToolCallback } from "@modelcontextprotocol/server"; import { App, PostMessageTransport, @@ -369,7 +366,7 @@ async function App_createSamplingMessage_simple(app: App) { */ async function App_createSamplingMessage_withTools( app: App, - messages: import("@modelcontextprotocol/sdk/types.js").SamplingMessage[], + messages: SamplingMessage[], ) { //#region App_createSamplingMessage_withTools if (!app.getHostCapabilities()?.sampling?.tools) return; diff --git a/src/app.test.ts b/src/app.test.ts new file mode 100644 index 0000000..8420e81 --- /dev/null +++ b/src/app.test.ts @@ -0,0 +1,381 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + InMemoryTransport, + Server, + type JSONRPCMessage, +} from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; + +import { App } from "./app.js"; +import { + LATEST_PROTOCOL_VERSION, + McpUiInitializeRequestSchema, + McpUiInitializeResultSchema, + McpUiInitializedNotificationSchema, +} from "./types.js"; + +type ConnectedPair = { + app: App; + server: Server; + sentByApp: JSONRPCMessage[]; + appsInitialized: Promise; +}; + +async function connectPair( + app = new App( + { name: "view-app", version: "1.0.0" }, + { tools: { listChanged: true } }, + { autoResize: false }, + ), +): Promise { + const server = new Server( + { name: "inner-mcp-server", version: "0.0.0" }, + { + capabilities: { resources: {}, tools: {} }, + }, + ); + const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const sentByApp: JSONRPCMessage[] = []; + const send = appTransport.send.bind(appTransport); + appTransport.send = async (message, options) => { + sentByApp.push(message); + await send(message, options); + }; + + let resolveAppsInitialized!: () => void; + const appsInitialized = new Promise((resolve) => { + resolveAppsInitialized = resolve; + }); + + server.setRequestHandler( + "ui/initialize", + { + params: McpUiInitializeRequestSchema.shape.params, + result: McpUiInitializeResultSchema, + }, + (params) => { + expect(params.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + return { + protocolVersion: LATEST_PROTOCOL_VERSION, + hostCapabilities: { openLinks: {} }, + hostInfo: { name: "apps-host", version: "2.0.0" }, + hostContext: { theme: "dark" as const, locale: "en-US" }, + }; + }, + ); + server.setNotificationHandler( + "ui/notifications/initialized", + { params: McpUiInitializedNotificationSchema.shape.params }, + () => resolveAppsInitialized(), + ); + server.setRequestHandler("tools/call", (request) => ({ + content: [{ type: "text", text: `called ${request.params.name}` }], + })); + server.setRequestHandler("resources/read", (request) => ({ + contents: [{ uri: request.params.uri, text: "resource body" }], + })); + server.setRequestHandler("resources/list", () => ({ + resources: [{ uri: "test://resource", name: "Test resource" }], + })); + server.setRequestHandler("sampling/createMessage", () => ({ + model: "test-model", + role: "assistant", + content: { type: "text", text: "sampled" }, + })); + + await server.connect(serverTransport); + await app.connect(appTransport); + await appsInitialized; + + return { app, server, sentByApp, appsInitialized }; +} + +const connected: Array> = []; + +afterEach(async () => { + await Promise.all( + connected + .splice(0) + .flatMap(({ app, server }) => [app.close(), server.close()]), + ); +}); + +describe("App base MCP SDK v2 Protocol migration", () => { + it("performs only the Apps handshake — no MCP initialize on the wire", async () => { + const pair = await connectPair(); + connected.push(pair); + + const lifecycleMethods = pair.sentByApp + .filter( + (message): message is JSONRPCMessage & { method: string } => + "method" in message, + ) + .map((message) => message.method) + .filter((method) => + [ + "initialize", + "notifications/initialized", + "ui/initialize", + "ui/notifications/initialized", + ].includes(method), + ); + + // The filter above includes the core MCP lifecycle methods, so this + // equality also proves no `initialize`/`notifications/initialized` was sent + // — the wire contract deployed v1 hosts depend on. + expect(lifecycleMethods).toEqual([ + "ui/initialize", + "ui/notifications/initialized", + ]); + }); + + it("uses ui/initialize as the authority for Apps host state", async () => { + const pair = await connectPair(); + connected.push(pair); + + expect(pair.app.getHostVersion()).toEqual({ + name: "apps-host", + version: "2.0.0", + }); + expect(pair.app.getHostCapabilities()).toEqual({ openLinks: {} }); + expect(pair.app.getHostContext()).toEqual({ + theme: "dark", + locale: "en-US", + }); + }); + + it("preserves registered App tools with the base MCP SDK v2 Client handler context", async () => { + const pair = await connectPair(); + connected.push(pair); + let receivedSignal: AbortSignal | undefined; + + pair.app.registerTool( + "greet", + { description: "Greets a person" }, + async (extra) => { + receivedSignal = extra.mcpReq.signal; + return { content: [{ type: "text", text: "hello" }] }; + }, + ); + + const listed = await pair.server.request({ + method: "tools/list", + params: {}, + }); + const result = await pair.server.request({ + method: "tools/call", + params: { name: "greet", arguments: {} }, + }); + + expect(listed.tools.map((tool) => tool.name)).toEqual(["greet"]); + expect(result.content).toEqual([{ type: "text", text: "hello" }]); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + }); + + it("uses base MCP SDK v2 method-keyed sends for standard MCP requests", async () => { + const pair = await connectPair(); + connected.push(pair); + + const toolResult = await pair.app.callServerTool({ + name: "refresh", + arguments: {}, + }); + const resource = await pair.app.readServerResource({ + uri: "test://resource", + }); + const resources = await pair.app.listServerResources(); + const sample = await pair.app.createSamplingMessage({ + messages: [{ role: "user", content: { type: "text", text: "hello" } }], + maxTokens: 20, + }); + + expect(toolResult.content).toEqual([ + { type: "text", text: "called refresh" }, + ]); + expect(resource.contents[0]).toEqual({ + uri: "test://resource", + text: "resource body", + }); + expect(resources.resources.map(({ uri }) => uri)).toEqual([ + "test://resource", + ]); + expect(sample.content).toEqual({ type: "text", text: "sampled" }); + }); + + it("preserves reconnect behavior after close", async () => { + const first = await connectPair(); + await first.app.close(); + await first.server.close(); + + const second = await connectPair(first.app); + connected.push(second); + + expect(second.app.getHostVersion()).toEqual({ + name: "apps-host", + version: "2.0.0", + }); + }); + + it("preserves auto-resize setup after Apps initialization", async () => { + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: true }, + ); + let setupCalls = 0; + app.setupSizeChangedNotifications = () => { + setupCalls += 1; + return () => {}; + }; + + const pair = await connectPair(app); + connected.push(pair); + + expect(setupCalls).toBe(1); + }); + + it("preserves strict late-handler timing errors", async () => { + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false, strict: true }, + ); + const pair = await connectPair(app); + connected.push(pair); + + expect(() => { + app.ontoolinput = () => {}; + }).toThrow(/handler registered after connect/); + }); + + it("direct setRequestHandler throws when a handler is already registered", async () => { + const pair = await connectPair(); + connected.push(pair); + const params = z.object({}); + let handledBy = 0; + + pair.app.setRequestHandler("test/method", { params }, () => { + handledBy = 1; + return {}; + }); + expect(() => { + pair.app.setRequestHandler("test/method", { params }, () => { + handledBy = 2; + return {}; + }); + }).toThrow(/already registered/); + + await pair.server.request( + { method: "test/method", params: {} }, + z.object({}), + ); + expect(handledBy).toBe(1); + }); + + it("direct setRequestHandler throws for methods owned by the App itself", async () => { + const pair = await connectPair(); + connected.push(pair); + expect(() => { + pair.app.setRequestHandler("ping", () => ({})); + }).toThrow(/already registered/); + }); + + it("removeRequestHandler releases the method for re-registration", async () => { + const pair = await connectPair(); + connected.push(pair); + const params = z.object({}); + let handledBy = 0; + + pair.app.setRequestHandler("test/method", { params }, () => { + handledBy = 1; + return {}; + }); + pair.app.removeRequestHandler("test/method"); + pair.app.setRequestHandler("test/method", { params }, () => { + handledBy = 2; + return {}; + }); + + await pair.server.request( + { method: "test/method", params: {} }, + z.object({}), + ); + expect(handledBy).toBe(2); + }); + + it("direct setNotificationHandler throws when a handler is already registered", async () => { + const pair = await connectPair(); + connected.push(pair); + const calls: number[] = []; + const params = z.object({}); + + pair.app.setNotificationHandler("test/notification", { params }, () => { + calls.push(1); + }); + expect(() => { + pair.app.setNotificationHandler("test/notification", { params }, () => { + calls.push(2); + }); + }).toThrow(/already registered/); + await pair.server.notification({ + method: "test/notification", + params: {}, + }); + + expect(calls).toEqual([1]); + }); + + it("direct setNotificationHandler throws for event-mapped methods (listener first)", () => { + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false }, + ); + app.addEventListener("toolinput", () => {}); + expect(() => { + app.setNotificationHandler( + "ui/notifications/tool-input", + { params: z.object({}) }, + () => {}, + ); + }).toThrow(/already registered/); + }); + + it("event registration throws when a direct handler already owns the method", () => { + const app = new App( + { name: "view-app", version: "1.0.0" }, + {}, + { autoResize: false }, + ); + app.setNotificationHandler( + "ui/notifications/tool-input", + { params: z.object({}) }, + () => {}, + ); + expect(() => { + app.ontoolinput = () => {}; + }).toThrow(/already registered/); + expect(() => { + app.addEventListener("toolinput", () => {}); + }).toThrow(/already registered/); + }); + + it("on* request setters keep replace semantics", async () => { + const pair = await connectPair(); + connected.push(pair); + let handledBy = 0; + pair.app.onteardown = async () => { + handledBy = 1; + return {}; + }; + pair.app.onteardown = async () => { + handledBy = 2; + return {}; + }; + await pair.server.request( + { method: "ui/resource-teardown", params: {} }, + z.object({}), + ); + expect(handledBy).toBe(2); + }); +}); diff --git a/src/app.ts b/src/app.ts index adfad5c..89c6c35 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,40 +1,45 @@ import { + Protocol, + type BaseContext, + type ProtocolOptions, type RequestOptions, - mergeCapabilities, - ProtocolOptions, -} from "@modelcontextprotocol/sdk/shared/protocol.js"; - + type CallToolRequest, + type CallToolResult, + type CreateMessageRequest, + type CreateMessageResult, + type CreateMessageResultWithTools, + type Implementation, + type ListResourcesRequest, + type ListResourcesResult, + type ListToolsRequest, + type ListToolsResult, + type LoggingMessageNotification, + type ReadResourceRequest, + type ReadResourceResult, + type Tool, + type ToolAnnotations, + type ToolListChangedNotification, + type Transport, +} from "@modelcontextprotocol/client"; +import { EmptyResultSchema } from "@modelcontextprotocol/core"; +export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js"; +import { EventDispatcher, MethodRegistry } from "./events.js"; import { - CallToolRequest, - CallToolRequestSchema, - CallToolResult, - CallToolResultSchema, - CreateMessageRequest, - CreateMessageResult, - CreateMessageResultSchema, - CreateMessageResultWithTools, - CreateMessageResultWithToolsSchema, - EmptyResultSchema, - Implementation, - ListResourcesRequest, - ListResourcesResult, - ListResourcesResultSchema, - ListToolsRequest, - ListToolsRequestSchema, - ListToolsResult, - LoggingMessageNotification, - PingRequestSchema, - ReadResourceRequest, - ReadResourceResult, - ReadResourceResultSchema, - Tool, - ToolAnnotations, - ToolListChangedNotification, -} from "@modelcontextprotocol/sdk/types.js"; -import { AppNotification, AppRequest, AppResult } from "./types"; -import { ProtocolWithEvents } from "./events"; -export { ProtocolWithEvents }; -import { PostMessageTransport } from "./message-transport"; + toModernArgs, + type LegacyNotificationHandlerSetter, + type LegacyRequestHandlerSetter, +} from "./legacy-handlers.js"; +export type { + LegacyMethodSchema, + LegacyNotificationHandler, + LegacyNotificationHandlerSetter, + LegacyRequestHandler, + LegacyRequestHandlerExtra, + LegacyRequestHandlerSetter, +} from "./legacy-handlers.js"; +export { EventDispatcher } from "./events.js"; + +import { PostMessageTransport } from "./message-transport.js"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -55,6 +60,7 @@ import { McpUiResourceTeardownRequest, McpUiResourceTeardownRequestSchema, McpUiResourceTeardownResult, + McpUiResourceTeardownResultSchema, McpUiRequestTeardownNotification, McpUiSizeChangedNotification, McpUiToolCancelledNotification, @@ -67,104 +73,60 @@ import { McpUiToolResultNotificationSchema, McpUiRequestDisplayModeRequest, McpUiRequestDisplayModeResultSchema, -} from "./types"; -import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +} from "./types.js"; import { StandardSchemaV1, standardSchemaToJsonSchema, validateStandardSchema, -} from "./standard-schema"; -import { z } from "zod/v4"; +} from "./standard-schema.js"; +import { z, type ZodLiteral, type ZodObject, type ZodType } from "zod/v4"; + +type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void; + +type MethodSchema = ZodObject<{ + method: ZodLiteral; + params: ZodType; +}>; + +function mergeAppCapabilities( + base: McpUiAppCapabilities, + additional: McpUiAppCapabilities, +): McpUiAppCapabilities { + const result = { ...base, ...additional }; + if (additional.experimental !== undefined) { + result.experimental = { + ...base.experimental, + ...additional.experimental, + }; + } + if (additional.tools !== undefined) { + result.tools = { ...base.tools, ...additional.tools }; + } + return result; +} export type { StandardSchemaV1, StandardSchemaWithJSON, -} from "./standard-schema"; +} from "./standard-schema.js"; -export { PostMessageTransport } from "./message-transport"; -export * from "./types"; +export { PostMessageTransport } from "./message-transport.js"; +export * from "./types.js"; export { applyHostStyleVariables, applyHostFonts, getDocumentTheme, applyDocumentTheme, -} from "./styles"; - -/** - * Metadata key for associating a UI resource URI with a tool. - * - * MCP servers include this key in tool definition metadata (via `tools/list`) - * to indicate which UI resource should be displayed when the tool is called. - * When hosts see a tool with this metadata, they fetch and render the - * corresponding {@link App `App`}. - * - * **Note**: This constant is provided for reference and backwards compatibility. - * Server developers should use {@link server-helpers!registerAppTool `registerAppTool`} - * with the `_meta.ui.resourceUri` format instead. Host developers must check both - * formats for compatibility. - * - * @example Modern format (server-side, not in Apps) - * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat" - * // Preferred: Use registerAppTool with nested ui.resourceUri - * registerAppTool( - * server, - * "weather", - * { - * description: "Get weather forecast", - * _meta: { - * ui: { resourceUri: "ui://weather/forecast" }, - * }, - * }, - * handler, - * ); - * ``` - * - * @example Legacy format (deprecated, for backwards compatibility) - * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat" - * // Deprecated: Direct use of RESOURCE_URI_META_KEY - * server.registerTool( - * "weather", - * { - * description: "Get weather forecast", - * _meta: { - * [RESOURCE_URI_META_KEY]: "ui://weather/forecast", - * }, - * }, - * handler, - * ); - * ``` - * - * @example How hosts check for this metadata (must support both formats) - * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide" - * // Hosts should check both modern and legacy formats - * const meta = tool._meta; - * const uiMeta = meta?.ui as McpUiToolMeta | undefined; - * const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined; - * const uiUri = uiMeta?.resourceUri ?? legacyUri; - * if (typeof uiUri === "string" && uiUri.startsWith("ui://")) { - * // Fetch the resource and display the UI - * } - * ``` - */ -export const RESOURCE_URI_META_KEY = "ui/resourceUri"; - -/** - * MIME type for MCP UI resources. - * - * Identifies HTML content as an MCP App UI resource. - * - * Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources. - */ -export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; +} from "./styles.js"; /** * Options for configuring {@link App `App`} behavior. * - * Extends `ProtocolOptions` from the MCP SDK with `App`-specific configuration. + * Extends `ProtocolOptions` from the base MCP SDK with `App`-specific configuration. * - * @see `ProtocolOptions` from @modelcontextprotocol/sdk for inherited options + * @see `ProtocolOptions` from @modelcontextprotocol/client for inherited options */ -export type AppOptions = ProtocolOptions & { +export type AppOptions = Omit & { /** * Automatically report size changes to the host using `ResizeObserver`. * @@ -204,9 +166,7 @@ export type AppOptions = ProtocolOptions & { allowUnsafeEval?: boolean; }; -type RequestHandlerExtra = Parameters< - Parameters[1] ->[1]; +type RequestHandlerExtra = BaseContext; /** * Result of an app-registered tool callback. When `Out` is provided, @@ -228,7 +188,7 @@ export type AppToolResult< * validated/parsed input; when `In` is `undefined`, the callback receives only * `extra`. When `Out` is provided, the return's `structuredContent` is typed. * - * Mirrors `ToolCallback` from `@modelcontextprotocol/sdk/server/mcp.js` but is + * Mirrors `ToolCallback` from `@modelcontextprotocol/server` but is * parameterized over {@link StandardSchemaV1} instead of zod, so any * Standard-Schema-compatible library (Zod, ArkType, Valibot, …) can be used. */ @@ -246,7 +206,7 @@ export type AppToolCallback< /** * Handle returned by {@link App.registerTool}. Mirrors `RegisteredTool` from - * `@modelcontextprotocol/sdk/server/mcp.js` but stores + * `@modelcontextprotocol/server` but stores * {@link StandardSchemaV1} schemas. */ export type RegisteredAppTool = { @@ -271,9 +231,9 @@ export type RegisteredAppTool = { /** * Maps DOM-style event names to their notification `params` types. * - * Used by {@link App `App`} (which extends {@link ProtocolWithEvents `ProtocolWithEvents`}) - * to provide type-safe `addEventListener` / `removeEventListener` and - * singular `on*` handler support. + * Used by {@link App `App`} and its composed {@link EventDispatcher} to + * provide type-safe `addEventListener` / `removeEventListener` and singular + * `on*` handler support. */ export type AppEventMap = { toolinput: McpUiToolInputNotification["params"]; @@ -287,9 +247,9 @@ export type AppEventMap = { * Main class for MCP Apps to communicate with their host. * * The `App` class provides a framework-agnostic way to build interactive MCP Apps - * that run inside host applications. It extends the MCP SDK's `Protocol` class and - * handles the connection lifecycle, initialization handshake, and bidirectional - * communication with the host. + * that run inside host applications. It extends the base MCP SDK's public + * `Protocol` class, composes an {@link EventDispatcher}, and adds the Apps + * handshake, lifecycle, and high-level APIs. * * ## Architecture * @@ -304,16 +264,16 @@ export type AppEventMap = { * 3. **Interactive**: Send requests, receive notifications, call tools * 4. **Teardown**: Host sends teardown request before unmounting * - * ## Inherited Methods + * ## Protocol and event methods * - * As a subclass of {@link ProtocolWithEvents `ProtocolWithEvents`}, `App` inherits: + * From the base MCP SDK's `Protocol`, `App` provides: * - `setRequestHandler()` - Register handlers for requests from host * - `setNotificationHandler()` - Register handlers for notifications from host + * + * From its composed {@link EventDispatcher}, `App` also provides: * - `addEventListener()` - Append a listener for a notification event (multi-listener) * - `removeEventListener()` - Remove a previously added listener * - * @see {@link ProtocolWithEvents `ProtocolWithEvents`} for the DOM-model event system - * * ## Notification Setters (DOM-model `on*` handlers) * * For common notifications, the `App` class provides getter/setter properties @@ -342,17 +302,90 @@ export type AppEventMap = { * await app.connect(); * ``` */ -export class App extends ProtocolWithEvents< - AppRequest, - AppNotification, - AppResult, - AppEventMap -> { +export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; private _hostContext?: McpUiHostContext; private _registeredTools: { [name: string]: RegisteredAppTool } = {}; private _initializedSent = false; + private readonly _registeredEvents = new Set(); + private readonly _events = new EventDispatcher(); + private readonly _methods = new MethodRegistry(); + + // ── Handler registration with double-set protection ───────────────── + // + // The base SDK `Protocol` silently replaces an existing handler. The four + // overrides below restore the v1 behaviour: a direct `setRequestHandler` / + // `setNotificationHandler` for a method that already has a handler throws, + // so a stray registration cannot silently disconnect `on*` handlers or + // `addEventListener` listeners. They are arrow-function class fields rather + // than prototype methods so that `Protocol`'s constructor — which registers + // its own ping/cancelled/progress handlers before our fields initialize — + // hits the base implementation and skips tracking. + + /** + * Registers a request handler. Throws if a handler for the same method has + * already been registered — use the `on*` setter for replace semantics. + * + * @throws {Error} if a handler for this method is already registered. + */ + override setRequestHandler: LegacyRequestHandlerSetter< + Protocol["setRequestHandler"] + > = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("request", args) ?? args; + this._methods.claim(method as string, "setRequestHandler"); + (super.setRequestHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + /** + * Registers a notification handler. Throws if a handler for the same method + * has already been registered — use the `on*` setter (replace semantics) or + * `addEventListener` (multi-listener) for mapped events. + * + * @throws {Error} if a handler for this method is already registered. + */ + override setNotificationHandler: LegacyNotificationHandlerSetter< + Protocol["setNotificationHandler"] + > = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("notification", args) ?? args; + this._methods.claim(method as string, "setNotificationHandler"); + (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + override removeRequestHandler: Protocol["removeRequestHandler"] = + (method: string) => { + this._methods.release(method); + super.removeRequestHandler(method); + }; + + override removeNotificationHandler: Protocol["removeNotificationHandler"] = + (method: string) => { + this._methods.release(method); + super.removeNotificationHandler(method); + }; + + /** + * Register a request handler with replace semantics, bypassing the + * double-set protection of {@link setRequestHandler `setRequestHandler`}. + * Used by the `on*` request-handler setters. + */ + protected replaceRequestHandler: Protocol["setRequestHandler"] = + (method: string, ...rest: unknown[]) => { + this._methods.replace(method); + (super.setRequestHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; /** * Warn if a host-bound method is called before {@link connect `connect`} has @@ -378,7 +411,9 @@ export class App extends ProtocolWithEvents< console.warn(`${msg}. This will throw in a future release.`); } - protected readonly eventSchemas = { + private readonly eventSchemas: { + [K in keyof AppEventMap]: MethodSchema; + } = { toolinput: McpUiToolInputNotificationSchema, toolinputpartial: McpUiToolInputPartialNotificationSchema, toolresult: McpUiToolResultNotificationSchema, @@ -427,28 +462,65 @@ export class App extends ProtocolWithEvents< console.warn(msg); } - protected override setEventHandler( + protected setEventHandler( event: K, handler: ((params: AppEventMap[K]) => void) | undefined, ): void { if (handler) this._assertHandlerTiming(event); - super.setEventHandler(event, handler); + this._ensureEventSlot(event); + this._events.setHandler(event, handler); } - override addEventListener( + addEventListener( event: K, handler: (params: AppEventMap[K]) => void, ): void { this._assertHandlerTiming(event); - super.addEventListener(event, handler); + this._ensureEventSlot(event); + this._events.addEventListener(event, handler); } - protected override onEventDispatch( + removeEventListener( event: K, - params: AppEventMap[K], + handler: (params: AppEventMap[K]) => void, ): void { - if (event === "hostcontextchanged") { - this._hostContext = { ...this._hostContext, ...params }; + this._events.removeEventListener(event, handler); + } + + private _ensureEventSlot(event: K): void { + if (this._registeredEvents.has(event)) return; + const schema = this.eventSchemas[event]; + if (!schema) throw new Error(`Unknown event: ${String(event)}`); + this.setNotificationHandler( + schema.shape.method.value, + { params: schema.shape.params }, + (params) => { + const eventParams = params as AppEventMap[K]; + if (event === "hostcontextchanged") { + this._hostContext = { ...this._hostContext, ...eventParams }; + } + this._events.dispatch(event, eventParams); + }, + ); + this._registeredEvents.add(event); + } + + protected getEventHandler( + event: K, + ): ((params: AppEventMap[K]) => void) | undefined { + return this._events.getHandler(event); + } + + protected warnIfRequestHandlerReplaced( + name: string, + previous: unknown, + next: unknown, + ): void { + if (previous && next) { + console.warn( + `[MCP Apps] ${name} handler replaced. ` + + `Previous handler will no longer be called.`, + ); } } @@ -456,7 +528,7 @@ export class App extends ProtocolWithEvents< * Create a new MCP App instance. * * @param _appInfo - App identification (name and version) - * @param _capabilities - Features and capabilities this app provides + * @param _appCapabilities - Features and capabilities this app provides * @param options - Configuration options including `autoResize` behavior * * @example @@ -470,33 +542,44 @@ export class App extends ProtocolWithEvents< */ constructor( private _appInfo: Implementation, - private _capabilities: McpUiAppCapabilities = {}, + private _appCapabilities: McpUiAppCapabilities = {}, private options: AppOptions = { autoResize: true }, ) { super(options); + // Claim the handlers the base Protocol constructor installed so a direct + // setNotificationHandler cannot silently replace them either. + this._methods.replace("notifications/cancelled"); + this._methods.replace("notifications/progress"); if (!options.allowUnsafeEval) { z.config({ jitless: true }); } - this.setRequestHandler(PingRequestSchema, (request) => { + this.replaceRequestHandler("ping", (request) => { console.log("Received ping:", request.params); return {}; }); - // Eagerly register the hostcontextchanged event slot so that - // onEventDispatch (which merges into _hostContext) fires even if the - // user never assigns onhostcontextchanged or calls addEventListener. + // Eagerly register hostcontextchanged so the owner-side context merge runs + // even if the user never assigns a handler or adds a listener. this.setEventHandler("hostcontextchanged", undefined); } - private registerCapabilities(capabilities: McpUiAppCapabilities): void { + /** @internal */ + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + + private registerAppCapabilities(capabilities: McpUiAppCapabilities): void { if (this.transport) { throw new Error( "Cannot register capabilities after transport is established", ); } - this._capabilities = mergeCapabilities(this._capabilities, capabilities); + this._appCapabilities = mergeAppCapabilities( + this._appCapabilities, + capabilities, + ); } registerTool< @@ -519,7 +602,7 @@ export class App extends ProtocolWithEvents< } const app = this; const notify = () => { - if (app._initializedSent && app._capabilities.tools?.listChanged) { + if (app._initializedSent && app._appCapabilities.tools?.listChanged) { void app.sendToolListChanged(); } }; @@ -591,8 +674,8 @@ export class App extends ProtocolWithEvents< // to declare { tools: {} } in the constructor just to use registerTool. // Only do this pre-connect; post-connect the capability was already // advertised (or wasn't) and can't change. - if (!this._capabilities.tools && !this.transport) { - this.registerCapabilities({ tools: { listChanged: true } }); + if (!this._appCapabilities.tools && !this.transport) { + this.registerAppCapabilities({ tools: { listChanged: true } }); } this.ensureToolHandlersInitialized(); @@ -924,9 +1007,9 @@ export class App extends ProtocolWithEvents< * without replacing. * * Notification params are automatically merged into the internal host context - * via {@link onEventDispatch `onEventDispatch`} before any handler or listener - * fires. This means {@link getHostContext `getHostContext`} will return the - * updated values even before your callback runs. + * before any handler or listener fires. This means + * {@link getHostContext `getHostContext`} will return the updated values even + * before your callback runs. * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * @@ -1005,10 +1088,14 @@ export class App extends ProtocolWithEvents< this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback); this._onteardown = callback; this.replaceRequestHandler( - McpUiResourceTeardownRequestSchema, - (request, extra) => { + "ui/resource-teardown", + { + params: McpUiResourceTeardownRequestSchema.shape.params, + result: McpUiResourceTeardownResultSchema, + }, + (params, extra) => { if (!this._onteardown) throw new Error("No onteardown handler set"); - return this._onteardown(request.params, extra); + return this._onteardown(params, extra); }, ); } @@ -1058,7 +1145,7 @@ export class App extends ProtocolWithEvents< ) { this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); this._oncalltool = callback; - this.replaceRequestHandler(CallToolRequestSchema, (request, extra) => { + this.replaceRequestHandler("tools/call", (request, extra) => { if (!this._oncalltool) throw new Error("No oncalltool handler set"); return this._oncalltool(request.params, extra); }); @@ -1129,7 +1216,7 @@ export class App extends ProtocolWithEvents< callback, ); this._onlisttools = callback; - this.replaceRequestHandler(ListToolsRequestSchema, (request, extra) => { + this.replaceRequestHandler("tools/list", (request, extra) => { if (!this._onlisttools) throw new Error("No onlisttools handler set"); return this._onlisttools(request.params, extra); }); @@ -1139,7 +1226,7 @@ export class App extends ProtocolWithEvents< * Verify that the host supports the capability required for the given request method. * @internal */ - assertCapabilityForMethod(method: AppRequest["method"]): void { + protected override assertCapabilityForMethod(method: string): void { switch (method) { case "sampling/createMessage": if (!this._hostCapabilities?.sampling) { @@ -1155,21 +1242,21 @@ export class App extends ProtocolWithEvents< * Verify that the app declared the capability required for the given request method. * @internal */ - assertRequestHandlerCapability(method: AppRequest["method"]): void { + protected override assertRequestHandlerCapability(method: string): void { switch (method) { case "tools/call": case "tools/list": - if (!this._capabilities.tools) { + if (!this._appCapabilities.tools) { throw new Error( `Client does not support tool capability (required for ${method})`, ); } return; - case "ping": - case "ui/resource-teardown": - return; default: - throw new Error(`No handler for method ${method} registered`); + // `ping`, `ui/*`, and custom (vendor-prefixed) methods need no + // declared capability. The base SDK explicitly supports custom + // request handlers via the `{ params, result }` form. + return; } } @@ -1177,26 +1264,10 @@ export class App extends ProtocolWithEvents< * Verify that the app supports the capability required for the given notification method. * @internal */ - assertNotificationCapability(_method: AppNotification["method"]): void { + protected override assertNotificationCapability(_method: string): void { // TODO } - /** - * Verify that task creation is supported for the given request method. - * @internal - */ - protected assertTaskCapability(_method: string): void { - throw new Error("Tasks are not supported in MCP Apps"); - } - - /** - * Verify that task handler is supported for the given method. - * @internal - */ - protected assertTaskHandlerCapability(_method: string): void { - throw new Error("Task handlers are not supported in MCP Apps"); - } - /** * Call a tool on the originating MCP server (proxied through the host). * @@ -1245,7 +1316,6 @@ export class App extends ProtocolWithEvents< } return await this.request( { method: "tools/call", params }, - CallToolResultSchema, { // Hosts may interpose long-running or user-interactive steps before the // tool result arrives. Opting in here lets a host heartbeat keep the @@ -1302,11 +1372,7 @@ export class App extends ProtocolWithEvents< options?: RequestOptions, ): Promise { this._assertInitialized("readServerResource"); - return await this.request( - { method: "resources/read", params }, - ReadResourceResultSchema, - options, - ); + return await this.request({ method: "resources/read", params }, options); } /** @@ -1350,11 +1416,7 @@ export class App extends ProtocolWithEvents< options?: RequestOptions, ): Promise { this._assertInitialized("listServerResources"); - return await this.request( - { method: "resources/list", params }, - ListResourcesResultSchema, - options, - ); + return await this.request({ method: "resources/list", params }, options); } /** @@ -1415,8 +1477,8 @@ export class App extends ProtocolWithEvents< * } * ``` * - * @see `CreateMessageRequest` from @modelcontextprotocol/sdk for the request type - * @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/sdk for result types + * @see `CreateMessageRequest` from @modelcontextprotocol/client for the request type + * @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client for result types */ async createSamplingMessage( params: CreateMessageRequest["params"] & { tools?: undefined }, @@ -1431,12 +1493,8 @@ export class App extends ProtocolWithEvents< options?: RequestOptions, ): Promise { this._assertInitialized("createSamplingMessage"); - const resultSchema = params.tools - ? CreateMessageResultWithToolsSchema - : CreateMessageResultSchema; return await this.request( { method: "sampling/createMessage", params }, - resultSchema, options, ); } @@ -1953,14 +2011,18 @@ export class App extends ProtocolWithEvents< ); } this._initializedSent = false; - await super.connect(transport); try { + // Protocol.connect only wires the transport — no MCP handshake. The + // ui/initialize request below is the sole negotiation on this channel, + // matching the v1 wire behavior deployed hosts expect. + await super.connect(transport); + const result = await this.request( { method: "ui/initialize", params: { - appCapabilities: this._capabilities, + appCapabilities: this._appCapabilities, appInfo: this._appInfo, protocolVersion: LATEST_PROTOCOL_VERSION, }, diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c879a03 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,66 @@ +/** + * Metadata key for associating a UI resource URI with a tool. + * + * MCP servers include this key in tool definition metadata (via `tools/list`) + * to indicate which UI resource should be displayed when the tool is called. + * When hosts see a tool with this metadata, they fetch and render the + * corresponding {@link app!App `App`}. + * + * **Note**: This constant is provided for reference and backwards compatibility. + * Server developers should use {@link server-helpers!registerAppTool `registerAppTool`} + * with the `_meta.ui.resourceUri` format instead. Host developers must check both + * formats for compatibility. + * + * @example Modern format (server-side, not in Apps) + * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat" + * // Preferred: Use registerAppTool with nested ui.resourceUri + * registerAppTool( + * server, + * "weather", + * { + * description: "Get weather forecast", + * _meta: { + * ui: { resourceUri: "ui://weather/forecast" }, + * }, + * }, + * handler, + * ); + * ``` + * + * @example Legacy format (deprecated, for backwards compatibility) + * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat" + * // Deprecated: Direct use of RESOURCE_URI_META_KEY + * server.registerTool( + * "weather", + * { + * description: "Get weather forecast", + * _meta: { + * [RESOURCE_URI_META_KEY]: "ui://weather/forecast", + * }, + * }, + * handler, + * ); + * ``` + * + * @example How hosts check for this metadata (must support both formats) + * ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide" + * // Hosts should check both modern and legacy formats + * const meta = tool._meta; + * const uiMeta = meta?.ui as McpUiToolMeta | undefined; + * const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined; + * const uiUri = uiMeta?.resourceUri ?? legacyUri; + * if (typeof uiUri === "string" && uiUri.startsWith("ui://")) { + * // Fetch the resource and display the UI + * } + * ``` + */ +export const RESOURCE_URI_META_KEY = "ui/resourceUri"; + +/** + * MIME type for MCP UI resources. + * + * Identifies HTML content as an MCP App UI resource. + * + * Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources. + */ +export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; diff --git a/src/events.test.ts b/src/events.test.ts new file mode 100644 index 0000000..60e7c35 --- /dev/null +++ b/src/events.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, spyOn } from "bun:test"; + +import { EventDispatcher } from "./events.js"; + +type TestEventMap = { + change: { value: number }; +}; + +describe("EventDispatcher", () => { + it("replaces the singular handler", () => { + const dispatcher = new EventDispatcher(); + const calls: string[] = []; + const first = () => calls.push("first"); + const second = () => calls.push("second"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + + try { + dispatcher.setHandler("change", first); + dispatcher.setHandler("change", second); + + expect(dispatcher.getHandler("change")).toBe(second); + dispatcher.dispatch("change", { value: 1 }); + expect(calls).toEqual(["second"]); + } finally { + warn.mockRestore(); + } + }); + + it("dispatches the singular handler before listeners in insertion order", () => { + const dispatcher = new EventDispatcher(); + const calls: string[] = []; + + dispatcher.setHandler("change", () => calls.push("handler")); + dispatcher.addEventListener("change", () => calls.push("listener-1")); + dispatcher.addEventListener("change", () => calls.push("listener-2")); + + dispatcher.dispatch("change", { value: 1 }); + + expect(calls).toEqual(["handler", "listener-1", "listener-2"]); + }); + + it("uses a listener snapshot when a listener is removed during dispatch", () => { + const dispatcher = new EventDispatcher(); + const calls: string[] = []; + const second = () => calls.push("second"); + const first = () => { + calls.push("first"); + dispatcher.removeEventListener("change", second); + }; + + dispatcher.addEventListener("change", first); + dispatcher.addEventListener("change", second); + + dispatcher.dispatch("change", { value: 1 }); + dispatcher.dispatch("change", { value: 2 }); + + expect(calls).toEqual(["first", "second", "first"]); + }); + + it("warns only when one singular handler replaces another", () => { + const dispatcher = new EventDispatcher(); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const first = () => {}; + const second = () => {}; + + try { + dispatcher.setHandler("change", first); + dispatcher.setHandler("change", undefined); + dispatcher.setHandler("change", first); + expect(warn).not.toHaveBeenCalled(); + + dispatcher.setHandler("change", second); + + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain( + "onchange handler replaced", + ); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/src/events.ts b/src/events.ts index 4d17ca4..aa2e862 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,17 +1,6 @@ -import { Protocol } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { - Request, - Notification, - Result, -} from "@modelcontextprotocol/sdk/types.js"; -import { ZodLiteral, ZodObject } from "zod/v4"; - -type MethodSchema = ZodObject<{ method: ZodLiteral }>; - /** - * Per-event state: a singular `on*` handler (replace semantics) plus a - * listener array (`addEventListener` semantics), mirroring the DOM model - * where `el.onclick` and `el.addEventListener("click", …)` coexist. + * Per-event state: a singular `on*` handler (replace semantics) plus an + * ordered listener array (`addEventListener` semantics). */ interface EventSlot { onHandler?: ((params: T) => void) | undefined; @@ -19,118 +8,19 @@ interface EventSlot { } /** - * Intermediate base class that adds DOM-style event support on top of the - * MCP SDK's `Protocol`. + * Protocol-independent DOM-style event fan-out. * - * The base `Protocol` class stores one handler per method: - * `setRequestHandler()` and `setNotificationHandler()` replace any existing - * handler for the same method silently. This class introduces a two-channel - * event model inspired by the DOM: + * Owners perform event-specific state updates before calling + * {@link dispatch}. Dispatch then invokes the singular handler followed by a + * snapshot of listeners in insertion order. * - * ### Singular `on*` handler (like `el.onclick`) - * - * Subclasses expose `get`/`set` pairs that delegate to - * {@link setEventHandler `setEventHandler`} / - * {@link getEventHandler `getEventHandler`}. Assigning replaces the previous - * handler; assigning `undefined` clears it. `addEventListener` listeners are - * unaffected. - * - * ### Multi-listener (`addEventListener` / `removeEventListener`) - * - * Append to a per-event listener array. Listeners fire in insertion order - * after the singular `on*` handler. - * - * ### Dispatch order - * - * When a notification arrives for a mapped event: - * 1. {@link onEventDispatch `onEventDispatch`} (subclass side-effects) - * 2. The singular `on*` handler (if set) - * 3. All `addEventListener` listeners in insertion order - * - * ### Double-set protection - * - * Direct calls to {@link setRequestHandler `setRequestHandler`} / - * {@link setNotificationHandler `setNotificationHandler`} throw if a handler - * for the same method has already been registered (through any path), so - * accidental overwrites surface as errors instead of silent bugs. - * - * @typeParam EventMap - Maps event names to the listener's `params` type. + * @typeParam EventMap - Maps event names to their listener parameter types. */ -export abstract class ProtocolWithEvents< - SendRequestT extends Request, - SendNotificationT extends Notification, - SendResultT extends Result, - EventMap extends Record, -> extends Protocol { - private _registeredMethods = new Set(); - private _eventSlots = new Map(); +export class EventDispatcher> { + private readonly _eventSlots = new Map(); - /** - * Event name → notification schema. Subclasses populate this so that - * the event system can lazily register a dispatcher with the correct - * schema on first use. - */ - protected abstract readonly eventSchemas: { - [K in keyof EventMap]: MethodSchema; - }; - - /** - * Called once per incoming notification, before any handlers or listeners - * fire. Subclasses may override to perform side effects such as merging - * notification params into cached state. - */ - protected onEventDispatch( - _event: K, - _params: EventMap[K], - ): void {} - - // ── Event system (DOM model) ──────────────────────────────────────── - - /** - * Lazily create the event slot and register a single dispatcher with the - * base `Protocol`. The dispatcher fans out to the `on*` handler and all - * `addEventListener` listeners. - */ - private _ensureEventSlot( - event: K, - ): EventSlot { - let slot = this._eventSlots.get(event) as - | EventSlot - | undefined; - if (!slot) { - const schema = this.eventSchemas[event]; - if (!schema) { - throw new Error(`Unknown event: ${String(event)}`); - } - slot = { listeners: [] }; - this._eventSlots.set(event, slot as EventSlot); - - // Claim this method so direct setNotificationHandler calls throw. - const method = schema.shape.method.value; - this._registeredMethods.add(method); - - const s = slot; // stable reference for the closure - super.setNotificationHandler(schema, (n) => { - const params = (n as { params: EventMap[K] }).params; - this.onEventDispatch(event, params); - // 1. Singular on* handler - s.onHandler?.(params); - // 2. addEventListener listeners — snapshot to tolerate removal during - // dispatch (e.g., a listener that calls removeEventListener on itself) - for (const l of [...s.listeners]) l(params); - }); - } - return slot; - } - - /** - * Set or clear the singular `on*` handler for an event. - * - * Replace semantics — like the DOM's `el.onclick = fn`. Assigning - * `undefined` clears the handler without affecting `addEventListener` - * listeners. - */ - protected setEventHandler( + /** Set, replace, or clear the singular handler for an event. */ + setHandler( event: K, handler: ((params: EventMap[K]) => void) | undefined, ): void { @@ -144,31 +34,15 @@ export abstract class ProtocolWithEvents< slot.onHandler = handler; } - /** - * Get the singular `on*` handler for an event, or `undefined` if none is - * set. `addEventListener` listeners are not reflected here. - */ - protected getEventHandler( + /** Get the singular handler for an event, if one is set. */ + getHandler( event: K, ): ((params: EventMap[K]) => void) | undefined { return (this._eventSlots.get(event) as EventSlot | undefined) ?.onHandler; } - /** - * Add a listener for a notification event. - * - * Unlike the singular `on*` handler, calling this multiple times appends - * listeners rather than replacing them. All registered listeners fire in - * insertion order after the `on*` handler when the notification arrives. - * - * Registration is lazy: the first call (for a given event, from either - * this method or the `on*` setter) registers a dispatcher with the base - * `Protocol`. - * - * @param event - Event name (a key of the `EventMap` type parameter). - * @param handler - Listener invoked with the notification `params`. - */ + /** Add a listener. Listeners run in insertion order after the handler. */ addEventListener( event: K, handler: (params: EventMap[K]) => void, @@ -176,11 +50,7 @@ export abstract class ProtocolWithEvents< this._ensureEventSlot(event).listeners.push(handler); } - /** - * Remove a previously registered event listener. The dispatcher stays - * registered even if the listener array becomes empty; future - * notifications simply have no listeners to call. - */ + /** Remove the first matching listener, if present. */ removeEventListener( event: K, handler: (params: EventMap[K]) => void, @@ -193,87 +63,70 @@ export abstract class ProtocolWithEvents< if (idx !== -1) slot.listeners.splice(idx, 1); } - // ── Handler registration with double-set protection ───────────────── - - // The two overrides below are arrow-function class fields rather than - // prototype methods so that Protocol's constructor — which registers its - // own ping/cancelled/progress handlers via `this.setRequestHandler` - // before our fields initialize — hits the base implementation and skips - // tracking. Converting these to proper methods would crash with - // `_registeredMethods` undefined during super(). - /** - * Registers a request handler. Throws if a handler for the same method - * has already been registered — use the `on*` setter (replace semantics) - * or `addEventListener` (multi-listener) for notification events. - * - * @throws {Error} if a handler for this method is already registered. + * Dispatch an event to its singular handler and a listener snapshot. + * Listener mutations during dispatch take effect on the next dispatch. */ - override setRequestHandler: Protocol< - SendRequestT, - SendNotificationT, - SendResultT - >["setRequestHandler"] = (schema, handler) => { - this._assertMethodNotRegistered(schema, "setRequestHandler"); - super.setRequestHandler(schema, handler); - }; - - /** - * Registers a notification handler. Throws if a handler for the same - * method has already been registered — use the `on*` setter (replace - * semantics) or `addEventListener` (multi-listener) for mapped events. - * - * @throws {Error} if a handler for this method is already registered. - */ - override setNotificationHandler: Protocol< - SendRequestT, - SendNotificationT, - SendResultT - >["setNotificationHandler"] = (schema, handler) => { - this._assertMethodNotRegistered(schema, "setNotificationHandler"); - super.setNotificationHandler(schema, handler); - }; - - /** - * Warn if a request handler `on*` setter is replacing a previously-set - * handler. Call from each request setter before updating the backing field. - */ - protected warnIfRequestHandlerReplaced( - name: string, - previous: unknown, - next: unknown, - ): void { - if (previous && next) { - console.warn( - `[MCP Apps] ${name} handler replaced. ` + - `Previous handler will no longer be called.`, - ); - } + dispatch(event: K, params: EventMap[K]): void { + const slot = this._eventSlots.get(event) as + | EventSlot + | undefined; + if (!slot) return; + slot.onHandler?.(params); + for (const l of [...slot.listeners]) l(params); } - /** - * Replace a request handler, bypassing double-set protection. Used by - * `on*` request-handler setters that need replace semantics. - */ - protected replaceRequestHandler: Protocol< - SendRequestT, - SendNotificationT, - SendResultT - >["setRequestHandler"] = (schema, handler) => { - const method = (schema as MethodSchema).shape.method.value; - this._registeredMethods.add(method); - super.setRequestHandler(schema, handler); - }; + private _ensureEventSlot( + event: K, + ): EventSlot { + let slot = this._eventSlots.get(event) as + | EventSlot + | undefined; + if (!slot) { + slot = { listeners: [] }; + this._eventSlots.set(event, slot as EventSlot); + } + return slot; + } +} - private _assertMethodNotRegistered(schema: unknown, via: string): void { - const method = (schema as MethodSchema).shape.method.value; - if (this._registeredMethods.has(method)) { +/** + * Tracks which JSON-RPC methods already have a handler registered through the + * owning `App` / `AppBridge`, so a second direct `setRequestHandler` / + * `setNotificationHandler` call for the same method throws instead of + * silently replacing the first handler (the base SDK `Protocol` replaces). + * + * The `on*` setters use {@link replace} for DOM-style replace semantics, and + * `removeRequestHandler` / `removeNotificationHandler` use {@link release}. + * + * @internal + */ +export class MethodRegistry { + private readonly _methods = new Set(); + + /** Claim `method`. Throws if a handler is already registered for it. */ + claim(method: string, via: string): void { + if (this._methods.has(method)) { throw new Error( `Handler for "${method}" already registered (via ${via}). ` + `Use addEventListener() to attach multiple listeners, ` + `or the on* setter for replace semantics.`, ); } - this._registeredMethods.add(method); + this._methods.add(method); + } + + /** Claim `method` with replace semantics (never throws). */ + replace(method: string): void { + this._methods.add(method); + } + + /** Release `method` so it can be claimed again. */ + release(method: string): void { + this._methods.delete(method); + } + + has(method: string): boolean { + return this._methods.has(method); } } diff --git a/src/generated/schema.json b/src/generated/schema.json index 80b4ac6..246ed7d 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -9,10 +9,19 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features (structure TBD).", + "description": "Experimental features keyed by identifier.", "type": "object", - "properties": {}, - "additionalProperties": false + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Experimental features keyed by identifier." + } }, "tools": { "description": "App exposes MCP-style tools that the host can call.", @@ -298,10 +307,19 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features (structure TBD).", + "description": "Experimental features keyed by identifier.", "type": "object", - "properties": {}, - "additionalProperties": false + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Experimental features keyed by identifier." + } }, "openLinks": { "description": "Host supports opening external URLs.", @@ -596,7 +614,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {} + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } }, "required": { "type": "array", @@ -611,25 +631,10 @@ "outputSchema": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "object" - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "required": { - "type": "array", - "items": { - "type": "string" - } + "$schema": { + "type": "string" } }, - "required": ["type"], "additionalProperties": {} }, "annotations": { @@ -1286,7 +1291,40 @@ } }, "required": ["method", "params"], - "additionalProperties": false + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + ] + } + } }, "McpUiHostContext": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1359,7 +1397,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {} + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } }, "required": { "type": "array", @@ -1374,25 +1414,10 @@ "outputSchema": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "object" - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "required": { - "type": "array", - "items": { - "type": "string" - } + "$schema": { + "type": "string" } }, - "required": ["type"], "additionalProperties": {} }, "annotations": { @@ -2044,7 +2069,40 @@ "additionalProperties": false } }, - "additionalProperties": {} + "additionalProperties": {}, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + ] + } + } }, "McpUiHostCss": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -2537,10 +2595,19 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features (structure TBD).", + "description": "Experimental features keyed by identifier.", "type": "object", - "properties": {}, - "additionalProperties": false + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Experimental features keyed by identifier." + } }, "tools": { "description": "App exposes MCP-style tools that the host can call.", @@ -2651,10 +2718,19 @@ "type": "object", "properties": { "experimental": { - "description": "Experimental features (structure TBD).", + "description": "Experimental features keyed by identifier.", "type": "object", - "properties": {}, - "additionalProperties": false + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Experimental features keyed by identifier." + } }, "openLinks": { "description": "Host supports opening external URLs.", @@ -2942,7 +3018,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {} + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } }, "required": { "type": "array", @@ -2957,25 +3035,10 @@ "outputSchema": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "object" - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "required": { - "type": "array", - "items": { - "type": "string" - } + "$schema": { + "type": "string" } }, - "required": ["type"], "additionalProperties": {} }, "annotations": { @@ -3637,7 +3700,40 @@ "hostCapabilities", "hostContext" ], - "additionalProperties": {} + "additionalProperties": {}, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + ] + } + } }, "McpUiInitializedNotification": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -5324,26 +5420,52 @@ "_meta": { "type": "object", "properties": { - "progressToken": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "io.modelcontextprotocol/related-task": { + "io.modelcontextprotocol/serverInfo": { "type": "object", "properties": { - "taskId": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "icons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizes": { + "type": "array", + "items": { + "type": "string" + } + }, + "theme": { + "type": "string", + "enum": ["light", "dark"] + } + }, + "required": ["src"], + "additionalProperties": false + } + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + }, + "description": { "type": "string" } }, - "required": ["taskId"], + "required": ["name", "version"], "additionalProperties": false } }, @@ -5669,13 +5791,7 @@ ] } }, - "structuredContent": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, + "structuredContent": {}, "isError": { "type": "boolean" } diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 4368737..214b227 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -10,7 +10,7 @@ import { RequestIdSchema, ResourceLinkSchema, ToolSchema, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/core"; /** * @description Color theme preference for the host environment. @@ -496,11 +496,16 @@ export const McpUiRequestTeardownNotificationSchema = z.object({ * @see {@link McpUiInitializeResult `McpUiInitializeResult`} for the initialization result that includes these capabilities */ export const McpUiHostCapabilitiesSchema = z.object({ - /** @description Experimental features (structure TBD). */ + /** @description Experimental features keyed by identifier. */ experimental: z - .object({}) + .record( + z.string(), + z + .record(z.string(), z.any()) + .describe("Experimental features keyed by identifier."), + ) .optional() - .describe("Experimental features (structure TBD)."), + .describe("Experimental features keyed by identifier."), /** @description Host supports opening external URLs. */ openLinks: z .object({}) @@ -583,11 +588,16 @@ export const McpUiHostCapabilitiesSchema = z.object({ * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} for the initialization request that includes these capabilities */ export const McpUiAppCapabilitiesSchema = z.object({ - /** @description Experimental features (structure TBD). */ + /** @description Experimental features keyed by identifier. */ experimental: z - .object({}) + .record( + z.string(), + z + .record(z.string(), z.any()) + .describe("Experimental features keyed by identifier."), + ) .optional() - .describe("Experimental features (structure TBD)."), + .describe("Experimental features keyed by identifier."), /** @description App exposes MCP-style tools that the host can call. */ tools: z .object({ diff --git a/src/interop-v1.test.ts b/src/interop-v1.test.ts new file mode 100644 index 0000000..31dbfa1 --- /dev/null +++ b/src/interop-v1.test.ts @@ -0,0 +1,379 @@ +/** + * Cross-version interop against the *published* 1.x package. + * + * `ext-apps-v1` is an npm alias for `@modelcontextprotocol/ext-apps@1.7.5` + * (built on `@modelcontextprotocol/sdk` 1.x). Each pairing wires a real + * `App` to a real `AppBridge` of the other major over an in-memory duplex, + * with the bridge fronting a real MCP `Client`+`McpServer` of its own SDK + * generation, and asserts the observable outcomes. Complements + * `wire-compat.test.ts`, which replays hand-shaped 1.x JSON instead. + */ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + Client as ClientV2, + InMemoryTransport as InMemoryTransportV2, + ProtocolError, + type Transport, +} from "@modelcontextprotocol/client"; +import { EmptyResultSchema as EmptyResultSchemaV2 } from "@modelcontextprotocol/core"; +import { McpServer as McpServerV2 } from "@modelcontextprotocol/server"; +import { Client as ClientV1 } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport as InMemoryTransportV1 } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer as McpServerV1 } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Protocol as ProtocolV1 } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + EmptyResultSchema as EmptyResultSchemaV1, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import { App as AppV1 } from "ext-apps-v1"; +import { AppBridge as AppBridgeV1 } from "ext-apps-v1/app-bridge"; +import { z } from "zod/v4"; + +import { App as AppV2 } from "./app.js"; +import { AppBridge as AppBridgeV2 } from "./app-bridge.js"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); + +/** In-memory duplex. Structurally a `Transport` for both SDK generations. */ +function createDuplex(): [Transport, Transport] { + const make = () => { + let closed = false; + const t: Transport & { peer?: typeof t } = { + async start() {}, + async send(message) { + // Serialize like a real transport so each side parses plain JSON. + t.peer?.onmessage?.(JSON.parse(JSON.stringify(message))); + }, + async close() { + if (closed) return; + closed = true; + t.onclose?.(); + await t.peer?.close(); + }, + }; + return t; + }; + const [a, b] = [make(), make()]; + a.peer = b; + b.peer = a; + return [a, b]; +} + +/** One SDK generation's MCP pieces, as used by the App side or the host side. */ +const sdkV1 = { + Client: ClientV1, + McpServer: McpServerV1, + InMemoryTransport: InMemoryTransportV1, + EmptyResultSchema: EmptyResultSchemaV1, + error: (code: number, message: string): Error => new McpError(code, message), +}; +const sdkV2 = { + Client: ClientV2, + McpServer: McpServerV2, + InMemoryTransport: InMemoryTransportV2, + EmptyResultSchema: EmptyResultSchemaV2, + error: (code: number, message: string): Error => + new ProtocolError(code, message), +}; + +const pairings = [ + { + name: "2.x App with 1.x AppBridge (sdk 1.x host)", + App: AppV2 as any, + AppBridge: AppBridgeV1 as any, + view: sdkV2, + host: sdkV1, + // sdk 1.x reports a Zod params failure as InternalError. + invalidParamsCode: -32603, + // sdk 1.x forwards a handler-thrown -32002 unchanged. + handlerThrown32002Code: -32002, + // McpServer 1.x answers an unknown tool with an isError result. + unknownToolRejects: false, + }, + { + name: "1.x App with 2.x AppBridge (sdk 2.x host)", + App: AppV1 as any, + AppBridge: AppBridgeV2 as any, + view: sdkV1, + host: sdkV2, + invalidParamsCode: -32602, + // SDK 2.x never emits -32002 on the wire (see wire-compat.test.ts). + handlerThrown32002Code: -32602, + // McpServer 2.x answers an unknown tool with a JSON-RPC error. + unknownToolRejects: true, + }, +]; + +const hostInfo = { name: "TestHost", version: "9.9.9" }; +const appInfo = { name: "TestView", version: "1.2.3" }; +const hostCapabilities = { + openLinks: {}, + serverTools: {}, + serverResources: {}, + logging: {}, + updateModelContext: {}, +}; +const hostContext = { theme: "dark", displayMode: "inline", locale: "en-US" }; + +it("pits 2.x against the published 1.x package, not against itself", () => { + const v1PackageJson = JSON.parse( + readFileSync( + new URL("../../package.json", import.meta.resolve("ext-apps-v1")), + "utf8", + ), + ); + expect(v1PackageJson).toMatchObject({ + name: "@modelcontextprotocol/ext-apps", + version: "1.7.5", + }); + // 1.x classes extend the sdk-1 Protocol; 2.x classes do not. + expect(AppV1.prototype).toBeInstanceOf(ProtocolV1); + expect(AppBridgeV1.prototype).toBeInstanceOf(ProtocolV1); + expect(AppV2.prototype).not.toBeInstanceOf(ProtocolV1); + expect(AppBridgeV2.prototype).not.toBeInstanceOf(ProtocolV1); +}); + +// The tests in each pairing share one connected App/AppBridge fixture and run +// in file order (bun does not randomize); later tests assume earlier ones +// left the pair connected. +describe.each(pairings)("$name", (pairing) => { + const { view, host } = pairing; + let server: any, client: any, bridge: any, app: any; + const events: string[] = []; + const record = (name: string) => (params?: unknown) => { + events.push(`${name}:${JSON.stringify(params ?? null)}`); + }; + const drain = () => events.splice(0); + // Captured inside oninitialized: the bridge must have processed + // ui/initialize before ui/notifications/initialized arrives. + let appVersionAtInitialized: unknown = "not-fired"; + let hostAbortReason: unknown; + + beforeAll(async () => { + server = new host.McpServer({ name: "TestServer", version: "1.0.0" }); + server.registerTool( + "echo", + { inputSchema: z.object({ a: z.number() }) }, + async ({ a }: { a: number }) => ({ + content: [{ type: "text", text: `echo ${a}` }], + }), + ); + server.registerTool("boom", { inputSchema: z.object({}) }, async () => { + throw new Error("tool exploded"); + }); + server.registerResource( + "res", + "test://res", + { mimeType: "text/plain" }, + async (uri: URL) => ({ + contents: [{ uri: uri.href, mimeType: "text/plain", text: "hello" }], + }), + ); + const [clientTransport, serverTransport] = + host.InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + client = new host.Client({ name: "HostOuterClient", version: "1.0.0" }); + await client.connect(clientTransport); + + bridge = new pairing.AppBridge(client, hostInfo, hostCapabilities, { + hostContext, + }); + app = new pairing.App( + appInfo, + { tools: { listChanged: true } }, + { + autoResize: false, + }, + ); + bridge.oninitialized = () => { + appVersionAtInitialized = bridge.getAppVersion(); + }; + for (const name of ["onsizechange", "onloggingmessage"]) { + bridge[name] = record(`bridge.${name}`); + } + for (const name of ["onmessage", "onupdatemodelcontext"]) { + bridge[name] = async (params: unknown) => { + record(`bridge.${name}`)(params); + return {}; + }; + } + bridge.onopenlink = (params: { url: string }, extra: any) => { + record("bridge.onopenlink")(params); + if (params.url !== "https://slow") return {}; + // Hang until the view cancels; sdk 1.x passes `extra.signal`, 2.x `extra.mcpReq.signal`. + const signal: AbortSignal = extra.signal ?? extra.mcpReq.signal; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + hostAbortReason = signal.reason; + reject(new Error("aborted")); + }); + }); + }; + for (const name of [ + "ontoolinput", + "ontoolresult", + "ontoolcancelled", + "onhostcontextchanged", + ]) { + app[name] = record(`app.${name}`); + } + app.onteardown = async (params: unknown) => { + record("app.onteardown")(params); + return {}; + }; + }); + + afterAll(async () => { + await app?.close().catch(() => {}); + await bridge?.close().catch(() => {}); + await client?.close().catch(() => {}); + await server?.close().catch(() => {}); + }); + + it("completes ui/initialize and delivers initialized in order", async () => { + const [appTransport, bridgeTransport] = createDuplex(); + // Tap the raw ui/initialize result so an additive key on the 2.x side + // (which the getters below would not surface) still fails here. + let initializeResult: unknown; + const send = bridgeTransport.send.bind(bridgeTransport); + bridgeTransport.send = async (message, options) => { + if ("result" in message && "id" in message && message.id === 0) { + initializeResult = message.result; + } + await send(message, options); + }; + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + await flush(); + + expect(app.getHostContext()).toEqual(hostContext); + expect(app.getHostCapabilities()).toEqual(hostCapabilities); + expect(app.getHostVersion()).toEqual(hostInfo); + expect(bridge.getAppCapabilities()).toEqual({ + tools: { listChanged: true }, + }); + expect(appVersionAtInitialized).toEqual(appInfo); + expect(initializeResult).toEqual({ + protocolVersion: "2026-01-26", + hostCapabilities, + hostInfo, + hostContext, + }); + }); + + it("delivers host-to-view notifications", async () => { + bridge.sendToolInput({ arguments: { location: "NYC" } }); + bridge.sendToolResult({ content: [{ type: "text", text: "sunny" }] }); + bridge.sendToolCancelled({ reason: "user" }); + bridge.setHostContext({ theme: "light" }); + await flush(); + + expect(drain()).toEqual([ + 'app.ontoolinput:{"arguments":{"location":"NYC"}}', + 'app.ontoolresult:{"content":[{"type":"text","text":"sunny"}]}', + 'app.ontoolcancelled:{"reason":"user"}', + 'app.onhostcontextchanged:{"theme":"light"}', + ]); + expect(app.getHostContext()).toEqual({ ...hostContext, theme: "light" }); + }); + + it("round-trips view-to-host requests and notifications", async () => { + const message = { role: "user", content: [{ type: "text", text: "hi" }] }; + expect(await app.sendMessage(message)).toEqual({}); + expect(await app.openLink({ url: "https://example.com" })).toEqual({}); + expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({ + mode: "inline", + }); + bridge.onrequestdisplaymode = async (params: { mode: string }) => params; + expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({ + mode: "fullscreen", + }); + const context = { content: [{ type: "text", text: "ctx" }] }; + expect(await app.updateModelContext(context)).toEqual({}); + await app.sendLog({ level: "info", data: { m: "hello" } }); + await app.sendSizeChanged({ width: 320, height: 240 }); + await flush(); + + expect(drain()).toEqual([ + `bridge.onmessage:${JSON.stringify(message)}`, + 'bridge.onopenlink:{"url":"https://example.com"}', + `bridge.onupdatemodelcontext:${JSON.stringify(context)}`, + 'bridge.onloggingmessage:{"level":"info","data":{"m":"hello"}}', + 'bridge.onsizechange:{"width":320,"height":240}', + ]); + }); + + it("proxies tools/call and resources/read to the real MCP server", async () => { + expect( + await app.callServerTool({ name: "echo", arguments: { a: 1 } }), + ).toEqual({ content: [{ type: "text", text: "echo 1" }] }); + expect( + await app.callServerTool({ name: "boom", arguments: {} }), + ).toMatchObject({ + isError: true, + content: [{ type: "text", text: "tool exploded" }], + }); + const unknown = app.callServerTool({ name: "nope", arguments: {} }); + if (pairing.unknownToolRejects) { + await expect(unknown).rejects.toMatchObject({ code: -32602 }); + } else { + expect(await unknown).toMatchObject({ isError: true }); + } + expect(await app.readServerResource({ uri: "test://res" })).toEqual({ + contents: [{ uri: "test://res", mimeType: "text/plain", text: "hello" }], + }); + }); + + it("propagates JSON-RPC errors with the expected codes", async () => { + const raw = (method: string, params: unknown) => + app.request({ method, params }, view.EmptyResultSchema); + await expect(raw("ui/does-not-exist", {})).rejects.toMatchObject({ + code: -32601, + }); + await expect(raw("ui/open-link", { url: 42 })).rejects.toMatchObject({ + code: pairing.invalidParamsCode, + }); + bridge.onreadresource = async () => { + throw host.error(-32002, "Resource not found (host-thrown -32002)"); + }; + const notFound = app.readServerResource({ uri: "test://missing" }); + await expect(notFound).rejects.toMatchObject({ + code: pairing.handlerThrown32002Code, + message: expect.stringContaining( + "Resource not found (host-thrown -32002)", + ), + }); + }); + + it("cancels an in-flight request via AbortSignal on both ends", async () => { + const controller = new AbortController(); + const pending = app.openLink( + { url: "https://slow" }, + { signal: controller.signal }, + ); + await flush(); + controller.abort("user-cancelled"); + await expect(pending).rejects.toMatchObject({ + message: expect.stringContaining("user-cancelled"), + }); + await flush(); + expect(String(hostAbortReason)).toContain("user-cancelled"); + drain(); + }); + + it("tears down, then closes both ends", async () => { + expect(await bridge.teardownResource({})).toEqual({}); + expect(drain()).toEqual(["app.onteardown:{}"]); + + const closed: string[] = []; + app.onclose = () => closed.push("app"); + bridge.onclose = () => closed.push("bridge"); + await app.close(); + await flush(); + expect(closed.sort()).toEqual(["app", "bridge"]); + await expect(app.openLink({ url: "https://after-close" })).rejects.toThrow( + "Not connected", + ); + }); +}); diff --git a/src/legacy-handlers.test.ts b/src/legacy-handlers.test.ts new file mode 100644 index 0000000..0ef36d6 --- /dev/null +++ b/src/legacy-handlers.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { InMemoryTransport, Server } from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; + +import { App } from "./app.js"; +import { AppBridge } from "./app-bridge.js"; +import { + McpUiInitializeRequestSchema, + McpUiInitializeResultSchema, + McpUiInitializedNotificationSchema, + McpUiResourceTeardownRequestSchema, + McpUiToolInputNotificationSchema, + McpUiSizeChangedNotificationSchema, + McpUiOpenLinkRequestSchema, +} from "./types.js"; + +const CustomRequestSchema = z.object({ + method: z.literal("test/echo"), + params: z.object({ text: z.string() }), +}); + +const CustomNotificationSchema = z.object({ + method: z.literal("test/ping"), + params: z.object({ n: z.number() }), +}); + +let warn: ReturnType; +beforeEach(() => { + warn = spyOn(console, "warn").mockImplementation(() => {}); +}); +afterEach(() => warn.mockRestore()); + +describe("1.x setRequestHandler(Schema, handler) on App", () => { + it("registers a custom request and hands the handler the whole message plus a 1.x extra", async () => { + const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); + const server = new Server( + { name: "s", version: "1" }, + { capabilities: {} }, + ); + server.setRequestHandler( + "ui/initialize", + { + params: McpUiInitializeRequestSchema.shape.params, + result: McpUiInitializeResultSchema, + }, + () => ({ + protocolVersion: "2026-01-26", + hostCapabilities: {}, + hostInfo: { name: "h", version: "1" }, + hostContext: {}, + }), + ); + server.setNotificationHandler( + "ui/notifications/initialized", + { params: McpUiInitializedNotificationSchema.shape.params }, + () => {}, + ); + + let seen: unknown; + let seenExtra: { signal: AbortSignal; requestId: unknown } | undefined; + app.setRequestHandler(CustomRequestSchema, (request, extra) => { + seen = request; + seenExtra = extra; + return { echoed: request.params.text }; + }); + + const [at, st] = InMemoryTransport.createLinkedPair(); + await server.connect(st); + await app.connect(at); + + const result = await server.request( + { method: "test/echo", params: { text: "hi" } }, + z.object({ echoed: z.string() }), + ); + + expect(result).toEqual({ echoed: "hi" }); + expect(seen).toEqual({ method: "test/echo", params: { text: "hi" } }); + expect(seenExtra?.signal).toBeInstanceOf(AbortSignal); + expect(seenExtra?.requestId).toBeDefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain( + 'setRequestHandler("test/echo", { params: Schema.shape.params }, handler)', + ); + + await app.close(); + await server.close(); + }); + + it("still throws for a method an on* setter already owns", () => { + const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); + app.onteardown = async () => ({}); + expect(() => + app.setRequestHandler( + McpUiResourceTeardownRequestSchema, + async () => ({}), + ), + ).toThrow(/already registered/); + }); + + it("still throws for an event-mapped notification", () => { + const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); + app.ontoolinput = () => {}; + expect(() => + app.setNotificationHandler(McpUiToolInputNotificationSchema, () => {}), + ).toThrow(/already registered/); + }); +}); + +describe("1.x setNotificationHandler(Schema, handler) on AppBridge", () => { + it("registers a custom notification and hands the handler the whole message", async () => { + const bridge = new AppBridge(null, { name: "h", version: "1" }, {}); + // A bare Server stands in for the View: unlike Client, connect() sends no + // MCP initialize, matching what a real App does on this channel. + const view = new Server({ name: "v", version: "1" }, { capabilities: {} }); + let seen: unknown; + bridge.setNotificationHandler(CustomNotificationSchema, (n) => { + seen = n; + }); + + const [bt, vt] = InMemoryTransport.createLinkedPair(); + await bridge.connect(bt); + await view.connect(vt); + await view.notification({ method: "test/ping", params: { n: 3 } }); + await new Promise((r) => setTimeout(r, 10)); + + expect(seen).toEqual({ method: "test/ping", params: { n: 3 } }); + await view.close(); + await bridge.close(); + }); + + it("still throws for a method an on* setter already owns", () => { + const bridge = new AppBridge(null, { name: "h", version: "1" }, {}); + bridge.onsizechange = () => {}; + expect(() => + bridge.setNotificationHandler( + McpUiSizeChangedNotificationSchema, + () => {}, + ), + ).toThrow(/already registered/); + bridge.onopenlink = async () => ({}); + expect(() => + bridge.setRequestHandler(McpUiOpenLinkRequestSchema, async () => ({})), + ).toThrow(/already registered/); + }); +}); diff --git a/src/legacy-handlers.ts b/src/legacy-handlers.ts new file mode 100644 index 0000000..237b8eb --- /dev/null +++ b/src/legacy-handlers.ts @@ -0,0 +1,121 @@ +import type { BaseContext, RequestId } from "@modelcontextprotocol/client"; +import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; + +/** + * A whole-message Zod schema (`method` literal plus `params`), as accepted by + * the 1.x `setRequestHandler(Schema, handler)` forms. + */ +export type LegacyMethodSchema = ZodObject<{ + method: ZodLiteral; + params: ZodType; +}>; + +/** + * The `extra` argument 1.x request handlers received. + * + * @deprecated Use the 2.x `BaseContext` (`extra.mcpReq.signal`, `extra.mcpReq.id`). + */ +export type LegacyRequestHandlerExtra = { + signal: AbortSignal; + requestId: RequestId; + sessionId?: string; + _meta?: BaseContext["mcpReq"]["_meta"]; + sendRequest: BaseContext["mcpReq"]["send"]; + sendNotification: BaseContext["mcpReq"]["notify"]; + authInfo?: NonNullable["authInfo"]; +}; + +/** @deprecated Use `setRequestHandler("method", { params, result }, (params, ctx) => …)`. */ +export type LegacyRequestHandler = ( + request: S["_output"], + extra: LegacyRequestHandlerExtra, +) => unknown; + +/** @deprecated Use `setNotificationHandler("method", { params }, (params) => …)`. */ +export type LegacyNotificationHandler = ( + notification: S["_output"], +) => void | Promise; + +/** + * `setRequestHandler` with the 1.x `(Schema, handler)` form kept as a + * deprecated overload next to the 2.x forms in `Modern`. + */ +export type LegacyRequestHandlerSetter = Modern & { + /** + * @deprecated 1.x form. Pass the method name and `{ params }` instead; + * removed in 3.0. + */ + ( + schema: S, + handler: LegacyRequestHandler, + ): void; +}; + +/** `setNotificationHandler` counterpart of {@link LegacyRequestHandlerSetter}. */ +export type LegacyNotificationHandlerSetter = Modern & { + /** + * @deprecated 1.x form. Pass the method name and `{ params }` instead; + * removed in 3.0. + */ + ( + schema: S, + handler: LegacyNotificationHandler, + ): void; +}; + +/** + * Arguments for the 2.x three-argument `setRequestHandler` / + * `setNotificationHandler` form. + */ +type ModernArgs = [ + method: string, + schemas: { params: ZodType }, + handler: (params: unknown, ctx: BaseContext) => unknown, +]; + +const warned = new Set(); + +/** + * Translate a 1.x `(Schema, handler)` registration into the 2.x + * `(method, { params }, handler)` form, or return `undefined` when the call + * is already in the 2.x form. The 1.x handler receives the reassembled + * `{ method, params }` message and, for requests, a 1.x-shaped `extra`. + */ +export function toModernArgs( + kind: "request" | "notification", + args: unknown[], +): ModernArgs | undefined { + const [first, handler] = args; + const shape = (first as Partial | undefined)?.shape; + if (typeof shape?.method?.value !== "string") return undefined; + const method = shape.method.value; + const setter = + kind === "request" ? "setRequestHandler" : "setNotificationHandler"; + if (!warned.has(setter)) { + warned.add(setter); + console.warn( + `[ext-apps] ${setter}(Schema, handler) is deprecated; use ` + + `${setter}("${method}", { params: Schema.shape.params }, handler).`, + ); + } + const call = handler as (...a: unknown[]) => unknown; + return [ + method, + { params: shape.params }, + kind === "request" + ? (params, ctx) => call({ method, params }, legacyExtra(ctx)) + : (params) => call({ method, params }), + ]; +} + +function legacyExtra(ctx: BaseContext): LegacyRequestHandlerExtra { + return { + signal: ctx.mcpReq.signal, + requestId: ctx.mcpReq.id, + sessionId: ctx.sessionId, + _meta: ctx.mcpReq._meta, + sendRequest: ctx.mcpReq.send, + sendNotification: ctx.mcpReq.notify, + authInfo: ctx.http?.authInfo, + }; +} diff --git a/src/message-transport.test.ts b/src/message-transport.test.ts new file mode 100644 index 0000000..e2518cc --- /dev/null +++ b/src/message-transport.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import type { JSONRPCMessage } from "@modelcontextprotocol/client"; + +import { PostMessageTransport } from "./message-transport.js"; + +/** + * Minimal `window` stub for bun's DOM-less test environment. + * + * Captures listeners registered via `addEventListener` so tests can dispatch + * fake `MessageEvent`-like objects directly. We deliberately avoid pulling in + * jsdom/happy-dom — this file tests the transport's contract, not the browser. + */ +type Listener = (event: MessageEvent) => void; + +function createFakeWindow() { + const listeners = new Map>(); + return { + addEventListener(type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + removeEventListener(type: string, listener: Listener) { + listeners.get(type)?.delete(listener); + }, + /** Test helper: invoke all listeners for `type` with the given event. */ + dispatch(type: string, event: unknown) { + listeners.get(type)?.forEach((l) => l(event as MessageEvent)); + }, + /** Test helper: how many listeners are registered for `type`? */ + listenerCount(type: string) { + return listeners.get(type)?.size ?? 0; + }, + }; +} + +/** + * A valid JSON-RPC request payload. Used by most happy-path tests. + * Kept deliberately minimal — we care that *a* valid message is delivered, + * not about exercising every JSON-RPC shape (that's the schema's job). + */ +const validRequest: JSONRPCMessage = { + jsonrpc: "2.0", + id: 1, + method: "ping", +}; + +describe("PostMessageTransport", () => { + let fakeWindow: ReturnType; + let trustedSource: object; + let untrustedSource: object; + + /** Stand-in for the target window (e.g. window.parent or iframe.contentWindow). */ + let targetPostMessage: ReturnType; + let eventTarget: { postMessage: typeof targetPostMessage }; + + // The transport logs liberally at debug/error level. Silence during tests + // so failures are readable; restore afterwards so we don't leak state. + let restoreConsole: () => void; + + beforeEach(() => { + fakeWindow = createFakeWindow(); + (globalThis as { window?: unknown }).window = fakeWindow; + + // Distinct object identities — the transport compares with `!==`. + trustedSource = { id: "trusted" }; + untrustedSource = { id: "untrusted" }; + + targetPostMessage = mock(() => {}); + eventTarget = { postMessage: targetPostMessage }; + + const origDebug = console.debug; + const origError = console.error; + console.debug = () => {}; + console.error = () => {}; + restoreConsole = () => { + console.debug = origDebug; + console.error = origError; + }; + }); + + afterEach(() => { + restoreConsole(); + delete (globalThis as { window?: unknown }).window; + }); + + /** Create and start a transport wired to the shared fakes. */ + async function createStartedTransport() { + const transport = new PostMessageTransport( + eventTarget as unknown as Window, + trustedSource as MessageEventSource, + ); + await transport.start(); + return transport; + } + + // ========================================================================== + // Source validation — the security boundary at this layer. + // The transport validates `event.source` (window identity), not `event.origin`. + // Origin checks live in the sandbox proxy relay between the two endpoints + // (see examples/basic-host/src/sandbox.ts). Here, source===contentWindow is + // the narrower check; the app side can't know its sandbox's origin anyway. + // ========================================================================== + describe("source validation", () => { + it("delivers messages from the configured eventSource", async () => { + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + transport.onmessage = (msg) => received.push(msg); + + fakeWindow.dispatch("message", { + source: trustedSource, + data: validRequest, + }); + + expect(received).toEqual([validRequest]); + }); + + it("drops messages from a different source", async () => { + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + const errors: Error[] = []; + transport.onmessage = (msg) => received.push(msg); + transport.onerror = (err) => errors.push(err); + + fakeWindow.dispatch("message", { + source: untrustedSource, + data: validRequest, + }); + + // The message is silently dropped — neither delivered nor surfaced as + // an error. An attacker flooding us with forged messages should not be + // able to DoS the error handler. + expect(received).toEqual([]); + expect(errors).toEqual([]); + }); + + it("drops messages with a null source", async () => { + // Some browser-injected messages (extensions, devtools) arrive with + // source === null. These must not reach the protocol layer. + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + transport.onmessage = (msg) => received.push(msg); + + fakeWindow.dispatch("message", { + source: null, + data: validRequest, + }); + + expect(received).toEqual([]); + }); + + it("continues delivering trusted messages after dropping an untrusted one", async () => { + // Regression guard: a rejected message must not break the listener or + // close the transport. + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + transport.onmessage = (msg) => received.push(msg); + + fakeWindow.dispatch("message", { + source: untrustedSource, + data: validRequest, + }); + fakeWindow.dispatch("message", { + source: trustedSource, + data: validRequest, + }); + + expect(received).toEqual([validRequest]); + }); + }); + + // ========================================================================== + // Message format validation. + // Three paths: valid → onmessage, non-JSON-RPC → silent, malformed → onerror. + // ========================================================================== + describe("message format validation", () => { + it("delivers valid JSON-RPC requests via onmessage", async () => { + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + transport.onmessage = (msg) => received.push(msg); + + fakeWindow.dispatch("message", { + source: trustedSource, + data: { jsonrpc: "2.0", id: 42, method: "tools/call" }, + }); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ id: 42, method: "tools/call" }); + }); + + it("delivers valid JSON-RPC notifications via onmessage", async () => { + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + transport.onmessage = (msg) => received.push(msg); + + fakeWindow.dispatch("message", { + source: trustedSource, + data: { jsonrpc: "2.0", method: "notifications/initialized" }, + }); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ + method: "notifications/initialized", + }); + }); + + it("silently ignores non-JSON-RPC payloads", async () => { + // Real iframes receive all sorts of ambient postMessage traffic: + // React DevTools, browser extensions, ad frames. These must not crash + // the transport or surface as protocol errors. + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + const errors: Error[] = []; + transport.onmessage = (msg) => received.push(msg); + transport.onerror = (err) => errors.push(err); + + fakeWindow.dispatch("message", { + source: trustedSource, + data: { type: "react-devtools-hook", payload: {} }, + }); + fakeWindow.dispatch("message", { source: trustedSource, data: "hello" }); + fakeWindow.dispatch("message", { source: trustedSource, data: null }); + fakeWindow.dispatch("message", { source: trustedSource, data: 42 }); + + expect(received).toEqual([]); + expect(errors).toEqual([]); + }); + + it("calls onerror for malformed messages claiming to be JSON-RPC", async () => { + // `jsonrpc: "2.0"` but missing required fields — this IS a protocol + // violation worth surfacing, unlike ambient noise. + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + const errors: Error[] = []; + transport.onmessage = (msg) => received.push(msg); + transport.onerror = (err) => errors.push(err); + + fakeWindow.dispatch("message", { + source: trustedSource, + data: { jsonrpc: "2.0" }, // no id/method/result — invalid + }); + + expect(received).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + }); + + it("does not throw when onerror is unset and a malformed message arrives", async () => { + const transport = await createStartedTransport(); + transport.onmessage = () => {}; + // onerror deliberately left unset + + expect(() => + fakeWindow.dispatch("message", { + source: trustedSource, + data: { jsonrpc: "2.0" }, + }), + ).not.toThrow(); + }); + + it("does not throw when onmessage is unset and a valid message arrives", async () => { + await createStartedTransport(); + // onmessage deliberately left unset — may happen briefly during wiring + + expect(() => + fakeWindow.dispatch("message", { + source: trustedSource, + data: validRequest, + }), + ).not.toThrow(); + }); + }); + + // ========================================================================== + // send() + // ========================================================================== + describe("send()", () => { + it("posts the message to the configured eventTarget with '*' origin", async () => { + const transport = await createStartedTransport(); + + const msg: JSONRPCMessage = { + jsonrpc: "2.0", + id: 7, + method: "ui/initialize", + }; + await transport.send(msg); + + expect(targetPostMessage).toHaveBeenCalledTimes(1); + expect(targetPostMessage).toHaveBeenCalledWith(msg, "*"); + }); + + it("posts multiple messages in order", async () => { + const transport = await createStartedTransport(); + + const a: JSONRPCMessage = { jsonrpc: "2.0", id: 1, method: "a" }; + const b: JSONRPCMessage = { jsonrpc: "2.0", id: 2, method: "b" }; + await transport.send(a); + await transport.send(b); + + expect(targetPostMessage).toHaveBeenCalledTimes(2); + expect(targetPostMessage.mock.calls[0][0]).toEqual(a); + expect(targetPostMessage.mock.calls[1][0]).toEqual(b); + }); + }); + + // ========================================================================== + // Lifecycle: start() / close() + // ========================================================================== + describe("lifecycle", () => { + it("start() registers a message listener on window", async () => { + const transport = new PostMessageTransport( + eventTarget as unknown as Window, + trustedSource as MessageEventSource, + ); + + expect(fakeWindow.listenerCount("message")).toBe(0); + await transport.start(); + expect(fakeWindow.listenerCount("message")).toBe(1); + }); + + it("close() removes the message listener", async () => { + const transport = await createStartedTransport(); + + expect(fakeWindow.listenerCount("message")).toBe(1); + await transport.close(); + expect(fakeWindow.listenerCount("message")).toBe(0); + }); + + it("messages dispatched after close() are not delivered", async () => { + const transport = await createStartedTransport(); + const received: JSONRPCMessage[] = []; + transport.onmessage = (msg) => received.push(msg); + + await transport.close(); + fakeWindow.dispatch("message", { + source: trustedSource, + data: validRequest, + }); + + expect(received).toEqual([]); + }); + + it("close() invokes onclose when set", async () => { + const transport = await createStartedTransport(); + const onclose = mock(() => {}); + transport.onclose = onclose; + + await transport.close(); + + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it("close() does not throw when onclose is unset", async () => { + const transport = await createStartedTransport(); + // onclose deliberately left unset + + expect(transport.close()).resolves.toBeUndefined(); + }); + + it("two transports listen independently", async () => { + // Host scenario: multiple iframes, each with its own transport. + // Each transport must only accept messages from its own iframe. + const sourceA = { id: "iframe-a" }; + const sourceB = { id: "iframe-b" }; + + const transportA = new PostMessageTransport( + eventTarget as unknown as Window, + sourceA as unknown as MessageEventSource, + ); + const transportB = new PostMessageTransport( + eventTarget as unknown as Window, + sourceB as unknown as MessageEventSource, + ); + await transportA.start(); + await transportB.start(); + + const receivedA: JSONRPCMessage[] = []; + const receivedB: JSONRPCMessage[] = []; + transportA.onmessage = (msg) => receivedA.push(msg); + transportB.onmessage = (msg) => receivedB.push(msg); + + fakeWindow.dispatch("message", { source: sourceA, data: validRequest }); + + expect(receivedA).toHaveLength(1); + expect(receivedB).toHaveLength(0); + + await transportA.close(); + await transportB.close(); + }); + }); +}); diff --git a/src/message-transport.ts b/src/message-transport.ts index 9d19543..afc85e9 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -1,13 +1,11 @@ -import { +import { JSONRPCMessageSchema } from "@modelcontextprotocol/core"; +import type { JSONRPCMessage, - JSONRPCMessageSchema, MessageExtraInfo, -} from "@modelcontextprotocol/sdk/types.js"; -import { Transport, TransportSendOptions, -} from "@modelcontextprotocol/sdk/shared/transport.js"; -import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types"; +} from "@modelcontextprotocol/client"; +import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types.js"; /** * JSON-RPC transport using `window.postMessage` for iframe↔parent communication. diff --git a/src/react/index.tsx b/src/react/index.tsx index e76e197..9693fb1 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -31,7 +31,7 @@ * } * ``` */ -export * from "./useApp"; -export * from "./useAutoResize"; -export * from "./useDocumentTheme"; -export * from "./useHostStyles"; +export * from "./useApp.js"; +export * from "./useAutoResize.js"; +export * from "./useDocumentTheme.js"; +export * from "./useHostStyles.js"; diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 6189246..a8e1a09 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -1,13 +1,12 @@ import { useEffect, useState } from "react"; -import { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import { Client } from "@modelcontextprotocol/sdk/client"; +import type { Implementation } from "@modelcontextprotocol/client"; import { App, AppOptions, McpUiAppCapabilities, PostMessageTransport, -} from "../app"; -export * from "../app"; +} from "../app.js"; +export * from "../app.js"; /** * Options for configuring the {@link useApp `useApp`} hook. diff --git a/src/react/useAutoResize.ts b/src/react/useAutoResize.ts index 78217d6..66af07e 100644 --- a/src/react/useAutoResize.ts +++ b/src/react/useAutoResize.ts @@ -1,5 +1,5 @@ import { useEffect, RefObject } from "react"; -import { App } from "../app"; +import { App } from "../app.js"; /** * React hook that automatically reports UI size changes to the host. diff --git a/src/react/useDocumentTheme.ts b/src/react/useDocumentTheme.ts index 2fdfcc2..3dc851e 100644 --- a/src/react/useDocumentTheme.ts +++ b/src/react/useDocumentTheme.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { getDocumentTheme } from "../styles"; -import { McpUiTheme } from "../types"; +import { getDocumentTheme } from "../styles.js"; +import { McpUiTheme } from "../types.js"; /** * React hook that provides the current document theme reactively. diff --git a/src/react/useHostStyles.ts b/src/react/useHostStyles.ts index e380efa..d8376a0 100644 --- a/src/react/useHostStyles.ts +++ b/src/react/useHostStyles.ts @@ -1,11 +1,11 @@ import { useEffect, useRef } from "react"; -import { App } from "../app"; +import { App } from "../app.js"; import { applyDocumentTheme, applyHostFonts, applyHostStyleVariables, -} from "../styles"; -import { McpUiHostContext } from "../types"; +} from "../styles.js"; +import { McpUiHostContext } from "../types.js"; /** * React hook that applies host style variables and theme as CSS custom properties. diff --git a/src/server/index.examples.ts b/src/server/index.examples.ts index d583ae8..9ceebce 100644 --- a/src/server/index.examples.ts +++ b/src/server/index.examples.ts @@ -9,17 +9,14 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; -import type { - McpServer, - ToolCallback, - ReadResourceCallback, -} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer, ToolCallback } from "@modelcontextprotocol/server"; import { z } from "zod"; import { registerAppTool, registerAppResource, getUiCapability, RESOURCE_MIME_TYPE, + type ReadResourceCallback, } from "./index.js"; // Stubs for external functions used in examples @@ -74,7 +71,7 @@ function registerAppTool_basicUsage(server: McpServer) { { title: "Get Weather", description: "Get current weather for a location", - inputSchema: { location: z.string() }, + inputSchema: z.object({ location: z.string() }), _meta: { ui: { resourceUri: "ui://weather/view.html" }, }, @@ -122,7 +119,7 @@ function registerAppTool_appOnlyVisibility(server: McpServer) { "update-quantity", { description: "Update item quantity in cart", - inputSchema: { itemId: z.string(), quantity: z.number() }, + inputSchema: z.object({ itemId: z.string(), quantity: z.number() }), _meta: { ui: { resourceUri: "ui://shop/cart.html", diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 1853cc0..489d0b0 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -6,8 +6,14 @@ import { RESOURCE_MIME_TYPE, getUiCapability, EXTENSION_ID, -} from "./index"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +} from "./index.js"; +import { + InMemoryTransport, + McpServer as RealMcpServer, + type McpServer, +} from "@modelcontextprotocol/server"; +import { Client } from "@modelcontextprotocol/client"; +import { z } from "zod/v4"; describe("registerAppTool", () => { it("should pass through config to server.registerTool", () => { @@ -197,6 +203,119 @@ describe("registerAppTool", () => { }); }); +describe("registerAppTool schema forms", () => { + /** Registers tools on a fresh server, then connects a client to it. */ + async function connect(register: (server: RealMcpServer) => void) { + const server = new RealMcpServer({ name: "test", version: "0.0.0" }); + register(server); + const client = new Client({ name: "client", version: "0.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; + } + + it("accepts a z.object() input schema and validates arguments", async () => { + const client = await connect((server) => + registerAppTool( + server, + "greet", + { + inputSchema: z.object({ name: z.string() }), + _meta: { ui: { resourceUri: "ui://greet/view.html" } }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `hello ${name}` }], + }), + ), + ); + + const { tools } = await client.listTools(); + expect(tools).toHaveLength(1); + expect(tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } }, + }); + expect(tools[0]._meta).toMatchObject({ + ui: { resourceUri: "ui://greet/view.html" }, + [RESOURCE_URI_META_KEY]: "ui://greet/view.html", + }); + + const result = await client.callTool({ + name: "greet", + arguments: { name: "world" }, + }); + expect(result.content).toEqual([{ type: "text", text: "hello world" }]); + + const invalid = await client.callTool({ + name: "greet", + arguments: { name: 42 }, + }); + expect(invalid.isError).toBe(true); + }); + + it("accepts a raw zod shape (deprecated form) and wraps it with z.object()", async () => { + const client = await connect((server) => + registerAppTool( + server, + "greet", + { + inputSchema: { name: z.string() }, + outputSchema: { greeting: z.string() }, + _meta: { ui: { resourceUri: "ui://greet/view.html" } }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `hello ${name}` }], + structuredContent: { greeting: `hello ${name}` }, + }), + ), + ); + + const { tools } = await client.listTools(); + expect(tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } }, + }); + expect(tools[0].outputSchema).toMatchObject({ + type: "object", + properties: { greeting: { type: "string" } }, + }); + + const result = await client.callTool({ + name: "greet", + arguments: { name: "world" }, + }); + expect(result.structuredContent).toEqual({ greeting: "hello world" }); + + const invalid = await client.callTool({ + name: "greet", + arguments: { name: 42 }, + }); + expect(invalid.isError).toBe(true); + }); + + it("accepts the raw-shape form without an input schema", async () => { + const client = await connect((server) => + registerAppTool( + server, + "ping", + { + outputSchema: { ok: z.boolean() }, + _meta: { ui: { resourceUri: "ui://ping/view.html" } }, + }, + async () => ({ + content: [{ type: "text", text: "pong" }], + structuredContent: { ok: true }, + }), + ), + ); + + const result = await client.callTool({ name: "ping", arguments: {} }); + expect(result.structuredContent).toEqual({ ok: true }); + }); +}); + describe("registerAppResource", () => { it("should register a resource with default MIME type", () => { let capturedName: string | undefined; diff --git a/src/server/index.ts b/src/server/index.ts index c90514a..5fad055 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -32,46 +32,76 @@ */ import { - RESOURCE_URI_META_KEY, - RESOURCE_MIME_TYPE, McpUiResourceCsp, McpUiResourceMeta, McpUiToolMeta, McpUiClientCapabilities, -} from "../app.js"; +} from "../spec.types.js"; +import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js"; import type { - BaseToolCallback, + CallToolResult, + ClientCapabilities, + InputRequiredResult, McpServer, + ReadResourceResult, RegisteredTool, ResourceMetadata, + ServerContext, + StandardSchemaWithJSON, + ToolAnnotations, ToolCallback, ReadResourceCallback as _ReadResourceCallback, RegisteredResource, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - AnySchema, - ZodRawShapeCompat, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import type { StandardSchemaWithJSON } from "../standard-schema"; -import type { - ClientCapabilities, - ReadResourceResult, - ToolAnnotations, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; +import type { z } from "zod/v4"; // Re-exports for convenience export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE }; export type { ResourceMetadata, ToolCallback }; +/** + * A plain `{ field: z.string() }` record accepted by the deprecated raw-shape + * form of {@link registerAppTool `registerAppTool`}; the SDK auto-wraps it with `z.object()`. + * + * @deprecated Wrap with `z.object({...})` instead. + */ +export type ZodRawShape = Record; + +/** + * {@link ToolCallback `ToolCallback`} variant used when `inputSchema` is a {@link ZodRawShape `ZodRawShape`}. + * Mirrors the callback type of the SDK's deprecated `registerTool` overload. + * + * @deprecated Wrap with `z.object({...})` instead and use {@link ToolCallback `ToolCallback`}. + */ +export type LegacyToolCallback = + Args extends ZodRawShape + ? ( + args: z.infer>, + ctx: ServerContext, + ) => + | CallToolResult + | InputRequiredResult + | Promise + : ( + ctx: ServerContext, + ) => + | CallToolResult + | InputRequiredResult + | Promise; + /** * Base tool configuration matching the standard MCP server tool options. * Extended by {@link McpUiAppToolConfig `McpUiAppToolConfig`} to add UI metadata requirements. + * + * `inputSchema`/`outputSchema` accept any Standard JSON Schema (zod v4, ArkType, + * Valibot, ...). A raw zod shape (`{ field: z.string() }`) is still accepted for + * backward compatibility but deprecated. */ export interface ToolConfig { title?: string; description?: string; - inputSchema?: ZodRawShapeCompat | StandardSchemaWithJSON; - outputSchema?: ZodRawShapeCompat | StandardSchemaWithJSON; + inputSchema?: ZodRawShape | StandardSchemaWithJSON; + outputSchema?: ZodRawShape | StandardSchemaWithJSON; annotations?: ToolAnnotations; _meta?: Record; } @@ -157,7 +187,7 @@ export interface McpUiAppResourceConfig extends ResourceMetadata { * { * title: "Get Weather", * description: "Get current weather for a location", - * inputSchema: { location: z.string() }, + * inputSchema: z.object({ location: z.string() }), * _meta: { * ui: { resourceUri: "ui://weather/view.html" }, * }, @@ -197,7 +227,7 @@ export interface McpUiAppResourceConfig extends ResourceMetadata { * "update-quantity", * { * description: "Update item quantity in cart", - * inputSchema: { itemId: z.string(), quantity: z.number() }, + * inputSchema: z.object({ itemId: z.string(), quantity: z.number() }), * _meta: { * ui: { * resourceUri: "ui://shop/cart.html", @@ -215,8 +245,25 @@ export interface McpUiAppResourceConfig extends ResourceMetadata { * @see {@link registerAppResource `registerAppResource`} to register the HTML resource referenced by the tool */ export function registerAppTool< - OutputArgs extends ZodRawShapeCompat | StandardSchemaWithJSON, - InputArgs extends undefined | ZodRawShapeCompat | StandardSchemaWithJSON = + OutputArgs extends StandardSchemaWithJSON, + InputArgs extends StandardSchemaWithJSON | undefined = undefined, +>( + server: Pick, + name: string, + config: McpUiAppToolConfig & { + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + }, + cb: ToolCallback, +): RegisteredTool; +/** + * @deprecated Wrap with `z.object({...})` instead. Raw-shape form: + * `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; + * the SDK auto-wraps it with `z.object()`. + */ +export function registerAppTool< + InputArgs extends ZodRawShape, + OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined, >( server: Pick, @@ -225,15 +272,14 @@ export function registerAppTool< inputSchema?: InputArgs; outputSchema?: OutputArgs; }, - // The widened constraint signals the v2 API shape, but NOTE: McpServer in - // sdk@1.x still calls zod internals at runtime, so non-zod schemas will fail - // here until we depend on sdk v2. Zod (which all current callers use) is - // unaffected. The cast below bridges the 1.x type signature. - cb: ToolCallback< - InputArgs extends undefined | ZodRawShapeCompat | AnySchema - ? InputArgs - : AnySchema - >, + cb: LegacyToolCallback, +): RegisteredTool; +export function registerAppTool( + server: Pick, + name: string, + config: McpUiAppToolConfig, + // Widest callback shape accepted by either overload above. + cb: (...args: never[]) => unknown, ): RegisteredTool { // Normalize metadata for backward compatibility: // - If _meta.ui.resourceUri is set, also set the legacy flat key @@ -251,13 +297,15 @@ export function registerAppTool< normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } }; } - // Cast bridges the widened StandardSchemaWithJSON constraint to the - // sdk@1.x zod-typed signature. Drops once we depend on sdk v2. - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // The public overloads above guarantee `config`/`cb` match one of the SDK's + // own registerTool overloads; the union-typed implementation cannot select + // between them, so widen for the forwarding call. return server.registerTool( name, - { ...config, _meta: normalizedMeta } as any, - cb as any, + { ...config, _meta: normalizedMeta } as Parameters< + McpServer["registerTool"] + >[1], + cb as ToolCallback, ); } @@ -418,10 +466,6 @@ export const EXTENSION_ID = "io.modelcontextprotocol/ui"; * This helper retrieves the capability object from the `extensions` field * where MCP Apps advertises its support. * - * Note: The `clientCapabilities` parameter extends the SDK's `ClientCapabilities` - * type with an `extensions` field (pending SEP-1724). Once `extensions` is added - * to the SDK, this can use `ClientCapabilities` directly. - * * @param clientCapabilities - The client capabilities from the initialize response * @returns The MCP Apps capability settings, or `undefined` if not supported * @@ -456,10 +500,7 @@ export const EXTENSION_ID = "io.modelcontextprotocol/ui"; * ``` */ export function getUiCapability( - clientCapabilities: - | (ClientCapabilities & { extensions?: Record }) - | null - | undefined, + clientCapabilities: ClientCapabilities | null | undefined, ): McpUiClientCapabilities | undefined { if (!clientCapabilities) { return undefined; diff --git a/src/spec.types.ts b/src/spec.types.ts index 7a8b337..0c45396 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -18,7 +18,7 @@ import type { RequestId, ResourceLink, Tool, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/client"; /** * Current protocol version supported by this SDK. @@ -163,8 +163,8 @@ export interface McpUiOpenLinkResult { /** @description True if the host failed to open the URL (e.g., due to security policy). */ isError?: boolean; /** - * Index signature required for MCP SDK `Protocol` class compatibility. - * Note: The generated schema uses passthrough() to allow additional properties. + * Additional result properties are preserved for protocol extensibility. + * The generated schema uses passthrough() to allow them. */ [key: string]: unknown; } @@ -195,8 +195,8 @@ export interface McpUiDownloadFileResult { /** @description True if the download failed (e.g., user cancelled or host denied). */ isError?: boolean; /** - * Index signature required for MCP SDK `Protocol` class compatibility. - * Note: The generated schema uses passthrough() to allow additional properties. + * Additional result properties are preserved for protocol extensibility. + * The generated schema uses passthrough() to allow them. */ [key: string]: unknown; } @@ -223,8 +223,8 @@ export interface McpUiMessageResult { /** @description True if the host rejected or failed to deliver the message. */ isError?: boolean; /** - * Index signature required for MCP SDK `Protocol` class compatibility. - * Note: The generated schema uses passthrough() to allow additional properties. + * Additional result properties are preserved for protocol extensibility. + * The generated schema uses passthrough() to allow them. */ [key: string]: unknown; } @@ -454,7 +454,7 @@ export interface McpUiResourceTeardownRequest { */ export interface McpUiResourceTeardownResult { /** - * Index signature required for MCP SDK `Protocol` class compatibility. + * Additional result properties are preserved for protocol extensibility. */ [key: string]: unknown; } @@ -492,8 +492,8 @@ export interface McpUiRequestTeardownNotification { * @see {@link McpUiInitializeResult `McpUiInitializeResult`} for the initialization result that includes these capabilities */ export interface McpUiHostCapabilities { - /** @description Experimental features (structure TBD). */ - experimental?: {}; + /** @description Experimental features keyed by identifier. */ + experimental?: Record; /** @description Host supports opening external URLs. */ openLinks?: {}; /** @description Host supports file downloads via ui/download-file. */ @@ -536,8 +536,8 @@ export interface McpUiHostCapabilities { * @see {@link McpUiInitializeRequest `McpUiInitializeRequest`} for the initialization request that includes these capabilities */ export interface McpUiAppCapabilities { - /** @description Experimental features (structure TBD). */ - experimental?: {}; + /** @description Experimental features keyed by identifier. */ + experimental?: Record; /** @description App exposes MCP-style tools that the host can call. */ tools?: { /** @description App supports tools/list_changed notifications. */ @@ -577,8 +577,8 @@ export interface McpUiInitializeResult { /** @description Rich context about the host environment. */ hostContext: McpUiHostContext; /** - * Index signature required for MCP SDK `Protocol` class compatibility. - * Note: The generated schema uses passthrough() to allow additional properties. + * Additional result properties are preserved for protocol extensibility. + * The generated schema uses passthrough() to allow them. */ [key: string]: unknown; } @@ -752,7 +752,7 @@ export interface McpUiRequestDisplayModeResult { /** @description The display mode that was actually set. May differ from requested if not supported. */ mode: McpUiDisplayMode; /** - * Index signature required for MCP SDK `Protocol` class compatibility. + * Additional result properties are preserved for protocol extensibility. * Note: The generated schema uses passthrough() to allow additional properties. */ [key: string]: unknown; diff --git a/src/standard-schema.ts b/src/standard-schema.ts index 4bb85de..a06864a 100644 --- a/src/standard-schema.ts +++ b/src/standard-schema.ts @@ -4,36 +4,22 @@ import type { StandardTypedV1, } from "@standard-schema/spec"; -export type { StandardJSONSchemaV1, StandardSchemaV1, StandardTypedV1 }; - -// TODO(sdk-v2): once @modelcontextprotocol/core v2 is stable, import -// StandardSchemaWithJSON / standardSchemaToJsonSchema / validateStandardSchema -// from there and delete this file. At that point decide whether to tighten -// App.registerTool to StandardSchemaWithJSON (drops zod 3 from the peer range -// and the lazy z.toJSONSchema fallback below). +import type { StandardSchemaWithJSON } from "@modelcontextprotocol/client"; /** * A schema that implements both Standard Schema (validation) and Standard JSON * Schema (serialization). Zod v4, ArkType, and Valibot (via - * `@valibot/to-json-schema`) all satisfy this. - * - * Mirrors the type of the same name in `@modelcontextprotocol/core` v2 so that - * bumping to that package later is a drop-in import swap. + * `@valibot/to-json-schema`) all satisfy this. Re-exported from the SDK so + * View authors can import it alongside {@link app!App `App`}. * * @see https://standardschema.dev/ - * @see https://github.com/modelcontextprotocol/typescript-sdk/pull/1689 */ -export interface StandardSchemaWithJSON { - readonly "~standard": StandardSchemaV1.Props & - StandardJSONSchemaV1.Props; -} - -export namespace StandardSchemaWithJSON { - export type InferInput = - StandardTypedV1.InferInput; - export type InferOutput = - StandardTypedV1.InferOutput; -} +export type { + StandardJSONSchemaV1, + StandardSchemaV1, + StandardSchemaWithJSON, + StandardTypedV1, +}; /** JSON-Schema target draft used for tool input/output schemas (matches core MCP). */ const TARGET = { target: "draft-2020-12" } as const; @@ -41,11 +27,8 @@ const TARGET = { target: "draft-2020-12" } as const; /** * Serialize a Standard Schema to JSON Schema for the given direction. * - * Uses `~standard.jsonSchema` when present (zod v4, ArkType, Valibot, …). - * Falls back to a lazy `zod/v4` import for zod v3.25.x — which implements - * `~standard.validate` but not yet `~standard.jsonSchema` — so the existing - * `^3.25.0 || ^4.0.0` peer range keeps working. Non-zod schemas without - * `jsonSchema` throw. + * Requires `~standard.jsonSchema` (zod v4, ArkType, Valibot, …); schemas + * without it throw. */ export async function standardSchemaToJsonSchema( schema: StandardSchemaV1, @@ -57,11 +40,6 @@ export async function standardSchemaToJsonSchema( if (std.jsonSchema) { return std.jsonSchema[io](TARGET); } - if (std.vendor === "zod") { - const { z } = await import("zod/v4"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridging StandardSchemaV1 → zod's $ZodType for the v3.25 fallback - return z.toJSONSchema(schema as any, { io }); - } throw new Error( `Schema (vendor: ${std.vendor}) does not implement Standard JSON Schema (~standard.jsonSchema). ` + `Use a library that does (zod v4, ArkType, Valibot) or wrap your schema accordingly.`, diff --git a/src/styles.ts b/src/styles.ts index ad900e2..895073b 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,4 +1,4 @@ -import { McpUiStyles, McpUiTheme } from "./types"; +import { McpUiStyles, McpUiTheme } from "./types.js"; /** * Get the current document theme from the root HTML element. diff --git a/src/types.ts b/src/types.ts index 7fc6b71..e4ed629 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,55 @@ * @see `generated/schema.test.ts` for compile-time verification */ +import type { + CallToolRequest, + CallToolResult, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + EmptyResult, + ListPromptsRequest, + ListPromptsResult, + ListResourceTemplatesRequest, + ListResourceTemplatesResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsRequest, + ListToolsResult, + LoggingMessageNotification, + PingRequest, + PromptListChangedNotification, + ReadResourceRequest, + ReadResourceResult, + ResourceListChangedNotification, + ToolListChangedNotification, +} from "@modelcontextprotocol/client"; +import type { + McpUiDownloadFileRequest, + McpUiDownloadFileResult, + McpUiHostContextChangedNotification, + McpUiInitializeRequest, + McpUiInitializeResult, + McpUiInitializedNotification, + McpUiMessageRequest, + McpUiMessageResult, + McpUiOpenLinkRequest, + McpUiOpenLinkResult, + McpUiRequestDisplayModeRequest, + McpUiRequestDisplayModeResult, + McpUiRequestTeardownNotification, + McpUiResourceTeardownRequest, + McpUiResourceTeardownResult, + McpUiSandboxProxyReadyNotification, + McpUiSandboxResourceReadyNotification, + McpUiSizeChangedNotification, + McpUiToolCancelledNotification, + McpUiToolInputNotification, + McpUiToolInputPartialNotification, + McpUiToolResultNotification, + McpUiUpdateModelContextRequest, +} from "./spec.types.js"; + // Re-export all types from spec.types.ts export { LATEST_PROTOCOL_VERSION, @@ -69,33 +118,6 @@ export { type McpUiClientCapabilities, } from "./spec.types.js"; -// Import types needed for protocol type unions (not re-exported, just used internally) -import type { - McpUiInitializeRequest, - McpUiOpenLinkRequest, - McpUiDownloadFileRequest, - McpUiMessageRequest, - McpUiUpdateModelContextRequest, - McpUiResourceTeardownRequest, - McpUiRequestDisplayModeRequest, - McpUiHostContextChangedNotification, - McpUiToolInputNotification, - McpUiToolInputPartialNotification, - McpUiToolResultNotification, - McpUiToolCancelledNotification, - McpUiSandboxResourceReadyNotification, - McpUiInitializedNotification, - McpUiSizeChangedNotification, - McpUiSandboxProxyReadyNotification, - McpUiRequestTeardownNotification, - McpUiInitializeResult, - McpUiOpenLinkResult, - McpUiDownloadFileResult, - McpUiMessageResult, - McpUiResourceTeardownResult, - McpUiRequestDisplayModeResult, -} from "./spec.types.js"; - // Re-export all schemas from generated/schema.ts (already PascalCase) export { McpUiThemeSchema, @@ -136,40 +158,11 @@ export { McpUiToolMetaSchema, } from "./generated/schema.js"; -// Re-export SDK types used in protocol type unions -import { - CallToolRequest, - CallToolResult, - CreateMessageRequest, - CreateMessageResult, - CreateMessageResultWithTools, - EmptyResult, - ListPromptsRequest, - ListPromptsResult, - ListResourcesRequest, - ListResourcesResult, - ListResourceTemplatesRequest, - ListResourceTemplatesResult, - ListToolsRequest, - ListToolsResult, - LoggingMessageNotification, - PingRequest, - PromptListChangedNotification, - ReadResourceRequest, - ReadResourceResult, - ResourceListChangedNotification, - ToolListChangedNotification, -} from "@modelcontextprotocol/sdk/types.js"; - /** - * All request types in the MCP Apps protocol. + * Union of every request an {@link app!App `App`} may send or receive. * - * Includes: - * - MCP UI requests (initialize, open-link, message, resource-teardown, request-display-mode) - * - MCP server requests forwarded from the app (tools/call, tools/list, resources/list, - * resources/templates/list, resources/read, prompts/list) - * - MCP client requests forwarded to the host (sampling/createMessage) - * - Protocol requests (ping) + * @deprecated 1.x fed this to the SDK's `Protocol` generics; SDK 2.x derives + * request types from the method name, so nothing consumes it anymore. */ export type AppRequest = | McpUiInitializeRequest @@ -189,17 +182,9 @@ export type AppRequest = | PingRequest; /** - * All notification types in the MCP Apps protocol. + * Union of every notification an {@link app!App `App`} may send or receive. * - * Host to app: - * - Tool lifecycle (input, input-partial, result, cancelled) - * - Host context changes - * - MCP list changes (tools, resources, prompts) - * - Sandbox resource ready - * - * App to host: - * - Initialized, size-changed, sandbox-proxy-ready, request-teardown - * - Logging messages + * @deprecated See {@link AppRequest}. */ export type AppNotification = // Sent to app @@ -220,7 +205,9 @@ export type AppNotification = | LoggingMessageNotification; /** - * All result types in the MCP Apps protocol. + * Union of every result an {@link app!App `App`} may send or receive. + * + * @deprecated See {@link AppRequest}. */ export type AppResult = | McpUiInitializeResult diff --git a/src/wire-compat.test.ts b/src/wire-compat.test.ts new file mode 100644 index 0000000..762ac77 --- /dev/null +++ b/src/wire-compat.test.ts @@ -0,0 +1,576 @@ +/** + * Cross-version wire compatibility. + * + * Replays JSON-RPC messages captured from ext-apps 1.7.x (built on + * `@modelcontextprotocol/sdk` 1.x) through the 2.x `App` and `AppBridge` over + * a raw in-memory transport, and asserts what 2.x emits back. The MCP Apps + * wire protocol is meant to be unchanged across the major bump; the few + * host-side error deltas are pinned here so they stay deliberate. + */ +import { describe, it, expect, afterEach } from "bun:test"; +import { + Client, + InMemoryTransport, + ProtocolError, + type JSONRPCMessage, + type Transport, +} from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; + +import { App } from "./app.js"; +import { + AppBridge, + type McpUiHostCapabilities, + type McpUiHostContext, +} from "./app-bridge.js"; +import { LATEST_PROTOCOL_VERSION } from "./types.js"; + +/** Wait for pending microtasks/timers to complete. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); + +/** + * One end of a raw in-memory channel. The 2.x side gets `transport`; the test + * plays the role of the 1.x peer by calling `inject()` with captured JSON and + * reading `sent` to see exactly what 2.x put on the wire. + */ +function createRawChannel() { + const sent: JSONRPCMessage[] = []; + let closed = false; + const transport: Transport = { + async start() {}, + async send(message) { + // Serialize like a real transport would so we assert on plain JSON. + sent.push(JSON.parse(JSON.stringify(message))); + }, + async close() { + if (closed) return; + closed = true; + transport.onclose?.(); + }, + }; + return { + transport, + sent, + /** Deliver a raw message (as the 1.x peer would have sent it). */ + inject(message: unknown) { + transport.onmessage?.(message as JSONRPCMessage); + }, + /** Messages sent after the given index. */ + since(index: number) { + return sent.slice(index); + }, + }; +} + +const hostInfo = { name: "TestHost", version: "9.9.9" }; +const hostCapabilities: McpUiHostCapabilities = { + openLinks: {}, + serverTools: {}, + serverResources: {}, + logging: {}, + updateModelContext: {}, +}; +const hostContext: McpUiHostContext = { + theme: "dark", + displayMode: "inline", + locale: "en-US", +}; + +/** Shaped like ext-apps 1.7.x View traffic (sdk-1 key order, 0-based numeric ids); not a recorded capture. */ +const v1View = { + initialize: { + method: "ui/initialize", + params: { + appCapabilities: { tools: { listChanged: true } }, + appInfo: { name: "TestView", version: "1.2.3" }, + protocolVersion: "2026-01-26", + }, + jsonrpc: "2.0", + id: 0, + }, + initialized: { method: "ui/notifications/initialized", jsonrpc: "2.0" }, + callTool: { + method: "tools/call", + params: { name: "echo", arguments: { a: 1 }, _meta: { progressToken: 6 } }, + jsonrpc: "2.0", + id: 6, + }, + readResource: { + method: "resources/read", + params: { uri: "test://x" }, + jsonrpc: "2.0", + id: 12, + }, + toolsListResult: (id: number) => ({ + result: { + tools: [ + { + name: "viewtool", + description: "view tool", + inputSchema: { + type: "object", + properties: { x: { type: "number" } }, + }, + }, + ], + }, + jsonrpc: "2.0", + id, + }), +}; + +/** Shaped like ext-apps 1.7.x AppBridge traffic (sdk 1.x); not a recorded capture. */ +const v1Host = { + initializeResult: (id: number) => ({ + result: { + protocolVersion: "2026-01-26", + hostCapabilities, + hostInfo, + hostContext, + }, + jsonrpc: "2.0", + id, + }), + toolInput: { + method: "ui/notifications/tool-input", + params: { arguments: { location: "NYC" } }, + jsonrpc: "2.0", + }, + callToolResult: (id: number) => ({ + result: { content: [{ type: "text", text: "echo 1" }] }, + jsonrpc: "2.0", + id, + }), + /** McpServer 1.x reports an unknown tool as an isError result, not an error. */ + callToolUnknownResult: (id: number) => ({ + result: { + content: [ + { type: "text", text: "MCP error -32602: Tool nope not found" }, + ], + isError: true, + }, + jsonrpc: "2.0", + id, + }), + resourceNotFoundError: (id: number) => ({ + jsonrpc: "2.0", + id, + error: { + code: -32002, + message: "MCP error -32002: Resource not found (host-thrown -32002)", + }, + }), + toolsList: { method: "tools/list", jsonrpc: "2.0", id: 0 }, + callViewTool: { + method: "tools/call", + params: { name: "viewtool", arguments: { x: 7 } }, + jsonrpc: "2.0", + id: 1, + }, +}; + +describe("wire compatibility: 2.x AppBridge with a 1.x View", () => { + let bridge: AppBridge; + + afterEach(async () => { + await bridge?.close().catch(() => {}); + }); + + async function connectBridge() { + const channel = createRawChannel(); + bridge = new AppBridge(null, hostInfo, hostCapabilities, { hostContext }); + await bridge.connect(channel.transport); + return channel; + } + + async function handshake() { + const channel = await connectBridge(); + let initialized = 0; + bridge.oninitialized = () => { + initialized++; + }; + channel.inject(v1View.initialize); + await flush(); + channel.inject(v1View.initialized); + await flush(); + return { channel, initialized: () => initialized }; + } + + it("sends nothing on connect: no MCP initialize toward the View", async () => { + const channel = await connectBridge(); + await flush(); + expect(channel.sent).toEqual([]); + }); + + it("answers a 1.x ui/initialize with the 1.x result shape and same id", async () => { + const channel = await connectBridge(); + channel.inject(v1View.initialize); + await flush(); + + expect(channel.sent).toEqual([ + { + jsonrpc: "2.0", + id: 0, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + hostCapabilities, + hostInfo, + hostContext, + }, + }, + ]); + expect(bridge.getAppCapabilities()).toEqual({ + tools: { listChanged: true }, + }); + expect(bridge.getAppVersion()).toEqual({ + name: "TestView", + version: "1.2.3", + }); + }); + + it("accepts ui/notifications/initialized without params", async () => { + const { channel, initialized } = await handshake(); + expect(initialized()).toBe(1); + // Only the initialize response went out; the notification gets no reply + // and the bridge never starts an MCP handshake of its own. + expect(channel.sent).toHaveLength(1); + expect(channel.sent.some((m) => "method" in m)).toBe(false); + }); + + it("answers a 1.x tools/call request through oncalltool", async () => { + const { channel } = await handshake(); + bridge.oncalltool = async (params) => ({ + content: [{ type: "text", text: `echo ${params.arguments?.a}` }], + }); + const mark = channel.sent.length; + channel.inject(v1View.callTool); + await flush(); + + expect(channel.since(mark)).toEqual([ + { + jsonrpc: "2.0", + id: 6, + result: { content: [{ type: "text", text: "echo 1" }] }, + }, + ]); + }); + + it("re-encodes a handler-thrown -32002 as -32602 without the MCP error prefix", async () => { + // Documented host-side delta: SDK 2.x never emits -32002 on the wire. + const { channel } = await handshake(); + bridge.onreadresource = async () => { + throw new ProtocolError( + -32002, + "Resource not found (host-thrown -32002)", + ); + }; + const mark = channel.sent.length; + channel.inject(v1View.readResource); + await flush(); + + const [reply] = channel.since(mark); + expect(reply).toMatchObject({ + jsonrpc: "2.0", + id: 12, + error: { code: -32602 }, + }); + const message = (reply as { error: { message: string } }).error.message; + expect(message).toBe("Resource not found (host-thrown -32002)"); + expect(message).not.toMatch(/^MCP error/); + }); + + it("rejects invalid params on a ui/* request with -32602", async () => { + const { channel } = await handshake(); + bridge.onopenlink = async () => ({}); + const mark = channel.sent.length; + channel.inject({ + method: "ui/open-link", + params: { url: 42 }, + jsonrpc: "2.0", + id: 14, + }); + await flush(); + + expect(channel.since(mark)).toHaveLength(1); + expect(channel.since(mark)[0]).toMatchObject({ + jsonrpc: "2.0", + id: 14, + error: { code: -32602 }, + }); + }); + + it("lists and calls View tools with 1.x-shaped responses", async () => { + const { channel } = await handshake(); + const mark = channel.sent.length; + const listPromise = bridge.listTools({}); + await flush(); + + const [listRequest] = channel.since(mark) as Array<{ + id: number; + method: string; + }>; + expect(listRequest).toMatchObject({ jsonrpc: "2.0", method: "tools/list" }); + channel.inject(v1View.toolsListResult(listRequest.id)); + const list = await listPromise; + expect(list.tools.map((t) => t.name)).toEqual(["viewtool"]); + + const callPromise = bridge.callTool({ + name: "viewtool", + arguments: { x: 7 }, + }); + await flush(); + const [callRequest] = channel.since(mark + 1) as Array<{ id: number }>; + expect(callRequest).toMatchObject({ + jsonrpc: "2.0", + method: "tools/call", + params: { name: "viewtool", arguments: { x: 7 } }, + }); + channel.inject({ + result: { content: [{ type: "text", text: "view got 7" }] }, + jsonrpc: "2.0", + id: callRequest.id, + }); + expect(await callPromise).toEqual({ + content: [{ type: "text", text: "view got 7" }], + }); + }); + + it("only ever emits jsonrpc 2.0 messages, never an initialize request", async () => { + const { channel } = await handshake(); + bridge.oncalltool = async () => ({ content: [] }); + channel.inject(v1View.callTool); + bridge.sendToolInput({ arguments: { q: "x" } }); + bridge.sendToolResult({ content: [] }); + await flush(); + + for (const message of channel.sent) { + expect(message.jsonrpc).toBe("2.0"); + } + const methods = channel.sent + .filter((m): m is JSONRPCMessage & { method: string } => "method" in m) + .map((m) => m.method); + expect(methods).toEqual([ + "ui/notifications/tool-input", + "ui/notifications/tool-result", + ]); + expect(methods).not.toContain("initialize"); + expect(methods).not.toContain("notifications/initialized"); + }); +}); + +describe("wire compatibility: 2.x App with a 1.x host", () => { + let app: App; + + afterEach(async () => { + await app?.close().catch(() => {}); + }); + + /** Connect the App and answer its ui/initialize the way a 1.x host did. */ + async function handshake( + capabilities: ConstructorParameters[1] = {}, + setup: (app: App) => void = () => {}, + ) { + const channel = createRawChannel(); + app = new App({ name: "TestView", version: "1.2.3" }, capabilities, { + autoResize: false, + }); + setup(app); + const connected = app.connect(channel.transport); + await flush(); + + const [initialize] = channel.sent as Array<{ id: number }>; + channel.inject(v1Host.initializeResult(initialize.id)); + await connected; + await flush(); + return { channel, initialize }; + } + + it("sends a 1.x-compatible ui/initialize and ui/notifications/initialized", async () => { + const { channel } = await handshake({ tools: { listChanged: true } }); + + expect(channel.sent).toEqual([ + { + jsonrpc: "2.0", + id: 0, + method: "ui/initialize", + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + appInfo: { name: "TestView", version: "1.2.3" }, + appCapabilities: { tools: { listChanged: true } }, + }, + }, + { jsonrpc: "2.0", method: "ui/notifications/initialized" }, + ]); + expect(app.getHostContext()).toEqual(hostContext); + expect(app.getHostCapabilities()).toEqual(hostCapabilities); + expect(app.getHostVersion()).toEqual(hostInfo); + }); + + it("delivers a 1.x notification with sdk-1 key order to ontoolinput", async () => { + const received: unknown[] = []; + const { channel } = await handshake({}, (app) => { + app.ontoolinput = (params) => { + received.push(params); + }; + }); + channel.inject(v1Host.toolInput); + await flush(); + expect(received).toEqual([{ arguments: { location: "NYC" } }]); + }); + + it("resolves callServerTool from a 1.x tools/call response", async () => { + const { channel } = await handshake(); + const mark = channel.sent.length; + const promise = app.callServerTool({ name: "echo", arguments: { a: 1 } }); + await flush(); + + const [request] = channel.since(mark) as Array<{ id: number }>; + expect(request).toMatchObject({ + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "echo", + arguments: { a: 1 }, + _meta: { progressToken: request.id }, + }, + }); + channel.inject(v1Host.callToolResult(request.id)); + expect(await promise).toEqual({ + content: [{ type: "text", text: "echo 1" }], + }); + }); + + it("passes through a 1.x isError tool result unchanged", async () => { + const { channel } = await handshake(); + const mark = channel.sent.length; + const promise = app.callServerTool({ name: "nope", arguments: {} }); + await flush(); + const [request] = channel.since(mark) as Array<{ id: number }>; + channel.inject(v1Host.callToolUnknownResult(request.id)); + expect(await promise).toEqual({ + content: [ + { type: "text", text: "MCP error -32602: Tool nope not found" }, + ], + isError: true, + }); + }); + + it("surfaces a 1.x -32002 error as a ProtocolError with code -32002", async () => { + const { channel } = await handshake(); + const mark = channel.sent.length; + const promise = app.readServerResource({ uri: "test://x" }); + await flush(); + const [request] = channel.since(mark) as Array<{ id: number }>; + channel.inject(v1Host.resourceNotFoundError(request.id)); + + const error = await promise.then( + () => undefined, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(ProtocolError); + expect((error as ProtocolError).code).toBe(-32002); + expect((error as ProtocolError).message).toContain( + "Resource not found (host-thrown -32002)", + ); + }); + + it("answers 1.x tools/list (no params) and tools/call requests from the host", async () => { + const { channel } = await handshake({ tools: { listChanged: true } }); + app.registerTool( + "viewtool", + { description: "view tool", inputSchema: z.object({ x: z.number() }) }, + async ({ x }) => ({ + content: [{ type: "text", text: `view got ${x}` }], + }), + ); + await flush(); + const mark = channel.sent.length; + + channel.inject(v1Host.toolsList); + await flush(); + const [listReply] = channel.since(mark) as unknown as Array<{ + result: { tools: Array<{ name: string; inputSchema: unknown }> }; + }>; + expect(listReply).toMatchObject({ jsonrpc: "2.0", id: 0 }); + expect(listReply.result.tools).toHaveLength(1); + expect(listReply.result.tools[0]).toMatchObject({ + name: "viewtool", + description: "view tool", + inputSchema: { + type: "object", + properties: { x: { type: "number" } }, + required: ["x"], + }, + }); + + channel.inject(v1Host.callViewTool); + await flush(); + expect(channel.since(mark + 1)).toEqual([ + { + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "view got 7" }] }, + }, + ]); + }); +}); + +describe("AppBridge proxy over a default (modern era) 2.x Client/Server", () => { + let client: Client; + let server: McpServer; + let app: App; + let bridge: AppBridge; + + afterEach(async () => { + await app?.close().catch(() => {}); + await bridge?.close().catch(() => {}); + await client?.close().catch(() => {}); + await server?.close().catch(() => {}); + }); + + it("forwards tools/call and tools/list_changed with default negotiation", async () => { + server = new McpServer({ name: "ActualServer", version: "1.0.0" }); + server.registerTool( + "echo", + { inputSchema: z.object({ a: z.number() }) }, + async ({ a }) => ({ content: [{ type: "text", text: `echo ${a}` }] }), + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + client = new Client({ name: "HostOuterClient", version: "1.0.0" }); + await client.connect(clientTransport); + expect(client.getServerCapabilities()?.tools).toEqual({ + listChanged: true, + }); + + bridge = new AppBridge(client, hostInfo, hostCapabilities, { + hostContext, + }); + app = new App( + { name: "TestView", version: "1.2.3" }, + {}, + { + autoResize: false, + }, + ); + const listChanged: string[] = []; + app.setNotificationHandler("notifications/tools/list_changed", () => { + listChanged.push("tools"); + }); + const [appTransport, bridgeTransport] = + InMemoryTransport.createLinkedPair(); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect( + await app.callServerTool({ name: "echo", arguments: { a: 1 } }), + ).toMatchObject({ content: [{ type: "text", text: "echo 1" }] }); + + server.sendToolListChanged(); + await flush(); + expect(listChanged).toEqual(["tools"]); + }); +}); diff --git a/tests/e2e/pdf-incremental-load.spec.ts b/tests/e2e/pdf-incremental-load.spec.ts new file mode 100644 index 0000000..52a99ee --- /dev/null +++ b/tests/e2e/pdf-incremental-load.spec.ts @@ -0,0 +1,271 @@ +import { test, expect, type Page } from "@playwright/test"; +import { + startRangeServer, + type RangeServer, +} from "../helpers/range-counting-server"; + +/** + * Regression guard for incremental PDF loading. + * + * Asserts that display_pdf does not pull the entire file before the viewer + * starts streaming, that form schema is still returned in the initial response, + * and that no byte range is fetched server-side more than once. + * + * The "noforms <30%" test is the load-bearing regression check: it fails on the + * pre-range-transport implementation (which downloads 100% during display_pdf + * for form analysis) and passes once form extraction uses range transport. + */ + +test.setTimeout(120_000); + +let rangeServer: RangeServer; + +test.beforeAll(async () => { + rangeServer = await startRangeServer(); +}); + +test.afterAll(async () => { + await rangeServer.close(); +}); + +test.beforeEach(() => { + rangeServer.resetStats(); +}); + +function getAppFrame(page: Page) { + return page.frameLocator("iframe").first().frameLocator("iframe").first(); +} + +async function waitForAppLoad(page: Page) { + const outerFrame = page.frameLocator("iframe").first(); + await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30_000 }); +} + +/** + * Load basic-host, select PDF Server, call display_pdf with a custom URL. + * Resolves once the tool result panel appears (server-side display_pdf done); + * does NOT wait for the viewer iframe — call waitForAppLoad separately so + * byte-count assertions can isolate server-side fetches from viewer fetches. + */ +async function displayPdf(page: Page, url: string) { + await page.goto("/?theme=hide"); + await expect(page.locator("select").first()).toBeEnabled({ timeout: 30_000 }); + await page.locator("select").first().selectOption({ label: "PDF Server" }); + await page.locator("textarea").fill(JSON.stringify({ url })); + await page.click('button:has-text("Call Tool")'); + await expect(page.locator('text="📤 Tool Result"').first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** Read and parse the most recent tool result's structuredContent. */ +async function readStructuredContent( + page: Page, +): Promise> { + const resultPanel = page.locator('text="📤 Tool Result"').first(); + await expect(resultPanel).toBeVisible({ timeout: 30_000 }); + await resultPanel.click(); + const pre = page.locator("pre").last(); + await expect(pre).toBeVisible({ timeout: 5_000 }); + const raw = (await pre.textContent()) ?? "{}"; + const parsed = JSON.parse(raw) as { structuredContent?: object }; + return (parsed.structuredContent ?? {}) as Record; +} + +async function waitForFirstPageRendered(page: Page) { + const canvas = getAppFrame(page).locator("canvas").first(); + await expect(canvas).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => canvas.evaluate((c: HTMLCanvasElement) => c.width)) + .toBeGreaterThan(0); +} + +test.describe("PDF Server — incremental loading", () => { + test("display_pdf on a form PDF returns form fields in initial response", async ({ + page, + }) => { + await displayPdf(page, `${rangeServer.baseUrl}/forms.pdf`); + await waitForAppLoad(page); + const sc = await readStructuredContent(page); + const fields = sc.formFields as Array<{ name: string }> | undefined; + expect(fields?.map((f) => f.name).sort()).toEqual([ + "city", + "email", + "name", + "notes", + "phone", + ]); + }); + + test("display_pdf on a no-forms PDF stays under byte budget and bounded overlap", async ({ + page, + }) => { + const fileSize = rangeServer.fileSizes["/noforms.pdf"]; + await displayPdf(page, `${rangeServer.baseUrl}/noforms.pdf`); + + // Measure before the viewer iframe loads so the count reflects only the + // server-side display_pdf range fetches. + expect(rangeServer.stats().totalBytesServed).toBeLessThan(fileSize * 0.3); + + await waitForAppLoad(page); + const sc = await readStructuredContent(page); + expect(sc.formFields).toBeUndefined(); + + await waitForFirstPageRendered(page); + // Server-side display_pdf and the viewer each open the document + // independently, so the xref/trailer/catalog is fetched twice (≈25%). + // This guards against the pre-range-transport behavior where the server + // alone pulled 100% (then 200% with the double-parse), giving overlap >> + // file size once the viewer also loaded. + expect(rangeServer.stats().overlapBytes).toBeLessThan(fileSize * 0.5); + }); + + test("first page renders under stall, then page 2 renders the >512KB image after release", async ({ + page, + }) => { + const fileSize = rangeServer.fileSizes["/noforms.pdf"]; + // Allow ~40% through (header + trailer/xref + page-1 content) then stall. + // The 1.1MB image stream referenced only by pages 2+ is the bulk. + const budget = Math.floor(fileSize * 0.4); + await displayPdf( + page, + `${rangeServer.baseUrl}/noforms.pdf?stallAfterBytes=${budget}`, + ); + await waitForAppLoad(page); + await waitForFirstPageRendered(page); + expect(rangeServer.stats().totalBytesServed).toBeLessThan(fileSize); + + rangeServer.release(); + const app = getAppFrame(page); + await app.locator("#next-btn").click(); + await expect(app.locator("#page-input")).toHaveValue("2", { + timeout: 30_000, + }); + // Page 2 references the ~1.1MB embedded JPEG. Rendering it exercises the + // viewer's range transport on a >MAX_CHUNK_BYTES object after the stall + // is released. (The server-side PdfCacheRangeTransport accumulate-once + // path is covered by the unit test in server.test.ts; on noforms.pdf + // display_pdf bails at the empty getFieldObjects() check before touching + // the image stream, so this test does not exercise it.) + await waitForFirstPageRendered(page); + expect(rangeServer.stats().totalBytesServed).toBeGreaterThan( + fileSize * 0.9, + ); + }); +}); + +// Kept in this spec because the test needs an HTTP-served PDF and the +// range-counting fixture is the convenient place; it does not use any +// byte-accounting features. +test.describe("PDF Server — annotation tombstone preservation", () => { + // FIXME(https://github.com/modelcontextprotocol/ext-apps/issues/642): + // basic-host doesn't replay the cached tool result on inner-iframe reload, + // and a fresh display_pdf call gets a new toolId → new storage key, so the + // restore-from-localStorage path can't be reached. The fix itself is covered + // by the computeDiff/serializeDiff contract tests in + // src/pdf-annotations.test.ts. + test.fixme("deleted native annotation tombstone survives a persist before its page is scanned", async ({ + page, + }) => { + // Regression for the lazy baseline scan: restoredRemovedIds must be + // unioned into persistAnnotations() and getAnnotatedPdfBytes() so a + // delete on page 2 isn't silently dropped when an unrelated edit on + // page 1 triggers a persist before page 2 has been re-scanned. + + await displayPdf(page, `${rangeServer.baseUrl}/with-native-annot.pdf`); + await waitForAppLoad(page); + await waitForFirstPageRendered(page); + const sc = await readStructuredContent(page); + const viewUUID = sc.viewUUID as string; + expect(viewUUID).toBeTruthy(); + + const app = getAppFrame(page); + + // 1. Go to page 2, open the panel, delete the native annotation via UI. + await app.locator("#next-btn").click(); + await expect(app.locator("#page-input")).toHaveValue("2"); + await app.locator("#annotations-btn").click(); + const nativeCard = app.locator( + '.annotation-card[data-annotation-id^="pdf-"]', + ); + await expect(nativeCard).toBeVisible({ timeout: 10_000 }); + const nativeId = await nativeCard.getAttribute("data-annotation-id"); + expect(nativeId).toMatch(/^pdf-\d+R?$/); + await nativeCard.locator(".annotation-card-delete").click(); + // Deleting a native annotation re-renders the card as a crossed-out + // tombstone (annotation-panel.ts createRemovedAnnotationCard) with the + // same data-annotation-id — it doesn't disappear from the DOM. + await expect(nativeCard).toHaveClass(/annotation-card-cleared/); + + // 2. Back to page 1 so the post-reload viewer restores there (page 2 + // must stay unscanned until the very end). + await app.locator("#page-input").fill("1"); + await app.locator("#page-input").press("Enter"); + await expect(app.locator("#page-input")).toHaveValue("1"); + + // 3. Capture the annotation localStorage key and confirm the delete was + // persisted. + const storageKey = await app + .locator("body") + .evaluate( + () => + Object.keys(localStorage).find( + (k) => k.startsWith("pdf-annot:") || k.endsWith(":annotations"), + ) ?? null, + ); + expect(storageKey).toBeTruthy(); + const diffBefore = await app + .locator("body") + .evaluate((_, k) => localStorage.getItem(k), storageKey!); + expect(JSON.parse(diffBefore!).removed).toContain(nativeId); + + // 4. Reload the inner viewer iframe ONLY (basic-host keeps the same + // cached tool result → same viewUUID/toolId → same storage key). + // restoreAnnotations() now seeds restoredRemovedIds from localStorage + // while the lazy scan has only seen page 1. + await app.locator("body").evaluate(() => location.reload()); + await waitForFirstPageRendered(page); + await expect(app.locator("#page-input")).toHaveValue("1"); + + // 5. Trigger persistAnnotations() via an unrelated edit on page 1 — the + // bug scenario: page 2 has not been scanned yet. + const toolSelect = page.locator("select").nth(1); + await toolSelect.selectOption("interact"); + await page.locator("textarea").fill( + JSON.stringify({ + viewUUID, + action: "add_annotations", + annotations: [ + { + id: "probe-on-page-1", + type: "highlight", + page: 1, + rects: [{ x: 50, y: 700, width: 100, height: 12 }], + }, + ], + }), + ); + await page.click('button:has-text("Call Tool")'); + await expect( + app.locator('[data-annotation-id="probe-on-page-1"]'), + ).toHaveCount(1, { timeout: 10_000 }); + + // 6. Load-bearing assertion: the persisted diff still carries the + // tombstone. Pre-fix, computeDiff() over the page-1-only baseline + // yielded removed=[], overwriting it. + const diffAfter = await app + .locator("body") + .evaluate((_, k) => localStorage.getItem(k), storageKey!); + const removedAfter: string[] = JSON.parse(diffAfter!).removed; + expect(removedAfter).toContain(nativeId); + + // 7. Belt-and-suspenders: navigate to page 2 (lazy scan now sees the + // native annotation) and confirm the panel shows it as a cleared + // tombstone, not a live (resurrected) card. + await app.locator("#next-btn").click(); + await expect(app.locator("#page-input")).toHaveValue("2"); + await expect( + app.locator(`.annotation-card[data-annotation-id="${nativeId}"]`), + ).toHaveClass(/annotation-card-cleared/); + }); +}); diff --git a/tests/helpers/assets/fw9.pdf b/tests/helpers/assets/fw9.pdf new file mode 100644 index 0000000..c5eb646 Binary files /dev/null and b/tests/helpers/assets/fw9.pdf differ diff --git a/tests/helpers/range-counting-server.ts b/tests/helpers/range-counting-server.ts new file mode 100644 index 0000000..6f0dcd7 --- /dev/null +++ b/tests/helpers/range-counting-server.ts @@ -0,0 +1,254 @@ +/** + * HTTP test fixture serving programmatically-generated PDFs with byte-range + * accounting. Used by pdf-incremental-load.spec.ts to assert that display_pdf + * doesn't pull the whole file before the viewer starts streaming. + * + * Plain HTTP on loopback — playwright.config.ts sets + * PDF_SERVER_ALLOW_LOOPBACK_HTTP=1 so validateUrl accepts http://127.0.0.1. + */ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { + PDFDocument, + PDFName, + PDFString, + StandardFonts, +} from "@cantoo/pdf-lib"; + +export interface RangeServerStats { + /** Total bytes written across all responses (sum of slice lengths). */ + totalBytesServed: number; + /** Bytes that were served more than once for the same path. */ + overlapBytes: number; +} + +export interface RangeServer { + port: number; + baseUrl: string; + /** Map of served path → byte length. */ + fileSizes: Record; + stats(): RangeServerStats; + resetStats(): void; + /** Resolve any requests currently stalled by ?stallAfterBytes=N. */ + release(): void; + close(): Promise; +} + +const LOREM = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod " + + "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim " + + "veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea " + + "commodo consequat. "; + +async function buildNoFormsPdf(): Promise { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + // Page 1 is text-only (small) so first paint needs minimal bytes. Pages 2+ + // each reference a large embedded JPEG so the bulk of the file is in image + // streams page 1 doesn't need. The stallAfterBytes test holds those back + // and asserts page 1 still renders. The image is >MAX_CHUNK_BYTES (512KB) + // so rendering page 2 also exercises the viewer's >512KB range path. + const big = await doc.embedJpg(makeRandomJpeg(1_100 * 1024)); + const page1 = doc.addPage([612, 792]); + for (let line = 0; line < 30; line++) { + page1.drawText(`1.${line + 1} ${LOREM}`, { + x: 36, + y: 760 - line * 22, + size: 10, + font, + }); + } + for (let p = 1; p < 20; p++) { + const page = doc.addPage([612, 792]); + page.drawImage(big, { x: 36, y: 200, width: 540, height: 540 }); + page.drawText(`Page ${p + 1}`, { x: 36, y: 760, size: 10, font }); + } + return doc.save(); +} + +/** Minimal valid JPEG with `len` bytes of incompressible scan data. */ +export function makeRandomJpeg(len: number): Uint8Array { + // SOI, APP0 (JFIF), SOF0 (baseline 8x8 1-component), DHT (minimal), + // SOS, , EOI. pdf-lib only needs to parse the headers + // to embed; the scan data is opaque. + const header = Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x0b, + 0x08, 0x00, 0x08, 0x00, 0x08, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, + 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, + 0x01, 0x00, 0x00, 0x3f, 0x00, + ]); + const scan = new Uint8Array(len); + for (let i = 0; i < len; i++) scan[i] = (i * 1103515245 + 12345) & 0xff; + // Avoid 0xFF in scan data so we don't accidentally form a marker. + for (let i = 0; i < len; i++) if (scan[i] === 0xff) scan[i] = 0xfe; + const eoi = Uint8Array.from([0xff, 0xd9]); + const out = new Uint8Array(header.length + scan.length + eoi.length); + out.set(header, 0); + out.set(scan, header.length); + out.set(eoi, header.length + scan.length); + return out; +} + +/** + * Two pages, page 1 text-only, page 2 carries one native /Text (sticky-note) + * annotation. Used by the tombstone-preservation e2e: the viewer's lazy + * baseline scan must not have visited page 2 when persistAnnotations runs. + */ +async function buildWithNativeAnnotPdf(): Promise { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const page1 = doc.addPage([612, 792]); + page1.drawText("Page 1 — no native annotations here.", { + x: 36, + y: 740, + size: 12, + font, + }); + const page2 = doc.addPage([612, 792]); + page2.drawText("Page 2 — has one native /Text annot.", { + x: 36, + y: 740, + size: 12, + font, + }); + const annotRef = doc.context.register( + doc.context.obj({ + Type: "Annot", + Subtype: "Text", + Rect: [100, 700, 120, 720], + Contents: PDFString.of("native sticky note"), + Open: false, + Name: "Comment", + }), + ); + page2.node.set(PDFName.of("Annots"), doc.context.obj([annotRef])); + return doc.save(); +} + +async function buildFormsPdf(): Promise { + const doc = await PDFDocument.create(); + const form = doc.getForm(); + for (let p = 0; p < 2; p++) doc.addPage([612, 792]); + const [page1] = doc.getPages(); + const fields = ["name", "email", "phone", "city", "notes"]; + fields.forEach((name, i) => { + const f = form.createTextField(name); + f.addToPage(page1, { x: 100, y: 650 - i * 60, width: 300, height: 24 }); + }); + return doc.save(); +} + +export async function startRangeServer(): Promise { + if (process.env.NODE_ENV === "production") { + throw new Error( + "range-counting-server is a test fixture; refusing to start with NODE_ENV=production", + ); + } + const files: Record = { + "/noforms.pdf": await buildNoFormsPdf(), + "/forms.pdf": await buildFormsPdf(), + "/with-native-annot.pdf": await buildWithNativeAnnotPdf(), + }; + const fileSizes = Object.fromEntries( + Object.entries(files).map(([k, v]) => [k, v.length]), + ); + + // Per-path hit count per byte, for overlap accounting. + const hitCounts: Record = {}; + const initHits = () => { + for (const [k, v] of Object.entries(files)) { + hitCounts[k] = new Uint8Array(v.length); + } + }; + initHits(); + + let totalBytesServed = 0; + let releaseResolve: (() => void) | undefined; + let releasePromise = new Promise((r) => (releaseResolve = r)); + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const body = files[url.pathname]; + if (!body) { + res.writeHead(404).end(); + return; + } + + const stallAfterBytes = url.searchParams.get("stallAfterBytes"); + const total = body.length; + const range = req.headers.range; + + let begin = 0; + let end = total; // exclusive + let status = 200; + if (range) { + const m = /^bytes=(\d+)-(\d*)$/.exec(range); + if (m) { + begin = parseInt(m[1], 10); + end = m[2] ? parseInt(m[2], 10) + 1 : total; + begin = Math.min(begin, total); + end = Math.min(end, total); + status = 206; + } + } + + // Stall once N bytes have already been served — lets pdfjs read the + // header/trailer/xref (scattered across the file) before blocking the + // bulk content streams. + if (stallAfterBytes !== null) { + if (totalBytesServed >= parseInt(stallAfterBytes, 10)) { + await releasePromise; + } + } + + const slice = body.subarray(begin, end); + totalBytesServed += slice.length; + const hits = hitCounts[url.pathname]; + for (let i = begin; i < end; i++) hits[i]++; + + const headers: Record = { + "Content-Type": "application/pdf", + "Accept-Ranges": "bytes", + "Content-Length": String(slice.length), + }; + if (status === 206) { + headers["Content-Range"] = `bytes ${begin}-${end - 1}/${total}`; + } + res.writeHead(status, headers); + res.end(slice); + }); + + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as AddressInfo).port; + + return { + port, + baseUrl: `http://127.0.0.1:${port}`, + fileSizes, + stats() { + let overlapBytes = 0; + for (const hits of Object.values(hitCounts)) { + for (let i = 0; i < hits.length; i++) if (hits[i] > 1) overlapBytes++; + } + return { totalBytesServed, overlapBytes }; + }, + resetStats() { + totalBytesServed = 0; + initHits(); + // Unblock any handlers parked on the previous stall before re-arming, + // otherwise they hold sockets open forever and close() hangs. + releaseResolve?.(); + releasePromise = new Promise((r) => (releaseResolve = r)); + }, + release() { + releaseResolve?.(); + }, + close() { + releaseResolve?.(); + server.closeAllConnections?.(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 08ff110..934b950 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node", "bun"], "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM"], diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 4c5baa3..c9b9476 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -1,5 +1,7 @@ import { OptionDefaults } from "typedoc"; +const BASE_SDK_DOCS = "https://ts.sdk.modelcontextprotocol.io/v2/"; + /** @type {Partial} */ const config = { name: "MCP Apps", @@ -17,6 +19,7 @@ const config = { "docs/authorization.md", "docs/csp-cors.md", "docs/migrate_from_openai_apps.md", + "docs/migrate-to-2.md", ], entryPoints: [ "src/server/index.ts", @@ -28,7 +31,32 @@ const config = { ], excludePrivate: true, excludeInternal: false, - intentionallyNotExported: ["MethodSchema", "RequestHandlerExtra"], + intentionallyNotExported: ["RequestHandlerExtra"], + externalSymbolLinkMappings: { + "@modelcontextprotocol/client": { + ClientOptions: BASE_SDK_DOCS, + ConnectOptions: BASE_SDK_DOCS, + DiscoverResult: BASE_SDK_DOCS, + "MessageExtraInfo.classification": BASE_SDK_DOCS, + ProtocolError: BASE_SDK_DOCS, + "Protocol.close": BASE_SDK_DOCS, + "Protocol.notification": BASE_SDK_DOCS, + ResponseCacheStore: BASE_SDK_DOCS, + SdkError: BASE_SDK_DOCS, + "SdkErrorCode.MethodNotSupportedByProtocolVersion": BASE_SDK_DOCS, + StandardJSONSchemaV1: BASE_SDK_DOCS, + "SdkErrorCode.UnsupportedResultType": BASE_SDK_DOCS, + "__type.enforceStrictCapabilities": BASE_SDK_DOCS, + }, + "@modelcontextprotocol/server": { + LoggingMessageNotification: BASE_SDK_DOCS, + "McpServer.registerTool": BASE_SDK_DOCS, + "MessageExtraInfo.classification": BASE_SDK_DOCS, + "Protocol.close": BASE_SDK_DOCS, + "Protocol.notification": BASE_SDK_DOCS, + "SdkErrorCode.UnsupportedResultType": BASE_SDK_DOCS, + }, + }, blockTags: [...OptionDefaults.blockTags, "@description"], jsDocCompatibility: { exampleTag: false, @@ -49,6 +77,7 @@ const config = { out: "docs/api", plugin: [ "typedoc-github-theme", + "./scripts/typedoc-plugin-base-sdk-links.mjs", "./scripts/typedoc-plugin-fix-mermaid-entities.mjs", "./scripts/typedoc-plugin-seo.mjs", "./scripts/typedoc-plugin-mcpstyle.mjs",