From 0bfaf3f375eda2a19a5efbd36e93354f9569ab42 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:48:50 +0000 Subject: [PATCH 01/27] chore(examples): collapse examples/{server,client} into one per-story package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two split packages become one `@modelcontextprotocol/examples` workspace member at `examples/`. Story directories live directly under it; each story imports the new dual-transport scaffold (`examples/harness.ts`), which selects `serveStdio(factory)` vs `createMcpHandler(factory)` from argv server-side and stdio-spawn vs Streamable HTTP client-side. ESLint enforces public-API-only imports in story files (no `@modelcontextprotocol/*/src/*`, no `packages/*` paths, no `core`, no test-helpers). Dead `scripts/cli.ts` / `prepack` scripts are dropped from `examples/shared`. Workspace topology change: `pnpm-workspace.yaml` adds `examples` as a root, `.changeset` config replaces the two old package names with the new one. The lockfile is updated for the topology change only — no new external dependencies. --- .changeset/config.json | 3 +- .changeset/pre.json | 3 +- examples/README.md | 56 +++++++++++++++++ examples/eslint.config.mjs | 43 +++++++++++++ examples/harness.ts | 116 +++++++++++++++++++++++++++++++++++ examples/package.json | 49 +++++++++++++++ examples/shared/package.json | 6 +- examples/tsconfig.json | 27 ++++++++ pnpm-lock.yaml | 98 +++++++++++------------------ pnpm-workspace.yaml | 1 + 10 files changed, 330 insertions(+), 72 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/eslint.config.mjs create mode 100644 examples/harness.ts create mode 100644 examples/package.json create mode 100644 examples/tsconfig.json diff --git a/.changeset/config.json b/.changeset/config.json index eb43bdc7fd..b2d84e7038 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,9 +8,8 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@modelcontextprotocol/examples-client", + "@modelcontextprotocol/examples", "@modelcontextprotocol/examples-client-quickstart", - "@modelcontextprotocol/examples-server", "@modelcontextprotocol/examples-server-quickstart", "@modelcontextprotocol/examples-shared" ] diff --git a/.changeset/pre.json b/.changeset/pre.json index c4c3cf31a8..b7e0b8e055 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -5,9 +5,8 @@ "@modelcontextprotocol/eslint-config": "2.0.0", "@modelcontextprotocol/tsconfig": "2.0.0", "@modelcontextprotocol/vitest-config": "2.0.0", - "@modelcontextprotocol/examples-client": "2.0.0-alpha.0", + "@modelcontextprotocol/examples": "2.0.0-alpha.0", "@modelcontextprotocol/examples-client-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-server": "2.0.0-alpha.0", "@modelcontextprotocol/examples-server-quickstart": "2.0.0-alpha.0", "@modelcontextprotocol/examples-shared": "2.0.0-alpha.0", "@modelcontextprotocol/client": "2.0.0-alpha.0", diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..28b43c1496 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,56 @@ +# MCP TypeScript SDK examples + +One **story** per directory. Every story is a runnable, self-verifying client/server pair: `server.ts` is what you would deploy, `client.ts` is what a host would write — it connects, exercises the feature with the public client API, asserts results, and exits 0. CI runs every +pair over every transport it supports (`scripts/run-examples.ts`); a non-zero exit fails the build. + +Run any pair from the repo root: + +```bash +# stdio (the client spawns the server itself): +pnpm tsx examples//client.ts + +# Streamable HTTP (two terminals): +pnpm tsx examples//server.ts --http --port 3000 +pnpm tsx examples//client.ts --http http://127.0.0.1:3000/ +``` + +## Start here + +| Story | What it teaches | +| ------------------------------------- | ------------------------------------------------------------------------ | +| [`tools/`](./tools/README.md) | Register tools, infer input/output schemas, call them, structured output | +| [`prompts/`](./prompts/README.md) | Prompts + argument completion | +| [`resources/`](./resources/README.md) | Static + templated resources, list/read | +| [`dual-era/`](./dual-era/README.md) | One factory, both protocol eras, both transports | + +## Feature stories + +| Story | What it teaches | Transports | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | +| [`elicitation-form/`](./elicitation-form/README.md) | Form-mode elicitation (server requests user input) | stdio | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | http | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | + +## HTTP hosting variants + +| Story | What it teaches | Transports | +| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | +| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | +| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | +| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | +| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | +| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | + +## Excluded + +The interactive OAuth set lives under [`oauth/`](./oauth/README.md) and is excluded from the harness (browser flow / no in-repo Authorization Server). The [`guides/`](./guides/README.md) directory holds the snippet collections synced into `docs/server.md` and `docs/client.md` — +typecheck-only, not runnable. `shared/` is the demo OAuth provider library used by the OAuth examples. The `server-quickstart/` and `client-quickstart/` packages are the website-tutorial sources (external network / API key; typecheck-only). diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs new file mode 100644 index 0000000000..807cb09106 --- /dev/null +++ b/examples/eslint.config.mjs @@ -0,0 +1,43 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + // The nested workspace packages (shared, *-quickstart) are linted by their own configs. + ignores: ['shared/**', 'server-quickstart/**', 'client-quickstart/**'] + }, + { + files: ['**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Examples write to stdout/stderr deliberately. + 'no-console': 'off', + // Story client.ts files are self-verifying tests that exit non-zero on failure. + 'unicorn/no-process-exit': 'off', + // Examples MUST use only what a consumer would `npm install` and import: + // public package entry points and the local harness. Anything reaching into + // package internals or workspace source is banned. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { group: ['@modelcontextprotocol/*/src/*'], message: 'Examples must import only public package entry points.' }, + { + group: ['**/packages/*', '../../packages/*', '../../../packages/*'], + message: 'Examples must not reach into workspace source.' + }, + { + group: ['@modelcontextprotocol/core', '@modelcontextprotocol/core/*'], + message: 'Examples must import from @modelcontextprotocol/{server,client}, not core.' + }, + { + group: ['@modelcontextprotocol/test-helpers', '@modelcontextprotocol/test-helpers/*'], + message: 'Examples must not depend on test helpers.' + } + ] + } + ] + } + } +]; diff --git a/examples/harness.ts b/examples/harness.ts new file mode 100644 index 0000000000..726978f777 --- /dev/null +++ b/examples/harness.ts @@ -0,0 +1,116 @@ +/** + * Tiny dual-transport scaffold shared by every `examples//` pair. + * + * The same factory backs both transports of one example: a story's `server.ts` + * calls {@linkcode runServerFromArgs} so one binary serves stdio (default) or + * HTTP under `--http --port `; its `client.ts` calls + * {@linkcode connectFromArgs} so one binary spawns the sibling server over + * stdio (default) or connects to a running endpoint under `--http `. The + * client's body is wrapped in {@linkcode runClient} so any thrown assertion + * exits non-zero with a `FAIL:` line, making each example a self-verifying e2e + * test that `scripts/run-examples.ts` can iterate. + * + * Re-exported `check` is `node:assert/strict` for readable inline assertions. + */ + +import { createServer } from 'node:http'; +import path from 'node:path'; + +import type { ClientOptions } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import type { McpServerFactory } from '@modelcontextprotocol/server'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +export { strict as check } from 'node:assert'; + +/** + * Serve the given factory over EITHER transport, selected from `process.argv`. + * + * - default: `serveStdio(factory)` — the deployable shape; the client spawns + * this binary and speaks JSON-RPC over the pipe. + * - `--http [--port N]`: `createMcpHandler(factory)` mounted on `node:http` + * at `/` (so the harness's readiness poll and the client's URL agree). + * + * Logs go to **stderr** so stdio's stdout JSON-RPC stream stays clean. + */ +export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000): void { + const argv = process.argv.slice(2); + if (argv.includes('--http')) { + const portIdx = argv.indexOf('--port'); + const port = portIdx === -1 ? Number(process.env.PORT ?? defaultPort) : Number(argv[portIdx + 1]); + const handler = createMcpHandler(factory, { onerror: e => console.error('[server] handler error:', e.message) }); + const server = createServer((req, res) => void handler.node(req, res)); + server.listen(port, () => console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`)); + const exit = async () => { + await handler.close(); + server.close(); + process.exit(0); + }; + process.on('SIGINT', exit); + process.on('SIGTERM', exit); + } else { + const handle = serveStdio(factory); + console.error('[server] serving over stdio'); + const exit = async () => { + await handle.close(); + process.exit(0); + }; + process.on('SIGINT', exit); + process.on('SIGTERM', exit); + } +} + +/** + * Construct a {@link Client} and connect it over EITHER transport, selected + * from `process.argv`. Under `--http ` it connects to the given endpoint + * via Streamable HTTP; otherwise it spawns the sibling `server.ts` (resolved + * relative to the calling client's `import.meta.dirname`) via stdio. + * + * The client defaults to `versionNegotiation: { mode: 'auto' }` so the modern + * `server/discover` probe negotiates the 2026-07-28 revision against either + * transport without per-story envelope plumbing. Pass + * `options.versionNegotiation` explicitly to opt out for legacy-only stories. + */ +export async function connectFromArgs(siblingDir: string, options: ClientOptions = {}): Promise { + const argv = process.argv.slice(2); + const client = new Client( + { name: `${path.basename(siblingDir)}-example-client`, version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, ...options } + ); + const httpIdx = argv.indexOf('--http'); + if (httpIdx === -1) { + const serverSource = path.resolve(siblingDir, 'server.ts'); + await client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', serverSource] })); + } else { + const url = argv[httpIdx + 1] ?? 'http://127.0.0.1:3000/'; + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(url))); + } + return client; +} + +/** Transport leg the client is running on this invocation. */ +export function transportLeg(): 'stdio' | 'http' { + return process.argv.includes('--http') ? 'http' : 'stdio'; +} + +/** + * Run a self-verifying client scenario. Any thrown error (including + * `node:assert/strict` failures) prints a `FAIL:` line to stderr and exits + * non-zero so the harness records the failure; on success it prints an `OK:` + * line and exits 0. + */ +export function runClient(name: string, scenario: () => Promise): void { + void (async () => { + try { + await scenario(); + console.log(`OK: ${name} (${transportLeg()})`); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(`FAIL: ${name} (${transportLeg()}): ${message}`); + process.exit(1); + } + })(); +} diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000000..133557f2eb --- /dev/null +++ b/examples/package.json @@ -0,0 +1,49 @@ +{ + "name": "@modelcontextprotocol/examples", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Runnable MCP TypeScript SDK examples — one story per directory, each a self-verifying client/server pair", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "lint": "eslint . && prettier --ignore-path ../.prettierignore --check .", + "lint:fix": "eslint . --fix && prettier --ignore-path ../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/examples-shared": "workspace:^", + "@modelcontextprotocol/express": "workspace:^", + "@modelcontextprotocol/hono": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@valibot/to-json-schema": "catalog:devTools", + "ajv": "catalog:runtimeShared", + "arktype": "catalog:devTools", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "hono": "catalog:runtimeServerOnly", + "open": "^11.0.0", + "valibot": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "tsx": "catalog:devTools" + } +} diff --git a/examples/shared/package.json b/examples/shared/package.json index 0bab8be920..3530f785fa 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -21,15 +21,11 @@ ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "prepack": "pnpm run build:esm && pnpm run build:cjs", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "pnpm run typecheck && pnpm run lint", "test": "vitest run", - "test:watch": "vitest", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "test:watch": "vitest" }, "dependencies": { "@modelcontextprotocol/core": "workspace:^", diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000000..687b234be9 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist", "shared", "server-quickstart", "client-quickstart"], + "compilerOptions": { + "noEmit": true, + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], + "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ], + "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483ebc939c..bbbcea48f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,82 +293,38 @@ importers: specifier: catalog:devTools version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - examples/client: - dependencies: - '@modelcontextprotocol/client': - specifier: workspace:^ - version: link:../../packages/client - ajv: - specifier: catalog:runtimeShared - version: 8.18.0 - open: - specifier: ^11.0.0 - version: 11.0.0 - zod: - specifier: catalog:runtimeShared - version: 4.3.6 - devDependencies: - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/examples-shared': - specifier: workspace:^ - version: link:../shared - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config - tsdown: - specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) - - examples/client-quickstart: - dependencies: - '@anthropic-ai/sdk': - specifier: ^0.74.0 - version: 0.74.0(zod@4.3.6) - '@modelcontextprotocol/client': - specifier: workspace:^ - version: link:../../packages/client - devDependencies: - '@types/node': - specifier: ^24.10.1 - version: 24.12.0 - typescript: - specifier: catalog:devTools - version: 5.9.3 - - examples/server: + examples: dependencies: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.11(hono@4.12.9) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../packages/client '@modelcontextprotocol/examples-shared': specifier: workspace:^ - version: link:../shared + version: link:shared '@modelcontextprotocol/express': specifier: workspace:^ - version: link:../../packages/middleware/express + version: link:../packages/middleware/express '@modelcontextprotocol/hono': specifier: workspace:^ - version: link:../../packages/middleware/hono + version: link:../packages/middleware/hono '@modelcontextprotocol/node': specifier: workspace:^ - version: link:../../packages/middleware/node + version: link:../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:^ - version: link:../../packages/server + version: link:../packages/server '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 arktype: specifier: catalog:devTools version: 2.2.0 - better-auth: - specifier: ^1.4.17 - version: 1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))) cors: specifier: catalog:runtimeServerOnly version: 2.8.6 @@ -378,6 +334,9 @@ importers: hono: specifier: catalog:runtimeServerOnly version: 4.12.9 + open: + specifier: ^11.0.0 + version: 11.0.0 valibot: specifier: catalog:devTools version: 1.3.1(typescript@5.9.3) @@ -387,22 +346,35 @@ importers: devDependencies: '@modelcontextprotocol/eslint-config': specifier: workspace:^ - version: link:../../common/eslint-config + version: link:../common/eslint-config '@modelcontextprotocol/tsconfig': specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config + version: link:../common/tsconfig '@types/cors': specifier: catalog:devTools version: 2.8.19 '@types/express': specifier: catalog:devTools version: 5.0.6 - tsdown: + tsx: specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + version: 4.21.0 + + examples/client-quickstart: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.74.0 + version: 0.74.0(zod@4.3.6) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 examples/server-quickstart: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e15c6b22b8..f5302c1ee6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - packages/**/* - '!packages/codemod/batch-test/**' - common/**/* + - examples - examples/**/* - test/**/* From a44f9d4d8a326f700092020464c2818ce0f32391 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:49:25 +0000 Subject: [PATCH 02/27] test(examples): self-verifying example-pair runner + CI job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/run-examples.ts` iterates `examples/*/` (skipping `shared`, `guides`, `oauth`, the quickstarts, and any directory whose `manifest.json` carries an `excluded` reason). For each story it runs the client over every transport the story supports (default: both): stdio runs the client alone (which spawns the sibling server); HTTP launches `server.ts --http --port

` on a per-story port, polls the port, runs `client.ts --http `, checks exit 0 (and the optional `expects.stdout` substring), then kills the server. Aggregate exit is non-zero if any leg failed. A new `examples` CI job (`.github/workflows/examples.yml`) builds the workspace first — fixing the gap that killed an earlier examples smoke suite — then runs the script. `pnpm run:examples` is the local entry point. --- .github/workflows/examples.yml | 42 +++++++ package.json | 3 +- scripts/run-examples.ts | 201 +++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/examples.yml create mode 100644 scripts/run-examples.ts diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000000..e7e912c691 --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,42 @@ +on: + push: + branches: + - main + - v2-2026-07-28 + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Builds the workspace + examples and e2e-runs every examples// pair + # over every transport it supports. Each client.ts is a self-verifying test + # (asserts and exits non-zero on any mismatch). This is part of the per-PR + # gate basket — a red examples run blocks merge. + examples: + name: examples (build + e2e) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - run: pnpm install + + # The workspace packages the examples import resolve to built dists + # (the gap that killed an earlier examples smoke suite). + - run: pnpm run build:all + + - name: Run all example pairs (stdio + http) + run: pnpm tsx scripts/run-examples.ts diff --git a/package.json b/package.json index 03c4132988..8c5950040a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", - "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples-server exec tsx --watch src/simpleStreamableHttp.ts --oauth", + "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples exec tsx --watch oauth/simpleStreamableHttpServer.ts --oauth", + "run:examples": "tsx scripts/run-examples.ts", "docs": "typedoc", "docs:multi": "bash scripts/generate-multidoc.sh", "docs:check": "typedoc", diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts new file mode 100644 index 0000000000..5bd5e8a72c --- /dev/null +++ b/scripts/run-examples.ts @@ -0,0 +1,201 @@ +#!/usr/bin/env tsx +/** + * Build-and-e2e-run every story under `examples/` over every transport it + * supports. Each story's `client.ts` is a self-verifying e2e test (it asserts + * the server's behaviour and exits non-zero on any mismatch). + * + * - **stdio** (default for dual-transport stories): run `client.ts` with no + * transport flag; it spawns the sibling server binary itself and speaks + * MCP over the pipe. + * - **HTTP**: start `server.ts --http --port

`, poll until ready, run + * `client.ts --http http://127.0.0.1:

/`, kill the server. + * + * A per-directory `manifest.json` overrides defaults — most stories have none. + * `excluded` stories are listed (with their reason) but not run. Stories + * without a `client.ts` are skipped. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { connect } from 'node:net'; +import { join, resolve } from 'node:path'; + +interface Manifest { + /** `'dual'` (stdio + http; the default), `'http'`, or `'stdio'`. */ + transport?: 'dual' | 'http' | 'stdio'; + /** HTTP port (default: a per-story port assigned below). */ + port?: number; + /** Endpoint path (default: `'/'`). */ + path?: string; + /** Extra environment for the server process. */ + env?: Record; + /** Per-transport timeout in milliseconds (default: 30000). */ + timeoutMs?: number; + /** Optional substring the client's stdout must contain. */ + expects?: { stdout?: string }; + /** When present, the story is skipped (with this reason printed). */ + excluded?: string; +} + +const ROOT = resolve(import.meta.dirname, '..'); +const EXAMPLES = join(ROOT, 'examples'); +const TSX = join(ROOT, 'node_modules', '.bin', 'tsx'); + +/** Directories that are never stories. */ +const NON_STORY = new Set(['shared', 'guides', 'oauth', 'server-quickstart', 'client-quickstart', 'node_modules']); + +/** Distinct per-story HTTP ports so the servers never collide. */ +let nextPort = 8530; +const portFor = new Map(); +function assignPort(story: string, manifest: Manifest): number { + if (manifest.port) return manifest.port; + if (!portFor.has(story)) portFor.set(story, nextPort++); + return portFor.get(story)!; +} + +function readManifest(dir: string): Manifest { + const file = join(dir, 'manifest.json'); + return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as Manifest) : {}; +} + +function run( + cmd: string, + args: string[], + opts: { cwd: string; env?: Record; timeoutMs: number } +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise(resolvePromise => { + const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => (stdout += String(d))); + child.stderr.on('data', d => (stderr += String(d))); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out' }); + }, opts.timeoutMs); + child.on('close', code => { + clearTimeout(timer); + resolvePromise({ code: code ?? 1, stdout, stderr }); + }); + child.on('error', err => { + clearTimeout(timer); + resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}` }); + }); + }); +} + +async function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ok = await new Promise(resolvePromise => { + const sock = connect({ port, host: '127.0.0.1' }, () => { + sock.destroy(); + resolvePromise(true); + }); + sock.on('error', () => resolvePromise(false)); + }); + if (ok) return true; + await new Promise(r => setTimeout(r, 200)); + } + return false; +} + +interface LegResult { + story: string; + leg: 'stdio' | 'http'; + ok: boolean; + detail: string; +} + +async function runStdioLeg(story: string, dir: string, manifest: Manifest): Promise { + const timeoutMs = manifest.timeoutMs ?? 30_000; + const result = await run(TSX, [join(dir, 'client.ts')], { cwd: ROOT, timeoutMs }); + const ok = result.code === 0 && (!manifest.expects?.stdout || result.stdout.includes(manifest.expects.stdout)); + return { + story, + leg: 'stdio', + ok, + detail: ok ? (result.stdout.trim().split('\n').pop() ?? '') : `exit ${result.code}\n${result.stderr || result.stdout}` + }; +} + +async function runHttpLeg(story: string, dir: string, manifest: Manifest): Promise { + const timeoutMs = manifest.timeoutMs ?? 30_000; + const port = assignPort(story, manifest); + const path = manifest.path ?? '/'; + const url = `http://127.0.0.1:${port}${path}`; + let serverStderr = ''; + const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], { + cwd: ROOT, + env: { ...process.env, PORT: String(port), ...manifest.env } + }); + server.stderr?.on('data', d => (serverStderr += String(d))); + server.stdout?.on('data', d => (serverStderr += String(d))); + try { + const ready = await waitForPort(port, 15_000); + if (!ready) { + return { story, leg: 'http', ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` }; + } + const result = await run(TSX, [join(dir, 'client.ts'), '--http', url], { cwd: ROOT, timeoutMs }); + const ok = result.code === 0 && (!manifest.expects?.stdout || result.stdout.includes(manifest.expects.stdout)); + return { + story, + leg: 'http', + ok, + detail: ok + ? (result.stdout.trim().split('\n').pop() ?? '') + : `exit ${result.code}\n${result.stderr || result.stdout}\n--- server log ---\n${serverStderr}` + }; + } finally { + server.kill('SIGTERM'); + await new Promise(r => setTimeout(r, 100)); + if (!server.killed) server.kill('SIGKILL'); + } +} + +async function main(): Promise { + const stories = readdirSync(EXAMPLES, { withFileTypes: true }) + .filter(d => d.isDirectory() && !NON_STORY.has(d.name)) + .map(d => d.name) + .filter(name => existsSync(join(EXAMPLES, name, 'client.ts'))) + .sort(); + + const results: LegResult[] = []; + const excluded: Array<{ story: string; reason: string }> = []; + + for (const story of stories) { + const dir = join(EXAMPLES, story); + const manifest = readManifest(dir); + if (manifest.excluded) { + excluded.push({ story, reason: manifest.excluded }); + console.log(`\n::group::example ${story}\nSKIPPED: ${manifest.excluded}\n::endgroup::`); + continue; + } + const transport = manifest.transport ?? 'dual'; + console.log(`\n::group::example ${story} (${transport})`); + if (transport === 'stdio' || transport === 'dual') { + const r = await runStdioLeg(story, dir, manifest); + results.push(r); + console.log(`[stdio] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); + if (!r.ok) console.log(r.detail); + } + if (transport === 'http' || transport === 'dual') { + const r = await runHttpLeg(story, dir, manifest); + results.push(r); + console.log(`[http] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); + if (!r.ok) console.log(r.detail); + } + console.log('::endgroup::'); + } + + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok); + console.log('\n=== examples e2e summary ==='); + console.log(`stories: ${stories.length - excluded.length} run / ${excluded.length} excluded`); + console.log(`legs: ${passed} passed / ${failed.length} failed`); + for (const r of failed) console.log(` FAIL ${r.story} [${r.leg}]`); + for (const e of excluded) console.log(` SKIP ${e.story}: ${e.reason}`); + + process.exit(failed.length === 0 ? 0 : 1); +} + +void main(); From e786e8235fc6c8bf7a2431647cc98bb4137e2fd9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:49:25 +0000 Subject: [PATCH 03/27] refactor(examples): move existing pairs into per-story directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dual-era (collapsed from dualEraStdio + dualEraStreamableHttp), mrtr, and custom-methods are re-hosted on the dual-transport scaffold; each gets a self-verifying client.ts (the dual-era client now asserts both eras over the selected transport; the mrtr client asserts both auto-fulfil and manual flows reach `deployed to …`; the custom-methods client spawns the server from source via tsx instead of dist/). sse-polling, standalone-get, the guide snippet collections, and the interactive OAuth set are moved verbatim into their directories with a manifest/README; sse-polling/standalone-get stay excluded for now (long- running sessionful 2025), oauth/ stays excluded (browser flow). `examples/{server,client}` (the old split packages) are removed. `streamableHttpWithSseFallbackClient.ts` is retired (the 1.x transport- fallback story is no longer distinct under v2; the snippet stays in the client guide). --- examples/client/eslint.config.mjs | 14 - examples/client/package.json | 47 -- examples/client/src/customMethodExample.ts | 25 - examples/client/src/dualEraStdioClient.ts | 56 -- .../client/src/multipleClientsParallel.ts | 152 ------ .../client/src/parallelToolCallsClient.ts | 175 ------- .../streamableHttpWithSseFallbackClient.ts | 181 ------- examples/client/tsconfig.json | 22 - examples/client/tsdown.config.ts | 25 - examples/client/vitest.config.js | 3 - examples/custom-methods/README.md | 8 + examples/custom-methods/client.ts | 31 ++ examples/custom-methods/server.ts | 28 + examples/dual-era/README.md | 12 + examples/dual-era/client.ts | 36 ++ examples/dual-era/server.ts | 43 ++ examples/guides/README.md | 3 + .../src => guides}/clientGuide.examples.ts | 0 .../src => guides}/serverGuide.examples.ts | 0 examples/mrtr/README.md | 10 + .../client.ts} | 67 +-- .../src/multiRoundTrip.ts => mrtr/server.ts} | 27 +- examples/oauth/README.md | 6 + .../{client/src => oauth}/dualModeAuth.ts | 0 .../elicitationUrlClient.ts} | 0 .../elicitationUrlServer.ts} | 2 +- .../interactiveReplClient.ts} | 0 examples/oauth/manifest.json | 3 + .../src => oauth}/simpleClientCredentials.ts | 0 .../src => oauth}/simpleOAuthClient.ts | 0 .../simpleOAuthClientProvider.ts | 0 .../simpleStreamableHttpServer.ts} | 2 +- .../src => oauth}/simpleTokenProvider.ts | 0 examples/server/eslint.config.mjs | 14 - examples/server/package.json | 58 --- examples/server/src/arktypeExample.ts | 29 -- examples/server/src/customMethodExample.ts | 23 - examples/server/src/customProtocolVersion.ts | 65 --- examples/server/src/dualEraStdio.ts | 69 --- examples/server/src/dualEraStreamableHttp.ts | 93 ---- examples/server/src/elicitationFormExample.ts | 488 ------------------ .../src/honoWebStandardStreamableHttp.ts | 73 --- .../server/src/jsonResponseStreamableHttp.ts | 168 ------ examples/server/src/mcpServerOutputSchema.ts | 83 --- examples/server/src/resourceServerOnly.ts | 87 ---- .../src/simpleStatelessStreamableHttp.ts | 171 ------ examples/server/src/toolWithSampleServer.ts | 60 --- examples/server/src/valibotExample.ts | 31 -- examples/server/tsconfig.json | 25 - examples/server/tsdown.config.ts | 25 - examples/server/vitest.config.js | 3 - examples/sse-polling/README.md | 10 + .../client.ts} | 0 .../src => sse-polling}/inMemoryEventStore.ts | 0 examples/sse-polling/manifest.json | 4 + .../server.ts} | 0 examples/standalone-get/README.md | 5 + examples/standalone-get/client.ts | 29 ++ examples/standalone-get/manifest.json | 5 + .../server.ts} | 0 60 files changed, 265 insertions(+), 2331 deletions(-) delete mode 100644 examples/client/eslint.config.mjs delete mode 100644 examples/client/package.json delete mode 100644 examples/client/src/customMethodExample.ts delete mode 100644 examples/client/src/dualEraStdioClient.ts delete mode 100644 examples/client/src/multipleClientsParallel.ts delete mode 100644 examples/client/src/parallelToolCallsClient.ts delete mode 100644 examples/client/src/streamableHttpWithSseFallbackClient.ts delete mode 100644 examples/client/tsconfig.json delete mode 100644 examples/client/tsdown.config.ts delete mode 100644 examples/client/vitest.config.js create mode 100644 examples/custom-methods/README.md create mode 100644 examples/custom-methods/client.ts create mode 100644 examples/custom-methods/server.ts create mode 100644 examples/dual-era/README.md create mode 100644 examples/dual-era/client.ts create mode 100644 examples/dual-era/server.ts create mode 100644 examples/guides/README.md rename examples/{client/src => guides}/clientGuide.examples.ts (100%) rename examples/{server/src => guides}/serverGuide.examples.ts (100%) create mode 100644 examples/mrtr/README.md rename examples/{client/src/multiRoundTripClient.ts => mrtr/client.ts} (50%) rename examples/{server/src/multiRoundTrip.ts => mrtr/server.ts} (85%) create mode 100644 examples/oauth/README.md rename examples/{client/src => oauth}/dualModeAuth.ts (100%) rename examples/{client/src/elicitationUrlExample.ts => oauth/elicitationUrlClient.ts} (100%) rename examples/{server/src/elicitationUrlExample.ts => oauth/elicitationUrlServer.ts} (99%) rename examples/{client/src/simpleStreamableHttp.ts => oauth/interactiveReplClient.ts} (100%) create mode 100644 examples/oauth/manifest.json rename examples/{client/src => oauth}/simpleClientCredentials.ts (100%) rename examples/{client/src => oauth}/simpleOAuthClient.ts (100%) rename examples/{client/src => oauth}/simpleOAuthClientProvider.ts (100%) rename examples/{server/src/simpleStreamableHttp.ts => oauth/simpleStreamableHttpServer.ts} (99%) rename examples/{client/src => oauth}/simpleTokenProvider.ts (100%) delete mode 100644 examples/server/eslint.config.mjs delete mode 100644 examples/server/package.json delete mode 100644 examples/server/src/arktypeExample.ts delete mode 100644 examples/server/src/customMethodExample.ts delete mode 100644 examples/server/src/customProtocolVersion.ts delete mode 100644 examples/server/src/dualEraStdio.ts delete mode 100644 examples/server/src/dualEraStreamableHttp.ts delete mode 100644 examples/server/src/elicitationFormExample.ts delete mode 100644 examples/server/src/honoWebStandardStreamableHttp.ts delete mode 100644 examples/server/src/jsonResponseStreamableHttp.ts delete mode 100644 examples/server/src/mcpServerOutputSchema.ts delete mode 100644 examples/server/src/resourceServerOnly.ts delete mode 100644 examples/server/src/simpleStatelessStreamableHttp.ts delete mode 100644 examples/server/src/toolWithSampleServer.ts delete mode 100644 examples/server/src/valibotExample.ts delete mode 100644 examples/server/tsconfig.json delete mode 100644 examples/server/tsdown.config.ts delete mode 100644 examples/server/vitest.config.js create mode 100644 examples/sse-polling/README.md rename examples/{client/src/ssePollingClient.ts => sse-polling/client.ts} (100%) rename examples/{server/src => sse-polling}/inMemoryEventStore.ts (100%) create mode 100644 examples/sse-polling/manifest.json rename examples/{server/src/ssePollingExample.ts => sse-polling/server.ts} (100%) create mode 100644 examples/standalone-get/README.md create mode 100644 examples/standalone-get/client.ts create mode 100644 examples/standalone-get/manifest.json rename examples/{server/src/standaloneSseWithGetStreamableHttp.ts => standalone-get/server.ts} (100%) diff --git a/examples/client/eslint.config.mjs b/examples/client/eslint.config.mjs deleted file mode 100644 index 83b79879f6..0000000000 --- a/examples/client/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], - rules: { - // Allow console statements in examples only - 'no-console': 'off' - } - } -]; diff --git a/examples/client/package.json b/examples/client/package.json deleted file mode 100644 index 57b329fd2d..0000000000 --- a/examples/client/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@modelcontextprotocol/examples-client", - "private": true, - "version": "2.0.0-alpha.0", - "description": "Model Context Protocol implementation for TypeScript", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build:esm && pnpm run build:cjs", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "@modelcontextprotocol/client": "workspace:^", - "ajv": "catalog:runtimeShared", - "open": "^11.0.0", - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/examples-shared": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "tsdown": "catalog:devTools" - } -} diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts deleted file mode 100644 index a289af0a47..0000000000 --- a/examples/client/src/customMethodExample.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Custom (non-spec) method example: a client that sends `acme/search` and - * listens for `acme/searchProgress` notifications. - * - * Build `examples/server` first; this client spawns the server via stdio. - */ -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { z } from 'zod/v4'; - -const SearchResult = z.object({ items: z.array(z.string()) }); -const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); - -const client = new Client({ name: 'acme-search-client', version: '0.0.0' }); - -client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { - console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`); -}); - -await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); - -const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); -console.log('items:', result.items); - -await client.close(); diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts deleted file mode 100644 index 9a9f6fe864..0000000000 --- a/examples/client/src/dualEraStdioClient.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`, - * a `serveStdio` server) with both kinds of client, each over its own real - * child-process pipe: - * - * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; - * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the - * `server/discover` probe negotiates the 2026-07-28 revision on the pipe - * (no `initialize` is ever sent), and the client attaches the per-request - * `_meta` envelope to every outgoing request itself. - * - * The client spawns the server example directly from source over stdio: - * - * tsx examples/client/src/dualEraStdioClient.ts - */ -import path from 'node:path'; - -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; - -// Spawn the sibling server example straight from its source (no build step), -// located relative to this file so the demo runs from any working directory. -const SERVER_SOURCE = path.resolve(import.meta.dirname, '../../server/src/dualEraStdio.ts'); -const SERVER = { command: 'npx', args: ['tsx', SERVER_SOURCE] }; - -async function legacyLeg(): Promise { - console.log('--- leg 1: plain 2025 client (initialize handshake) ---'); - const client = new Client({ name: 'legacy-demo-client', version: '1.0.0' }); - await client.connect(new StdioClientTransport(SERVER)); - - console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - const tools = await client.listTools(); - console.log( - 'tools:', - tools.tools.map(tool => tool.name) - ); - const result = await client.callTool({ name: 'greet', arguments: { name: '2025 client' } }); - console.log('greet result:', JSON.stringify(result.content)); - await client.close(); -} - -async function modernLeg(): Promise { - console.log('--- leg 2: 2026-capable client (server/discover negotiation) ---'); - const client = new Client({ name: 'modern-demo-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StdioClientTransport(SERVER)); - - console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - - const result = await client.callTool({ name: 'greet', arguments: { name: '2026 client' } }); - console.log('greet result:', JSON.stringify(result.content)); - await client.close(); -} - -await legacyLeg(); -await modernLeg(); -console.log('both legs served by the same dual-era stdio server factory.'); diff --git a/examples/client/src/multipleClientsParallel.ts b/examples/client/src/multipleClientsParallel.ts deleted file mode 100644 index 6543bae020..0000000000 --- a/examples/client/src/multipleClientsParallel.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { CallToolResult } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Multiple Clients MCP Example - * - * This client demonstrates how to: - * 1. Create multiple MCP clients in parallel - * 2. Each client calls a single tool - * 3. Track notifications from each client independently - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -interface ClientConfig { - id: string; - name: string; - toolName: string; - toolArguments: Record; -} - -async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { - console.log(`[${config.id}] Creating client: ${config.name}`); - - const client = new Client({ - name: config.name, - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - - // Set up client-specific error handler - client.onerror = error => { - console.error(`[${config.id}] Client error:`, error); - }; - - // Set up client-specific notification handler - client.setNotificationHandler('notifications/message', notification => { - console.log(`[${config.id}] Notification: ${notification.params.data}`); - }); - - try { - // Connect to the server - await client.connect(transport); - console.log(`[${config.id}] Connected to MCP server`); - - // Call the specified tool - console.log(`[${config.id}] Calling tool: ${config.toolName}`); - const result = await client.callTool({ - name: config.toolName, - arguments: { - ...config.toolArguments, - // Add client ID to arguments for identification in notifications - caller: config.id - } - }); - console.log(`[${config.id}] Tool call completed`); - - // Keep the connection open for a bit to receive notifications - await new Promise(resolve => setTimeout(resolve, 5000)); - - // Disconnect - await transport.close(); - console.log(`[${config.id}] Disconnected from MCP server`); - - return { id: config.id, result }; - } catch (error) { - console.error(`[${config.id}] Error:`, error); - throw error; - } -} - -async function main(): Promise { - console.log('MCP Multiple Clients Example'); - console.log('============================'); - console.log(`Server URL: ${serverUrl}`); - console.log(''); - - try { - // Define client configurations - const clientConfigs: ClientConfig[] = [ - { - id: 'client1', - name: 'basic-client-1', - toolName: 'start-notification-stream', - toolArguments: { - interval: 3, // 1 second between notifications - count: 5 // Send 5 notifications - } - }, - { - id: 'client2', - name: 'basic-client-2', - toolName: 'start-notification-stream', - toolArguments: { - interval: 2, // 2 seconds between notifications - count: 3 // Send 3 notifications - } - }, - { - id: 'client3', - name: 'basic-client-3', - toolName: 'start-notification-stream', - toolArguments: { - interval: 1, // 0.5 second between notifications - count: 8 // Send 8 notifications - } - } - ]; - - // Start all clients in parallel - console.log(`Starting ${clientConfigs.length} clients in parallel...`); - console.log(''); - - const clientPromises = clientConfigs.map(config => createAndRunClient(config)); - const results = await Promise.all(clientPromises); - - // Display results from all clients - console.log('\n=== Final Results ==='); - for (const { id, result } of results) { - console.log(`\n[${id}] Tool result:`); - if (Array.isArray(result.content)) { - for (const item of result.content) { - if (item.type === 'text' && item.text) { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } else { - console.log(` Unexpected result format:`, result); - } - } - - console.log('\n=== All clients completed successfully ==='); - } catch (error) { - console.error('Error running multiple clients:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -// Start the example -try { - await main(); -} catch (error) { - console.error('Error running multiple clients:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/parallelToolCallsClient.ts b/examples/client/src/parallelToolCallsClient.ts deleted file mode 100644 index 5b16cc9cc8..0000000000 --- a/examples/client/src/parallelToolCallsClient.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { CallToolResult, ListToolsRequest } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Parallel Tool Calls MCP Client - * - * This client demonstrates how to: - * 1. Start multiple tool calls in parallel - * 2. Track notifications from each tool call using a caller parameter - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -async function main(): Promise { - console.log('MCP Parallel Tool Calls Client'); - console.log('=============================='); - console.log(`Connecting to server at: ${serverUrl}`); - - let client: Client; - let transport: StreamableHTTPClientTransport; - - try { - // Create client with streamable HTTP transport - client = new Client({ - name: 'parallel-tool-calls-client', - version: '1.0.0' - }); - - client.onerror = error => { - console.error('Client error:', error); - }; - - // Connect to the server - transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - await client.connect(transport); - console.log('Successfully connected to MCP server'); - - // Set up notification handler with caller identification - client.setNotificationHandler('notifications/message', notification => { - console.log(`Notification: ${notification.params.data}`); - }); - - console.log('List tools'); - const toolsRequest = await listTools(client); - console.log('Tools:', toolsRequest); - - // 2. Start multiple notification tools in parallel - console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); - const toolResults = await startParallelNotificationTools(client); - - // Log the results from each tool call - for (const [caller, result] of Object.entries(toolResults)) { - console.log(`\n=== Tool result for ${caller} ===`); - for (const item of result.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } - - // 3. Wait for all notifications (10 seconds) - console.log('\n=== Waiting for all notifications ==='); - await new Promise(resolve => setTimeout(resolve, 10_000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -/** - * List available tools on the server - */ -async function listTools(client: Client): Promise { - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server: ${error}`); - } -} - -/** - * Start multiple notification tools in parallel with different configurations - * Each tool call includes a caller parameter to identify its notifications - */ -async function startParallelNotificationTools(client: Client): Promise> { - try { - // Define multiple tool calls with different configurations - const toolCalls = [ - { - caller: 'fast-notifier', - args: { - interval: 2, // 0.5 second between notifications - count: 10, // Send 10 notifications - caller: 'fast-notifier' // Identify this tool call - } - }, - { - caller: 'slow-notifier', - args: { - interval: 5, // 2 seconds between notifications - count: 5, // Send 5 notifications - caller: 'slow-notifier' // Identify this tool call - } - }, - { - caller: 'burst-notifier', - args: { - interval: 1, // 0.1 second between notifications - count: 3, // Send just 3 notifications - caller: 'burst-notifier' // Identify this tool call - } - } - ]; - - console.log(`Starting ${toolCalls.length} notification tools in parallel...`); - - // Start all tool calls in parallel - const toolPromises = toolCalls.map(({ caller, args }) => { - console.log(`Starting tool call for ${caller}...`); - return client - .callTool({ name: 'start-notification-stream', arguments: args }) - .then(result => ({ caller, result })) - .catch(error => { - console.error(`Error in tool call for ${caller}:`, error); - throw error; - }); - }); - - // Wait for all tool calls to complete - const results = await Promise.all(toolPromises); - - // Organize results by caller - const resultsByTool: Record = {}; - for (const { caller, result } of results) { - resultsByTool[caller] = result; - } - - return resultsByTool; - } catch (error) { - console.error(`Error starting parallel notification tools:`, error); - throw error; - } -} - -try { - // Run the client - await main(); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/streamableHttpWithSseFallbackClient.ts b/examples/client/src/streamableHttpWithSseFallbackClient.ts deleted file mode 100644 index 0925f8dd0b..0000000000 --- a/examples/client/src/streamableHttpWithSseFallbackClient.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { ListToolsRequest } from '@modelcontextprotocol/client'; -import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Simplified Backwards Compatible MCP Client - * - * This client demonstrates backward compatibility with both: - * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) - * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) - * - * Following the MCP specification for backwards compatibility: - * - Attempts to POST an initialize request to the server URL first (modern transport) - * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -async function main(): Promise { - console.log('MCP Backwards Compatible Client'); - console.log('==============================='); - console.log(`Connecting to server at: ${serverUrl}`); - - let client: Client; - let transport: StreamableHTTPClientTransport | SSEClientTransport; - - try { - // Try connecting with automatic transport detection - const connection = await connectWithBackwardsCompatibility(serverUrl); - client = connection.client; - transport = connection.transport; - - // Set up notification handler - client.setNotificationHandler('notifications/message', notification => { - console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); - }); - - // DEMO WORKFLOW: - // 1. List available tools - console.log('\n=== Listing Available Tools ==='); - await listTools(client); - - // 2. Call the notification tool - console.log('\n=== Starting Notification Stream ==='); - await startNotificationTool(client); - - // 3. Wait for all notifications (5 seconds) - console.log('\n=== Waiting for all notifications ==='); - await new Promise(resolve => setTimeout(resolve, 5000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -/** - * Connect to an MCP server with backwards compatibility - * Following the spec for client backward compatibility - */ -async function connectWithBackwardsCompatibility(url: string): Promise<{ - client: Client; - transport: StreamableHTTPClientTransport | SSEClientTransport; - transportType: 'streamable-http' | 'sse'; -}> { - console.log('1. Trying Streamable HTTP transport first...'); - - // Step 1: Try Streamable HTTP transport first - const client = new Client({ - name: 'backwards-compatible-client', - version: '1.0.0' - }); - - client.onerror = error => { - console.error('Client error:', error); - }; - const baseUrl = new URL(url); - - try { - // Create modern transport - const streamableTransport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(streamableTransport); - - console.log('Successfully connected using modern Streamable HTTP transport.'); - return { - client, - transport: streamableTransport, - transportType: 'streamable-http' - }; - } catch (error) { - // Step 2: If transport fails, try the older SSE transport - console.log(`StreamableHttp transport connection failed: ${error}`); - console.log('2. Falling back to deprecated HTTP+SSE transport...'); - - try { - // Create SSE transport pointing to /sse endpoint - const sseTransport = new SSEClientTransport(baseUrl); - const sseClient = new Client({ - name: 'backwards-compatible-client', - version: '1.0.0' - }); - await sseClient.connect(sseTransport); - - console.log('Successfully connected using deprecated HTTP+SSE transport.'); - return { - client: sseClient, - transport: sseTransport, - transportType: 'sse' - }; - } catch (sseError) { - console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); - throw new Error('Could not connect to server with any available transport'); - } - } -} - -/** - * List available tools on the server - */ -async function listTools(client: Client): Promise { - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server: ${error}`); - } -} - -/** - * Start a notification stream by calling the notification tool - */ -async function startNotificationTool(client: Client): Promise { - try { - console.log('Calling notification tool...'); - const result = await client.callTool({ - name: 'start-notification-stream', - arguments: { - interval: 1000, // 1 second between notifications - count: 5 // Send 5 notifications - } - }); - - console.log('Tool result:'); - for (const item of result.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } catch (error) { - console.log(`Error calling notification tool: ${error}`); - } -} - -// Start the client -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/tsconfig.json b/examples/client/tsconfig.json deleted file mode 100644 index 5c1f7fc764..0000000000 --- a/examples/client/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], - "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], - "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], - "@modelcontextprotocol/core": [ - "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" - ], - "@modelcontextprotocol/core/public": [ - "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" - ], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] - } - } -} diff --git a/examples/client/tsdown.config.ts b/examples/client/tsdown.config.ts deleted file mode 100644 index efc4299d35..0000000000 --- a/examples/client/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - // 1. Entry Points - // Directly matches package.json include/exclude globs - entry: ['src/**/*.ts'], - - // 2. Output Configuration - format: ['esm'], - outDir: 'dist', - clean: true, // Recommended: Cleans 'dist' before building - sourcemap: true, - - // 3. Platform & Target - target: 'esnext', - platform: 'node', - shims: true, // Polyfills common Node.js shims (__dirname, etc.) - - // 4. Type Definitions - // Bundles d.ts files into a single output - dts: false, - // 5. Vendoring Strategy - Bundle the code for this specific package into the output, - // but treat all other dependencies as external (require/import). - noExternal: ['@modelcontextprotocol/examples-shared'] -}); diff --git a/examples/client/vitest.config.js b/examples/client/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/examples/client/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/examples/custom-methods/README.md b/examples/custom-methods/README.md new file mode 100644 index 0000000000..6f778ebee9 --- /dev/null +++ b/examples/custom-methods/README.md @@ -0,0 +1,8 @@ +# custom-methods + +Bidirectional custom (non-spec) JSON-RPC methods: the server handles a vendor-prefixed `acme/search` request via `server.setRequestHandler` and emits `acme/searchProgress` notifications via `ctx.mcpReq.notify`; the client sends the typed request via +`client.request(method, schema)` and receives the typed notifications via `client.setNotificationHandler('acme/searchProgress', { params })`. + +```bash +pnpm tsx examples/custom-methods/client.ts +``` diff --git a/examples/custom-methods/client.ts b/examples/custom-methods/client.ts new file mode 100644 index 0000000000..4feaf8e7e7 --- /dev/null +++ b/examples/custom-methods/client.ts @@ -0,0 +1,31 @@ +/** + * Custom (non-spec) method example: a client that sends `acme/search` and + * listens for `acme/searchProgress` notifications. + * + * The client spawns the sibling server straight from source over stdio (no + * build step), or connects to a running endpoint under `--http `. + */ +import { z } from 'zod/v4'; + +import { check, connectFromArgs, runClient } from '../harness.js'; + +const SearchResult = z.object({ items: z.array(z.string()) }); +const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +runClient('custom-methods', async () => { + // Custom methods carry no envelope semantics — connect as a plain 2025 + // client so the request reaches the server's setRequestHandler exactly as + // a hand-wired stdio client would. + const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); + + const stages: string[] = []; + client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { + stages.push(params.stage); + }); + + const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); + check.deepEqual(result.items, ['mcp-0', 'mcp-1', 'mcp-2']); + check.deepEqual(stages, ['start', 'done']); + + await client.close(); +}); diff --git a/examples/custom-methods/server.ts b/examples/custom-methods/server.ts new file mode 100644 index 0000000000..09b9444c5b --- /dev/null +++ b/examples/custom-methods/server.ts @@ -0,0 +1,28 @@ +/** + * Custom (non-spec) method example: a server that handles a vendor-prefixed + * `acme/search` request and emits `acme/searchProgress` notifications. + * + * One binary, either transport (selected by the shared scaffold from argv). + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); + + mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); + return { items }; + }); + + return mcp; +} + +runServerFromArgs(buildServer); diff --git a/examples/dual-era/README.md b/examples/dual-era/README.md new file mode 100644 index 0000000000..0da44000b2 --- /dev/null +++ b/examples/dual-era/README.md @@ -0,0 +1,12 @@ +# dual-era + +One server factory, both protocol eras (2025 `initialize` and 2026-07-28 per-request envelope), both transports (stdio and Streamable HTTP). The client connects once as a plain 2025 client and once with `versionNegotiation: { mode: 'auto' }`; the same `greet` tool answers both +and reports which era served the call. + +This is the recommended **first** example to read if you are migrating an existing server to the 2026 era: the entry (`serveStdio` / `createMcpHandler`) owns the era decision, the factory is era-agnostic. + +```bash +pnpm tsx examples/dual-era/client.ts # stdio +pnpm tsx examples/dual-era/server.ts --http --port 3000 # term 1 +pnpm tsx examples/dual-era/client.ts --http http://127.0.0.1:3000/ # term 2 +``` diff --git a/examples/dual-era/client.ts b/examples/dual-era/client.ts new file mode 100644 index 0000000000..0e830f233f --- /dev/null +++ b/examples/dual-era/client.ts @@ -0,0 +1,36 @@ +/** + * Drives the dual-era server (`./server.ts`) over the selected transport with + * BOTH kinds of client: + * + * 1. a plain 2025 client — the `initialize` handshake, served exactly as + * today (the server reports `era === 'legacy'`); + * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the + * `server/discover` probe negotiates the 2026-07-28 revision (no + * `initialize` is ever sent) and the SDK attaches the per-request `_meta` + * envelope itself (the server reports `era === 'modern'`). + * + * Asserts both legs and exits 0 — used as a self-verifying e2e by + * `scripts/run-examples.ts` over stdio AND http. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('dual-era', async () => { + // --- leg 1: plain 2025 client (initialize handshake) --- + const legacy = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); + const legacyTools = await legacy.listTools(); + check.ok(legacyTools.tools.some(t => t.name === 'greet')); + const legacyGreet = await legacy.callTool({ name: 'greet', arguments: { name: '2025 client' } }); + const legacyText = legacyGreet.content?.[0]?.type === 'text' ? legacyGreet.content[0].text : ''; + check.match(legacyText, /Hello, 2025 client! \(served on the legacy protocol era\)/); + await legacy.close(); + + // --- leg 2: 2026-capable client (server/discover negotiation) --- + const modern = await connectFromArgs(import.meta.dirname); + check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28'); + const modernGreet = await modern.callTool({ name: 'greet', arguments: { name: '2026 client' } }); + const modernText = modernGreet.content?.[0]?.type === 'text' ? modernGreet.content[0].text : ''; + check.match(modernText, /Hello, 2026 client! \(served on the modern protocol era\)/); + await modern.close(); + + console.log('both eras served by the same factory over the same transport.'); +}); diff --git a/examples/dual-era/server.ts b/examples/dual-era/server.ts new file mode 100644 index 0000000000..ac2639d63b --- /dev/null +++ b/examples/dual-era/server.ts @@ -0,0 +1,43 @@ +/** + * Dual-era serving from one factory, both transports. + * + * The same factory backs both protocol eras: a 2025-era client connects with + * the `initialize` handshake; a 2026-capable client + * (`versionNegotiation: { mode: 'auto' }`) probes with `server/discover`, + * negotiates the 2026-07-28 revision, and the SDK attaches the per-request + * `_meta` envelope to every outgoing request itself. Tools are defined once + * and served identically to either kind of client. + * + * One binary, either transport (selected by the shared `runServerFromArgs` + * scaffold from argv): stdio by default (`serveStdio(factory)`), or HTTP + * under `--http --port ` (`createMcpHandler(factory)` on its default + * posture — modern served per request, 2025-era traffic served stateless from + * the same factory). + */ +import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const buildServer = (ctx: McpRequestContext) => { + const server = new McpServer( + { name: 'dual-era-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } + ); + + server.registerTool( + 'greet', + { + description: 'Greets the caller and reports which protocol era served the request', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => ({ + content: [{ type: 'text', text: `Hello, ${name}! (served on the ${ctx.era} protocol era)` }] + }) + ); + + return server; +}; + +runServerFromArgs(buildServer); diff --git a/examples/guides/README.md b/examples/guides/README.md new file mode 100644 index 0000000000..d0ed7dbe93 --- /dev/null +++ b/examples/guides/README.md @@ -0,0 +1,3 @@ +# guides + +Snippet collections synced into `docs/server.md` and `docs/client.md` via `pnpm sync:snippets`. Typecheck-only — these are not runnable programs. diff --git a/examples/client/src/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts similarity index 100% rename from examples/client/src/clientGuide.examples.ts rename to examples/guides/clientGuide.examples.ts diff --git a/examples/server/src/serverGuide.examples.ts b/examples/guides/serverGuide.examples.ts similarity index 100% rename from examples/server/src/serverGuide.examples.ts rename to examples/guides/serverGuide.examples.ts diff --git a/examples/mrtr/README.md b/examples/mrtr/README.md new file mode 100644 index 0000000000..d2ef926de6 --- /dev/null +++ b/examples/mrtr/README.md @@ -0,0 +1,10 @@ +# mrtr (multi-round-trip requests) + +A write-once `deploy` tool that requests client input by **returning** `inputRequired(...)` instead of pushing a server→client request (protocol revision 2026-07-28). State between rounds is carried in `requestState`, which the example HMAC-protects and verifies via the +`ServerOptions.requestState.verify` hook (a wire-level `-32602` on tamper). + +The client drives both the default auto-fulfilment mode (your existing `elicitation/create` handler is dispatched for you and `callTool()` returns a plain `CallToolResult`) and manual mode (`autoFulfill: false` + `allowInputRequired: true`). + +```bash +pnpm tsx examples/mrtr/client.ts +``` diff --git a/examples/client/src/multiRoundTripClient.ts b/examples/mrtr/client.ts similarity index 50% rename from examples/client/src/multiRoundTripClient.ts rename to examples/mrtr/client.ts index 13921bdd95..338f88387b 100644 --- a/examples/client/src/multiRoundTripClient.ts +++ b/examples/mrtr/client.ts @@ -1,6 +1,5 @@ /** - * Drives the multi-round-trip server example - * (`examples/server/src/multiRoundTrip.ts`) two ways on a 2026-07-28 + * Drives the multi-round-trip server (`./server.ts`) two ways on a 2026-07-28 * connection: * * 1. **auto-fulfilment** (the default) — the same `elicitation/create` @@ -13,61 +12,42 @@ * the example collects responses, echoes `requestState`, and retries * itself. * - * Start the server first, then: - * - * tsx examples/client/src/multiRoundTripClient.ts + * Asserts both flows reach `deployed to …` and exits 0. */ import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; -import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { isInputRequiredResult } from '@modelcontextprotocol/client'; -const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; -const CLIENT_INFO = { name: 'mrtr-example-client', version: '1.0.0' }; +import { check, connectFromArgs, runClient } from '../harness.js'; -async function autoFulfilLeg(): Promise { - console.log('--- auto-fulfilment (the default) ---'); - const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: 'auto' }, +runClient('mrtr', async () => { + // --- auto-fulfilment (the default) --- + const auto = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } } }); // The SAME handler a 2025-flow client registers: the auto-fulfilment // engine dispatches embedded form and URL elicitations through it. - client.setRequestHandler('elicitation/create', async request => { + auto.setRequestHandler('elicitation/create', async request => { const params = request.params as { mode?: string; message: string; url?: string }; - if (params.mode === 'url') { - console.log(`[client] (auto) url elicitation: ${params.message} → ${params.url}`); - return { action: 'accept' }; - } - console.log(`[client] (auto) form elicitation: ${params.message}`); + if (params.mode === 'url') return { action: 'accept' }; return { action: 'accept', content: { confirm: true } }; }); - - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); - // callTool returns a plain CallToolResult — the interactive rounds happen // inside the call. - const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); - console.log('deploy result:', JSON.stringify(result.content)); - await client.close(); -} + const autoResult = await auto.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + const autoText = autoResult.content?.[0]?.type === 'text' ? autoResult.content[0].text : ''; + check.equal(autoText, 'deployed to prod'); + await auto.close(); -async function manualLeg(): Promise { - console.log('--- manual mode (autoFulfill: false + allowInputRequired) ---'); - const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: 'auto' }, + // --- manual mode (autoFulfill: false + allowInputRequired) --- + const manual = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } }, inputRequired: { autoFulfill: false } }); - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - let inputResponses: Record | undefined; let requestState: string | undefined; + let final: CallToolResult | undefined; for (let round = 0; round < 10; round++) { - // allowInputRequired: true → the call resolves with either the - // complete CallToolResult or the input-required value (use - // `withInputRequired(schema)` on the explicit-schema path to type - // both outcomes; here the method-keyed path is used for brevity). - const value = (await client.request( + const value = (await manual.request( { method: 'tools/call', params: { @@ -80,19 +60,18 @@ async function manualLeg(): Promise { { allowInputRequired: true } )) as CallToolResult | InputRequiredResult; if (!isInputRequiredResult(value)) { - console.log('deploy result:', JSON.stringify(value.content)); + final = value; break; } // Collect responses and echo requestState byte-exact. - console.log(`[client] (manual) round ${round + 1}: server asked for ${Object.keys(value.inputRequests ?? {}).join(', ')}`); inputResponses = {}; for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; } requestState = value.requestState; } - await client.close(); -} - -await autoFulfilLeg(); -await manualLeg(); + check.ok(final, 'manual flow should reach a CallToolResult within 10 rounds'); + const manualText = final?.content?.[0]?.type === 'text' ? final.content[0].text : ''; + check.equal(manualText, 'deployed to staging'); + await manual.close(); +}); diff --git a/examples/server/src/multiRoundTrip.ts b/examples/mrtr/server.ts similarity index 85% rename from examples/server/src/multiRoundTrip.ts rename to examples/mrtr/server.ts index 51abba4eb2..3df6cc7d06 100644 --- a/examples/server/src/multiRoundTrip.ts +++ b/examples/mrtr/server.ts @@ -1,6 +1,6 @@ /** - * A write-once tool served via `createMcpHandler` that requests client input - * with multi round-trip results (protocol revision 2026-07-28). + * A write-once tool that requests client input with multi-round-trip results + * (protocol revision 2026-07-28). * * The `deploy` tool returns `inputRequired(...)` instead of pushing a * server→client request: a form-mode elicitation for confirmation, then a @@ -15,21 +15,16 @@ * key and rejects tampered state via the {@linkcode ServerOptions.requestState} * `verify` hook, which answers a wire-level `-32602` Invalid Params error. * - * Run with: - * - * tsx examples/server/src/multiRoundTrip.ts - * - * and point the paired client example at it: - * - * tsx examples/client/src/multiRoundTripClient.ts + * One binary, either transport (selected by the shared scaffold from argv). */ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; -import { createServer } from 'node:http'; import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; -import { acceptedContent, createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { acceptedContent, inputRequired, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; +import { runServerFromArgs } from '../harness.js'; + const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; // Per-process integrity key for requestState. The 2026-07-28 path serves every @@ -124,12 +119,4 @@ function buildServer(): McpServer { return server; } -// Host with the per-request HTTP entry on its default posture (2026-07-28 -// served per request; 2025-era traffic served stateless from the same -// factory). -const handler = createMcpHandler(() => buildServer()); -const port = Number(process.env.PORT ?? '3000'); - -createServer((req, res) => void handler.node(req, res)).listen(port, () => { - console.error(`multi-round-trip example server listening on http://localhost:${port}/`); -}); +runServerFromArgs(buildServer); diff --git a/examples/oauth/README.md b/examples/oauth/README.md new file mode 100644 index 0000000000..f2941216cd --- /dev/null +++ b/examples/oauth/README.md @@ -0,0 +1,6 @@ +# oauth (excluded) + +The interactive OAuth set: full browser authorization-code flow, URL-elicitation end-to-end, readline REPL clients, dual-mode auth (host token vs `OAuthClientProvider`), client_credentials / private-key-JWT. Typecheck-only — these need a browser, a callback server on `:8090`, and +(for client_credentials) an Authorization Server that doesn't ship in-repo. + +Excluded from the harness (`manifest.json#excluded`); revisit after the auth-surface walk. For the headless bearer-token resource-server case see `../bearer-auth/`. diff --git a/examples/client/src/dualModeAuth.ts b/examples/oauth/dualModeAuth.ts similarity index 100% rename from examples/client/src/dualModeAuth.ts rename to examples/oauth/dualModeAuth.ts diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/oauth/elicitationUrlClient.ts similarity index 100% rename from examples/client/src/elicitationUrlExample.ts rename to examples/oauth/elicitationUrlClient.ts diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/oauth/elicitationUrlServer.ts similarity index 99% rename from examples/server/src/elicitationUrlExample.ts rename to examples/oauth/elicitationUrlServer.ts index 93b59152f8..5504206c63 100644 --- a/examples/server/src/elicitationUrlExample.ts +++ b/examples/oauth/elicitationUrlServer.ts @@ -19,7 +19,7 @@ import type { Request, Response } from 'express'; import express from 'express'; import * as z from 'zod/v4'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; +import { InMemoryEventStore } from '../sse-polling/inMemoryEventStore.js'; // Create an MCP server with implementation details const getServer = () => { diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/oauth/interactiveReplClient.ts similarity index 100% rename from examples/client/src/simpleStreamableHttp.ts rename to examples/oauth/interactiveReplClient.ts diff --git a/examples/oauth/manifest.json b/examples/oauth/manifest.json new file mode 100644 index 0000000000..8db48a625d --- /dev/null +++ b/examples/oauth/manifest.json @@ -0,0 +1,3 @@ +{ + "excluded": "Interactive OAuth flows: browser auth, readline REPLs, callback server on :8090, no in-repo Authorization Server for client_credentials. Revisit after the auth-surface walk." +} diff --git a/examples/client/src/simpleClientCredentials.ts b/examples/oauth/simpleClientCredentials.ts similarity index 100% rename from examples/client/src/simpleClientCredentials.ts rename to examples/oauth/simpleClientCredentials.ts diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/oauth/simpleOAuthClient.ts similarity index 100% rename from examples/client/src/simpleOAuthClient.ts rename to examples/oauth/simpleOAuthClient.ts diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/oauth/simpleOAuthClientProvider.ts similarity index 100% rename from examples/client/src/simpleOAuthClientProvider.ts rename to examples/oauth/simpleOAuthClientProvider.ts diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/oauth/simpleStreamableHttpServer.ts similarity index 99% rename from examples/server/src/simpleStreamableHttp.ts rename to examples/oauth/simpleStreamableHttpServer.ts index 1f0998cca9..444740df2c 100644 --- a/examples/server/src/simpleStreamableHttp.ts +++ b/examples/oauth/simpleStreamableHttpServer.ts @@ -15,7 +15,7 @@ import cors from 'cors'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; +import { InMemoryEventStore } from '../sse-polling/inMemoryEventStore.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); diff --git a/examples/client/src/simpleTokenProvider.ts b/examples/oauth/simpleTokenProvider.ts similarity index 100% rename from examples/client/src/simpleTokenProvider.ts rename to examples/oauth/simpleTokenProvider.ts diff --git a/examples/server/eslint.config.mjs b/examples/server/eslint.config.mjs deleted file mode 100644 index 83b79879f6..0000000000 --- a/examples/server/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], - rules: { - // Allow console statements in examples only - 'no-console': 'off' - } - } -]; diff --git a/examples/server/package.json b/examples/server/package.json deleted file mode 100644 index fcff95d9a9..0000000000 --- a/examples/server/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@modelcontextprotocol/examples-server", - "private": true, - "version": "2.0.0-alpha.0", - "description": "Model Context Protocol implementation for TypeScript", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build:esm && pnpm run build:cjs", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "@hono/node-server": "catalog:runtimeServerOnly", - "@modelcontextprotocol/examples-shared": "workspace:^", - "@modelcontextprotocol/express": "workspace:^", - "@modelcontextprotocol/hono": "workspace:^", - "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", - "@valibot/to-json-schema": "catalog:devTools", - "arktype": "catalog:devTools", - "better-auth": "^1.4.17", - "cors": "catalog:runtimeServerOnly", - "express": "catalog:runtimeServerOnly", - "hono": "catalog:runtimeServerOnly", - "valibot": "catalog:devTools", - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@types/cors": "catalog:devTools", - "@types/express": "catalog:devTools", - "tsdown": "catalog:devTools" - } -} diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts deleted file mode 100644 index 4a470532ed..0000000000 --- a/examples/server/src/arktypeExample.ts +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal MCP server using ArkType for schema validation. - * ArkType implements the Standard Schema spec with built-in JSON Schema conversion. - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { type } from 'arktype'; - -const server = new McpServer({ - name: 'arktype-example', - version: '1.0.0' -}); - -// Register a tool with ArkType schema -server.registerTool( - 'greet', - { - description: 'Generate a greeting', - inputSchema: type({ name: 'string' }) - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts deleted file mode 100644 index 6968a26e6c..0000000000 --- a/examples/server/src/customMethodExample.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Custom (non-spec) method example: a server that handles a vendor-prefixed - * `acme/search` request and emits `acme/searchProgress` notifications. - * - * Spawned via stdio by `examples/client/src/customMethodExample.ts`; do not run standalone. - */ -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { z } from 'zod/v4'; - -const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); -const SearchResult = z.object({ items: z.array(z.string()) }); - -const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); - -mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { - await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); - const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); - await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); - return { items }; -}); - -await mcp.connect(new StdioServerTransport()); diff --git a/examples/server/src/customProtocolVersion.ts b/examples/server/src/customProtocolVersion.ts deleted file mode 100644 index c580432e4b..0000000000 --- a/examples/server/src/customProtocolVersion.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Example: Custom Protocol Version Support - * - * This demonstrates how to support protocol versions not yet in the SDK. - * First version in the list is used as fallback when client requests - * an unsupported version. - * - * Run with: pnpm tsx src/customProtocolVersion.ts - */ - -import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:http'; - -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; - -// Add support for a newer protocol version (first in list is fallback) -const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; - -const server = new McpServer( - { name: 'custom-protocol-server', version: '1.0.0' }, - { - supportedProtocolVersions: CUSTOM_VERSIONS, - capabilities: { tools: {} } - } -); - -// Register a tool that shows the protocol configuration -server.registerTool( - 'get-protocol-info', - { - title: 'Protocol Info', - description: 'Returns protocol version configuration' - }, - async (): Promise => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }, null, 2) - } - ] - }) -); - -// Create transport - server passes versions automatically during connect() -const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID() -}); - -await server.connect(transport); - -// Simple HTTP server -const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; - -createServer(async (req, res) => { - if (req.url === '/mcp') { - await transport.handleRequest(req, res); - } else { - res.writeHead(404).end('Not Found'); - } -}).listen(PORT, () => { - console.log(`MCP server with custom protocol versions on port ${PORT}`); - console.log(`Supported versions: ${CUSTOM_VERSIONS.join(', ')}`); -}); diff --git a/examples/server/src/dualEraStdio.ts b/examples/server/src/dualEraStdio.ts deleted file mode 100644 index 4153c38aeb..0000000000 --- a/examples/server/src/dualEraStdio.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Dual-era stdio serving with `serveStdio`: one server process, both protocol - * eras, one factory. - * - * The entry owns the era decision per connection: the client's opening - * exchange selects the era, one instance from the factory is pinned for the - * connection lifetime, and that instance serves only that era. - * - * - a plain 2025 client connects with the `initialize` handshake and is served - * by a 2025-era instance exactly as today; - * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) probes with - * `server/discover`, negotiates the 2026-07-28 revision, and is served by a - * 2026-era instance — every request carrying the per-request `_meta` - * envelope. - * - * The same factory backs both: tools are defined once and served identically - * to either kind of client. - * - * Run with `tsx examples/server/src/dualEraStdio.ts` (or point any stdio MCP - * client at it). `examples/client/src/dualEraStdioClient.ts` drives both legs - * against this file. - */ -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; -import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -// One factory for both eras: tools are defined once and served identically to -// 2025-era and 2026-era clients. The entry constructs one instance per -// connection, for the era that connection's client opened with. -const buildServer = () => { - const server = new McpServer( - { - name: 'dual-era-stdio-server', - version: '1.0.0' - }, - { - capabilities: { tools: {} }, - instructions: 'A small dual-era stdio demo server.' - } - ); - - server.registerTool( - 'greet', - { - description: 'Greets the caller', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - return server; -}; - -// The entry owns the stdio transport and the era decision; 2025-era clients -// are served by default (`legacy: 'serve'`). -const handle = serveStdio(buildServer); -console.error('dual-era stdio server ready (serving 2025-era initialize and 2026-07-28 envelope traffic)'); - -const exit = async () => { - await handle.close(); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}; - -process.on('SIGINT', exit); -process.on('SIGTERM', exit); diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts deleted file mode 100644 index 0ade70f793..0000000000 --- a/examples/server/src/dualEraStreamableHttp.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint, - * both protocol eras. - * - * The same factory backs both legacy postures; the `MCP_LEGACY_MODE` - * environment variable selects how 2025-era (non-envelope) traffic is handled: - * - * - unset / `MCP_LEGACY_MODE=stateless` → (the entry's default) 2025-era - * traffic is served per-request via the - * stateless idiom from the same factory. - * - `MCP_LEGACY_MODE=reject` → modern-only strict: 2026-07-28 requests are - * served, 2025-era requests get the documented - * rejection naming the supported revisions. - * - * To keep an existing sessionful 2025 deployment serving legacy traffic next - * to a strict endpoint, route in user land with the exported `isLegacyRequest` - * predicate in front of a `legacy: 'reject'` handler (see the createMcpHandler - * section of docs/migration.md for the pattern) — there is no handler-valued - * `legacy` option. - * - * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any - * plain 2025 client at http://localhost:3000/mcp (served through the legacy - * fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }` - * client negotiates 2026-07-28 against the same endpoint and attaches the - * per-request `_meta` envelope itself once a modern era is negotiated, so - * ordinary typed calls (for example `callTool`) work against the modern leg - * without any per-call plumbing. - */ -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -// One factory for both legs (and both postures): tools are defined once and -// served identically to 2025-era and 2026-era clients. -const getServer = (ctx: McpRequestContext) => { - const server = new McpServer( - { - name: 'dual-era-server', - version: '1.0.0' - }, - { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } - ); - - server.registerTool( - 'greet', - { - description: 'Greets the caller and reports which protocol era served the request', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => ({ - content: [{ type: 'text', text: `Hello, ${name}! (served on the ${ctx.era} protocol era)` }] - }) - ); - - return server; -}; - -const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless'; -const options: CreateMcpHandlerOptions = { - onerror: error => console.error('MCP handler error:', error.message) -}; -if (legacyMode === 'reject') { - // Modern-only strict: turn the default stateless legacy fallback off. - options.legacy = 'reject'; -} - -const handler = createMcpHandler(getServer, options); - -// Origin/Host validation is middleware, not entry, concern: the Express app -// factory arms both for localhost binds by default. -const app = createMcpExpressApp(); - -app.all('/mcp', (req: Request, res: Response) => { - void handler.node(req, res, req.body); -}); - -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`Dual-era MCP server listening on http://localhost:${PORT}/mcp (legacy mode: ${legacyMode})`); -}); - -process.on('SIGINT', async () => { - await handler.close(); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}); diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts deleted file mode 100644 index e059e8452d..0000000000 --- a/examples/server/src/elicitationFormExample.ts +++ /dev/null @@ -1,488 +0,0 @@ -// Run with: pnpm tsx src/elicitationFormExample.ts -// -// This example demonstrates how to use form elicitation to collect structured user input -// with JSON Schema validation via a local HTTP server with SSE streaming. -// Form elicitation allows servers to request *non-sensitive* user input through the client -// with schema-based validation. -// Note: See also elicitationUrlExample.ts for an example of using URL elicitation -// to collect *sensitive* user input via a browser. - -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; - -// Create a fresh MCP server per client connection to avoid shared state between clients. -// The validator supports format validation (email, date, etc.) if ajv-formats is installed. -const getServer = () => { - const mcpServer = new McpServer( - { - name: 'form-elicitation-example-server', - version: '1.0.0' - }, - { - capabilities: {} - } - ); - - /** - * Example 1: Simple user registration tool - * Collects username, email, and password from the user - */ - mcpServer.registerTool( - 'register_user', - { - description: 'Register a new user account by collecting their information' - }, - async () => { - try { - // Request user information through form elicitation - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 - }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } - }); - - // Handle the different possible actions - if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; - - return { - content: [ - { - type: 'text', - text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [ - { - type: 'text', - text: 'Registration cancelled by user.' - } - ] - }; - } else { - return { - content: [ - { - type: 'text', - text: 'Registration was cancelled.' - } - ] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - /** - * Example 2: Multi-step workflow with multiple form elicitation requests - * Demonstrates how to collect information in multiple steps - */ - mcpServer.registerTool( - 'create_event', - { - description: 'Create a calendar event by collecting event details' - }, - async () => { - try { - // Step 1: Collect basic event information - const basicInfo = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 1: Enter basic event information', - requestedSchema: { - type: 'object', - properties: { - title: { - type: 'string', - title: 'Event Title', - description: 'Name of the event', - minLength: 1 - }, - description: { - type: 'string', - title: 'Description', - description: 'Event description (optional)' - } - }, - required: ['title'] - } - }); - - if (basicInfo.action !== 'accept' || !basicInfo.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Step 2: Collect date and time - const dateTime = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 2: Enter date and time', - requestedSchema: { - type: 'object', - properties: { - date: { - type: 'string', - title: 'Date', - description: 'Event date', - format: 'date' - }, - startTime: { - type: 'string', - title: 'Start Time', - description: 'Event start time (HH:MM)' - }, - duration: { - type: 'integer', - title: 'Duration', - description: 'Duration in minutes', - minimum: 15, - maximum: 480 - } - }, - required: ['date', 'startTime', 'duration'] - } - }); - - if (dateTime.action !== 'accept' || !dateTime.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Combine all collected information - const event = { - ...basicInfo.content, - ...dateTime.content - }; - - return { - content: [ - { - type: 'text', - text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - /** - * Example 3: Collecting address information - * Demonstrates validation with patterns and optional fields - */ - mcpServer.registerTool( - 'update_shipping_address', - { - description: 'Update shipping address with validation' - }, - async () => { - try { - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your shipping address:', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Recipient name', - minLength: 1 - }, - street: { - type: 'string', - title: 'Street Address', - minLength: 1 - }, - city: { - type: 'string', - title: 'City', - minLength: 1 - }, - state: { - type: 'string', - title: 'State/Province', - minLength: 2, - maxLength: 2 - }, - zipCode: { - type: 'string', - title: 'ZIP/Postal Code', - description: '5-digit ZIP code' - }, - phone: { - type: 'string', - title: 'Phone Number (optional)', - description: 'Contact phone number' - } - }, - required: ['name', 'street', 'city', 'state', 'zipCode'] - } - }); - - if (result.action === 'accept' && result.content) { - return { - content: [ - { - type: 'text', - text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [{ type: 'text', text: 'Address update cancelled by user.' }] - }; - } else { - return { - content: [{ type: 'text', text: 'Address update was cancelled.' }] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - return mcpServer; -}; - -async function main() { - const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; - - const app = createMcpExpressApp(); - - // Map to store transports by session ID - const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - - // MCP POST endpoint - const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId) { - console.log(`Received MCP request for session: ${sessionId}`); - } - - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport for this session - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - create new transport - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect a fresh MCP server to the transport BEFORE handling the request - const mcpServer = getServer(); - await mcpServer.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } - }; - - app.post('/mcp', mcpPostHandler); - - // Handle GET requests for SSE streams - const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Establishing SSE stream for session ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - }; - - app.get('/mcp', mcpGetHandler); - - // Handle DELETE requests for session termination - const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } - }; - - app.delete('/mcp', mcpDeleteHandler); - - // Start listening - app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`Form elicitation example server is running on http://localhost:${PORT}/mcp`); - console.log('Available tools:'); - console.log(' - register_user: Collect user registration information'); - console.log(' - create_event: Multi-step event creation'); - console.log(' - update_shipping_address: Collect and validate address'); - console.log('\nConnect your MCP client to this server using the HTTP transport.'); - }); - - // Handle server shutdown - process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); - }); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/honoWebStandardStreamableHttp.ts b/examples/server/src/honoWebStandardStreamableHttp.ts deleted file mode 100644 index b15f9885fa..0000000000 --- a/examples/server/src/honoWebStandardStreamableHttp.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Example MCP server using Hono with WebStandardStreamableHTTPServerTransport - * - * This example demonstrates using the Web Standard transport directly with Hono, - * which works on any runtime: Node.js, Cloudflare Workers, Deno, Bun, etc. - * - * Run with: pnpm tsx src/honoWebStandardStreamableHttp.ts - */ - -import { serve } from '@hono/node-server'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import * as z from 'zod/v4'; - -// Create the MCP server -const server = new McpServer({ - name: 'hono-webstandard-mcp-server', - version: '1.0.0' -}); - -// Register a simple greeting tool -server.registerTool( - 'greet', - { - title: 'Greeting Tool', - description: 'A simple greeting tool', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => { - return { - content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] - }; - } -); - -// Create a stateless transport (no options = no session management) -const transport = new WebStandardStreamableHTTPServerTransport(); - -// Create the Hono app -const app = new Hono(); - -// Enable CORS for all origins -app.use( - '*', - cors({ - origin: '*', - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version'], - exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] - }) -); - -// Health check endpoint -app.get('/health', c => c.json({ status: 'ok' })); - -// MCP endpoint -app.all('/mcp', c => transport.handleRequest(c.req.raw)); - -// Start the server -const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; - -await server.connect(transport); - -console.log(`Starting Hono MCP server on port ${PORT}`); -console.log(`Health check: http://localhost:${PORT}/health`); -console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); - -serve({ - fetch: app.fetch, - port: PORT -}); diff --git a/examples/server/src/jsonResponseStreamableHttp.ts b/examples/server/src/jsonResponseStreamableHttp.ts deleted file mode 100644 index 01759d6fc6..0000000000 --- a/examples/server/src/jsonResponseStreamableHttp.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -// Create an MCP server with implementation details -const getServer = () => { - const server = new McpServer( - { - name: 'json-response-streamable-http-server', - version: '1.0.0' - }, - { - capabilities: { - logging: {} - } - } - ); - - // Register a simple tool that returns a greeting - server.registerTool( - 'greet', - { - description: 'A simple greeting tool', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!` - } - ] - }; - } - ); - - // Register a tool that sends multiple greetings with notifications - server.registerTool( - 'multi-greet', - { - description: 'A tool that sends different greetings with delays between them', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); - - await sleep(1000); // Wait 1 second before first greeting - - await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); - - await sleep(1000); // Wait another second before second greeting - - await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - use JSON response mode - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, // Enable JSON response mode - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Connect the transport to the MCP server BEFORE handling the request - const server = getServer(); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -// Handle GET requests for SSE streams according to spec -app.get('/mcp', async (req: Request, res: Response) => { - // Since this is a very simple example, we don't support GET requests for this server - // The spec requires returning 405 Method Not Allowed in this case - res.status(405).set('Allow', 'POST').send('Method Not Allowed'); -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - process.exit(0); -}); diff --git a/examples/server/src/mcpServerOutputSchema.ts b/examples/server/src/mcpServerOutputSchema.ts deleted file mode 100644 index 955855c419..0000000000 --- a/examples/server/src/mcpServerOutputSchema.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -/** - * Example MCP server using the high-level McpServer API with outputSchema - * This demonstrates how to easily create tools with structured output - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const server = new McpServer({ - name: 'mcp-output-schema-high-level-example', - version: '1.0.0' -}); - -// Define a tool with structured output - Weather data -server.registerTool( - 'get_weather', - { - description: 'Get weather information for a city', - inputSchema: z.object({ - city: z.string().describe('City name'), - country: z.string().describe('Country code (e.g., US, UK)') - }), - outputSchema: z.object({ - temperature: z.object({ - celsius: z.number(), - fahrenheit: z.number() - }), - conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), - humidity: z.number().min(0).max(100), - wind: z.object({ - speed_kmh: z.number(), - direction: z.string() - }) - }) - }, - async ({ city, country }) => { - // Parameters are available but not used in this example - void city; - void country; - // Simulate weather API call - const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)]; - - const structuredContent = { - temperature: { - celsius: temp_c, - fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] - } - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(structuredContent, null, 2) - } - ], - structuredContent - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('High-level Output Schema Example Server running on stdio'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/resourceServerOnly.ts b/examples/server/src/resourceServerOnly.ts deleted file mode 100644 index 1a1708177e..0000000000 --- a/examples/server/src/resourceServerOnly.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Minimal Resource-Server-only auth using the SDK's RS helpers - * (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`). - * - * No better-auth. The Authorization Server is external; this example points - * its metadata at a placeholder issuer. For a full AS+RS setup with a real - * demo Authorization Server, see {@link ./simpleStreamableHttp.ts}. - * - * Run: pnpm tsx src/resourceServerOnly.ts - * Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp - * curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ... - */ - -import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { - createMcpExpressApp, - getOAuthProtectedResourceMetadataUrl, - mcpAuthMetadataRouter, - requireBearerAuth -} from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server'; -import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -const PORT = 3000; -const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`); - -// In a real deployment this is your external Authorization Server's metadata -// (RFC 8414). The SDK router serves it verbatim at -// /.well-known/oauth-authorization-server so clients probing the RS origin -// can still discover the AS. -const oauthMetadata: OAuthMetadata = { - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - response_types_supported: ['code'] -}; - -// Replace with JWT verification, RFC 7662 introspection, etc. -const staticTokenVerifier: OAuthTokenVerifier = { - async verifyAccessToken(token): Promise { - if (token !== 'demo-token') { - throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); - } - return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; - } -}; - -const server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} }); -server.registerTool( - 'whoami', - { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, - async (_args, ctx): Promise => ({ - content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }] - }) -); - -const app = createMcpExpressApp(); - -app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpServerUrl, - resourceName: 'RS-only example' - }) -); - -const auth = requireBearerAuth({ - verifier: staticTokenVerifier, - requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -app.post('/mcp', auth, async (req: Request, res: Response) => { - const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - res.on('close', () => void transport.close()); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}); - -app.listen(PORT, () => { - console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`); - console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`); - console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`); -}); diff --git a/examples/server/src/simpleStatelessStreamableHttp.ts b/examples/server/src/simpleStatelessStreamableHttp.ts deleted file mode 100644 index 2b4f0363d8..0000000000 --- a/examples/server/src/simpleStatelessStreamableHttp.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -const getServer = () => { - // Create an MCP server with implementation details - const server = new McpServer( - { - name: 'stateless-streamable-http-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - // Register a simple prompt - server.registerPrompt( - 'greeting-template', - { - description: 'A simple greeting prompt template', - argsSchema: z.object({ - name: z.string().describe('Name to include in greeting') - }) - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.` - } - } - ] - }; - } - ); - - // Register a tool specifically for testing resumability - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: z.object({ - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(10) - }) - }, - async ({ interval, count }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - - // Create a simple resource at a fixed URI - server.registerResource( - 'greeting-resource', - 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!' - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -app.post('/mcp', async (req: Request, res: Response) => { - const server = getServer(); - try { - const transport: NodeStreamableHTTPServerTransport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - res.on('close', () => { - console.log('Request closed'); - transport.close(); - server.close(); - }); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -app.get('/mcp', async (req: Request, res: Response) => { - console.log('Received GET MCP request'); - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }) - ); -}); - -app.delete('/mcp', async (req: Request, res: Response) => { - console.log('Received DELETE MCP request'); - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }) - ); -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}); diff --git a/examples/server/src/toolWithSampleServer.ts b/examples/server/src/toolWithSampleServer.ts deleted file mode 100644 index f6b053cf24..0000000000 --- a/examples/server/src/toolWithSampleServer.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Run with: pnpm tsx src/toolWithSampleServer.ts - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const mcpServer = new McpServer({ - name: 'tools-with-sample-server', - version: '1.0.0' -}); - -// Tool that uses LLM sampling to summarize any text -mcpServer.registerTool( - 'summarize', - { - description: 'Summarize any text using an LLM', - inputSchema: z.object({ - text: z.string().describe('Text to summarize') - }) - }, - async ({ text }) => { - // Call the LLM through MCP sampling - const response = await mcpServer.server.createMessage({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please summarize the following text concisely:\n\n${text}` - } - } - ], - maxTokens: 500 - }); - - // Since we're not passing tools param to createMessage, response.content is single content - return { - content: [ - { - type: 'text', - text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' - } - ] - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await mcpServer.connect(transport); - console.log('MCP server is running...'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts deleted file mode 100644 index 8d92bf1993..0000000000 --- a/examples/server/src/valibotExample.ts +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal MCP server using Valibot for schema validation. - * Use toStandardJsonSchema() from @valibot/to-json-schema to create - * StandardJSONSchemaV1-compliant schemas. - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { toStandardJsonSchema } from '@valibot/to-json-schema'; -import * as v from 'valibot'; - -const server = new McpServer({ - name: 'valibot-example', - version: '1.0.0' -}); - -// Register a tool with Valibot schema -server.registerTool( - 'greet', - { - description: 'Generate a greeting', - inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json deleted file mode 100644 index 37a3e874f7..0000000000 --- a/examples/server/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], - "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], - "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], - "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], - "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], - "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], - "@modelcontextprotocol/core": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" - ], - "@modelcontextprotocol/core/public": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" - ], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] - } - } -} diff --git a/examples/server/tsdown.config.ts b/examples/server/tsdown.config.ts deleted file mode 100644 index efc4299d35..0000000000 --- a/examples/server/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - // 1. Entry Points - // Directly matches package.json include/exclude globs - entry: ['src/**/*.ts'], - - // 2. Output Configuration - format: ['esm'], - outDir: 'dist', - clean: true, // Recommended: Cleans 'dist' before building - sourcemap: true, - - // 3. Platform & Target - target: 'esnext', - platform: 'node', - shims: true, // Polyfills common Node.js shims (__dirname, etc.) - - // 4. Type Definitions - // Bundles d.ts files into a single output - dts: false, - // 5. Vendoring Strategy - Bundle the code for this specific package into the output, - // but treat all other dependencies as external (require/import). - noExternal: ['@modelcontextprotocol/examples-shared'] -}); diff --git a/examples/server/vitest.config.js b/examples/server/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/examples/server/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/examples/sse-polling/README.md b/examples/sse-polling/README.md new file mode 100644 index 0000000000..c8dd292bfd --- /dev/null +++ b/examples/sse-polling/README.md @@ -0,0 +1,10 @@ +# sse-polling + +SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). + +Excluded from the harness for now (the reconnect/replay flow needs a longer, bounded wait than the per-leg default). + +```bash +pnpm tsx examples/sse-polling/server.ts # term 1 (port 3001) +pnpm tsx examples/sse-polling/client.ts # term 2 +``` diff --git a/examples/client/src/ssePollingClient.ts b/examples/sse-polling/client.ts similarity index 100% rename from examples/client/src/ssePollingClient.ts rename to examples/sse-polling/client.ts diff --git a/examples/server/src/inMemoryEventStore.ts b/examples/sse-polling/inMemoryEventStore.ts similarity index 100% rename from examples/server/src/inMemoryEventStore.ts rename to examples/sse-polling/inMemoryEventStore.ts diff --git a/examples/sse-polling/manifest.json b/examples/sse-polling/manifest.json new file mode 100644 index 0000000000..97c967f082 --- /dev/null +++ b/examples/sse-polling/manifest.json @@ -0,0 +1,4 @@ +{ + "transport": "http", + "excluded": "SEP-1699 SSE polling/resumption story is sessionful 2025 with server-initiated disconnect; the client's reconnect-then-replay flow is a long (~5s) wait that the harness doesn't bound well. Typecheck-only for now; revisit after the harness gets per-leg timeouts." +} diff --git a/examples/server/src/ssePollingExample.ts b/examples/sse-polling/server.ts similarity index 100% rename from examples/server/src/ssePollingExample.ts rename to examples/sse-polling/server.ts diff --git a/examples/standalone-get/README.md b/examples/standalone-get/README.md new file mode 100644 index 0000000000..f8e2099d46 --- /dev/null +++ b/examples/standalone-get/README.md @@ -0,0 +1,5 @@ +# standalone-get + +Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The server adds a resource on a timer; the client opens the GET stream via `ClientOptions.listChanged` and asserts a notification arrives. + +**HTTP-only**, sessionful 2025 by definition. Excluded from the harness for now (timer-driven, long-running). diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts new file mode 100644 index 0000000000..790f8ca126 --- /dev/null +++ b/examples/standalone-get/client.ts @@ -0,0 +1,29 @@ +/** + * Connects, opens the standalone GET stream by registering a `listChanged` + * handler, and asserts at least one `notifications/resources/list_changed` + * arrives within the bound (the server adds a resource on a timer). + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; + +runClient('standalone-get', async () => { + let received = 0; + let done!: () => void; + const finished = new Promise(resolve => { + done = resolve; + }); + const client = new Client( + { name: 'standalone-get-client', version: '1.0.0' }, + { listChanged: { resources: { autoRefresh: false, onChanged: () => (++received >= 1 ? done() : undefined) } } } + ); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const list = await client.listResources(); + check.ok(list.resources.length > 0); + await Promise.race([finished, new Promise((_, reject) => setTimeout(() => reject(new Error('no listChanged within 8s')), 8000))]); + check.ok(received >= 1); + await client.close(); +}); diff --git a/examples/standalone-get/manifest.json b/examples/standalone-get/manifest.json new file mode 100644 index 0000000000..2a9672fd93 --- /dev/null +++ b/examples/standalone-get/manifest.json @@ -0,0 +1,5 @@ +{ + "transport": "http", + "path": "/mcp", + "excluded": "Sessionful 2025 timer-driven listChanged push over the standalone GET stream — long-running by design. Typecheck-only for now; revisit alongside the sse-polling re-host." +} diff --git a/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/examples/standalone-get/server.ts similarity index 100% rename from examples/server/src/standaloneSseWithGetStreamableHttp.ts rename to examples/standalone-get/server.ts From f2661d0ad63ce97f4031fcaee95e86a3a6791537 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:49:25 +0000 Subject: [PATCH 04/27] refactor(examples): rewrite the three HTTP hosting examples onto createMcpHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stateless-legacy/` is the minimal default-posture deployment (one factory, 2026 served per request, 2025 served stateless from the same factory) — the one-liner replacement for the 1.x per-POST stateless idiom. `json-response/` is `createMcpHandler({ responseMode: 'json' })` with a client that asserts a 2026-era request comes back as `application/json` and that the regular Client works unchanged. `hono/` mounts `handler.fetch` on a `createMcpHonoApp()` Hono app — the web-standard face for Workers/Deno/Bun/Node. --- examples/hono/README.md | 6 ++++ examples/hono/client.ts | 19 ++++++++++ examples/hono/manifest.json | 4 +++ examples/hono/server.ts | 34 ++++++++++++++++++ examples/json-response/README.md | 5 +++ examples/json-response/client.ts | 46 +++++++++++++++++++++++++ examples/json-response/manifest.json | 3 ++ examples/json-response/server.ts | 30 ++++++++++++++++ examples/stateless-legacy/README.md | 5 +++ examples/stateless-legacy/client.ts | 24 +++++++++++++ examples/stateless-legacy/manifest.json | 3 ++ examples/stateless-legacy/server.ts | 30 ++++++++++++++++ 12 files changed, 209 insertions(+) create mode 100644 examples/hono/README.md create mode 100644 examples/hono/client.ts create mode 100644 examples/hono/manifest.json create mode 100644 examples/hono/server.ts create mode 100644 examples/json-response/README.md create mode 100644 examples/json-response/client.ts create mode 100644 examples/json-response/manifest.json create mode 100644 examples/json-response/server.ts create mode 100644 examples/stateless-legacy/README.md create mode 100644 examples/stateless-legacy/client.ts create mode 100644 examples/stateless-legacy/manifest.json create mode 100644 examples/stateless-legacy/server.ts diff --git a/examples/hono/README.md b/examples/hono/README.md new file mode 100644 index 0000000000..6a17754900 --- /dev/null +++ b/examples/hono/README.md @@ -0,0 +1,6 @@ +# hono + +`createMcpHandler(...).fetch` mounted on a Hono app — the web-standard face that runs on Cloudflare Workers, Deno, Bun and Node.js (via `@hono/node-server`). The `@modelcontextprotocol/hono` adapter (`createMcpHonoApp()`) arms localhost DNS-rebinding / origin protection by +default. + +**HTTP-only** by definition. diff --git a/examples/hono/client.ts b/examples/hono/client.ts new file mode 100644 index 0000000000..d0e6ce8593 --- /dev/null +++ b/examples/hono/client.ts @@ -0,0 +1,19 @@ +/** + * Connects to the Hono-hosted server, lists tools and calls `greet`. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; + +runClient('hono', async () => { + const client = new Client({ name: 'hono-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const tools = await client.listTools(); + check.ok(tools.tools.some(t => t.name === 'greet')); + const result = await client.callTool({ name: 'greet', arguments: { name: 'hono' } }); + check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, hono!/); + await client.close(); +}); diff --git a/examples/hono/manifest.json b/examples/hono/manifest.json new file mode 100644 index 0000000000..59c1309570 --- /dev/null +++ b/examples/hono/manifest.json @@ -0,0 +1,4 @@ +{ + "transport": "http", + "path": "/mcp" +} diff --git a/examples/hono/server.ts b/examples/hono/server.ts new file mode 100644 index 0000000000..f5cf1ab3bd --- /dev/null +++ b/examples/hono/server.ts @@ -0,0 +1,34 @@ +/** + * Hosting on Hono / web-standard runtimes (Cloudflare Workers, Deno, Bun, + * Node.js via `@hono/node-server`). + * + * `createMcpHandler(...).fetch` is the web-standard face: pass the raw + * `Request` and return the `Response`. The `@modelcontextprotocol/hono` + * package adds the same DNS-rebinding / origin protection middleware the + * Express adapter ships. + */ +import { serve } from '@hono/node-server'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'hono-example', version: '1.0.0' }); + server.registerTool( + 'greet', + { title: 'Greeting Tool', description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (from Hono + createMcpHandler.fetch)` }] }) + ); + return server; +}); + +// `createMcpHonoApp()` arms localhost host/origin validation by default. +const app = createMcpHonoApp(); +app.get('/health', c => c.json({ status: 'ok' })); +app.all('/mcp', c => handler.fetch(c.req.raw)); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? Number(process.env.MCP_PORT ?? 3000) : Number(argv[portIdx + 1]); +console.error(`hono example server listening on http://127.0.0.1:${port}/mcp`); +serve({ fetch: app.fetch, port }); diff --git a/examples/json-response/README.md b/examples/json-response/README.md new file mode 100644 index 0000000000..b15f133209 --- /dev/null +++ b/examples/json-response/README.md @@ -0,0 +1,5 @@ +# json-response + +`createMcpHandler({ responseMode: 'json' })` — a single `application/json` body per request instead of an SSE stream. Useful for serverless / edge runtimes that can't hold a stream open. Mid-call notifications are dropped. + +**HTTP-only** by definition. diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts new file mode 100644 index 0000000000..360889d9bb --- /dev/null +++ b/examples/json-response/client.ts @@ -0,0 +1,46 @@ +/** + * Asserts the `responseMode: 'json'` server answers a `tools/call` with a + * `Content-Type: application/json` body (not `text/event-stream`) AND that the + * regular `Client` works against it unchanged. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; + +runClient('json-response', async () => { + // Low-level: a 2026-07-28 (envelope) request should come back as plain + // JSON. (`responseMode` applies to the per-request modern path; 2025-era + // traffic goes through the stateless legacy fallback unaffected.) + const probe = await fetch(URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'probe', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }) + }); + check.match(probe.headers.get('content-type') ?? '', /application\/json/); + check.equal(probe.status, 200); + + // High-level: the regular Client works unchanged. + const client = new Client({ name: 'json-response-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const result = await client.callTool({ name: 'greet', arguments: { name: 'json' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'Hello, json!'); + await client.close(); +}); diff --git a/examples/json-response/manifest.json b/examples/json-response/manifest.json new file mode 100644 index 0000000000..376c70cacb --- /dev/null +++ b/examples/json-response/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "http" +} diff --git a/examples/json-response/server.ts b/examples/json-response/server.ts new file mode 100644 index 0000000000..a5c82f883d --- /dev/null +++ b/examples/json-response/server.ts @@ -0,0 +1,30 @@ +/** + * `createMcpHandler` with `responseMode: 'json'` — single JSON response + * instead of an SSE stream. Useful for serverless deployments that can't + * hold a stream open. Mid-call notifications are dropped (the handler logs a + * warning at construction time). + */ +import { createServer } from 'node:http'; + +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler( + () => { + const server = new McpServer({ name: 'json-response-example', version: '1.0.0' }); + server.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + return server; + }, + { responseMode: 'json' } +); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`json-response example server listening on http://127.0.0.1:${port}/`); +}); diff --git a/examples/stateless-legacy/README.md b/examples/stateless-legacy/README.md new file mode 100644 index 0000000000..d3b0250ec1 --- /dev/null +++ b/examples/stateless-legacy/README.md @@ -0,0 +1,5 @@ +# stateless-legacy + +The minimal `createMcpHandler` deployment, on its default posture: 2026-07-28 traffic served per request, 2025-era traffic served stateless from the same factory. This is the one-liner replacement for the 1.x "new transport + new server per POST" stateless idiom. + +**HTTP-only** by definition; see `dual-era/` for the stdio analogue. diff --git a/examples/stateless-legacy/client.ts b/examples/stateless-legacy/client.ts new file mode 100644 index 0000000000..e5b5962973 --- /dev/null +++ b/examples/stateless-legacy/client.ts @@ -0,0 +1,24 @@ +/** + * Connects to the minimal `createMcpHandler` deployment as both a plain 2025 + * client (the `initialize` handshake, served stateless from the factory) and + * a 2026-capable client (`versionNegotiation: { mode: 'auto' }`, served per + * request). Asserts the same `greet` tool answers identically either way. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; + +runClient('stateless-legacy', async () => { + for (const mode of [undefined, { mode: 'auto' as const }]) { + const client = new Client({ name: 'stateless-legacy-client', version: '1.0.0' }, mode ? { versionNegotiation: mode } : {}); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const tools = await client.listTools(); + check.ok(tools.tools.some(t => t.name === 'greet')); + const result = await client.callTool({ name: 'greet', arguments: { name: 'world' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'Hello, world!'); + await client.close(); + } +}); diff --git a/examples/stateless-legacy/manifest.json b/examples/stateless-legacy/manifest.json new file mode 100644 index 0000000000..376c70cacb --- /dev/null +++ b/examples/stateless-legacy/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "http" +} diff --git a/examples/stateless-legacy/server.ts b/examples/stateless-legacy/server.ts new file mode 100644 index 0000000000..fce9094076 --- /dev/null +++ b/examples/stateless-legacy/server.ts @@ -0,0 +1,30 @@ +/** + * The minimal `createMcpHandler` deployment, on its default posture. + * + * One factory, one endpoint: 2026-07-28 traffic is served per request, and + * 2025-era (non-envelope) traffic is served stateless from the same factory + * (`legacy: 'stateless'`, the default). This replaces the hand-wired + * "new transport + new server per POST" stateless idiom of the 1.x SDK with + * a one-liner. + */ +import { createServer } from 'node:http'; + +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'stateless-legacy-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + return server; +}); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`stateless-legacy example server listening on http://127.0.0.1:${port}/`); +}); From 7d91a1891d7d064bd9809891db4064887f93ac93 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:49:25 +0000 Subject: [PATCH 05/27] feat(examples): add the six start-here / capstone stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/`, `prompts/`, `resources/` are the primitives a new author reads first (register, list, call/get/read, structured output, completion, templates). `streaming/` covers the in-flight channels — progress (`_meta.progressToken` → `onprogress`), logging (`notifications/message` sent as a request-tied notification), and cancellation (`ctx.mcpReq.signal`). `stickynotes/` is the real-app capstone: tools mutate a board, a resource per note, listChanged on add/remove, an elicitation-confirmed clear (cancel/unchecked/confirm proven). `caching/` declares cacheHints at three layers and the client reads the stamped `ttlMs`/`cacheScope` back (full client-side honouring is a follow-up). --- examples/caching/README.md | 10 +++ examples/caching/client.ts | 31 ++++++++ examples/caching/server.ts | 52 +++++++++++++ examples/prompts/README.md | 7 ++ examples/prompts/client.ts | 24 ++++++ examples/prompts/server.ts | 41 +++++++++++ examples/resources/README.md | 7 ++ examples/resources/client.ts | 24 ++++++ examples/resources/server.ts | 34 +++++++++ examples/stickynotes/README.md | 10 +++ examples/stickynotes/client.ts | 80 ++++++++++++++++++++ examples/stickynotes/manifest.json | 3 + examples/stickynotes/server.ts | 114 +++++++++++++++++++++++++++++ examples/streaming/README.md | 8 ++ examples/streaming/client.ts | 45 ++++++++++++ examples/streaming/server.ts | 58 +++++++++++++++ examples/tools/README.md | 8 ++ examples/tools/client.ts | 39 ++++++++++ examples/tools/server.ts | 49 +++++++++++++ 19 files changed, 644 insertions(+) create mode 100644 examples/caching/README.md create mode 100644 examples/caching/client.ts create mode 100644 examples/caching/server.ts create mode 100644 examples/prompts/README.md create mode 100644 examples/prompts/client.ts create mode 100644 examples/prompts/server.ts create mode 100644 examples/resources/README.md create mode 100644 examples/resources/client.ts create mode 100644 examples/resources/server.ts create mode 100644 examples/stickynotes/README.md create mode 100644 examples/stickynotes/client.ts create mode 100644 examples/stickynotes/manifest.json create mode 100644 examples/stickynotes/server.ts create mode 100644 examples/streaming/README.md create mode 100644 examples/streaming/client.ts create mode 100644 examples/streaming/server.ts create mode 100644 examples/tools/README.md create mode 100644 examples/tools/client.ts create mode 100644 examples/tools/server.ts diff --git a/examples/caching/README.md b/examples/caching/README.md new file mode 100644 index 0000000000..13e2a0600d --- /dev/null +++ b/examples/caching/README.md @@ -0,0 +1,10 @@ +# caching + +`CacheableResult` freshness hints (protocol revision 2026-07-28). The server declares hints at three layers (handler return → per-registration `cacheHint` → server-level `ServerOptions.cacheHints`); the SDK resolves most-specific-author-first and stamps `ttlMs`/`cacheScope` on +the wire toward modern clients only. The client reads the stamped values back. + +> Full client-side cache **honouring** (re-using a still-fresh result instead of re-requesting) is a follow-up; this example reads what the server emits today. + +```bash +pnpm tsx examples/caching/client.ts +``` diff --git a/examples/caching/client.ts b/examples/caching/client.ts new file mode 100644 index 0000000000..a95c341fd3 --- /dev/null +++ b/examples/caching/client.ts @@ -0,0 +1,31 @@ +/** + * Reads the cache hints emitted on cacheable results (2026-07-28 connections + * only) and asserts the configured values reached the wire. Full client-side + * cache *honouring* (re-using a fresh result instead of re-requesting) is a + * follow-up — see the SDK's tracking issue for client cache support. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +interface Cacheable { + ttlMs?: number; + cacheScope?: 'public' | 'private'; +} + +runClient('caching', async () => { + const client = await connectFromArgs(import.meta.dirname); + check.equal(client.getNegotiatedProtocolVersion(), '2026-07-28'); + + const tools = (await client.listTools()) as Cacheable & Awaited>; + check.equal(tools.ttlMs, 30_000); + check.equal(tools.cacheScope, 'public'); + + const resources = (await client.listResources()) as Cacheable & Awaited>; + check.equal(resources.ttlMs, 5000); + check.equal(resources.cacheScope, 'public'); + + const read = (await client.readResource({ uri: 'config://app' })) as Cacheable & Awaited>; + check.equal(read.ttlMs, 60_000); + check.equal(read.cacheScope, 'private'); + + await client.close(); +}); diff --git a/examples/caching/server.ts b/examples/caching/server.ts new file mode 100644 index 0000000000..bfaf57bbea --- /dev/null +++ b/examples/caching/server.ts @@ -0,0 +1,52 @@ +/** + * Cache hints (`CacheableResult`, protocol revision 2026-07-28). + * + * The 2026-07-28 revision requires `ttlMs`/`cacheScope` on the cacheable + * result types (the list operations and `resources/read`). The values are + * resolved most-specific-author-first: + * + * 1. fields the handler returns on the result itself, + * 2. a per-registration `cacheHint` (here: the resource's read result), + * 3. the server-level per-operation `ServerOptions.cacheHints`, + * 4. the conservative defaults (`ttlMs: 0`, `cacheScope: 'private'`). + * + * The fields are emitted ONLY toward 2026-era clients — a 2025-era response + * is byte-for-byte unchanged. One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'caching-example', version: '1.0.0' }, + { + // Server-level per-operation hints: any list/read result that does not + // override a field gets these. + cacheHints: { + 'resources/list': { ttlMs: 5000, cacheScope: 'public' }, + 'tools/list': { ttlMs: 30_000, cacheScope: 'public' } + } + } + ); + + // A direct resource carrying a per-registration hint that wins for its + // own resources/read result. + server.registerResource( + 'app-config', + 'config://app', + { + mimeType: 'application/json', + description: 'Static application config (rarely changes)', + cacheHint: { ttlMs: 60_000, cacheScope: 'private' } + }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] }) + ); + + // A tool, so tools/list has something to cache. + server.registerTool('noop', { description: 'no-op' }, async () => ({ content: [{ type: 'text', text: 'ok' }] })); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/prompts/README.md b/examples/prompts/README.md new file mode 100644 index 0000000000..fe42008587 --- /dev/null +++ b/examples/prompts/README.md @@ -0,0 +1,7 @@ +# prompts + +Register prompts with `McpServer.registerPrompt`; wrap argument schemas with `completable(...)` for autocompletion. The client lists prompts, completes the `language` argument, and renders one with `getPrompt()`. + +```bash +pnpm tsx examples/prompts/client.ts +``` diff --git a/examples/prompts/client.ts b/examples/prompts/client.ts new file mode 100644 index 0000000000..172fad49fb --- /dev/null +++ b/examples/prompts/client.ts @@ -0,0 +1,24 @@ +/** + * Drives the prompts example: list, complete an argument, get a prompt. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('prompts', async () => { + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listPrompts(); + check.ok(list.prompts.some(p => p.name === 'review-code')); + + const completion = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-code' }, + argument: { name: 'language', value: 'ty' } + }); + check.ok(completion.completion.values.includes('typescript')); + + const got = await client.getPrompt({ name: 'review-code', arguments: { language: 'rust', code: 'fn main() {}' } }); + check.equal(got.messages.length, 1); + const text = got.messages[0]?.content.type === 'text' ? got.messages[0].content.text : ''; + check.match(text, /Review this rust code/); + + await client.close(); +}); diff --git a/examples/prompts/server.ts b/examples/prompts/server.ts new file mode 100644 index 0000000000..524454fbaf --- /dev/null +++ b/examples/prompts/server.ts @@ -0,0 +1,41 @@ +/** + * Prompts primitive + completion. + * + * Register prompts with `McpServer.registerPrompt`; wrap an arg schema with + * `completable(...)` so the client's `complete()` call returns suggestions. + * One binary, either transport. + */ +import { completable, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const LANGUAGES = ['python', 'typescript', 'rust', 'go']; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'prompts-example', version: '1.0.0' }); + + server.registerPrompt( + 'review-code', + { + title: 'Code review', + description: 'Review code for quality and idioms', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => LANGUAGES.filter(l => l.startsWith(value))), + code: z.string().describe('The code to review') + }) + }, + async ({ language, code }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Review this ${language} code for quality and idioms:\n\n${code}` } + } + ] + }) + ); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/resources/README.md b/examples/resources/README.md new file mode 100644 index 0000000000..409df6833c --- /dev/null +++ b/examples/resources/README.md @@ -0,0 +1,7 @@ +# resources + +Direct resources (a fixed URI string) and templated resources (`ResourceTemplate('greeting://{name}')`). The client lists both, reads the direct config, and reads a templated greeting. + +```bash +pnpm tsx examples/resources/client.ts +``` diff --git a/examples/resources/client.ts b/examples/resources/client.ts new file mode 100644 index 0000000000..20bc19a99c --- /dev/null +++ b/examples/resources/client.ts @@ -0,0 +1,24 @@ +/** + * Drives the resources example: list, list templates, read direct + templated. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('resources', async () => { + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listResources(); + check.ok(list.resources.some(r => r.uri === 'config://app')); + + const templates = await client.listResourceTemplates(); + check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}')); + + const config = await client.readResource({ uri: 'config://app' }); + const configContent = config.contents[0]; + check.equal(configContent && 'text' in configContent ? configContent.text : '', '{"feature":true}'); + + const hello = await client.readResource({ uri: 'greeting://world' }); + const helloContent = hello.contents[0]; + check.equal(helloContent && 'text' in helloContent ? helloContent.text : '', 'Hello, world!'); + + await client.close(); +}); diff --git a/examples/resources/server.ts b/examples/resources/server.ts new file mode 100644 index 0000000000..db353928f1 --- /dev/null +++ b/examples/resources/server.ts @@ -0,0 +1,34 @@ +/** + * Resources primitive — direct + templated. + * + * `McpServer.registerResource` accepts either a fixed URI string (direct + * resource) or a `ResourceTemplate` (URI template with substitution). One + * binary, either transport. + */ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'resources-example', version: '1.0.0' }); + + // A direct resource at a fixed URI. + server.registerResource( + 'app-config', + 'config://app', + { mimeType: 'application/json', description: 'Static application config' }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] }) + ); + + // A templated resource: `greeting://{name}`. + server.registerResource( + 'greeting', + new ResourceTemplate('greeting://{name}', { list: undefined }), + { description: 'A greeting for the named subject' }, + async (uri, vars) => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) + ); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/stickynotes/README.md b/examples/stickynotes/README.md new file mode 100644 index 0000000000..23cc309d87 --- /dev/null +++ b/examples/stickynotes/README.md @@ -0,0 +1,10 @@ +# stickynotes + +The "real app" capstone: a sticky-notes board where tools mutate state, each note is a resource, the resource list changes on add/remove, and a destructive `remove_all` blocks on a form-mode elicitation. The client adds, lists, reads, removes, and proves `remove_all` only clears +the board on an explicit confirm. + +**stdio-only** in the harness: the `remove_all` confirmation is a push server→client elicitation, which needs either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`). + +```bash +pnpm tsx examples/stickynotes/client.ts +``` diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts new file mode 100644 index 0000000000..37ef2cf8ca --- /dev/null +++ b/examples/stickynotes/client.ts @@ -0,0 +1,80 @@ +/** + * Drives the sticky-notes board end to end on a 2026-07-28 connection: add + * two notes, list/read their resources, remove one, then attempt `remove_all` + * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board + * is cleared only on an explicit confirmation. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +interface AddResult { + id: string; + uri: string; +} +interface RemoveAllResult { + status: string; + removed: number; +} + +runClient('stickynotes', async () => { + // Push-style elicitation (the `remove_all` confirmation) is a 2025-era + // flow; connect as a plain 2025 client so `ctx.mcpReq.elicitInput` reaches + // this handler (the 2026-07-28 path uses multi-round-trip `inputRequired` + // instead — see ../mrtr/). + const client = await connectFromArgs(import.meta.dirname, { + versionNegotiation: undefined, + capabilities: { elicitation: { form: {} } } + }); + let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; + client.setRequestHandler('elicitation/create', async () => { + if (elicitAnswer === 'cancel') return { action: 'cancel' }; + return { action: 'accept', content: { confirm: elicitAnswer === 'confirm' } }; + }); + + // ADD two notes. + const first = await client.callTool({ name: 'add_note', arguments: { text: 'Buy milk' } }); + const firstNote = first.structuredContent as unknown as AddResult; + check.match(firstNote.uri, /^note:\/\/\//); + const second = await client.callTool({ name: 'add_note', arguments: { text: 'Walk the dog' } }); + const secondNote = second.structuredContent as unknown as AddResult; + check.notEqual(firstNote.id, secondNote.id); + + // LIST/READ — both notes should be listable resources. + const list = await client.listResources(); + const noteUris = new Set(list.resources.filter(r => r.uri.startsWith('note:///')).map(r => r.uri)); + check.ok(noteUris.has(firstNote.uri) && noteUris.has(secondNote.uri)); + const read = await client.readResource({ uri: firstNote.uri }); + const readContent = read.contents[0]; + check.equal(readContent && 'text' in readContent ? readContent.text : '', 'Buy milk'); + + // REMOVE ONE. + const removed = await client.callTool({ name: 'remove_note', arguments: { id: firstNote.id } }); + check.equal((removed.structuredContent as { removed?: boolean } | undefined)?.removed, true); + const after = await client.listResources(); + check.ok(!after.resources.some(r => r.uri === firstNote.uri)); + + // CANCEL — board untouched. + elicitAnswer = 'cancel'; + const cancelled = await client.callTool({ name: 'remove_all' }); + check.equal((cancelled.structuredContent as unknown as RemoveAllResult).status, 'cancelled'); + const afterCancel = await client.listResources(); + check.ok(afterCancel.resources.some(r => r.uri === secondNote.uri)); + + // UNCHECKED — accept with confirm:false → declined, board untouched. + elicitAnswer = 'unchecked'; + const declined = await client.callTool({ name: 'remove_all' }); + check.equal((declined.structuredContent as unknown as RemoveAllResult).status, 'declined'); + + // CONFIRM — accept with confirm:true → cleared. + elicitAnswer = 'confirm'; + const cleared = await client.callTool({ name: 'remove_all' }); + check.equal((cleared.structuredContent as unknown as RemoveAllResult).status, 'cleared'); + check.equal((cleared.structuredContent as unknown as RemoveAllResult).removed, 1); + const afterClear = await client.listResources(); + check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); + + // EMPTY — a follow-up remove_all reports 'empty' without eliciting. + const empty = await client.callTool({ name: 'remove_all' }); + check.equal((empty.structuredContent as unknown as RemoveAllResult).status, 'empty'); + + await client.close(); +}); diff --git a/examples/stickynotes/manifest.json b/examples/stickynotes/manifest.json new file mode 100644 index 0000000000..5299ca7150 --- /dev/null +++ b/examples/stickynotes/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "stdio" +} diff --git a/examples/stickynotes/server.ts b/examples/stickynotes/server.ts new file mode 100644 index 0000000000..ce9785d8e1 --- /dev/null +++ b/examples/stickynotes/server.ts @@ -0,0 +1,114 @@ +/** + * "Real app" capstone — a small stateful sticky-notes board that ties + * together tools that mutate state, a resource per piece of state, listChanged + * on add/remove, and a server→client elicitation guarding a destructive action. + * + * The board is process-local (one map per server process). Over stdio one + * `McpServer` instance is pinned for the connection lifetime, so the tools + * register/unregister note resources at runtime; over the per-request HTTP + * path the factory registers a resource per live note on every request. + * + * Tools: + * - `add_note(text)` — store a note, register `note:///{id}`, returns + * `{id, uri}`. + * - `remove_note(id)` — delete one note + unregister its resource. + * - `remove_all()` — delete every note, but FIRST blocks on a form-mode + * elicitation; declining/cancelling/unchecked all leave the board. + * + * One binary, either transport. + */ +import type { RegisteredResource } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +const notes = new Map(); +let nextId = 1; +const uriFor = (id: string) => `note:///${id}`; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'stickynotes-example', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); + // Registrations on THIS instance (so the stdio leg can unregister at runtime). + const registered = new Map(); + const registerNote = (id: string, text: string) => { + const r = server.registerResource( + `note-${id}`, + uriFor(id), + { mimeType: 'text/plain', description: `Sticky note #${id}` }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: notes.get(id) ?? text }] + }) + ); + registered.set(id, r); + }; + // Register a resource per live note (per-request HTTP path picks up the + // current board on every factory call; stdio re-uses one instance). + for (const [id, text] of notes) registerNote(id, text); + + server.registerTool( + 'add_note', + { + description: 'Add a sticky note; registers a note:///{id} resource for it.', + inputSchema: z.object({ text: z.string() }), + outputSchema: z.object({ id: z.string(), uri: z.string() }) + }, + async ({ text }) => { + const id = String(nextId++); + notes.set(id, text); + registerNote(id, text); + const structuredContent = { id, uri: uriFor(id) }; + return { content: [{ type: 'text', text: `added note #${id}` }], structuredContent }; + } + ); + + server.registerTool( + 'remove_note', + { + description: 'Remove one sticky note by id and unregister its resource.', + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ removed: z.boolean(), id: z.string() }) + }, + async ({ id }) => { + const removed = notes.delete(id); + if (removed) registered.get(id)?.remove(); + return { content: [{ type: 'text', text: removed ? `removed #${id}` : 'not found' }], structuredContent: { removed, id } }; + } + ); + + server.registerTool( + 'remove_all', + { + description: 'Remove ALL sticky notes after confirming via a server→client elicitation.', + outputSchema: z.object({ status: z.string(), removed: z.number() }) + }, + async ctx => { + if (notes.size === 0) { + return { content: [{ type: 'text', text: 'nothing to clear' }], structuredContent: { status: 'empty', removed: 0 } }; + } + const count = notes.size; + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Remove all ${count} sticky note(s)? This cannot be undone.`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Yes, permanently delete every sticky note' } }, + required: ['confirm'] + } + }); + if (result.action === 'cancel') { + return { content: [{ type: 'text', text: 'cancelled' }], structuredContent: { status: 'cancelled', removed: 0 } }; + } + if (result.action !== 'accept' || !(result.content as { confirm?: boolean } | undefined)?.confirm) { + return { content: [{ type: 'text', text: 'declined' }], structuredContent: { status: 'declined', removed: 0 } }; + } + for (const id of notes.keys()) registered.get(id)?.remove(); + notes.clear(); + return { content: [{ type: 'text', text: `cleared ${count}` }], structuredContent: { status: 'cleared', removed: count } }; + } + ); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/streaming/README.md b/examples/streaming/README.md new file mode 100644 index 0000000000..4da4cd893f --- /dev/null +++ b/examples/streaming/README.md @@ -0,0 +1,8 @@ +# streaming + +The three in-flight channels: progress (via `_meta.progressToken` → `notifications/progress` → the client's `onprogress` callback), logging (`ctx.mcpReq.log(level, data)` → `notifications/message`), and cancellation (the client's `AbortSignal` → `ctx.mcpReq.signal.aborted` +server-side). + +```bash +pnpm tsx examples/streaming/client.ts +``` diff --git a/examples/streaming/client.ts b/examples/streaming/client.ts new file mode 100644 index 0000000000..b4384e7497 --- /dev/null +++ b/examples/streaming/client.ts @@ -0,0 +1,45 @@ +/** + * Drives the streaming example: a `countdown` call with `onprogress` + * (asserts progress notifications arrived), a logging-notification handler + * (asserts log messages arrived), and a cancelled call (asserts the cancel + * propagated). + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('streaming', async () => { + const client = await connectFromArgs(import.meta.dirname); + + let logCount = 0; + client.setNotificationHandler('notifications/message', () => { + logCount++; + }); + + // --- progress + logging --- + let progressCount = 0; + const result = await client.callTool( + { name: 'countdown', arguments: { n: 5, delayMs: 20 } }, + { + onprogress: p => { + progressCount++; + check.equal(p.total, 5); + } + } + ); + check.equal((result.structuredContent as { completed?: number } | undefined)?.completed, 5); + check.equal((result.structuredContent as { cancelled?: boolean } | undefined)?.cancelled, false); + check.ok(progressCount >= 4, `expected >=4 progress notifications, got ${progressCount}`); + check.ok(logCount >= 4, `expected >=4 log notifications, got ${logCount}`); + + // --- cancellation propagation --- + const ac = new AbortController(); + setTimeout(() => ac.abort(), 60); + let cancelled = false; + try { + await client.callTool({ name: 'countdown', arguments: { n: 50, delayMs: 50 } }, { signal: ac.signal }); + } catch { + cancelled = true; + } + check.ok(cancelled, 'a client-side abort should reject the in-flight callTool'); + + await client.close(); +}); diff --git a/examples/streaming/server.ts b/examples/streaming/server.ts new file mode 100644 index 0000000000..c948f8f0a3 --- /dev/null +++ b/examples/streaming/server.ts @@ -0,0 +1,58 @@ +/** + * In-flight channels: progress, logging, cancellation. + * + * The `countdown` tool emits a `notifications/progress` per step (when the + * call carried a `_meta.progressToken`), a logging notification per step + * (when the server has the `logging` capability), and stops promptly when the + * client cancels (`ctx.mcpReq.signal.aborted`). One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'streaming-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + + server.registerTool( + 'countdown', + { + description: 'Counts down from N, emitting progress + log per step; stops on cancellation', + inputSchema: z.object({ n: z.number().int().min(1).max(50), delayMs: z.number().int().min(0).default(50) }), + outputSchema: z.object({ completed: z.number(), total: z.number(), cancelled: z.boolean() }) + }, + async ({ n, delayMs }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + let completed = 0; + for (let i = 0; i < n; i++) { + if (ctx.mcpReq.signal.aborted) break; + await new Promise(r => setTimeout(r, delayMs)); + completed++; + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: completed, total: n, message: `step ${completed}/${n}` } + }); + } + // Send the log message as a request-tied notification so it + // rides the same response stream as the progress notification + // (the connection-level `ctx.mcpReq.log` shorthand sends an + // unrelated notification, which a per-request HTTP entry + // cannot deliver mid-call). + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', logger: 'countdown', data: `countdown step ${completed}/${n}` } + }); + } + const structuredContent = { completed, total: n, cancelled: ctx.mcpReq.signal.aborted }; + return { + content: [{ type: 'text', text: `completed ${completed}/${n}${structuredContent.cancelled ? ' (cancelled)' : ''}` }], + structuredContent + }; + } + ); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/tools/README.md b/examples/tools/README.md new file mode 100644 index 0000000000..6d0bf11784 --- /dev/null +++ b/examples/tools/README.md @@ -0,0 +1,8 @@ +# tools + +**Start here.** Register tools with `McpServer.registerTool`; the SDK infers the JSON Schema from any Standard-Schema-compatible input (Zod here) and emits `structuredContent` matching `outputSchema`. The client lists tools, inspects schemas and `annotations`, calls them, and +asserts structured output. + +```bash +pnpm tsx examples/tools/client.ts +``` diff --git a/examples/tools/client.ts b/examples/tools/client.ts new file mode 100644 index 0000000000..24d46f75ab --- /dev/null +++ b/examples/tools/client.ts @@ -0,0 +1,39 @@ +/** + * Drives the tools example: list, inspect schemas + annotations, call, + * assert structured output, assert an unknown tool errors. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('tools', async () => { + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listTools(); + const names = new Set(list.tools.map(t => t.name)); + check.ok(names.has('calc') && names.has('echo'), 'tools/list should contain calc and echo'); + + const calc = list.tools.find(t => t.name === 'calc')!; + check.equal(calc.annotations?.readOnlyHint, true); + const required = (calc.inputSchema as { required?: string[] }).required ?? []; + check.ok(required.includes('op') && required.includes('a') && required.includes('b')); + check.ok(calc.outputSchema, 'calc should publish an outputSchema'); + + const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } }); + check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5); + check.equal((result.structuredContent as { op?: string } | undefined)?.op, 'add'); + + const echo = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); + check.equal(echo.content?.[0]?.type === 'text' ? echo.content[0].text : '', 'hi'); + check.equal(echo.structuredContent, undefined); + + // An unknown tool should be a tool error (isError) or a wire error — either is acceptable. + let unknownFailed = false; + try { + const r = await client.callTool({ name: 'nope', arguments: {} }); + unknownFailed = !!r.isError; + } catch { + unknownFailed = true; + } + check.ok(unknownFailed, 'calling an unknown tool should fail'); + + await client.close(); +}); diff --git a/examples/tools/server.ts b/examples/tools/server.ts new file mode 100644 index 0000000000..6dc0602482 --- /dev/null +++ b/examples/tools/server.ts @@ -0,0 +1,49 @@ +/** + * Tools primitive — start here. + * + * Register tools with `McpServer.registerTool`: typed input via any + * Standard-Schema-with-JSON library (Zod here), inferred output schema + + * `structuredContent` from `outputSchema`, `annotations` for behavioral hints + * (`readOnlyHint`, `destructiveHint`). One binary, either transport. + */ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'tools-example', version: '1.0.0' }); + + // A read-only tool with typed input and inferred structured output. + server.registerTool( + 'calc', + { + title: 'Calculator', + description: 'Apply an arithmetic operation to two numbers', + inputSchema: z.object({ + op: z.enum(['add', 'sub', 'mul']).describe('the operation to apply'), + a: z.number().describe('left operand'), + b: z.number().describe('right operand') + }), + outputSchema: z.object({ op: z.string(), result: z.number() }), + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ op, a, b }) => { + const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b; + const structuredContent = { op, result }; + return { content: [{ type: 'text', text: `${a} ${op} ${b} = ${result}` }], structuredContent }; + } + ); + + // A plain string-returning tool (no structuredContent). + server.registerTool( + 'echo', + { description: 'Echoes the input', inputSchema: z.object({ text: z.string() }) }, + async ({ text }): Promise => ({ content: [{ type: 'text', text }] }) + ); + + return server; +} + +runServerFromArgs(buildServer); From f9b0f953b35f3c35ac7396d4e4fc6b0f7fd4338d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:49:26 +0000 Subject: [PATCH 06/27] feat(examples): paired clients for the remaining audited stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every server now has a real client.ts: `sampling/` (canned `sampling/createMessage` handler, stdio-only — push is 2025-era); `elicitation-form/` (auto-answers the form, accept + decline, stdio-only); `schema-validators/` (Zod/ArkType/Valibot input + outputSchema → structured output); `custom-version/` (asserts the configured supportedProtocolVersions list); `legacy-routing/` (`isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict `legacy: 'reject'` entry on the same port — the documented v2 composition with a runnable example); `bearer-auth/` (`requireBearerAuth` in front of `createMcpHandler`; asserts 401 + WWW-Authenticate without a token, authInfo flow with one); `parallel-calls/` (multiple clients + parallel calls with attributed notifications, on a slim createMcpHandler server). `toolWithSampleServer`'s stdout-logging-into-the-protocol-stream bug is fixed in the move: every server log goes to stderr. --- examples/bearer-auth/README.md | 6 +++ examples/bearer-auth/client.ts | 29 ++++++++++ examples/bearer-auth/manifest.json | 4 ++ examples/bearer-auth/server.ts | 70 ++++++++++++++++++++++++ examples/custom-version/README.md | 7 +++ examples/custom-version/client.ts | 21 ++++++++ examples/custom-version/server.ts | 26 +++++++++ examples/elicitation-form/README.md | 11 ++++ examples/elicitation-form/client.ts | 32 +++++++++++ examples/elicitation-form/manifest.json | 3 ++ examples/elicitation-form/server.ts | 40 ++++++++++++++ examples/legacy-routing/README.md | 5 ++ examples/legacy-routing/client.ts | 28 ++++++++++ examples/legacy-routing/manifest.json | 3 ++ examples/legacy-routing/server.ts | 72 +++++++++++++++++++++++++ examples/parallel-calls/README.md | 5 ++ examples/parallel-calls/client.ts | 47 ++++++++++++++++ examples/parallel-calls/manifest.json | 3 ++ examples/parallel-calls/server.ts | 40 ++++++++++++++ examples/sampling/README.md | 11 ++++ examples/sampling/client.ts | 24 +++++++++ examples/sampling/manifest.json | 3 ++ examples/sampling/server.ts | 34 ++++++++++++ examples/schema-validators/README.md | 7 +++ examples/schema-validators/client.ts | 28 ++++++++++ examples/schema-validators/server.ts | 54 +++++++++++++++++++ 26 files changed, 613 insertions(+) create mode 100644 examples/bearer-auth/README.md create mode 100644 examples/bearer-auth/client.ts create mode 100644 examples/bearer-auth/manifest.json create mode 100644 examples/bearer-auth/server.ts create mode 100644 examples/custom-version/README.md create mode 100644 examples/custom-version/client.ts create mode 100644 examples/custom-version/server.ts create mode 100644 examples/elicitation-form/README.md create mode 100644 examples/elicitation-form/client.ts create mode 100644 examples/elicitation-form/manifest.json create mode 100644 examples/elicitation-form/server.ts create mode 100644 examples/legacy-routing/README.md create mode 100644 examples/legacy-routing/client.ts create mode 100644 examples/legacy-routing/manifest.json create mode 100644 examples/legacy-routing/server.ts create mode 100644 examples/parallel-calls/README.md create mode 100644 examples/parallel-calls/client.ts create mode 100644 examples/parallel-calls/manifest.json create mode 100644 examples/parallel-calls/server.ts create mode 100644 examples/sampling/README.md create mode 100644 examples/sampling/client.ts create mode 100644 examples/sampling/manifest.json create mode 100644 examples/sampling/server.ts create mode 100644 examples/schema-validators/README.md create mode 100644 examples/schema-validators/client.ts create mode 100644 examples/schema-validators/server.ts diff --git a/examples/bearer-auth/README.md b/examples/bearer-auth/README.md new file mode 100644 index 0000000000..61eb749511 --- /dev/null +++ b/examples/bearer-auth/README.md @@ -0,0 +1,6 @@ +# bearer-auth + +Resource-server-only auth: `requireBearerAuth` + `mcpAuthMetadataRouter` from `@modelcontextprotocol/express` in front of `createMcpHandler`. The client asserts `401` + `WWW-Authenticate` without a token, and that the verified `authInfo` reaches the factory (`ctx.authInfo`) with +one. + +**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (excluded from the harness). diff --git a/examples/bearer-auth/client.ts b/examples/bearer-auth/client.ts new file mode 100644 index 0000000000..47dddfd10e --- /dev/null +++ b/examples/bearer-auth/client.ts @@ -0,0 +1,29 @@ +/** + * Asserts a bare request is `401` with a `WWW-Authenticate` header, and that + * a request with `Authorization: Bearer demo-token` reaches the `whoami` tool + * with the verified `authInfo`. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; + +runClient('bearer-auth', async () => { + // Unauthenticated → 401 + WWW-Authenticate. + const unauth = await fetch(URL, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) + }); + check.equal(unauth.status, 401); + check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); + + // Authenticated → 200 and the tool sees the authInfo. + const client = new Client({ name: 'bearer-auth-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL), { authProvider: { token: async () => 'demo-token' } })); + const result = await client.callTool({ name: 'whoami', arguments: {} }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client'); + await client.close(); +}); diff --git a/examples/bearer-auth/manifest.json b/examples/bearer-auth/manifest.json new file mode 100644 index 0000000000..59c1309570 --- /dev/null +++ b/examples/bearer-auth/manifest.json @@ -0,0 +1,4 @@ +{ + "transport": "http", + "path": "/mcp" +} diff --git a/examples/bearer-auth/server.ts b/examples/bearer-auth/server.ts new file mode 100644 index 0000000000..00e8d616ec --- /dev/null +++ b/examples/bearer-auth/server.ts @@ -0,0 +1,70 @@ +/** + * Minimal Resource-Server-only auth using the SDK's RS helpers + * (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`). + * + * No Authorization Server in this repo — the metadata points at a placeholder + * issuer; the token verifier accepts a single static `demo-token`. The MCP + * endpoint is hosted on `createMcpHandler` with the verified `authInfo` passed + * through to the factory (`ctx.authInfo`). HTTP-only by definition. + */ +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`); + +const oauthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] +}; + +// Replace with JWT verification, RFC 7662 introspection, etc. +const staticTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + if (token !== 'demo-token') { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + } + return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + } +}; + +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'bearer-auth-example', version: '1.0.0' }); + server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({ + content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }] + })); + return server; +}); + +const app = createMcpExpressApp(); +app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + resourceName: 'bearer-auth example' + }) +); +const auth = requireBearerAuth({ + verifier: staticTokenVerifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it +// to the factory as `ctx.authInfo`. +app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); + +app.listen(PORT, () => { + console.error(`bearer-auth example server on http://127.0.0.1:${PORT}/mcp`); +}); diff --git a/examples/custom-version/README.md b/examples/custom-version/README.md new file mode 100644 index 0000000000..1bd05975ad --- /dev/null +++ b/examples/custom-version/README.md @@ -0,0 +1,7 @@ +# custom-version + +`ServerOptions.supportedProtocolVersions` — declare support for protocol versions not yet in the SDK. The first version in the list is the fallback when a client requests an unsupported one. + +```bash +pnpm tsx examples/custom-version/client.ts +``` diff --git a/examples/custom-version/client.ts b/examples/custom-version/client.ts new file mode 100644 index 0000000000..99c307ab77 --- /dev/null +++ b/examples/custom-version/client.ts @@ -0,0 +1,21 @@ +/** + * Initializes with a protocol version the server lists in + * `supportedProtocolVersions` (and one it does not, to assert the fallback). + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('custom-version', async () => { + // A plain (2025-handshake) client; the server supports the SDK's stock + // 2025 version so this negotiates that. + const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); + + // The server should advertise its supportedProtocolVersions in its + // tool's text payload. + const result = await client.callTool({ name: 'get-protocol-info' }); + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '{}'; + const info = JSON.parse(text) as { supportedVersions: string[] }; + check.ok(info.supportedVersions.includes('2026-01-01')); + check.ok(info.supportedVersions.length > 1); + + await client.close(); +}); diff --git a/examples/custom-version/server.ts b/examples/custom-version/server.ts new file mode 100644 index 0000000000..b0f7783476 --- /dev/null +++ b/examples/custom-version/server.ts @@ -0,0 +1,26 @@ +/** + * `supportedProtocolVersions`: support a protocol version not yet in the SDK. + * The first version in the list is the fallback when the client requests an + * unsupported one. One binary, either transport. + */ +import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +// Add support for a newer protocol version (first in list is fallback). +const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'custom-protocol-server', version: '1.0.0' }, + { supportedProtocolVersions: CUSTOM_VERSIONS, capabilities: { tools: {} } } + ); + + server.registerTool('get-protocol-info', { description: 'Returns protocol version configuration' }, async () => ({ + content: [{ type: 'text', text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }) }] + })); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/elicitation-form/README.md b/examples/elicitation-form/README.md new file mode 100644 index 0000000000..de1a724070 --- /dev/null +++ b/examples/elicitation-form/README.md @@ -0,0 +1,11 @@ +# elicitation-form + +Form-mode elicitation: the server requests structured user input via `ctx.mcpReq.elicitInput({ mode: 'form', requestedSchema })`; the client auto-answers the form. Covers accept and decline. + +For URL-mode elicitation see `../oauth/` (excluded from the harness; browser flow). For the 2026-07-28 multi-round-trip return style see `../mrtr/`. + +**stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the stateless per-request `createMcpHandler`. + +```bash +pnpm tsx examples/elicitation-form/client.ts +``` diff --git a/examples/elicitation-form/client.ts b/examples/elicitation-form/client.ts new file mode 100644 index 0000000000..05a8fb7fef --- /dev/null +++ b/examples/elicitation-form/client.ts @@ -0,0 +1,32 @@ +/** + * Auto-answers the registration form (accept once, decline once) and asserts + * the tool's text reflects the elicitation outcome. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('elicitation-form', async () => { + // Push-style elicitation is the 2025-era flow (the 2026-07-28 revision uses + // multi-round-trip `inputRequired` instead — see ../mrtr/). Connect as a + // plain 2025 client so `ctx.mcpReq.elicitInput` reaches this handler. + const client = await connectFromArgs(import.meta.dirname, { + versionNegotiation: undefined, + capabilities: { elicitation: { form: {} } } + }); + + let action: 'accept' | 'decline' = 'accept'; + client.setRequestHandler('elicitation/create', async request => { + const params = request.params as { requestedSchema?: { properties?: Record } }; + check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema'); + if (action === 'decline') return { action: 'decline' }; + return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', newsletter: true } }; + }); + + const accepted = await client.callTool({ name: 'register_user' }); + check.match(accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', /registered alice /); + + action = 'decline'; + const declined = await client.callTool({ name: 'register_user' }); + check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/); + + await client.close(); +}); diff --git a/examples/elicitation-form/manifest.json b/examples/elicitation-form/manifest.json new file mode 100644 index 0000000000..5299ca7150 --- /dev/null +++ b/examples/elicitation-form/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "stdio" +} diff --git a/examples/elicitation-form/server.ts b/examples/elicitation-form/server.ts new file mode 100644 index 0000000000..0027997cc4 --- /dev/null +++ b/examples/elicitation-form/server.ts @@ -0,0 +1,40 @@ +/** + * Form-mode elicitation: a tool that collects structured user input via + * `ctx.mcpReq.elicitInput({ mode: 'form', ... })`. The client validates the + * form against `requestedSchema` and answers `accept`/`decline`/`cancel`. + * One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'elicitation-form-example', version: '1.0.0' }); + + server.registerTool('register_user', { description: 'Register a new user account by collecting their information' }, async ctx => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: { + type: 'object', + properties: { + username: { type: 'string', title: 'Username', minLength: 3, maxLength: 20 }, + email: { type: 'string', title: 'Email', format: 'email' }, + newsletter: { type: 'boolean', title: 'Subscribe to newsletter?', default: false } + }, + required: ['username', 'email'] + } + }); + if (result.action !== 'accept' || !result.content) { + return { content: [{ type: 'text', text: `registration ${result.action}` }] }; + } + const { username, email, newsletter } = result.content as { username: string; email: string; newsletter?: boolean }; + return { + content: [{ type: 'text', text: `registered ${username} <${email}> (newsletter: ${newsletter ? 'yes' : 'no'})` }] + }; + }); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/legacy-routing/README.md b/examples/legacy-routing/README.md new file mode 100644 index 0000000000..9c26dbead3 --- /dev/null +++ b/examples/legacy-routing/README.md @@ -0,0 +1,5 @@ +# legacy-routing + +`isLegacyRequest` routing: keep an **existing** sessionful 1.x Streamable HTTP deployment serving 2025-era clients, add a strict `createMcpHandler({ legacy: 'reject' })` for 2026-07-28 traffic, on the **same port**. The predicate decides per request which arm handles it. + +**HTTP-only** by definition; see also `dual-era/` for the simple case where you don't have a sessionful deployment to keep. diff --git a/examples/legacy-routing/client.ts b/examples/legacy-routing/client.ts new file mode 100644 index 0000000000..dc43dcebd3 --- /dev/null +++ b/examples/legacy-routing/client.ts @@ -0,0 +1,28 @@ +/** + * Connects to the routing fork as both a plain 2025 client (lands on the + * existing sessionful transport, `era=legacy`) and a 2026-capable client + * (lands on the strict modern entry, `era=modern`). + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; + +runClient('legacy-routing', async () => { + // 2025 client → routed to the existing sessionful deployment. + const legacy = new Client({ name: 'legacy-routing-client', version: '1.0.0' }); + await legacy.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const lr = await legacy.callTool({ name: 'greet', arguments: { name: 'A' } }); + check.match(lr.content?.[0]?.type === 'text' ? lr.content[0].text : '', /era=legacy/); + await legacy.close(); + + // 2026 client → routed to the strict modern entry. + const modern = new Client({ name: 'legacy-routing-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modern.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28'); + const mr = await modern.callTool({ name: 'greet', arguments: { name: 'B' } }); + check.match(mr.content?.[0]?.type === 'text' ? mr.content[0].text : '', /era=modern/); + await modern.close(); +}); diff --git a/examples/legacy-routing/manifest.json b/examples/legacy-routing/manifest.json new file mode 100644 index 0000000000..376c70cacb --- /dev/null +++ b/examples/legacy-routing/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "http" +} diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts new file mode 100644 index 0000000000..c269b9494a --- /dev/null +++ b/examples/legacy-routing/server.ts @@ -0,0 +1,72 @@ +/** + * `isLegacyRequest` routing in front of an existing sessionful 1.x deployment, + * with a strict modern entry on the SAME port. + * + * This is the v2 answer to "I already have a sessionful Streamable HTTP + * deployment and want to add 2026-07-28 serving without disturbing it": + * route in user land — `await isLegacyRequest(req)` decides per request, + * legacy traffic goes to your existing transport, modern traffic to a strict + * `createMcpHandler(factory, { legacy: 'reject' })`. + * + * HTTP-only by definition. + */ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +// One factory for both legs. +const buildServer = (era: 'legacy' | 'modern') => { + const server = new McpServer({ name: 'legacy-routing-example', version: '1.0.0' }); + server.registerTool('greet', { description: 'Greets the caller', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}! (era=${era})` }] + })); + return server; +}; + +// --- the existing sessionful 2025 deployment, unchanged --- +const sessions = new Map(); +const ensureSessionful = async (sid: string | undefined) => { + if (sid && sessions.has(sid)) return sessions.get(sid)!; + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer('legacy').connect(transport); + return transport; +}; + +// --- the strict modern entry alongside it --- +const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' }); + +const app = createMcpExpressApp(); +app.all('/', async (req: Request, res: Response) => { + // The predicate inspects the same headers + body the entry does. Express + // has parsed the JSON body; pass it as `parsedBody` so the predicate need + // not re-read the stream. + const probe = new globalThis.Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers as Record + }); + if (await isLegacyRequest(probe, req.body)) { + const sid = req.headers['mcp-session-id'] as string | undefined; + const transport = await ensureSessionful(sid); + await transport.handleRequest(req, res, req.body); + } else { + await modern.node(req, res, req.body); + } +}); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +app.listen(port, () => { + console.error(`legacy-routing example server listening on http://127.0.0.1:${port}/`); +}); diff --git a/examples/parallel-calls/README.md b/examples/parallel-calls/README.md new file mode 100644 index 0000000000..b1d36682db --- /dev/null +++ b/examples/parallel-calls/README.md @@ -0,0 +1,5 @@ +# parallel-calls + +Multiple clients connecting to one endpoint in parallel, and one client making parallel `callTool()` calls — with per-call logging notifications attributed back to their caller. + +**HTTP-only**: parallel clients to one endpoint is the meaningful case. diff --git a/examples/parallel-calls/client.ts b/examples/parallel-calls/client.ts new file mode 100644 index 0000000000..1fb7037290 --- /dev/null +++ b/examples/parallel-calls/client.ts @@ -0,0 +1,47 @@ +/** + * Two clients in parallel, each calling the notification-emitting tool, and + * one client making two parallel tool calls — asserts every result returns + * and that notifications were attributed back to the right caller. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; + +async function makeClient(id: string): Promise<{ client: Client; notifications: string[] }> { + const client = new Client({ name: `parallel-${id}`, version: '1.0.0' }); + const notifications: string[] = []; + client.setNotificationHandler('notifications/message', n => { + notifications.push(String(n.params.data)); + }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + return { client, notifications }; +} + +runClient('parallel-calls', async () => { + // --- multiple clients, one call each --- + const [a, b] = await Promise.all([makeClient('A'), makeClient('B')]); + const [ra, rb] = await Promise.all([ + a.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'A', count: 3 } }), + b.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'B', count: 3 } }) + ]); + check.match(ra.content?.[0]?.type === 'text' ? ra.content[0].text : '', /\[A\] done/); + check.match(rb.content?.[0]?.type === 'text' ? rb.content[0].text : '', /\[B\] done/); + check.ok(a.notifications.every(m => m.includes('[A]'))); + check.ok(b.notifications.every(m => m.includes('[B]'))); + check.ok(a.notifications.length >= 3 && b.notifications.length >= 3); + await a.client.close(); + await b.client.close(); + + // --- one client, parallel tool calls --- + const c = await makeClient('C'); + const results = await Promise.all([ + c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C1', count: 2 } }), + c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C2', count: 2 } }) + ]); + check.equal(results.length, 2); + check.ok(c.notifications.some(m => m.includes('[C1]')) && c.notifications.some(m => m.includes('[C2]'))); + await c.client.close(); +}); diff --git a/examples/parallel-calls/manifest.json b/examples/parallel-calls/manifest.json new file mode 100644 index 0000000000..376c70cacb --- /dev/null +++ b/examples/parallel-calls/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "http" +} diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts new file mode 100644 index 0000000000..f8154a5c8e --- /dev/null +++ b/examples/parallel-calls/server.ts @@ -0,0 +1,40 @@ +/** + * A small `createMcpHandler` server with one notification-emitting tool, used + * by the parallel-calls client to drive multiple concurrent clients / parallel + * tool calls and attribute notifications back to their caller. HTTP-only. + */ +import { createServer } from 'node:http'; + +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'parallel-calls-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'start-notification-stream', + { + description: 'Sends a few periodic logging notifications tagged with the caller id', + inputSchema: z.object({ caller: z.string(), count: z.number().int().min(1).max(20).default(3) }) + }, + async ({ caller, count }, ctx) => { + for (let i = 1; i <= count; i++) { + // Send as a request-tied notification so it rides the same SSE + // stream as the eventual result. + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', data: `[${caller}] tick ${i}/${count}` } + }); + await new Promise(r => setTimeout(r, 20)); + } + return { content: [{ type: 'text', text: `[${caller}] done (${count})` }] }; + } + ); + return server; +}); + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`parallel-calls example server listening on http://127.0.0.1:${port}/`); +}); diff --git a/examples/sampling/README.md b/examples/sampling/README.md new file mode 100644 index 0000000000..f441a8654e --- /dev/null +++ b/examples/sampling/README.md @@ -0,0 +1,11 @@ +# sampling + +A tool that requests LLM sampling from the client via `ctx.mcpReq.requestSampling(...)`. The client advertises `sampling` and registers a `sampling/createMessage` handler returning a canned response. + +> Sampling is **deprecated** as of protocol revision 2026-07-28 (SEP-2577) but remains functional during the deprecation window. + +**stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the per-request `createMcpHandler`, which serves the 2026-07-28 path where sampling is unavailable. + +```bash +pnpm tsx examples/sampling/client.ts +``` diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts new file mode 100644 index 0000000000..443e2e655e --- /dev/null +++ b/examples/sampling/client.ts @@ -0,0 +1,24 @@ +/** + * Advertises the sampling capability, registers a `sampling/createMessage` + * handler that returns a canned summary, then calls the `summarize` tool and + * asserts the canned text round-tripped. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('sampling', async () => { + // Push-style sampling is a 2025-era flow (and is deprecated as of + // 2026-07-28). Connect as a plain 2025 client so the server's + // `ctx.mcpReq.requestSampling` reaches this handler. + const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined, capabilities: { sampling: {} } }); + client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: '[canned summary]' }, + model: 'stub', + stopReason: 'endTurn' + })); + + const result = await client.callTool({ name: 'summarize', arguments: { text: 'hello world' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', '[canned summary]'); + + await client.close(); +}); diff --git a/examples/sampling/manifest.json b/examples/sampling/manifest.json new file mode 100644 index 0000000000..5299ca7150 --- /dev/null +++ b/examples/sampling/manifest.json @@ -0,0 +1,3 @@ +{ + "transport": "stdio" +} diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts new file mode 100644 index 0000000000..048951574e --- /dev/null +++ b/examples/sampling/server.ts @@ -0,0 +1,34 @@ +/** + * A tool that requests LLM sampling from the client via + * `ctx.mcpReq.requestSampling(...)` (the request-context idiom — replaces the + * older `mcpServer.server.createMessage(...)`). One binary, either transport. + * + * Logs go to stderr only — stdio's stdout is the JSON-RPC stream. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'sampling-example', version: '1.0.0' }); + + server.registerTool( + 'summarize', + { description: 'Summarize text using the host LLM', inputSchema: z.object({ text: z.string() }) }, + async ({ text }, ctx) => { + const response = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: `Please summarize the following text concisely:\n\n${text}` } }], + maxTokens: 500 + }); + // `content` is a single block when no tools were passed. + const content = response.content; + const summary = !Array.isArray(content) && content.type === 'text' ? content.text : 'Unable to generate summary'; + return { content: [{ type: 'text', text: summary }] }; + } + ); + + return server; +} + +runServerFromArgs(buildServer); diff --git a/examples/schema-validators/README.md b/examples/schema-validators/README.md new file mode 100644 index 0000000000..ea83e07a61 --- /dev/null +++ b/examples/schema-validators/README.md @@ -0,0 +1,7 @@ +# schema-validators + +Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`. + +```bash +pnpm tsx examples/schema-validators/client.ts +``` diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts new file mode 100644 index 0000000000..6c9812a943 --- /dev/null +++ b/examples/schema-validators/client.ts @@ -0,0 +1,28 @@ +/** + * Calls each greet variant and asserts every inputSchema published as a JSON + * Schema with a required `name` string; calls `get-weather` and asserts the + * structured output matches. + */ +import { check, connectFromArgs, runClient } from '../harness.js'; + +runClient('schema-validators', async () => { + const client = await connectFromArgs(import.meta.dirname); + + const list = await client.listTools(); + for (const name of ['greet-zod', 'greet-arktype', 'greet-valibot']) { + const tool = list.tools.find(t => t.name === name); + check.ok(tool, `${name} should be listed`); + const required = (tool!.inputSchema as { required?: string[] }).required ?? []; + check.ok(required.includes('name'), `${name} inputSchema should require 'name'`); + const result = await client.callTool({ name, arguments: { name: 'world' } }); + check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, world!/); + } + + const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); + const sc = weather.structuredContent as { city?: string; conditions?: string; celsius?: number } | undefined; + check.equal(sc?.city, 'Tokyo'); + check.equal(sc?.conditions, 'sunny'); + check.equal(sc?.celsius, 21); + + await client.close(); +}); diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts new file mode 100644 index 0000000000..a8a4cd5d5f --- /dev/null +++ b/examples/schema-validators/server.ts @@ -0,0 +1,54 @@ +/** + * Tool input/output schemas via three Standard-Schema-compatible libraries + * (Zod, ArkType, Valibot) plus an `outputSchema` that emits + * `structuredContent`. The SDK accepts any Standard-Schema-with-JSON value; + * Valibot needs the `@valibot/to-json-schema` wrapper to expose JSON Schema + * conversion. One binary, either transport. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { type } from 'arktype'; +import * as v from 'valibot'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'schema-validators-example', version: '1.0.0' }); + + server.registerTool( + 'greet-zod', + { description: 'Greet (Zod inputSchema)', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (zod)` }] }) + ); + + server.registerTool( + 'greet-arktype', + { description: 'Greet (ArkType inputSchema)', inputSchema: type({ name: 'string' }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (arktype)` }] }) + ); + + server.registerTool( + 'greet-valibot', + { description: 'Greet (Valibot inputSchema)', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (valibot)` }] }) + ); + + // outputSchema → structuredContent. + server.registerTool( + 'get-weather', + { + description: 'Get (canned) weather information', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ city: z.string(), conditions: z.enum(['sunny', 'cloudy', 'rainy']), celsius: z.number() }) + }, + async ({ city }) => { + const structuredContent = { city, conditions: 'sunny' as const, celsius: 21 }; + return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }; + } + ); + + return server; +} + +runServerFromArgs(buildServer); From efdbc54dbe685c2f0b762e8e6b3d001eb5684d3e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:49:26 +0000 Subject: [PATCH 07/27] docs: re-point guide snippets and example links at the per-story layout `docs/server.md` and `docs/client.md` source the guide snippets from `examples/guides/{server,client}Guide.examples.ts`; prose links to the moved examples are updated. The root README and typedoc `projectDocuments` point at the new `examples/README.md` index. --- README.md | 3 +- docs/client-quickstart.md | 2 +- docs/client.md | 88 +++++++++++++++++++-------------------- docs/faq.md | 2 +- docs/server-quickstart.md | 2 +- docs/server.md | 64 ++++++++++++++-------------- typedoc.config.mjs | 2 +- 7 files changed, 81 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 55d8fb9d47..7f81e7f4ee 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,7 @@ Ready to build something real? Follow the step-by-step quickstart tutorials: The complete code for each tutorial is in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/) and [`examples/client-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart/). For more advanced runnable examples, see: -- [`examples/server/README.md`](examples/server/README.md) — server examples index -- [`examples/client/README.md`](examples/client/README.md) — client examples index +- [`examples/README.md`](examples/README.md) — runnable, self-verifying client/server example pairs (one story per directory) ## Documentation diff --git a/docs/client-quickstart.md b/docs/client-quickstart.md index 71b8a9e12a..f26324636f 100644 --- a/docs/client-quickstart.md +++ b/docs/client-quickstart.md @@ -420,5 +420,5 @@ If you see: Now that you have a working client, here are some ways to go further: - [**Client guide**](./client.md) — Add OAuth, middleware, sampling, and more to your client. -- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Browse runnable client examples. +- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable client examples. - [**FAQ**](./faq.md) — Troubleshoot common errors. diff --git a/docs/client.md b/docs/client.md index 042ba2861a..a89891622d 100644 --- a/docs/client.md +++ b/docs/client.md @@ -12,7 +12,7 @@ A client connects to a server, discovers what it offers — tools, resources, pr The examples below use these imports. Adjust based on which features and transport you need: -```ts source="../examples/client/src/clientGuide.examples.ts#imports" +```ts source="../examples/guides/clientGuide.examples.ts#imports" import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; import { applyMiddlewares, @@ -39,7 +39,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; For remote HTTP servers, use {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_streamableHttp" +```ts source="../examples/guides/clientGuide.examples.ts#connect_streamableHttp" const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); @@ -47,13 +47,13 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 await client.connect(transport); ``` -For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). +For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/interactiveReplClient.ts). ### stdio For local, process-spawned servers (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/client!client/stdio.StdioClientTransport | StdioClientTransport}. The transport spawns the server process and communicates over stdin/stdout: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_stdio" +```ts source="../examples/guides/clientGuide.examples.ts#connect_stdio" const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StdioClientTransport({ @@ -68,7 +68,7 @@ await client.connect(transport); To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_sseFallback" +```ts source="../examples/guides/clientGuide.examples.ts#connect_sseFallback" const baseUrl = new URL(url); try { @@ -86,13 +86,13 @@ try { } ``` -For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). +For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/guides/clientGuide.examples.ts). ### Protocol version negotiation (2026-07-28 revision) By default the client negotiates a 2025-era protocol version via the `initialize` handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via `ClientOptions.versionNegotiation`: -```ts source="../examples/client/src/clientGuide.examples.ts#Client_versionNegotiation" +```ts source="../examples/guides/clientGuide.examples.ts#Client_versionNegotiation" // Auto-negotiate: probe with server/discover, fall back to the 2025 handshake // against a 2025-only server. const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -114,7 +114,7 @@ Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await For Streamable HTTP, terminate the server-side session first (per the MCP specification): -```ts source="../examples/client/src/clientGuide.examples.ts#disconnect_streamableHttp" +```ts source="../examples/guides/clientGuide.examples.ts#disconnect_streamableHttp" await transport.terminateSession(); // notify the server (recommended) await client.close(); ``` @@ -125,7 +125,7 @@ For stdio, `client.close()` handles graceful process shutdown (closes stdin, the Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Retrieve it after connecting and include it in the model's system prompt: -```ts source="../examples/client/src/clientGuide.examples.ts#serverInstructions_basic" +```ts source="../examples/guides/clientGuide.examples.ts#serverInstructions_basic" const instructions = client.getInstructions(); const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); @@ -141,19 +141,19 @@ MCP servers can require authentication before accepting client connections (see For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_tokenProvider" +```ts source="../examples/guides/clientGuide.examples.ts#auth_tokenProvider" const authProvider: AuthProvider = { token: async () => getStoredToken() }; const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTokenProvider.ts) for a complete runnable example. +See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleTokenProvider.ts) for a complete runnable example. ### Client credentials {@linkcode @modelcontextprotocol/client!client/authExtensions.ClientCredentialsProvider | ClientCredentialsProvider} handles the `client_credentials` grant flow for service-to-service communication: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_clientCredentials" +```ts source="../examples/guides/clientGuide.examples.ts#auth_clientCredentials" const authProvider = new ClientCredentialsProvider({ clientId: 'my-service', clientSecret: 'my-secret' @@ -170,7 +170,7 @@ await client.connect(transport); {@linkcode @modelcontextprotocol/client!client/authExtensions.PrivateKeyJwtProvider | PrivateKeyJwtProvider} signs JWT assertions for the `private_key_jwt` token endpoint auth method, avoiding a shared client secret: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_privateKeyJwt" +```ts source="../examples/guides/clientGuide.examples.ts#auth_privateKeyJwt" const authProvider = new PrivateKeyJwtProvider({ clientId: 'my-service', privateKey: pemEncodedKey, @@ -180,13 +180,13 @@ const authProvider = new PrivateKeyJwtProvider({ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleClientCredentials.ts). +For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleClientCredentials.ts). ### Full OAuth with user authorization For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. -For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). +For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). ### Cross-App Access (Enterprise Managed Authorization) @@ -196,7 +196,7 @@ This provider handles a two-step OAuth flow: 1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange 2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant -```ts source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess" +```ts source="../examples/guides/clientGuide.examples.ts#auth_crossAppAccess" const authProvider = new CrossAppAccessProvider({ assertion: async ctx => { // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn @@ -239,7 +239,7 @@ Tools are callable actions offered by servers — discovering and invoking them Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect all pages: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_basic" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic" const allTools: Tool[] = []; let toolCursor: string | undefined; do { @@ -261,7 +261,7 @@ console.log(result.content); Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_structuredOutput" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" const result = await client.callTool({ name: 'calculate-bmi', arguments: { weightKg: 70, heightM: 1.75 } @@ -277,7 +277,7 @@ if (result.structuredContent) { Pass `onprogress` to receive incremental progress notifications from long-running tools. Use `resetTimeoutOnProgress` to keep the request alive while the server is actively reporting, and `maxTotalTimeout` as an absolute cap: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_progress" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_progress" const result = await client.callTool( { name: 'long-operation', arguments: {} }, { @@ -297,7 +297,7 @@ Resources are read-only data — files, database schemas, configuration — that Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on `nextCursor` to collect all pages: -```ts source="../examples/client/src/clientGuide.examples.ts#readResource_basic" +```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic" const allResources: Resource[] = []; let resourceCursor: string | undefined; do { @@ -322,7 +322,7 @@ To discover URI templates for dynamic resources, use {@linkcode @modelcontextpro If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribeResource | subscribeResource()} to receive notifications when a resource changes, then re-read it: -```ts source="../examples/client/src/clientGuide.examples.ts#subscribeResource_basic" +```ts source="../examples/guides/clientGuide.examples.ts#subscribeResource_basic" await client.subscribeResource({ uri: 'config://app' }); client.setNotificationHandler('notifications/resources/updated', async notification => { @@ -342,7 +342,7 @@ Prompts are reusable message templates that servers offer to help structure inte Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on `nextCursor` to collect all pages: -```ts source="../examples/client/src/clientGuide.examples.ts#getPrompt_basic" +```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic" const allPrompts: Prompt[] = []; let promptCursor: string | undefined; do { @@ -366,7 +366,7 @@ console.log(messages); Both prompts and resources can support argument completions. Use {@linkcode @modelcontextprotocol/client!client/client.Client#complete | complete()} to request autocompletion suggestions from the server as a user types: -```ts source="../examples/client/src/clientGuide.examples.ts#complete_basic" +```ts source="../examples/guides/clientGuide.examples.ts#complete_basic" const { completion } = await client.complete({ ref: { type: 'ref/prompt', @@ -386,7 +386,7 @@ console.log(completion.values); // e.g. ['typescript'] The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and error-first callbacks: -```ts source="../examples/client/src/clientGuide.examples.ts#listChanged_basic" +```ts source="../examples/guides/clientGuide.examples.ts#listChanged_basic" const client = new Client( { name: 'my-client', version: '1.0.0' }, { @@ -412,7 +412,7 @@ const client = new Client( For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}: -```ts source="../examples/client/src/clientGuide.examples.ts#notificationHandler_basic" +```ts source="../examples/guides/clientGuide.examples.ts#notificationHandler_basic" // Server log messages (sent by the server during request processing) client.setNotificationHandler('notifications/message', notification => { const { level, data } = notification.params; @@ -431,7 +431,7 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: -```ts source="../examples/client/src/clientGuide.examples.ts#setLoggingLevel_basic" +```ts source="../examples/guides/clientGuide.examples.ts#setLoggingLevel_basic" await client.setLoggingLevel('warning'); ``` @@ -442,7 +442,7 @@ await client.setLoggingLevel('warning'); MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: -```ts source="../examples/client/src/clientGuide.examples.ts#capabilities_declaration" +```ts source="../examples/guides/clientGuide.examples.ts#capabilities_declaration" const client = new Client( { name: 'my-client', version: '1.0.0' }, { @@ -461,7 +461,7 @@ const client = new Client( When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: -```ts source="../examples/client/src/clientGuide.examples.ts#sampling_handler" +```ts source="../examples/guides/clientGuide.examples.ts#sampling_handler" client.setRequestHandler('sampling/createMessage', async request => { const lastMessage = request.params.messages.at(-1); console.log('Sampling request:', lastMessage); @@ -482,7 +482,7 @@ client.setRequestHandler('sampling/createMessage', async request => { When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return the collected data, or `{ action: 'decline' }`: -```ts source="../examples/client/src/clientGuide.examples.ts#elicitation_handler" +```ts source="../examples/guides/clientGuide.examples.ts#elicitation_handler" client.setRequestHandler('elicitation/create', async request => { console.log('Server asks:', request.params.message); @@ -496,7 +496,7 @@ client.setRequestHandler('elicitation/create', async request => { }); ``` -For a full form-based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts). +For a full form-based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/interactiveReplClient.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/elicitationUrlClient.ts). ### Roots @@ -505,7 +505,7 @@ For a full form-based elicitation handler with AJV validation, see [`simpleStrea Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: -```ts source="../examples/client/src/clientGuide.examples.ts#roots_handler" +```ts source="../examples/guides/clientGuide.examples.ts#roots_handler" client.setRequestHandler('roots/list', async () => { return { roots: [ @@ -524,7 +524,7 @@ When the available roots change, notify the server with {@linkcode @modelcontext {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can *run but report failure* via `isError: true` in the result, or the *request itself can fail* and throw an exception. Always check both: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_toolErrors" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_toolErrors" try { const result = await client.callTool({ name: 'fetch-data', @@ -556,7 +556,7 @@ try { Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_lifecycle" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_lifecycle" // Out-of-band errors (SSE disconnects, parse errors) client.onerror = error => { console.error('Transport error:', error.message); @@ -572,7 +572,7 @@ client.onclose = () => { All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_timeout" try { const result = await client.callTool( { name: 'slow-operation', arguments: {} }, @@ -590,7 +590,7 @@ try { Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: -```ts source="../examples/client/src/clientGuide.examples.ts#middleware_basic" +```ts source="../examples/guides/clientGuide.examples.ts#middleware_basic" const authMiddleware = createMiddleware(async (next, input, init) => { const headers = new Headers(init?.headers); headers.set('X-Custom-Header', 'my-value'); @@ -608,7 +608,7 @@ The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelco Attach trace context to a single request via `_meta`: -```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_perRequest" +```ts source="../examples/guides/clientGuide.examples.ts#traceContext_perRequest" // Values would normally come from your tracer's active span context. const result = await client.callTool({ name: 'calculate-bmi', @@ -623,7 +623,7 @@ console.log(result.content); Or inject it into every outgoing request with fetch middleware (Streamable HTTP transport): -```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_middleware" +```ts source="../examples/guides/clientGuide.examples.ts#traceContext_middleware" const traceContextMiddleware = createMiddleware(async (next, input, init) => { if (typeof init?.body !== 'string') { return next(input, init); @@ -658,7 +658,7 @@ On the server side, handlers can read the incoming trace context from `ctx.mcpRe When using SSE-based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: -```ts source="../examples/client/src/clientGuide.examples.ts#resumptionToken_basic" +```ts source="../examples/guides/clientGuide.examples.ts#resumptionToken_basic" let lastToken: string | undefined; const result = await client.request( @@ -677,11 +677,11 @@ const result = await client.request( console.log(result); ``` -For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts). +For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts). ## See also -- [`examples/client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Full runnable client examples +- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable client examples - [Server guide](./server.md) — Building MCP servers with this SDK - [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives - [Migration guide](./migration.md) — Upgrading from previous SDK versions @@ -691,7 +691,7 @@ For an end-to-end example of server-initiated SSE disconnection and automatic cl | Feature | Description | Example | |---------|-------------|---------| -| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/parallelToolCallsClient.ts) | -| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts) | -| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/multipleClientsParallel.ts) | -| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts) | +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/elicitationUrlClient.ts) | diff --git a/docs/faq.md b/docs/faq.md index 5bc9d71c00..66f3d46c04 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -67,7 +67,7 @@ For production use, you can either: ### Where can I find runnable server examples? -The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/server/README.md`](../examples/server/README.md). +The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/README.md`](../examples/README.md). ### Where are the server auth helpers? diff --git a/docs/server-quickstart.md b/docs/server-quickstart.md index b8d19e7e1c..0ed198be18 100644 --- a/docs/server-quickstart.md +++ b/docs/server-quickstart.md @@ -472,5 +472,5 @@ This isn't an error - it just means there are no current weather alerts for that Now that your server is running locally, here are some ways to go further: - [**Server guide**](./server.md) — Add resources, prompts, logging, error handling, and remote transports to your server. -- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Browse runnable examples covering OAuth, streaming, sessions, and more. +- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable examples covering OAuth, streaming, sessions, and more. - [**FAQ**](./faq.md) — Troubleshoot common errors (Zod version conflicts, transport issues, etc.). diff --git a/docs/server.md b/docs/server.md index 53bd3e6051..3fdea8393f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -16,7 +16,7 @@ Building a server takes three steps: The examples below use these imports. Adjust based on which features and transport you need: -```ts source="../examples/server/src/serverGuide.examples.ts#imports" +```ts source="../examples/guides/serverGuide.examples.ts#imports" import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; @@ -38,7 +38,7 @@ MCP supports two transport mechanisms (see [Transport layer](https://modelcontex Create a {@linkcode @modelcontextprotocol/node!streamableHttp.NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} and connect it to your server: -```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_stateful" +```ts source="../examples/guides/serverGuide.examples.ts#streamableHttp_stateful" const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ @@ -50,13 +50,13 @@ await server.connect(transport); **Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. -For a complete server with sessions, logging, and CORS mounted on Express, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +For a complete server with sessions, logging, and CORS mounted on Express, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts). ### stdio For local, process-spawned integrations, use {@linkcode @modelcontextprotocol/server!server/stdio.StdioServerTransport | StdioServerTransport}: -```ts source="../examples/server/src/serverGuide.examples.ts#stdio_basic" +```ts source="../examples/guides/serverGuide.examples.ts#stdio_basic" const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new StdioServerTransport(); await server.connect(transport); @@ -79,13 +79,13 @@ serveStdio(() => { Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to refuse 2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for -details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at `examples/client/src/dualEraStdioClient.ts`. +details). A runnable example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.ts`. ## Server instructions Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions. -```ts source="../examples/server/src/serverGuide.examples.ts#instructions_basic" +```ts source="../examples/guides/serverGuide.examples.ts#instructions_basic" const server = new McpServer( { name: 'db-server', version: '1.0.0' }, { @@ -101,7 +101,7 @@ Tools let clients invoke actions on your server — they are usually the main wa Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_basic" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_basic" server.registerTool( 'calculate-bmi', { @@ -137,7 +137,7 @@ server.registerTool( Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_resourceLink" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_resourceLink" server.registerTool( 'list-files', { @@ -168,7 +168,7 @@ server.registerTool( Tools can include annotations that hint at their behavior — whether a tool is read-only, destructive, or idempotent. Annotations help clients present tools appropriately without changing execution semantics: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_annotations" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_annotations" server.registerTool( 'delete-file', { @@ -191,7 +191,7 @@ server.registerTool( Return `isError: true` to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_errorHandling" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_errorHandling" server.registerTool( 'fetch-data', { @@ -227,7 +227,7 @@ Resources expose read-only data — files, database schemas, configuration — t A static resource at a fixed URI: -```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_static" +```ts source="../examples/guides/serverGuide.examples.ts#registerResource_static" server.registerResource( 'config', 'config://app', @@ -244,7 +244,7 @@ server.registerResource( Dynamic resources use {@linkcode @modelcontextprotocol/server!server/mcp.ResourceTemplate | ResourceTemplate} with URI patterns. The `list` callback lets clients discover available instances: -```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_template" +```ts source="../examples/guides/serverGuide.examples.ts#registerResource_template" server.registerResource( 'user-profile', new ResourceTemplate('user://{userId}/profile', { @@ -278,7 +278,7 @@ server.registerResource( Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it. -```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_basic" +```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_basic" server.registerPrompt( 'review-code', { @@ -306,7 +306,7 @@ server.registerPrompt( Both prompts and resources can support argument completions. Wrap a field in the `argsSchema` with {@linkcode @modelcontextprotocol/server!server/completable.completable | completable()} to provide autocompletion suggestions: -```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_completion" +```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_completion" server.registerPrompt( 'review-code', { @@ -341,13 +341,13 @@ Logging lets your server send structured diagnostics — debug traces, progress Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler: -```ts source="../examples/server/src/serverGuide.examples.ts#logging_capability" +```ts source="../examples/guides/serverGuide.examples.ts#logging_capability" const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); ``` Then log from any handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_logging" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_logging" server.registerTool( 'fetch-data', { @@ -370,7 +370,7 @@ Progress notifications let a tool report incremental status updates during long- If the client includes a `progressToken` in the request `_meta`, send `notifications/progress` via `ctx.mcpReq.notify()` (from {@linkcode @modelcontextprotocol/server!index.BaseContext | BaseContext}): -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_progress" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_progress" server.registerTool( 'process-files', { @@ -409,7 +409,7 @@ The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelco Read the caller's trace context from `ctx.mcpReq._meta` in a handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_traceContext" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_traceContext" server.registerTool( 'traced-operation', { @@ -444,7 +444,7 @@ Sampling lets a tool handler request an LLM completion from the connected client Call `ctx.mcpReq.requestSampling(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_sampling" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_sampling" server.registerTool( 'summarize', { @@ -476,7 +476,7 @@ server.registerTool( ); ``` -For a full runnable example, see [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts). +For a full runnable example, see [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sampling/server.ts). ### Elicitation @@ -490,7 +490,7 @@ Elicitation lets a tool handler request direct input from the user — form fiel Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_elicitation" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_elicitation" server.registerTool( 'collect-feedback', { @@ -530,7 +530,7 @@ server.registerTool( ); ``` -For runnable examples, see [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationFormExample.ts) (form) and [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationUrlExample.ts) (URL). +For runnable examples, see [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation-form/server.ts) (form) and [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/elicitationUrlServer.ts) (URL). ### Roots @@ -539,7 +539,7 @@ For runnable examples, see [`elicitationFormExample.ts`](https://github.com/mode Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_roots" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_roots" server.registerTool( 'list-workspace-files', { @@ -558,7 +558,7 @@ server.registerTool( For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: -```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_statefulHttp" +```ts source="../examples/guides/serverGuide.examples.ts#shutdown_statefulHttp" // Capture the http.Server so it can be closed on shutdown const httpServer = app.listen(3000); @@ -578,14 +578,14 @@ Calling {@linkcode @modelcontextprotocol/server!index.Transport#close | transpor For stdio servers, {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#close | server.close()} is sufficient: -```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_stdio" +```ts source="../examples/guides/serverGuide.examples.ts#shutdown_stdio" process.on('SIGINT', async () => { await server.close(); process.exit(0); }); ``` -For a complete multi-session server with shutdown handling, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +For a complete multi-session server with shutdown handling, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts). ## Deployment @@ -595,7 +595,7 @@ Under normal circumstances, cross-origin browser restrictions limit what a malic The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from `@modelcontextprotocol/hono`), which enable Host header validation by default: -```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_basic" +```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_basic" // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) const app = createMcpExpressApp(); @@ -608,7 +608,7 @@ const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: -```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_allowedHosts" +```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_allowedHosts" const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] @@ -635,8 +635,8 @@ If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP frame | Feature | Description | Example | |---------|-------------|---------| -| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`honoWebStandardStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/honoWebStandardStreamableHttp.ts) | -| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | -| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/inMemoryEventStore.ts) | -| CORS | Expose MCP headers for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/inMemoryEventStore.ts) | +| CORS | Expose MCP headers for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts) | | Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | diff --git a/typedoc.config.mjs b/typedoc.config.mjs index f2a4e50f56..675e3656b3 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -32,7 +32,7 @@ export default { exclude: ['**/*.examples.ts'] }, highlightLanguages: [...OptionDefaults.highlightLanguages, 'powershell'], - projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/server/README.md', 'examples/client/README.md'], + projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/README.md'], hostedBaseUrl: 'https://ts.sdk.modelcontextprotocol.io/v2/', navigationLinks: { 'V1 Docs': '/' From 400c934a87bb792525d44729fba6cd629fcafb47 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:23:04 +0000 Subject: [PATCH 08/27] docs(examples): restore the subscriptions/listen story stacked on the listen client One factory, both transports: over HTTP the example publishes via the handler's ServerNotifier (handler.notify.toolsChanged()) on the cross-request ServerEventBus; over stdio it toggles a RegisteredTool on the pinned instance and the entry's listen router fans the instance's tools/list_changed onto every open subscription. The client drives both the auto-opened stream (ClientOptions.listChanged) and a manual client.listen() / McpSubscription, calling a flip_tools tool to mutate on demand so the harness has no timer race. --- examples/subscriptions/README.md | 16 +++++ examples/subscriptions/client.ts | 75 +++++++++++++++++++++++ examples/subscriptions/manifest.json | 4 ++ examples/subscriptions/server.ts | 89 ++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 examples/subscriptions/README.md create mode 100644 examples/subscriptions/client.ts create mode 100644 examples/subscriptions/manifest.json create mode 100644 examples/subscriptions/server.ts diff --git a/examples/subscriptions/README.md b/examples/subscriptions/README.md new file mode 100644 index 0000000000..c7e9d5ce3c --- /dev/null +++ b/examples/subscriptions/README.md @@ -0,0 +1,16 @@ +# subscriptions + +`subscriptions/listen` change-notification streams (protocol revision 2026-07-28). The server publishes `tools/list_changed`; the client receives it both via the auto-opened stream (`ClientOptions.listChanged`, the same option a 2025-era client sets) and a manual +`client.listen()` call. + +The publish surface differs by entry: over HTTP (`createMcpHandler`) the example calls `handler.notify.toolsChanged()` on the cross-request `ServerEventBus`; over stdio (`serveStdio`) it toggles a `RegisteredTool` on the pinned instance, whose `tools/list_changed` the entry's +listen router fans onto every open subscription. + +```bash +# stdio (the client spawns the server itself): +pnpm tsx examples/subscriptions/client.ts + +# Streamable HTTP (two terminals): +pnpm tsx examples/subscriptions/server.ts --http --port 3000 +pnpm tsx examples/subscriptions/client.ts --http http://127.0.0.1:3000/ +``` diff --git a/examples/subscriptions/client.ts b/examples/subscriptions/client.ts new file mode 100644 index 0000000000..db195f039c --- /dev/null +++ b/examples/subscriptions/client.ts @@ -0,0 +1,75 @@ +/** + * Drives the `subscriptions/listen` server (`./server.ts`) two ways on a + * 2026-07-28 connection: + * + * 1. **auto-open via `ClientOptions.listChanged`** — the same option a + * 2025-era client sets; on a modern connection the SDK auto-opens a + * listen stream with the filter derived from which sub-options were set, + * so the configured `onChanged` handlers fire on every published change; + * 2. **manual `client.listen()`** — opens a stream explicitly, registers a + * `notifications/tools/list_changed` handler the stream feeds, and closes + * after a few notifications. + * + * The example calls `flip_tools` to mutate the server's tool set on demand + * (rather than a timer), then asserts the change notification arrived. + */ +import type { McpSubscription } from '@modelcontextprotocol/client'; + +import { check, connectFromArgs, runClient } from '../harness.js'; + +/** Wait until `pred()` is true or `timeoutMs` elapses. */ +async function until(pred: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!pred()) { + if (Date.now() > deadline) throw new Error('timed out waiting for change notification'); + await new Promise(r => setTimeout(r, 25)); + } +} + +async function autoOpenLeg(): Promise { + let count = 0; + const client = await connectFromArgs(import.meta.dirname, { + listChanged: { + tools: { + autoRefresh: false, + // The default debounce coalesces bursts; this example asserts + // raw delivery, so disable it. + debounceMs: 0, + onChanged: () => void count++ + } + } + }); + check.ok(client.autoOpenedSubscription, 'a listChanged option should auto-open a subscription on a modern connection'); + check.ok(client.autoOpenedSubscription?.honoredFilter.toolsListChanged, 'auto-opened filter should include toolsListChanged'); + + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 1); + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 2); + + await client.autoOpenedSubscription?.close(); + await client.close(); + check.ok(count >= 2, 'auto-open leg should receive at least two tools/list_changed'); +} + +async function manualLeg(): Promise { + const client = await connectFromArgs(import.meta.dirname); + let count = 0; + client.setNotificationHandler('notifications/tools/list_changed', () => void count++); + const sub: McpSubscription = await client.listen({ toolsListChanged: true }); + check.ok(sub.honoredFilter.toolsListChanged, 'manual listen should honor toolsListChanged'); + + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 1); + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 2); + + await sub.close(); + await client.close(); + check.ok(count >= 2, 'manual leg should receive at least two tools/list_changed'); +} + +runClient('subscriptions', async () => { + await autoOpenLeg(); + await manualLeg(); +}); diff --git a/examples/subscriptions/manifest.json b/examples/subscriptions/manifest.json new file mode 100644 index 0000000000..d56bf3f53e --- /dev/null +++ b/examples/subscriptions/manifest.json @@ -0,0 +1,4 @@ +{ + "era": "modern", + "//": "subscriptions/listen is a 2026-07-28 protocol feature." +} diff --git a/examples/subscriptions/server.ts b/examples/subscriptions/server.ts new file mode 100644 index 0000000000..fa85321421 --- /dev/null +++ b/examples/subscriptions/server.ts @@ -0,0 +1,89 @@ +/** + * `subscriptions/listen` change notifications (protocol revision 2026-07-28). + * + * One factory, either transport — but the publish surface differs by entry: + * + * - **HTTP** (`createMcpHandler`): the handler exposes `.notify` + * ({@link ServerNotifier}) over its cross-request {@link ServerEventBus}; + * `handler.notify.toolsChanged()` reaches every open `subscriptions/listen` + * stream that opted in to `toolsListChanged`. + * - **stdio** (`serveStdio`): one `McpServer` instance is pinned for the + * connection; toggling a `RegisteredTool` (`.enable()/.disable()`) emits the + * instance's own `notifications/tools/list_changed`, which the stdio entry's + * listen router fans onto every open subscription. + * + * The `flip_tools` tool toggles the `farewell` tool and publishes the change, + * so the client decides when to mutate (no timer race in the harness). The + * shared `runServerFromArgs` scaffold doesn't expose the handler/instance, so + * this example branches on `--http` itself (same flags, same factory). + */ +import { createServer } from 'node:http'; + +import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +let extraToolEnabled = false; +/** + * Publishes `tools/list_changed` to every open subscription. Assigned by the + * transport branch below: `handler.notify.toolsChanged()` over HTTP; toggling + * the pinned instance's `RegisteredTool` over stdio. + */ +let publish: () => void = () => {}; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'subscriptions-listen-example', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } } + ); + + server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `hello, ${name}` }] + })); + const farewell: RegisteredTool = server.registerTool( + 'farewell', + { description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] }) + ); + if (!extraToolEnabled) farewell.disable(); + + server.registerTool( + 'flip_tools', + { description: 'Toggle the farewell tool and publish tools/list_changed to every open subscription' }, + async () => { + extraToolEnabled = !extraToolEnabled; + // Over stdio this `update` IS the publish (the entry's listen + // router fans the instance's outbound list_changed onto every open + // subscription); over HTTP it just keeps this per-request instance + // consistent and `publish()` reaches the cross-request bus. + farewell.update({ enabled: extraToolEnabled }); + publish(); + return { content: [{ type: 'text', text: `farewell ${extraToolEnabled ? 'enabled' : 'disabled'}` }] }; + } + ); + + return server; +} + +const argv = process.argv.slice(2); +if (argv.includes('--http')) { + const portIdx = argv.indexOf('--port'); + const port = portIdx === -1 ? Number(process.env.PORT ?? 3000) : Number(argv[portIdx + 1]); + // Host with the per-request HTTP entry on its default posture. The handler + // creates an in-process bus by default; supply your own `bus` for + // multi-process deployments. + const handler = createMcpHandler(buildServer); + const bus: ServerEventBus = handler.bus; + const notify: ServerNotifier = handler.notify; + void bus; // (the typed publish facade `notify` wraps `bus.publish`) + publish = () => notify.toolsChanged(); + createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`); + }); +} else { + // Over stdio the per-instance `farewell.update` inside `flip_tools` IS the + // publish, so `publish` stays a no-op here. + serveStdio(buildServer); + console.error('[server] serving over stdio'); +} From 3126794f367b1c4ab65a15814781c6882694e555 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:23:13 +0000 Subject: [PATCH 09/27] =?UTF-8?q?test(examples):=20run=20every=20story=20o?= =?UTF-8?q?ver=20the=20transport=20=C3=97=20era=20matrix=20it=20supports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness now runs each story over {stdio, http} × {modern, legacy}. The shared connectFromArgs reads --legacy from argv (versionNegotiation {mode:'legacy'} vs {mode:'auto'}); a per-story manifest.json era pin opts era-specific stories out of the leg they cannot serve. Dual-transport conversions: - parallel-calls: one factory via runServerFromArgs / connectFromArgs per client (was http-only). - stickynotes: http leg exercises add/list/read/remove and skips the push-elicitation-confirmed remove_all (no return path on per-request HTTP); stdio leg keeps the full flow. - subscriptions: dual per the previous commit. Era pins: - modern-only: mrtr, subscriptions, caching (2026-07-28 features); dual-era, legacy-routing, stateless-legacy, json-response, hono, bearer-auth (drive both eras themselves or do not use connectFromArgs). - legacy-only: elicitation-form, sampling, stickynotes (push-style server→client requests need a 2025 initialize handshake to advertise the capability and a long-lived bidirectional connection); custom-methods, custom-version (about the 2025 handshake / no envelope semantics). elicitation-form and sampling stay stdio-only: createMcpHandler's per-request/stateless posture has neither a durable client-capability record nor a return path for the elicitation/sampling response. Also names the Examples workflow. --- .github/workflows/examples.yml | 4 +- examples/README.md | 5 ++- examples/bearer-auth/manifest.json | 4 +- examples/caching/manifest.json | 4 ++ examples/custom-methods/manifest.json | 4 ++ examples/custom-version/manifest.json | 4 ++ examples/dual-era/manifest.json | 4 ++ examples/elicitation-form/client.ts | 10 ++--- examples/elicitation-form/manifest.json | 4 +- examples/harness.ts | 29 +++++++++---- examples/hono/manifest.json | 4 +- examples/json-response/manifest.json | 4 +- examples/legacy-routing/manifest.json | 4 +- examples/mrtr/manifest.json | 4 ++ examples/parallel-calls/README.md | 2 +- examples/parallel-calls/client.ts | 21 ++++----- examples/parallel-calls/manifest.json | 3 -- examples/parallel-calls/server.ts | 24 +++++------ examples/sampling/client.ts | 6 +-- examples/sampling/manifest.json | 4 +- examples/stateless-legacy/manifest.json | 4 +- examples/stickynotes/README.md | 7 +-- examples/stickynotes/client.ts | 26 +++++++---- examples/stickynotes/manifest.json | 3 +- scripts/run-examples.ts | 57 ++++++++++++++----------- 25 files changed, 151 insertions(+), 94 deletions(-) create mode 100644 examples/caching/manifest.json create mode 100644 examples/custom-methods/manifest.json create mode 100644 examples/custom-version/manifest.json create mode 100644 examples/dual-era/manifest.json create mode 100644 examples/mrtr/manifest.json delete mode 100644 examples/parallel-calls/manifest.json diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index e7e912c691..8fbb86063a 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -1,3 +1,5 @@ +name: Examples + on: push: branches: @@ -38,5 +40,5 @@ jobs: # (the gap that killed an earlier examples smoke suite). - run: pnpm run build:all - - name: Run all example pairs (stdio + http) + - name: Run all example pairs (transport × era) run: pnpm tsx scripts/run-examples.ts diff --git a/examples/README.md b/examples/README.md index 28b43c1496..216e791c85 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,15 +28,16 @@ pnpm tsx examples//client.ts --http http://127.0.0.1:3000/ | Story | What it teaches | Transports | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | | [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | | [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | | [`elicitation-form/`](./elicitation-form/README.md) | Form-mode elicitation (server requests user input) | stdio | | [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio | -| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | | [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | | [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | | [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | | [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | -| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | http | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | | [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | | [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | diff --git a/examples/bearer-auth/manifest.json b/examples/bearer-auth/manifest.json index 59c1309570..4f39904d89 100644 --- a/examples/bearer-auth/manifest.json +++ b/examples/bearer-auth/manifest.json @@ -1,4 +1,6 @@ { "transport": "http", - "path": "/mcp" + "path": "/mcp", + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." } diff --git a/examples/caching/manifest.json b/examples/caching/manifest.json new file mode 100644 index 0000000000..8073fec288 --- /dev/null +++ b/examples/caching/manifest.json @@ -0,0 +1,4 @@ +{ + "era": "modern", + "//": "cacheHints (ttlMs / cacheScope on cacheable results) are emitted only toward 2026-era clients." +} diff --git a/examples/custom-methods/manifest.json b/examples/custom-methods/manifest.json new file mode 100644 index 0000000000..256a72db00 --- /dev/null +++ b/examples/custom-methods/manifest.json @@ -0,0 +1,4 @@ +{ + "era": "legacy", + "//": "Custom methods carry no envelope semantics; the example connects as a plain 2025 client so the request reaches setRequestHandler exactly as a hand-wired client would." +} diff --git a/examples/custom-version/manifest.json b/examples/custom-version/manifest.json new file mode 100644 index 0000000000..3f1b00b32c --- /dev/null +++ b/examples/custom-version/manifest.json @@ -0,0 +1,4 @@ +{ + "era": "legacy", + "//": "supportedProtocolVersions / version negotiation is the 2025 initialize handshake; the modern era is its own negotiation story (../dual-era/)." +} diff --git a/examples/dual-era/manifest.json b/examples/dual-era/manifest.json new file mode 100644 index 0000000000..947ebb5b1b --- /dev/null +++ b/examples/dual-era/manifest.json @@ -0,0 +1,4 @@ +{ + "era": "modern", + "//": "The story body drives BOTH eras itself (legacy via versionNegotiation: undefined, modern via the harness default); pinned so the harness runs it once per transport." +} diff --git a/examples/elicitation-form/client.ts b/examples/elicitation-form/client.ts index 05a8fb7fef..5fc5459351 100644 --- a/examples/elicitation-form/client.ts +++ b/examples/elicitation-form/client.ts @@ -6,12 +6,10 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('elicitation-form', async () => { // Push-style elicitation is the 2025-era flow (the 2026-07-28 revision uses - // multi-round-trip `inputRequired` instead — see ../mrtr/). Connect as a - // plain 2025 client so `ctx.mcpReq.elicitInput` reaches this handler. - const client = await connectFromArgs(import.meta.dirname, { - versionNegotiation: undefined, - capabilities: { elicitation: { form: {} } } - }); + // multi-round-trip `inputRequired` instead — see ../mrtr/). The harness + // pins this story to the legacy era so `ctx.mcpReq.elicitInput` reaches + // this handler. + const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); let action: 'accept' | 'decline' = 'accept'; client.setRequestHandler('elicitation/create', async request => { diff --git a/examples/elicitation-form/manifest.json b/examples/elicitation-form/manifest.json index 5299ca7150..3baf5ed76f 100644 --- a/examples/elicitation-form/manifest.json +++ b/examples/elicitation-form/manifest.json @@ -1,3 +1,5 @@ { - "transport": "stdio" + "transport": "stdio", + "era": "legacy", + "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the elicitation capability; createMcpHandler's per-request/stateless posture has neither. The 2026-07-28 path is the multi-round-trip story (../mrtr/)." } diff --git a/examples/harness.ts b/examples/harness.ts index 726978f777..b25dd18d76 100644 --- a/examples/harness.ts +++ b/examples/harness.ts @@ -5,10 +5,12 @@ * calls {@linkcode runServerFromArgs} so one binary serves stdio (default) or * HTTP under `--http --port `; its `client.ts` calls * {@linkcode connectFromArgs} so one binary spawns the sibling server over - * stdio (default) or connects to a running endpoint under `--http `. The - * client's body is wrapped in {@linkcode runClient} so any thrown assertion - * exits non-zero with a `FAIL:` line, making each example a self-verifying e2e - * test that `scripts/run-examples.ts` can iterate. + * stdio (default) or connects to a running endpoint under `--http `, and + * negotiates the modern (2026-07-28) era by default or the 2025 `initialize` + * handshake under `--legacy`. The client's body is wrapped in + * {@linkcode runClient} so any thrown assertion exits non-zero with a `FAIL:` + * line, making each example a self-verifying e2e test that + * `scripts/run-examples.ts` can iterate over the transport × era matrix. * * Re-exported `check` is `node:assert/strict` for readable inline assertions. */ @@ -68,16 +70,19 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000) * via Streamable HTTP; otherwise it spawns the sibling `server.ts` (resolved * relative to the calling client's `import.meta.dirname`) via stdio. * - * The client defaults to `versionNegotiation: { mode: 'auto' }` so the modern + * The protocol era is selected from `process.argv` too: under `--legacy` the + * client uses `versionNegotiation: { mode: 'legacy' }` (the plain 2025 + * `initialize` handshake); otherwise `{ mode: 'auto' }` so the * `server/discover` probe negotiates the 2026-07-28 revision against either * transport without per-story envelope plumbing. Pass - * `options.versionNegotiation` explicitly to opt out for legacy-only stories. + * `options.versionNegotiation` explicitly to opt out (for stories that drive + * both eras within one body). */ export async function connectFromArgs(siblingDir: string, options: ClientOptions = {}): Promise { const argv = process.argv.slice(2); const client = new Client( { name: `${path.basename(siblingDir)}-example-client`, version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, ...options } + { versionNegotiation: { mode: argv.includes('--legacy') ? 'legacy' : 'auto' }, ...options } ); const httpIdx = argv.indexOf('--http'); if (httpIdx === -1) { @@ -95,6 +100,11 @@ export function transportLeg(): 'stdio' | 'http' { return process.argv.includes('--http') ? 'http' : 'stdio'; } +/** Protocol-era leg the client is running on this invocation. */ +export function eraLeg(): 'modern' | 'legacy' { + return process.argv.includes('--legacy') ? 'legacy' : 'modern'; +} + /** * Run a self-verifying client scenario. Any thrown error (including * `node:assert/strict` failures) prints a `FAIL:` line to stderr and exits @@ -103,13 +113,14 @@ export function transportLeg(): 'stdio' | 'http' { */ export function runClient(name: string, scenario: () => Promise): void { void (async () => { + const leg = `${transportLeg()}/${eraLeg()}`; try { await scenario(); - console.log(`OK: ${name} (${transportLeg()})`); + console.log(`OK: ${name} (${leg})`); process.exit(0); } catch (error) { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - console.error(`FAIL: ${name} (${transportLeg()}): ${message}`); + console.error(`FAIL: ${name} (${leg}): ${message}`); process.exit(1); } })(); diff --git a/examples/hono/manifest.json b/examples/hono/manifest.json index 59c1309570..4f39904d89 100644 --- a/examples/hono/manifest.json +++ b/examples/hono/manifest.json @@ -1,4 +1,6 @@ { "transport": "http", - "path": "/mcp" + "path": "/mcp", + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." } diff --git a/examples/json-response/manifest.json b/examples/json-response/manifest.json index 376c70cacb..a3d7667351 100644 --- a/examples/json-response/manifest.json +++ b/examples/json-response/manifest.json @@ -1,3 +1,5 @@ { - "transport": "http" + "transport": "http", + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." } diff --git a/examples/legacy-routing/manifest.json b/examples/legacy-routing/manifest.json index 376c70cacb..a3d7667351 100644 --- a/examples/legacy-routing/manifest.json +++ b/examples/legacy-routing/manifest.json @@ -1,3 +1,5 @@ { - "transport": "http" + "transport": "http", + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." } diff --git a/examples/mrtr/manifest.json b/examples/mrtr/manifest.json new file mode 100644 index 0000000000..d4bb80639b --- /dev/null +++ b/examples/mrtr/manifest.json @@ -0,0 +1,4 @@ +{ + "era": "modern", + "//": "Multi-round-trip inputRequired is a 2026-07-28 protocol feature." +} diff --git a/examples/parallel-calls/README.md b/examples/parallel-calls/README.md index b1d36682db..aed18429c1 100644 --- a/examples/parallel-calls/README.md +++ b/examples/parallel-calls/README.md @@ -2,4 +2,4 @@ Multiple clients connecting to one endpoint in parallel, and one client making parallel `callTool()` calls — with per-call logging notifications attributed back to their caller. -**HTTP-only**: parallel clients to one endpoint is the meaningful case. +Over HTTP every client connects to the one running endpoint; over stdio each client spawns its own server process (so the "one client / parallel calls" leg is the per-call attribution test on either transport). diff --git a/examples/parallel-calls/client.ts b/examples/parallel-calls/client.ts index 1fb7037290..5fee19dafe 100644 --- a/examples/parallel-calls/client.ts +++ b/examples/parallel-calls/client.ts @@ -2,27 +2,28 @@ * Two clients in parallel, each calling the notification-emitting tool, and * one client making two parallel tool calls — asserts every result returns * and that notifications were attributed back to the right caller. + * + * Over HTTP every client connects to the one running endpoint; over stdio + * each `connectFromArgs` spawns its own server process (so the + * "multiple clients" leg is per-process, while the "one client / parallel + * calls" leg exercises one server's per-call attribution either way). */ -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { Client } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, connectFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; - -async function makeClient(id: string): Promise<{ client: Client; notifications: string[] }> { - const client = new Client({ name: `parallel-${id}`, version: '1.0.0' }); +async function makeClient(): Promise<{ client: Client; notifications: string[] }> { + const client = await connectFromArgs(import.meta.dirname); const notifications: string[] = []; client.setNotificationHandler('notifications/message', n => { notifications.push(String(n.params.data)); }); - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); return { client, notifications }; } runClient('parallel-calls', async () => { // --- multiple clients, one call each --- - const [a, b] = await Promise.all([makeClient('A'), makeClient('B')]); + const [a, b] = await Promise.all([makeClient(), makeClient()]); const [ra, rb] = await Promise.all([ a.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'A', count: 3 } }), b.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'B', count: 3 } }) @@ -36,7 +37,7 @@ runClient('parallel-calls', async () => { await b.client.close(); // --- one client, parallel tool calls --- - const c = await makeClient('C'); + const c = await makeClient(); const results = await Promise.all([ c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C1', count: 2 } }), c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C2', count: 2 } }) diff --git a/examples/parallel-calls/manifest.json b/examples/parallel-calls/manifest.json deleted file mode 100644 index 376c70cacb..0000000000 --- a/examples/parallel-calls/manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "transport": "http" -} diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts index f8154a5c8e..23b24b6f1f 100644 --- a/examples/parallel-calls/server.ts +++ b/examples/parallel-calls/server.ts @@ -1,14 +1,15 @@ /** - * A small `createMcpHandler` server with one notification-emitting tool, used - * by the parallel-calls client to drive multiple concurrent clients / parallel - * tool calls and attribute notifications back to their caller. HTTP-only. + * One notification-emitting tool that the parallel-calls client drives with + * multiple concurrent clients (HTTP) or one client / multiple concurrent + * calls (both transports), asserting in-flight notifications are attributed + * back to the right caller. One binary, either transport. */ -import { createServer } from 'node:http'; - -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; -const handler = createMcpHandler(() => { +import { runServerFromArgs } from '../harness.js'; + +function buildServer(): McpServer { const server = new McpServer({ name: 'parallel-calls-example', version: '1.0.0' }, { capabilities: { logging: {} } }); server.registerTool( 'start-notification-stream', @@ -30,11 +31,6 @@ const handler = createMcpHandler(() => { } ); return server; -}); +} -const argv = process.argv.slice(2); -const portIdx = argv.indexOf('--port'); -const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); -createServer((req, res) => void handler.node(req, res)).listen(port, () => { - console.error(`parallel-calls example server listening on http://127.0.0.1:${port}/`); -}); +runServerFromArgs(buildServer); diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts index 443e2e655e..de632561c7 100644 --- a/examples/sampling/client.ts +++ b/examples/sampling/client.ts @@ -7,9 +7,9 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('sampling', async () => { // Push-style sampling is a 2025-era flow (and is deprecated as of - // 2026-07-28). Connect as a plain 2025 client so the server's - // `ctx.mcpReq.requestSampling` reaches this handler. - const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined, capabilities: { sampling: {} } }); + // 2026-07-28). The harness pins this story to the legacy era so the + // server's `ctx.mcpReq.requestSampling` reaches this handler. + const client = await connectFromArgs(import.meta.dirname, { capabilities: { sampling: {} } }); client.setRequestHandler('sampling/createMessage', async () => ({ role: 'assistant', content: { type: 'text', text: '[canned summary]' }, diff --git a/examples/sampling/manifest.json b/examples/sampling/manifest.json index 5299ca7150..803548e646 100644 --- a/examples/sampling/manifest.json +++ b/examples/sampling/manifest.json @@ -1,3 +1,5 @@ { - "transport": "stdio" + "transport": "stdio", + "era": "legacy", + "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the sampling capability; createMcpHandler's per-request/stateless posture has neither." } diff --git a/examples/stateless-legacy/manifest.json b/examples/stateless-legacy/manifest.json index 376c70cacb..a3d7667351 100644 --- a/examples/stateless-legacy/manifest.json +++ b/examples/stateless-legacy/manifest.json @@ -1,3 +1,5 @@ { - "transport": "http" + "transport": "http", + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." } diff --git a/examples/stickynotes/README.md b/examples/stickynotes/README.md index 23cc309d87..b759f1ae19 100644 --- a/examples/stickynotes/README.md +++ b/examples/stickynotes/README.md @@ -3,8 +3,5 @@ The "real app" capstone: a sticky-notes board where tools mutate state, each note is a resource, the resource list changes on add/remove, and a destructive `remove_all` blocks on a form-mode elicitation. The client adds, lists, reads, removes, and proves `remove_all` only clears the board on an explicit confirm. -**stdio-only** in the harness: the `remove_all` confirmation is a push server→client elicitation, which needs either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`). - -```bash -pnpm tsx examples/stickynotes/client.ts -``` +The harness runs both transports on the **legacy** era. The `remove_all` confirmation is a push server→client elicitation, which needs a long-lived bidirectional connection (stdio, or a sessionful HTTP transport — see `../legacy-routing/`); the http leg exercises the +add/list/read/remove path and skips the elicitation-confirmed clear. diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts index 37ef2cf8ca..59f1ac86da 100644 --- a/examples/stickynotes/client.ts +++ b/examples/stickynotes/client.ts @@ -4,7 +4,7 @@ * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board * is cleared only on an explicit confirmation. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, connectFromArgs, runClient, transportLeg } from '../harness.js'; interface AddResult { id: string; @@ -17,13 +17,10 @@ interface RemoveAllResult { runClient('stickynotes', async () => { // Push-style elicitation (the `remove_all` confirmation) is a 2025-era - // flow; connect as a plain 2025 client so `ctx.mcpReq.elicitInput` reaches - // this handler (the 2026-07-28 path uses multi-round-trip `inputRequired` - // instead — see ../mrtr/). - const client = await connectFromArgs(import.meta.dirname, { - versionNegotiation: undefined, - capabilities: { elicitation: { form: {} } } - }); + // flow; the harness pins this story to the legacy era so + // `ctx.mcpReq.elicitInput` reaches this handler (the 2026-07-28 path uses + // multi-round-trip `inputRequired` instead — see ../mrtr/). + const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; client.setRequestHandler('elicitation/create', async () => { if (elicitAnswer === 'cancel') return { action: 'cancel' }; @@ -52,6 +49,19 @@ runClient('stickynotes', async () => { const after = await client.listResources(); check.ok(!after.resources.some(r => r.uri === firstNote.uri)); + // The elicitation-confirmed `remove_all` path is stdio-only: push-style + // server→client requests need a long-lived bidirectional connection; + // `createMcpHandler`'s per-request/stateless posture has neither a durable + // client-capability record nor a return path for the elicitation response. + if (transportLeg() === 'http') { + const removedSecond = await client.callTool({ name: 'remove_note', arguments: { id: secondNote.id } }); + check.equal((removedSecond.structuredContent as { removed?: boolean } | undefined)?.removed, true); + const afterClear = await client.listResources(); + check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); + await client.close(); + return; + } + // CANCEL — board untouched. elicitAnswer = 'cancel'; const cancelled = await client.callTool({ name: 'remove_all' }); diff --git a/examples/stickynotes/manifest.json b/examples/stickynotes/manifest.json index 5299ca7150..0042dc3783 100644 --- a/examples/stickynotes/manifest.json +++ b/examples/stickynotes/manifest.json @@ -1,3 +1,4 @@ { - "transport": "stdio" + "era": "legacy", + "//": "The elicitation-confirmed remove_all path needs a 2025 initialize handshake to advertise the elicitation capability; the http leg additionally skips that path (no return path on per-request HTTP)." } diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts index 5bd5e8a72c..0bb0619526 100644 --- a/scripts/run-examples.ts +++ b/scripts/run-examples.ts @@ -1,14 +1,18 @@ #!/usr/bin/env tsx /** - * Build-and-e2e-run every story under `examples/` over every transport it - * supports. Each story's `client.ts` is a self-verifying e2e test (it asserts - * the server's behaviour and exits non-zero on any mismatch). + * Build-and-e2e-run every story under `examples/` over every transport × era + * leg it supports. Each story's `client.ts` is a self-verifying e2e test (it + * asserts the server's behaviour and exits non-zero on any mismatch). * * - **stdio** (default for dual-transport stories): run `client.ts` with no * transport flag; it spawns the sibling server binary itself and speaks * MCP over the pipe. * - **HTTP**: start `server.ts --http --port

`, poll until ready, run * `client.ts --http http://127.0.0.1:

/`, kill the server. + * - **modern** (default): the client negotiates the 2026-07-28 era + * (`versionNegotiation: { mode: 'auto' }`). + * - **legacy**: pass `--legacy` to the client so it uses the 2025 + * `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`). * * A per-directory `manifest.json` overrides defaults — most stories have none. * `excluded` stories are listed (with their reason) but not run. Stories @@ -19,16 +23,20 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { connect } from 'node:net'; import { join, resolve } from 'node:path'; +type Era = 'modern' | 'legacy'; + interface Manifest { /** `'dual'` (stdio + http; the default), `'http'`, or `'stdio'`. */ transport?: 'dual' | 'http' | 'stdio'; + /** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */ + era?: 'dual' | Era; /** HTTP port (default: a per-story port assigned below). */ port?: number; /** Endpoint path (default: `'/'`). */ path?: string; /** Extra environment for the server process. */ env?: Record; - /** Per-transport timeout in milliseconds (default: 30000). */ + /** Per-leg timeout in milliseconds (default: 30000). */ timeoutMs?: number; /** Optional substring the client's stdout must contain. */ expects?: { stdout?: string }; @@ -101,24 +109,26 @@ async function waitForPort(port: number, timeoutMs: number): Promise { interface LegResult { story: string; - leg: 'stdio' | 'http'; + leg: string; ok: boolean; detail: string; } -async function runStdioLeg(story: string, dir: string, manifest: Manifest): Promise { +const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []); + +async function runStdioLeg(story: string, dir: string, manifest: Manifest, era: Era): Promise { const timeoutMs = manifest.timeoutMs ?? 30_000; - const result = await run(TSX, [join(dir, 'client.ts')], { cwd: ROOT, timeoutMs }); + const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs }); const ok = result.code === 0 && (!manifest.expects?.stdout || result.stdout.includes(manifest.expects.stdout)); return { story, - leg: 'stdio', + leg: `stdio/${era}`, ok, detail: ok ? (result.stdout.trim().split('\n').pop() ?? '') : `exit ${result.code}\n${result.stderr || result.stdout}` }; } -async function runHttpLeg(story: string, dir: string, manifest: Manifest): Promise { +async function runHttpLeg(story: string, dir: string, manifest: Manifest, era: Era): Promise { const timeoutMs = manifest.timeoutMs ?? 30_000; const port = assignPort(story, manifest); const path = manifest.path ?? '/'; @@ -133,13 +143,13 @@ async function runHttpLeg(story: string, dir: string, manifest: Manifest): Promi try { const ready = await waitForPort(port, 15_000); if (!ready) { - return { story, leg: 'http', ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` }; + return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` }; } - const result = await run(TSX, [join(dir, 'client.ts'), '--http', url], { cwd: ROOT, timeoutMs }); + const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs }); const ok = result.code === 0 && (!manifest.expects?.stdout || result.stdout.includes(manifest.expects.stdout)); return { story, - leg: 'http', + leg: `http/${era}`, ok, detail: ok ? (result.stdout.trim().split('\n').pop() ?? '') @@ -171,18 +181,17 @@ async function main(): Promise { continue; } const transport = manifest.transport ?? 'dual'; - console.log(`\n::group::example ${story} (${transport})`); - if (transport === 'stdio' || transport === 'dual') { - const r = await runStdioLeg(story, dir, manifest); - results.push(r); - console.log(`[stdio] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); - if (!r.ok) console.log(r.detail); - } - if (transport === 'http' || transport === 'dual') { - const r = await runHttpLeg(story, dir, manifest); - results.push(r); - console.log(`[http] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); - if (!r.ok) console.log(r.detail); + const era = manifest.era ?? 'dual'; + const transports: Array<'stdio' | 'http'> = transport === 'dual' ? ['stdio', 'http'] : [transport]; + const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era]; + console.log(`\n::group::example ${story} (${transport} × ${era})`); + for (const t of transports) { + for (const e of eras) { + const r = t === 'stdio' ? await runStdioLeg(story, dir, manifest, e) : await runHttpLeg(story, dir, manifest, e); + results.push(r); + console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); + if (!r.ok) console.log(r.detail); + } } console.log('::endgroup::'); } From bf7bdf22e3c29aadc4ea08f2aa23d51b8f22f478 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:39:00 +0000 Subject: [PATCH 10/27] chore(examples): per-story workspace packages; harness reads package.json#example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each `examples//` is now its own private `@mcp-examples/` pnpm workspace package with `server`/`client` scripts and only the dependencies that story actually imports. The harness's per-story config moves from `manifest.json` (deleted) to a `package.json#example` field (`transports`, `era`, `path`, `excluded`, …) and the runner reads it from there. `examples/shared` is renamed `@mcp-examples/shared` and gains an `exports` entry so story packages can import it under tsx. The parent `@modelcontextprotocol/examples` package is kept (owns `harness.ts`, the typecheck/lint scripts, and `guides/`). Changeset `ignore` uses an `@mcp-examples/*` glob. --- .changeset/config.json | 2 +- .changeset/pre.json | 1 - examples/bearer-auth/manifest.json | 6 - examples/bearer-auth/package.json | 26 ++ examples/caching/manifest.json | 4 - examples/caching/package.json | 19 + examples/custom-methods/manifest.json | 4 - examples/custom-methods/package.json | 20 + examples/custom-version/manifest.json | 4 - examples/custom-version/package.json | 19 + examples/dual-era/manifest.json | 4 - examples/dual-era/package.json | 20 + examples/elicitation-form/manifest.json | 5 - examples/elicitation-form/package.json | 22 ++ examples/hono/manifest.json | 6 - examples/hono/package.json | 27 ++ examples/json-response/manifest.json | 5 - examples/json-response/package.json | 24 ++ examples/legacy-routing/manifest.json | 5 - examples/legacy-routing/package.json | 27 ++ examples/mrtr/manifest.json | 4 - examples/mrtr/package.json | 21 + examples/oauth/elicitationUrlServer.ts | 2 +- examples/oauth/manifest.json | 3 - examples/oauth/package.json | 23 ++ examples/oauth/simpleStreamableHttpServer.ts | 2 +- examples/package.json | 2 +- examples/parallel-calls/package.json | 17 + examples/prompts/package.json | 16 + examples/resources/package.json | 15 + examples/sampling/manifest.json | 5 - examples/sampling/package.json | 23 ++ examples/schema-validators/package.json | 19 + examples/shared/package.json | 5 +- examples/sse-polling/manifest.json | 4 - examples/sse-polling/package.json | 26 ++ examples/standalone-get/manifest.json | 5 - examples/standalone-get/package.json | 26 ++ examples/stateless-legacy/manifest.json | 5 - examples/stateless-legacy/package.json | 24 ++ examples/stickynotes/manifest.json | 4 - examples/stickynotes/package.json | 20 + examples/streaming/package.json | 16 + examples/subscriptions/manifest.json | 4 - examples/subscriptions/package.json | 21 + examples/tools/package.json | 16 + examples/tsconfig.json | 2 +- pnpm-lock.yaml | 389 ++++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/run-examples.ts | 60 +-- 50 files changed, 914 insertions(+), 116 deletions(-) delete mode 100644 examples/bearer-auth/manifest.json create mode 100644 examples/bearer-auth/package.json delete mode 100644 examples/caching/manifest.json create mode 100644 examples/caching/package.json delete mode 100644 examples/custom-methods/manifest.json create mode 100644 examples/custom-methods/package.json delete mode 100644 examples/custom-version/manifest.json create mode 100644 examples/custom-version/package.json delete mode 100644 examples/dual-era/manifest.json create mode 100644 examples/dual-era/package.json delete mode 100644 examples/elicitation-form/manifest.json create mode 100644 examples/elicitation-form/package.json delete mode 100644 examples/hono/manifest.json create mode 100644 examples/hono/package.json delete mode 100644 examples/json-response/manifest.json create mode 100644 examples/json-response/package.json delete mode 100644 examples/legacy-routing/manifest.json create mode 100644 examples/legacy-routing/package.json delete mode 100644 examples/mrtr/manifest.json create mode 100644 examples/mrtr/package.json delete mode 100644 examples/oauth/manifest.json create mode 100644 examples/oauth/package.json create mode 100644 examples/parallel-calls/package.json create mode 100644 examples/prompts/package.json create mode 100644 examples/resources/package.json delete mode 100644 examples/sampling/manifest.json create mode 100644 examples/sampling/package.json create mode 100644 examples/schema-validators/package.json delete mode 100644 examples/sse-polling/manifest.json create mode 100644 examples/sse-polling/package.json delete mode 100644 examples/standalone-get/manifest.json create mode 100644 examples/standalone-get/package.json delete mode 100644 examples/stateless-legacy/manifest.json create mode 100644 examples/stateless-legacy/package.json delete mode 100644 examples/stickynotes/manifest.json create mode 100644 examples/stickynotes/package.json create mode 100644 examples/streaming/package.json delete mode 100644 examples/subscriptions/manifest.json create mode 100644 examples/subscriptions/package.json create mode 100644 examples/tools/package.json diff --git a/.changeset/config.json b/.changeset/config.json index b2d84e7038..6821c8c0ce 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -11,6 +11,6 @@ "@modelcontextprotocol/examples", "@modelcontextprotocol/examples-client-quickstart", "@modelcontextprotocol/examples-server-quickstart", - "@modelcontextprotocol/examples-shared" + "@mcp-examples/*" ] } diff --git a/.changeset/pre.json b/.changeset/pre.json index b7e0b8e055..0fa4e3b738 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -8,7 +8,6 @@ "@modelcontextprotocol/examples": "2.0.0-alpha.0", "@modelcontextprotocol/examples-client-quickstart": "2.0.0-alpha.0", "@modelcontextprotocol/examples-server-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-shared": "2.0.0-alpha.0", "@modelcontextprotocol/client": "2.0.0-alpha.0", "@modelcontextprotocol/core": "2.0.0-alpha.0", "@modelcontextprotocol/express": "2.0.0-alpha.0", diff --git a/examples/bearer-auth/manifest.json b/examples/bearer-auth/manifest.json deleted file mode 100644 index 4f39904d89..0000000000 --- a/examples/bearer-auth/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "transport": "http", - "path": "/mcp", - "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." -} diff --git a/examples/bearer-auth/package.json b/examples/bearer-auth/package.json new file mode 100644 index 0000000000..d6b48aa1bc --- /dev/null +++ b/examples/bearer-auth/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/bearer-auth", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "path": "/mcp", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + } +} diff --git a/examples/caching/manifest.json b/examples/caching/manifest.json deleted file mode 100644 index 8073fec288..0000000000 --- a/examples/caching/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "modern", - "//": "cacheHints (ttlMs / cacheScope on cacheable results) are emitted only toward 2026-era clients." -} diff --git a/examples/caching/package.json b/examples/caching/package.json new file mode 100644 index 0000000000..9b4623f1a3 --- /dev/null +++ b/examples/caching/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/caching", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "cacheHints (ttlMs / cacheScope on cacheable results) are emitted only toward 2026-era clients." + } +} diff --git a/examples/custom-methods/manifest.json b/examples/custom-methods/manifest.json deleted file mode 100644 index 256a72db00..0000000000 --- a/examples/custom-methods/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "legacy", - "//": "Custom methods carry no envelope semantics; the example connects as a plain 2025 client so the request reaches setRequestHandler exactly as a hand-wired client would." -} diff --git a/examples/custom-methods/package.json b/examples/custom-methods/package.json new file mode 100644 index 0000000000..260123d877 --- /dev/null +++ b/examples/custom-methods/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/custom-methods", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "legacy", + "//": "Custom methods carry no envelope semantics; the example connects as a plain 2025 client so the request reaches setRequestHandler exactly as a hand-wired client would." + } +} diff --git a/examples/custom-version/manifest.json b/examples/custom-version/manifest.json deleted file mode 100644 index 3f1b00b32c..0000000000 --- a/examples/custom-version/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "legacy", - "//": "supportedProtocolVersions / version negotiation is the 2025 initialize handshake; the modern era is its own negotiation story (../dual-era/)." -} diff --git a/examples/custom-version/package.json b/examples/custom-version/package.json new file mode 100644 index 0000000000..60bb5ae272 --- /dev/null +++ b/examples/custom-version/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/custom-version", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "legacy", + "//": "supportedProtocolVersions / version negotiation is the 2025 initialize handshake; the modern era is its own negotiation story (../dual-era/)." + } +} diff --git a/examples/dual-era/manifest.json b/examples/dual-era/manifest.json deleted file mode 100644 index 947ebb5b1b..0000000000 --- a/examples/dual-era/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "modern", - "//": "The story body drives BOTH eras itself (legacy via versionNegotiation: undefined, modern via the harness default); pinned so the harness runs it once per transport." -} diff --git a/examples/dual-era/package.json b/examples/dual-era/package.json new file mode 100644 index 0000000000..9851ad1369 --- /dev/null +++ b/examples/dual-era/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/dual-era", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "The story body drives BOTH eras itself (legacy via versionNegotiation: undefined, modern via the harness default); pinned so the harness runs it once per transport." + } +} diff --git a/examples/elicitation-form/manifest.json b/examples/elicitation-form/manifest.json deleted file mode 100644 index 3baf5ed76f..0000000000 --- a/examples/elicitation-form/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "transport": "stdio", - "era": "legacy", - "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the elicitation capability; createMcpHandler's per-request/stateless posture has neither. The 2026-07-28 path is the multi-round-trip story (../mrtr/)." -} diff --git a/examples/elicitation-form/package.json b/examples/elicitation-form/package.json new file mode 100644 index 0000000000..70fc144bcb --- /dev/null +++ b/examples/elicitation-form/package.json @@ -0,0 +1,22 @@ +{ + "name": "@mcp-examples/elicitation-form", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "stdio" + ], + "era": "legacy", + "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the elicitation capability; createMcpHandler's per-request/stateless posture has neither. The 2026-07-28 path is the multi-round-trip story (../mrtr/)." + } +} diff --git a/examples/hono/manifest.json b/examples/hono/manifest.json deleted file mode 100644 index 4f39904d89..0000000000 --- a/examples/hono/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "transport": "http", - "path": "/mcp", - "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." -} diff --git a/examples/hono/package.json b/examples/hono/package.json new file mode 100644 index 0000000000..cd233ad23d --- /dev/null +++ b/examples/hono/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/hono", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "path": "/mcp", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + } +} diff --git a/examples/json-response/manifest.json b/examples/json-response/manifest.json deleted file mode 100644 index a3d7667351..0000000000 --- a/examples/json-response/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "transport": "http", - "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." -} diff --git a/examples/json-response/package.json b/examples/json-response/package.json new file mode 100644 index 0000000000..d878e2b932 --- /dev/null +++ b/examples/json-response/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/json-response", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + } +} diff --git a/examples/legacy-routing/manifest.json b/examples/legacy-routing/manifest.json deleted file mode 100644 index a3d7667351..0000000000 --- a/examples/legacy-routing/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "transport": "http", - "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." -} diff --git a/examples/legacy-routing/package.json b/examples/legacy-routing/package.json new file mode 100644 index 0000000000..7ba3c52b2c --- /dev/null +++ b/examples/legacy-routing/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/legacy-routing", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + } +} diff --git a/examples/mrtr/manifest.json b/examples/mrtr/manifest.json deleted file mode 100644 index d4bb80639b..0000000000 --- a/examples/mrtr/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "modern", - "//": "Multi-round-trip inputRequired is a 2026-07-28 protocol feature." -} diff --git a/examples/mrtr/package.json b/examples/mrtr/package.json new file mode 100644 index 0000000000..59e7ebc322 --- /dev/null +++ b/examples/mrtr/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcp-examples/mrtr", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "Multi-round-trip inputRequired is a 2026-07-28 protocol feature." + } +} diff --git a/examples/oauth/elicitationUrlServer.ts b/examples/oauth/elicitationUrlServer.ts index 5504206c63..78718c05fc 100644 --- a/examples/oauth/elicitationUrlServer.ts +++ b/examples/oauth/elicitationUrlServer.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto'; -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server'; diff --git a/examples/oauth/manifest.json b/examples/oauth/manifest.json deleted file mode 100644 index 8db48a625d..0000000000 --- a/examples/oauth/manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "excluded": "Interactive OAuth flows: browser auth, readline REPLs, callback server on :8090, no in-repo Authorization Server for client_credentials. Revisit after the auth-surface walk." -} diff --git a/examples/oauth/package.json b/examples/oauth/package.json new file mode 100644 index 0000000000..e981eef47a --- /dev/null +++ b/examples/oauth/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/oauth", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "ajv": "catalog:runtimeShared", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "open": "^11.0.0", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "excluded": "Interactive authorization-code OAuth flows: browser auth, readline REPLs, callback server on :8090. The machine-to-machine client_credentials grant is covered by ../oauth-client-credentials/. Revisit after the auth-surface walk." + } +} diff --git a/examples/oauth/simpleStreamableHttpServer.ts b/examples/oauth/simpleStreamableHttpServer.ts index 444740df2c..6237e7130b 100644 --- a/examples/oauth/simpleStreamableHttpServer.ts +++ b/examples/oauth/simpleStreamableHttpServer.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { diff --git a/examples/package.json b/examples/package.json index 133557f2eb..105f7669e5 100644 --- a/examples/package.json +++ b/examples/package.json @@ -24,7 +24,7 @@ "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/examples-shared": "workspace:^", + "@mcp-examples/shared": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", diff --git a/examples/parallel-calls/package.json b/examples/parallel-calls/package.json new file mode 100644 index 0000000000..3047392f58 --- /dev/null +++ b/examples/parallel-calls/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mcp-examples/parallel-calls", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/prompts/package.json b/examples/prompts/package.json new file mode 100644 index 0000000000..148c977c15 --- /dev/null +++ b/examples/prompts/package.json @@ -0,0 +1,16 @@ +{ + "name": "@mcp-examples/prompts", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/resources/package.json b/examples/resources/package.json new file mode 100644 index 0000000000..4b907b0f00 --- /dev/null +++ b/examples/resources/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mcp-examples/resources", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/sampling/manifest.json b/examples/sampling/manifest.json deleted file mode 100644 index 803548e646..0000000000 --- a/examples/sampling/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "transport": "stdio", - "era": "legacy", - "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the sampling capability; createMcpHandler's per-request/stateless posture has neither." -} diff --git a/examples/sampling/package.json b/examples/sampling/package.json new file mode 100644 index 0000000000..4374dc07dd --- /dev/null +++ b/examples/sampling/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/sampling", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "stdio" + ], + "era": "legacy", + "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the sampling capability; createMcpHandler's per-request/stateless posture has neither." + } +} diff --git a/examples/schema-validators/package.json b/examples/schema-validators/package.json new file mode 100644 index 0000000000..e6eafa6cd6 --- /dev/null +++ b/examples/schema-validators/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/schema-validators", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", + "valibot": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/shared/package.json b/examples/shared/package.json index 3530f785fa..4b99310e9d 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -1,5 +1,5 @@ { - "name": "@modelcontextprotocol/examples-shared", + "name": "@mcp-examples/shared", "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", @@ -8,6 +8,9 @@ "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", "type": "module", + "exports": { + ".": "./src/index.ts" + }, "repository": { "type": "git", "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" diff --git a/examples/sse-polling/manifest.json b/examples/sse-polling/manifest.json deleted file mode 100644 index 97c967f082..0000000000 --- a/examples/sse-polling/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "transport": "http", - "excluded": "SEP-1699 SSE polling/resumption story is sessionful 2025 with server-initiated disconnect; the client's reconnect-then-replay flow is a long (~5s) wait that the harness doesn't bound well. Typecheck-only for now; revisit after the harness gets per-leg timeouts." -} diff --git a/examples/sse-polling/package.json b/examples/sse-polling/package.json new file mode 100644 index 0000000000..32a5b77355 --- /dev/null +++ b/examples/sse-polling/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/sse-polling", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "excluded": "SEP-1699 SSE polling/resumption story is sessionful 2025 with server-initiated disconnect; the client's reconnect-then-replay flow is a long (~5s) wait that the harness doesn't bound well. Typecheck-only for now; revisit after the harness gets per-leg timeouts." + } +} diff --git a/examples/standalone-get/manifest.json b/examples/standalone-get/manifest.json deleted file mode 100644 index 2a9672fd93..0000000000 --- a/examples/standalone-get/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "transport": "http", - "path": "/mcp", - "excluded": "Sessionful 2025 timer-driven listChanged push over the standalone GET stream — long-running by design. Typecheck-only for now; revisit alongside the sse-polling re-host." -} diff --git a/examples/standalone-get/package.json b/examples/standalone-get/package.json new file mode 100644 index 0000000000..75a999a647 --- /dev/null +++ b/examples/standalone-get/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/standalone-get", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "path": "/mcp", + "excluded": "Sessionful 2025 timer-driven listChanged push over the standalone GET stream — long-running by design. Typecheck-only for now; revisit alongside the sse-polling re-host." + } +} diff --git a/examples/stateless-legacy/manifest.json b/examples/stateless-legacy/manifest.json deleted file mode 100644 index a3d7667351..0000000000 --- a/examples/stateless-legacy/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "transport": "http", - "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." -} diff --git a/examples/stateless-legacy/package.json b/examples/stateless-legacy/package.json new file mode 100644 index 0000000000..3066b195ec --- /dev/null +++ b/examples/stateless-legacy/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/stateless-legacy", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + } +} diff --git a/examples/stickynotes/manifest.json b/examples/stickynotes/manifest.json deleted file mode 100644 index 0042dc3783..0000000000 --- a/examples/stickynotes/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "legacy", - "//": "The elicitation-confirmed remove_all path needs a 2025 initialize handshake to advertise the elicitation capability; the http leg additionally skips that path (no return path on per-request HTTP)." -} diff --git a/examples/stickynotes/package.json b/examples/stickynotes/package.json new file mode 100644 index 0000000000..7211ec9d9b --- /dev/null +++ b/examples/stickynotes/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mcp-examples/stickynotes", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "legacy", + "//": "The elicitation-confirmed remove_all path needs a 2025 initialize handshake to advertise the elicitation capability; the http leg additionally skips that path (no return path on per-request HTTP)." + } +} diff --git a/examples/streaming/package.json b/examples/streaming/package.json new file mode 100644 index 0000000000..df70e5b96b --- /dev/null +++ b/examples/streaming/package.json @@ -0,0 +1,16 @@ +{ + "name": "@mcp-examples/streaming", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/subscriptions/manifest.json b/examples/subscriptions/manifest.json deleted file mode 100644 index d56bf3f53e..0000000000 --- a/examples/subscriptions/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "era": "modern", - "//": "subscriptions/listen is a 2026-07-28 protocol feature." -} diff --git a/examples/subscriptions/package.json b/examples/subscriptions/package.json new file mode 100644 index 0000000000..8e1fda7df6 --- /dev/null +++ b/examples/subscriptions/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcp-examples/subscriptions", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "subscriptions/listen is a 2026-07-28 protocol feature." + } +} diff --git a/examples/tools/package.json b/examples/tools/package.json new file mode 100644 index 0000000000..7c0791890d --- /dev/null +++ b/examples/tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "@mcp-examples/tools", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 687b234be9..f8f4ab184e 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -21,7 +21,7 @@ "@modelcontextprotocol/core/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" ], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] + "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbcea48f3..920c49e33f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,12 +298,12 @@ importers: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:^ + version: link:shared '@modelcontextprotocol/client': specifier: workspace:^ version: link:../packages/client - '@modelcontextprotocol/examples-shared': - specifier: workspace:^ - version: link:shared '@modelcontextprotocol/express': specifier: workspace:^ version: link:../packages/middleware/express @@ -360,6 +360,35 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/bearer-auth: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/caching: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/client-quickstart: dependencies: '@anthropic-ai/sdk': @@ -376,6 +405,242 @@ importers: specifier: catalog:devTools version: 5.9.3 + examples/custom-methods: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/custom-version: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/dual-era: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/elicitation-form: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/hono: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/json-response: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/legacy-routing: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/mrtr: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/oauth: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + open: + specifier: ^11.0.0 + version: 11.0.0 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/parallel-calls: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/prompts: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/resources: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/sampling: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/schema-validators: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + arktype: + specifier: catalog:devTools + version: 2.2.0 + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/server-quickstart: dependencies: '@modelcontextprotocol/server': @@ -468,6 +733,124 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + examples/sse-polling: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/standalone-get: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/stateless-legacy: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/stickynotes: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/streaming: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/subscriptions: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/tools: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + packages/client: dependencies: cross-spawn: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5302c1ee6..3923d58ef0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - '!packages/codemod/batch-test/**' - common/**/* - examples + - examples/* - examples/**/* - test/**/* diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts index 0bb0619526..d00cb58baf 100644 --- a/scripts/run-examples.ts +++ b/scripts/run-examples.ts @@ -14,9 +14,9 @@ * - **legacy**: pass `--legacy` to the client so it uses the 2025 * `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`). * - * A per-directory `manifest.json` overrides defaults — most stories have none. - * `excluded` stories are listed (with their reason) but not run. Stories - * without a `client.ts` are skipped. + * Per-story configuration lives in the story's `package.json` under the + * `"example"` field — most stories have none. `excluded` stories are listed + * (with their reason) but not run. Stories without a `client.ts` are skipped. */ import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; @@ -24,10 +24,11 @@ import { connect } from 'node:net'; import { join, resolve } from 'node:path'; type Era = 'modern' | 'legacy'; +type Transport = 'stdio' | 'http'; -interface Manifest { - /** `'dual'` (stdio + http; the default), `'http'`, or `'stdio'`. */ - transport?: 'dual' | 'http' | 'stdio'; +interface ExampleConfig { + /** Transports to run (default: `['stdio', 'http']`). */ + transports?: Transport[]; /** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */ era?: 'dual' | Era; /** HTTP port (default: a per-story port assigned below). */ @@ -54,15 +55,17 @@ const NON_STORY = new Set(['shared', 'guides', 'oauth', 'server-quickstart', 'cl /** Distinct per-story HTTP ports so the servers never collide. */ let nextPort = 8530; const portFor = new Map(); -function assignPort(story: string, manifest: Manifest): number { - if (manifest.port) return manifest.port; +function assignPort(story: string, config: ExampleConfig): number { + if (config.port) return config.port; if (!portFor.has(story)) portFor.set(story, nextPort++); return portFor.get(story)!; } -function readManifest(dir: string): Manifest { - const file = join(dir, 'manifest.json'); - return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as Manifest) : {}; +function readConfig(dir: string): ExampleConfig { + const file = join(dir, 'package.json'); + if (!existsSync(file)) return {}; + const pkg = JSON.parse(readFileSync(file, 'utf8')) as { example?: ExampleConfig }; + return pkg.example ?? {}; } function run( @@ -116,10 +119,10 @@ interface LegResult { const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []); -async function runStdioLeg(story: string, dir: string, manifest: Manifest, era: Era): Promise { - const timeoutMs = manifest.timeoutMs ?? 30_000; +async function runStdioLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { + const timeoutMs = config.timeoutMs ?? 30_000; const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs }); - const ok = result.code === 0 && (!manifest.expects?.stdout || result.stdout.includes(manifest.expects.stdout)); + const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); return { story, leg: `stdio/${era}`, @@ -128,15 +131,15 @@ async function runStdioLeg(story: string, dir: string, manifest: Manifest, era: }; } -async function runHttpLeg(story: string, dir: string, manifest: Manifest, era: Era): Promise { - const timeoutMs = manifest.timeoutMs ?? 30_000; - const port = assignPort(story, manifest); - const path = manifest.path ?? '/'; +async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { + const timeoutMs = config.timeoutMs ?? 30_000; + const port = assignPort(story, config); + const path = config.path ?? '/'; const url = `http://127.0.0.1:${port}${path}`; let serverStderr = ''; const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], { cwd: ROOT, - env: { ...process.env, PORT: String(port), ...manifest.env } + env: { ...process.env, PORT: String(port), ...config.env } }); server.stderr?.on('data', d => (serverStderr += String(d))); server.stdout?.on('data', d => (serverStderr += String(d))); @@ -146,7 +149,7 @@ async function runHttpLeg(story: string, dir: string, manifest: Manifest, era: E return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` }; } const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs }); - const ok = result.code === 0 && (!manifest.expects?.stdout || result.stdout.includes(manifest.expects.stdout)); + const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); return { story, leg: `http/${era}`, @@ -174,20 +177,19 @@ async function main(): Promise { for (const story of stories) { const dir = join(EXAMPLES, story); - const manifest = readManifest(dir); - if (manifest.excluded) { - excluded.push({ story, reason: manifest.excluded }); - console.log(`\n::group::example ${story}\nSKIPPED: ${manifest.excluded}\n::endgroup::`); + const config = readConfig(dir); + if (config.excluded) { + excluded.push({ story, reason: config.excluded }); + console.log(`\n::group::example ${story}\nSKIPPED: ${config.excluded}\n::endgroup::`); continue; } - const transport = manifest.transport ?? 'dual'; - const era = manifest.era ?? 'dual'; - const transports: Array<'stdio' | 'http'> = transport === 'dual' ? ['stdio', 'http'] : [transport]; + const transports: Transport[] = config.transports ?? ['stdio', 'http']; + const era = config.era ?? 'dual'; const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era]; - console.log(`\n::group::example ${story} (${transport} × ${era})`); + console.log(`\n::group::example ${story} (${transports.join('+')} × ${era})`); for (const t of transports) { for (const e of eras) { - const r = t === 'stdio' ? await runStdioLeg(story, dir, manifest, e) : await runHttpLeg(story, dir, manifest, e); + const r = t === 'stdio' ? await runStdioLeg(story, dir, config, e) : await runHttpLeg(story, dir, config, e); results.push(r); console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); if (!r.ok) console.log(r.detail); From 7378eb7be28f5c1da1d6497c8455d859cba44618 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:40:00 +0000 Subject: [PATCH 11/27] feat(examples): oauth-client-credentials story (machine-to-machine, no browser) A fully self-verifying `client_credentials` grant story closing the auth gap: `server.ts` hosts a minimal in-repo `client_credentials`-only Authorization Server (`createClientCredentialsAuthServer`, new in `@mcp-examples/shared`) on one port and a `createMcpHandler` resource server behind `requireBearerAuth` on another; `client.ts` asserts a bare request is 401, then connects with `ClientCredentialsProvider` so the SDK auth driver discovers the AS, exchanges id+secret for a Bearer token, and `ctx.authInfo` carries the granted clientId/scopes through the `whoami` tool. HTTP-only, modern-era. The better-auth/OIDC `setupAuthServer` only implements `authorization_code`, hence the new minimal AS. Its verifier explicitly models token expiry (and `requireBearerAuth` independently rejects on `expiresAt`), so the demo is not fail-open. The browser `authorization_code` flow stays under `examples/oauth/` (excluded). --- examples/README.md | 44 +++--- examples/oauth-client-credentials/README.md | 25 ++++ examples/oauth-client-credentials/client.ts | 59 ++++++++ .../oauth-client-credentials/package.json | 27 ++++ examples/oauth-client-credentials/server.ts | 76 ++++++++++ .../shared/src/clientCredentialsAuthServer.ts | 135 ++++++++++++++++++ examples/shared/src/index.ts | 4 + pnpm-lock.yaml | 22 +++ 8 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 examples/oauth-client-credentials/README.md create mode 100644 examples/oauth-client-credentials/client.ts create mode 100644 examples/oauth-client-credentials/package.json create mode 100644 examples/oauth-client-credentials/server.ts create mode 100644 examples/shared/src/clientCredentialsAuthServer.ts diff --git a/examples/README.md b/examples/README.md index 216e791c85..302d3cac31 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,15 +3,15 @@ One **story** per directory. Every story is a runnable, self-verifying client/server pair: `server.ts` is what you would deploy, `client.ts` is what a host would write — it connects, exercises the feature with the public client API, asserts results, and exits 0. CI runs every pair over every transport it supports (`scripts/run-examples.ts`); a non-zero exit fails the build. -Run any pair from the repo root: +Each story is its own private workspace package (`@mcp-examples/`). Run any pair from the repo root: ```bash # stdio (the client spawns the server itself): -pnpm tsx examples//client.ts +pnpm --filter @mcp-examples/ client # Streamable HTTP (two terminals): -pnpm tsx examples//server.ts --http --port 3000 -pnpm tsx examples//client.ts --http http://127.0.0.1:3000/ +pnpm --filter @mcp-examples/ server -- --http --port 3000 +pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/ ``` ## Start here @@ -25,21 +25,22 @@ pnpm tsx examples//client.ts --http http://127.0.0.1:3000/ ## Feature stories -| Story | What it teaches | Transports | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | -| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | -| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | -| [`elicitation-form/`](./elicitation-form/README.md) | Form-mode elicitation (server requests user input) | stdio | -| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio | -| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | -| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | -| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | -| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | -| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | -| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | -| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | -| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | +| Story | What it teaches | Transports | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | +| [`elicitation-form/`](./elicitation-form/README.md) | Form-mode elicitation (server requests user input) | stdio | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | ## HTTP hosting variants @@ -53,5 +54,6 @@ pnpm tsx examples//client.ts --http http://127.0.0.1:3000/ ## Excluded -The interactive OAuth set lives under [`oauth/`](./oauth/README.md) and is excluded from the harness (browser flow / no in-repo Authorization Server). The [`guides/`](./guides/README.md) directory holds the snippet collections synced into `docs/server.md` and `docs/client.md` — -typecheck-only, not runnable. `shared/` is the demo OAuth provider library used by the OAuth examples. The `server-quickstart/` and `client-quickstart/` packages are the website-tutorial sources (external network / API key; typecheck-only). +The interactive OAuth set lives under [`oauth/`](./oauth/README.md) and is excluded from the harness (browser authorization-code flow); the headless machine-to-machine grant is covered by `oauth-client-credentials/`. The [`guides/`](./guides/README.md) directory holds the snippet +collections synced into `docs/server.md` and `docs/client.md` — typecheck-only, not runnable. `shared/` is the demo OAuth provider library used by the OAuth examples. The `server-quickstart/` and `client-quickstart/` packages are the website-tutorial sources (external network / +API key; typecheck-only). diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md new file mode 100644 index 0000000000..7bb784f90b --- /dev/null +++ b/examples/oauth-client-credentials/README.md @@ -0,0 +1,25 @@ +# oauth-client-credentials + +OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, fully self-verifying with no browser. + +`client_credentials` is the grant a backend service uses to authenticate **as itself** (not on behalf of a user): it presents a pre-registered `client_id`/`client_secret` directly to the Authorization Server's token endpoint and receives a Bearer access token. There is no +redirect, no authorization code, no user consent screen. + +The interactive **authorization-code** flow (the one that opens a browser and asks a human to sign in) lives under [`../oauth/`](../oauth/README.md) and is excluded from the harness for that reason. + +## What runs + +- `server.ts` starts two listeners in one process: + - the MCP **resource server** on `--port` — `createMcpHandler` behind `requireBearerAuth` from `@modelcontextprotocol/express`, advertising the AS via `mcpAuthMetadataRouter` (RFC 9728 + RFC 8414). + - a minimal **`client_credentials`-only Authorization Server** on `--port + 1` (`createClientCredentialsAuthServer` from `@mcp-examples/shared`). The repo's full better-auth/OIDC demo AS only implements `authorization_code`, so this story ships its own purpose-built AS. +- `client.ts` first asserts a bare request is `401` with a `WWW-Authenticate` challenge, then connects with a `ClientCredentialsProvider` on the transport. The SDK auth driver discovers the AS from the challenge, posts `grant_type=client_credentials` (HTTP Basic auth) to + `/token`, attaches the returned Bearer token, and the `whoami` tool's `ctx.authInfo` carries the granted `clientId` and `scopes` end to end. + +## Run it + +```bash +pnpm --filter @mcp-examples/oauth-client-credentials server -- --http --port 3000 +pnpm --filter @mcp-examples/oauth-client-credentials client -- --http http://127.0.0.1:3000/mcp +``` + +HTTP-only, modern-era only. diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts new file mode 100644 index 0000000000..2ebb2e8864 --- /dev/null +++ b/examples/oauth-client-credentials/client.ts @@ -0,0 +1,59 @@ +/** + * Self-verifying `client_credentials` client. + * + * 1. A bare request is `401` with a `WWW-Authenticate` challenge that names the + * Protected Resource Metadata URL. + * 2. A `Client` with a {@linkcode ClientCredentialsProvider} on its transport + * follows that challenge → AS metadata → `POST /token` with + * `grant_type=client_credentials` (HTTP Basic `client_id:client_secret`) → + * Bearer token → reaches the `whoami` tool, whose `ctx.authInfo` carries the + * granted scopes. + * + * No browser, no readline. The SDK's auth driver does the discovery; the only + * thing the caller supplies is the pre-registered client's id+secret. + */ +import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +import { check, runClient } from '../harness.js'; + +const argv = process.argv.slice(2); +const URL_ARG = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; + +runClient('oauth-client-credentials', async () => { + // Unauthenticated → 401 + WWW-Authenticate naming the PRM URL. + const unauth = await fetch(URL_ARG, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) + }); + check.equal(unauth.status, 401, 'bare request must be 401'); + check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); + check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/); + + // Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes. + const provider = new ClientCredentialsProvider({ + clientId: 'demo-m2m-client', + clientSecret: 'demo-m2m-secret', + scope: 'mcp:tools mcp:read' + }); + const client = new Client({ name: 'client-credentials-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider })); + + const tokens = provider.tokens(); + check.ok(tokens?.access_token, 'ClientCredentialsProvider obtained an access_token'); + check.equal(tokens?.token_type, 'Bearer'); + + const result = await client.callTool({ name: 'whoami', arguments: {} }); + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; + const seen = JSON.parse(text) as { clientId: string; scopes: string[] }; + check.equal(seen.clientId, 'demo-m2m-client', 'ctx.authInfo.clientId round-trips'); + check.ok(seen.scopes.includes('mcp:tools'), 'ctx.authInfo.scopes carries the granted scope'); + + // Expiry: both the demo verifier and `requireBearerAuth` reject when + // `AuthInfo.expiresAt` is in the past, so an expired token would 401 here + // exactly like the bare-request leg above. Minting an expired token would + // mean reaching past the AS's public surface, so the path is documented + // rather than exercised. + + await client.close(); +}); diff --git a/examples/oauth-client-credentials/package.json b/examples/oauth-client-credentials/package.json new file mode 100644 index 0000000000..cd03c27584 --- /dev/null +++ b/examples/oauth-client-credentials/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/oauth-client-credentials", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "path": "/mcp", + "//": "OAuth is HTTP-only by definition; the client drives the modern era explicitly." + } +} diff --git a/examples/oauth-client-credentials/server.ts b/examples/oauth-client-credentials/server.ts new file mode 100644 index 0000000000..f125f4cd2b --- /dev/null +++ b/examples/oauth-client-credentials/server.ts @@ -0,0 +1,76 @@ +/** + * OAuth 2.0 **`client_credentials`** grant — the machine-to-machine story. + * + * One process hosts both halves on adjacent ports: + * + * - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth`, advertising the AS via `mcpAuthMetadataRouter` + * (RFC 9728 Protected Resource Metadata + RFC 8414 AS metadata). + * - `:PORT+1` — a minimal in-repo **Authorization Server** that supports the + * `client_credentials` grant only (`@mcp-examples/shared`'s + * `createClientCredentialsAuthServer`). The full better-auth/OIDC demo AS + * only implements `authorization_code`, hence this purpose-built one. + * + * The client (`./client.ts`) discovers the AS from a 401 challenge, exchanges + * its `client_id`/`client_secret` for a Bearer token at `/token`, and reaches + * the `whoami` tool — which echoes `ctx.authInfo` so the client can assert the + * granted scopes round-tripped end to end. HTTP-only by definition. + */ +import { createClientCredentialsAuthServer } from '@mcp-examples/shared'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const AUTH_PORT = PORT + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// harness passes the client byte-for-byte — the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${PORT}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}/`); + +// Demo confidential client. DEMO ONLY — never hard-code real credentials. +export const DEMO_CLIENT = { clientId: 'demo-m2m-client', clientSecret: 'demo-m2m-secret', allowedScopes: ['mcp:tools', 'mcp:read'] }; + +// ---- Authorization Server (client_credentials only) ---- +const as = createClientCredentialsAuthServer({ authServerUrl, clients: [DEMO_CLIENT] }); +as.app.listen(AUTH_PORT, () => console.error(`[auth-server] client_credentials AS on ${authServerUrl.href}`)); + +// ---- Resource Server (MCP) ---- +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'oauth-client-credentials-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the authenticated client and its granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }] + }) + ); + return server; +}); + +const app = createMcpExpressApp(); +app.use( + mcpAuthMetadataRouter({ + oauthMetadata: as.metadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools', 'mcp:read'], + resourceName: 'oauth-client-credentials example' + }) +); +const auth = requireBearerAuth({ + verifier: as.verifier, + requiredScopes: ['mcp:tools'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it +// to the factory as `ctx.authInfo`. +app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); + +app.listen(PORT, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`)); diff --git a/examples/shared/src/clientCredentialsAuthServer.ts b/examples/shared/src/clientCredentialsAuthServer.ts new file mode 100644 index 0000000000..b4d87b0462 --- /dev/null +++ b/examples/shared/src/clientCredentialsAuthServer.ts @@ -0,0 +1,135 @@ +/** + * Minimal OAuth 2.0 Authorization Server supporting the **`client_credentials`** + * grant only — for the machine-to-machine MCP example. + * + * DEMO ONLY — NOT FOR PRODUCTION + * + * The full {@link setupAuthServer} (better-auth/OIDC) only supports the + * `authorization_code` grant; this is the headless counterpart so the + * `oauth-client-credentials/` example can be fully self-verifying without a + * browser. + * + * Exposes RFC 8414 metadata at `/.well-known/oauth-authorization-server` and a + * `/token` endpoint that accepts `client_secret_basic` or `client_secret_post` + * authentication. Issued access tokens are random opaque strings tracked in an + * in-memory map and validated by {@link clientCredentialsTokenVerifier}. + */ + +import { randomBytes } from 'node:crypto'; + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import express from 'express'; + +export interface RegisteredClient { + clientId: string; + clientSecret: string; + /** Scopes the AS is willing to grant this client (defaults to whatever it asks for). */ + allowedScopes?: string[]; +} + +export interface ClientCredentialsAuthServerOptions { + /** Public base URL of this AS (issuer). */ + authServerUrl: URL; + /** Pre-registered confidential clients. */ + clients: RegisteredClient[]; +} + +export interface ClientCredentialsAuthServer { + app: express.Application; + metadata: OAuthMetadata; + /** Pass to `requireBearerAuth({ verifier })` on the Resource Server. */ + verifier: OAuthTokenVerifier; +} + +/** Tokens issued by the most-recently-created `client_credentials` AS. */ +const issuedTokens = new Map(); + +/** + * Builds (but does not `listen()`) a minimal `client_credentials`-only + * Authorization Server. The caller mounts `app` on the port matching + * `authServerUrl`. + */ +export function createClientCredentialsAuthServer(options: ClientCredentialsAuthServerOptions): ClientCredentialsAuthServer { + const { authServerUrl, clients } = options; + const issuer = authServerUrl.href.replace(/\/$/, ''); + const clientById = new Map(clients.map(c => [c.clientId, c])); + + const metadata: OAuthMetadata = { + issuer, + token_endpoint: `${issuer}/token`, + // Required by the RFC 8414 schema even though this AS doesn't implement the endpoint. + authorization_endpoint: `${issuer}/authorize`, + response_types_supported: [], + grant_types_supported: ['client_credentials'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + scopes_supported: ['mcp:tools', 'mcp:read'] + }; + + const app = express(); + app.use(cors()); + app.use(express.urlencoded({ extended: false })); + + app.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json(metadata); + }); + + app.post('/token', (req, res) => { + const body = req.body as Record; + if (body.grant_type !== 'client_credentials') { + res.status(400).json({ error: 'unsupported_grant_type' }); + return; + } + // RFC 6749 §2.3.1 — try Basic, then body. + let id: string | undefined; + let secret: string | undefined; + const authz = req.header('authorization'); + if (authz?.startsWith('Basic ')) { + const decoded = Buffer.from(authz.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + id = decodeURIComponent(decoded.slice(0, sep)); + secret = decodeURIComponent(decoded.slice(sep + 1)); + } else { + id = body.client_id; + secret = body.client_secret; + } + const client = id ? clientById.get(id) : undefined; + if (!client || client.clientSecret !== secret) { + res.status(401).set('WWW-Authenticate', 'Basic realm="oauth"').json({ error: 'invalid_client' }); + return; + } + const requested = (body.scope ?? '').split(' ').filter(Boolean); + const granted = client.allowedScopes ? requested.filter(s => client.allowedScopes!.includes(s)) : requested; + const accessToken = randomBytes(24).toString('base64url'); + const expiresIn = 3600; + issuedTokens.set(accessToken, { + token: accessToken, + clientId: client.clientId, + scopes: granted, + expiresAt: Math.floor(Date.now() / 1000) + expiresIn + }); + res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope: granted.join(' ') }); + }); + + return { app, metadata, verifier: clientCredentialsTokenVerifier }; +} + +/** + * `OAuthTokenVerifier` that validates Bearer tokens against the in-memory + * issued-tokens map of {@link createClientCredentialsAuthServer}. + */ +export const clientCredentialsTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + const info = issuedTokens.get(token); + if (!info) throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + // Model expiry explicitly even in the demo so copy-paste users don't ship a fail-open verifier. + // `requireBearerAuth` also independently rejects when `AuthInfo.expiresAt` is in the past. + if (info.expiresAt !== undefined && Math.floor(Date.now() / 1000) >= info.expiresAt) { + issuedTokens.delete(token); + throw new OAuthError(OAuthErrorCode.InvalidToken, 'token expired'); + } + return info; + } +}; diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 47c4d67109..6b014cee1d 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -5,3 +5,7 @@ export { createDemoAuth } from './auth.js'; // Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express) export type { SetupAuthServerOptions } from './authServer.js'; export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js'; + +// Minimal client_credentials-only AS (machine-to-machine; no browser) +export type { ClientCredentialsAuthServer, ClientCredentialsAuthServerOptions, RegisteredClient } from './clientCredentialsAuthServer.js'; +export { clientCredentialsTokenVerifier, createClientCredentialsAuthServer } from './clientCredentialsAuthServer.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 920c49e33f..f8612b7eca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -567,6 +567,28 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/oauth-client-credentials: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/parallel-calls: dependencies: '@modelcontextprotocol/client': From 89961938f979c49e769c2dfb57af59b828186504 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:45:30 +0000 Subject: [PATCH 12/27] chore(changeset): drop stale examples-server changeset (package removed in the per-story restructure) --- .changeset/fix-session-status-codes.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/fix-session-status-codes.md diff --git a/.changeset/fix-session-status-codes.md b/.changeset/fix-session-status-codes.md deleted file mode 100644 index ff2a264bfc..0000000000 --- a/.changeset/fix-session-status-codes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/examples-server': patch ---- - -Example servers now return HTTP 404 (not 400) when a request includes an unknown session ID, so clients can correctly detect they need to start a new session. Requests missing a session ID entirely still return 400. From 8501d6d871400877b107493da83ce2a5da0afa84 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 20:02:02 +0000 Subject: [PATCH 13/27] fix(examples): legacy-routing 404 for unknown sid; drop dead doc cross-refs (#2325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - legacy-routing/server.ts: replace ensureSessionful() with a handleLegacy() that follows the standard sessionful pattern — unknown Mcp-Session-Id → 404 'Session not found', missing → 400. Restores the behavior the (now-removed) fix-session-status-codes changeset documented; the rework had regressed it by minting a fresh transport for any unknown sid. - docs/client.md: drop the dangling reference to the deleted streamableHttpWithSseFallbackClient.ts; the inline connect_sseFallback snippet is the complete pattern. - examples/README.md: generic two-terminal HTTP command now points at /mcp (bearer-auth/hono/oauth-client-credentials/standalone-get all mount there) with a note to check each story's package.json#example.path for the exact endpoint. --- docs/client.md | 2 +- examples/README.md | 4 ++- examples/legacy-routing/server.ts | 41 +++++++++++++++++-------------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/docs/client.md b/docs/client.md index a89891622d..567a82b8fe 100644 --- a/docs/client.md +++ b/docs/client.md @@ -86,7 +86,7 @@ try { } ``` -For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/guides/clientGuide.examples.ts). +The snippet above is the complete pattern; wrap the `catch` body with whatever error reporting your host needs. ### Protocol version negotiation (2026-07-28 revision) diff --git a/examples/README.md b/examples/README.md index 302d3cac31..3ad4310edd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,9 +11,11 @@ pnpm --filter @mcp-examples/ client # Streamable HTTP (two terminals): pnpm --filter @mcp-examples/ server -- --http --port 3000 -pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/ +pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/mcp ``` +Some stories mount at a different path (e.g. `/`); check the story's `package.json#example.path` or its README for the exact URL. + ## Start here | Story | What it teaches | diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index c269b9494a..fb64d431d3 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -15,7 +15,7 @@ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; @@ -30,17 +30,26 @@ const buildServer = (era: 'legacy' | 'modern') => { // --- the existing sessionful 2025 deployment, unchanged --- const sessions = new Map(); -const ensureSessionful = async (sid: string | undefined) => { - if (sid && sessions.has(sid)) return sessions.get(sid)!; - const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: id => { - sessions.set(id, transport); - } - }); - transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); - await buildServer('legacy').connect(transport); - return transport; +const handleLegacy = async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer('legacy').connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + // Unknown session ID → 404 so the client knows to start a new session. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } }; // --- the strict modern entry alongside it --- @@ -55,13 +64,7 @@ app.all('/', async (req: Request, res: Response) => { method: req.method, headers: req.headers as Record }); - if (await isLegacyRequest(probe, req.body)) { - const sid = req.headers['mcp-session-id'] as string | undefined; - const transport = await ensureSessionful(sid); - await transport.handleRequest(req, res, req.body); - } else { - await modern.node(req, res, req.body); - } + await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modern.node(req, res, req.body)); }); const argv = process.argv.slice(2); From fa0524d38c90e072ff0235360c6dad4ab63f793f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 20:27:26 +0000 Subject: [PATCH 14/27] refactor(examples): trim oauth/ to the browser auth-code flow; carve out repl/ playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete elicitationUrl{Server,Client}.ts (URL-mode elicitation now lives in examples/mrtr/; these had unescaped HTML interpolation of session/elicit ids) - delete simpleClientCredentials.ts (superseded by examples/oauth-client-credentials/) - delete simpleStreamableHttpServer.ts (quarried into the new repl/server.ts) - escape the one query-derived value (`error`) interpolated into simpleOAuthClient.ts's callback HTML via a small escHtml helper; sweep confirmed no other HTML responses in the remaining oauth/ files - move interactiveReplClient.ts -> examples/repl/client.ts and pair it with a new fully-featured HTTP server (tools w/ input/output schemas + annotations, prompts w/ completion, direct + templated resources, logging, resources/list_changed published via handler.notify); excluded from the harness (interactive REPL — run manually) - update oauth/README + package.json to the trimmed contents; repoint the docs/{client,server}.md cross-refs that named deleted files (#2325) --- docs/client.md | 104 ++- docs/server.md | 95 +- examples/oauth/README.md | 9 +- examples/oauth/elicitationUrlClient.ts | 824 ------------------ examples/oauth/elicitationUrlServer.ts | 738 ---------------- examples/oauth/package.json | 12 +- examples/oauth/simpleClientCredentials.ts | 83 -- examples/oauth/simpleOAuthClient.ts | 7 +- examples/oauth/simpleStreamableHttpServer.ts | 658 -------------- examples/repl/README.md | 13 + .../client.ts} | 0 examples/repl/package.json | 21 + examples/repl/server.ts | 259 ++++++ pnpm-lock.yaml | 43 +- 14 files changed, 444 insertions(+), 2422 deletions(-) delete mode 100644 examples/oauth/elicitationUrlClient.ts delete mode 100644 examples/oauth/elicitationUrlServer.ts delete mode 100644 examples/oauth/simpleClientCredentials.ts delete mode 100644 examples/oauth/simpleStreamableHttpServer.ts create mode 100644 examples/repl/README.md rename examples/{oauth/interactiveReplClient.ts => repl/client.ts} (100%) create mode 100644 examples/repl/package.json create mode 100644 examples/repl/server.ts diff --git a/docs/client.md b/docs/client.md index 567a82b8fe..951e6dce89 100644 --- a/docs/client.md +++ b/docs/client.md @@ -47,7 +47,7 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 await client.connect(transport); ``` -For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/interactiveReplClient.ts). +For a full interactive client over Streamable HTTP, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). ### stdio @@ -66,7 +66,8 @@ await client.connect(transport); ### SSE fallback for legacy servers -To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: +To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode +@modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: ```ts source="../examples/guides/clientGuide.examples.ts#connect_sseFallback" const baseUrl = new URL(url); @@ -106,7 +107,9 @@ client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' - **`mode: 'auto'`** — `connect()` probes with `server/discover`; a 2025-only server rejects the probe and the client falls back to the plain `initialize` handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server. - **`mode: { pin: '2026-07-28' }`** — modern era at exactly that revision; no fallback. Against a 2025-only server `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). -Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [migration guide](./migration.md#opt-in-protocol-version-negotiation-2026-07-28-draft) for the full failure semantics, probe policy, and the `'auto'`-mode compatibility table. +Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an +already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [migration guide](./migration.md#opt-in-protocol-version-negotiation-2026-07-28-draft) for the full failure semantics, +probe policy, and the `'auto'`-mode compatibility table. ### Disconnecting @@ -123,7 +126,8 @@ For stdio, `client.close()` handles graceful process shutdown (closes stdin, the ### Server instructions -Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Retrieve it after connecting and include it in the model's system prompt: +Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP +specification). Retrieve it after connecting and include it in the model's system prompt: ```ts source="../examples/guides/clientGuide.examples.ts#serverInstructions_basic" const instructions = client.getInstructions(); @@ -135,11 +139,13 @@ console.log(systemPrompt); ## Authentication -MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. +MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | +AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. ### Bearer tokens -For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately: +For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | +UnauthorizedError} immediately: ```ts source="../examples/guides/clientGuide.examples.ts#auth_tokenProvider" const authProvider: AuthProvider = { token: async () => getStoredToken() }; @@ -180,19 +186,24 @@ const authProvider = new PrivateKeyJwtProvider({ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleClientCredentials.ts). +For a runnable example supporting both auth methods via environment variables, see [`oauth-client-credentials/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth-client-credentials/client.ts). ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode +@modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode +@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. -For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). +For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and +[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). ### Cross-App Access (Enterprise Managed Authorization) -{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf. +{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access +protected MCP servers on their behalf. This provider handles a two-step OAuth flow: + 1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange 2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant @@ -220,24 +231,27 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 ``` The `assertion` callback receives a context object with: + - `authorizationServerUrl` – The MCP server's authorization server (discovered automatically) - `resourceUrl` – The MCP resource URL (discovered automatically) - `scope` – Optional scope passed to `auth()` or from `clientMetadata` - `fetchFn` – Fetch implementation to use for HTTP requests For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`: + - `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP - `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition - `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server -> [!NOTE] -> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth standards. +> [!NOTE] See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth +> standards. ## Tools Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect +all pages: ```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic" const allTools: Tool[] = []; @@ -295,7 +309,8 @@ console.log(result.content); Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on +`nextCursor` to collect all pages: ```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic" const allResources: Resource[] = []; @@ -340,7 +355,8 @@ await client.unsubscribeResource({ uri: 'config://app' }); Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on +`nextCursor` to collect all pages: ```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic" const allPrompts: Prompt[] = []; @@ -384,7 +400,8 @@ console.log(completion.values); // e.g. ['typescript'] ### Automatic list-change tracking -The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and error-first callbacks: +The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and +error-first callbacks: ```ts source="../examples/guides/clientGuide.examples.ts#listChanged_basic" const client = new Client( @@ -426,8 +443,8 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = }); ``` -> [!WARNING] -> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. +> [!WARNING] MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the +> [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: @@ -435,12 +452,12 @@ To control the minimum severity of log messages the server sends, use {@linkcode await client.setLoggingLevel('warning'); ``` -> [!WARNING] -> `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. +> [!WARNING] `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. ## Handling server-initiated requests -MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: +MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability +when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: ```ts source="../examples/guides/clientGuide.examples.ts#capabilities_declaration" const client = new Client( @@ -456,8 +473,8 @@ const client = new Client( ### Sampling -> [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to calling LLM provider APIs directly. +> [!WARNING] Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers +> should migrate to calling LLM provider APIs directly. When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: @@ -480,7 +497,8 @@ client.setRequestHandler('sampling/createMessage', async request => { ### Elicitation -When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return the collected data, or `{ action: 'decline' }`: +When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return +the collected data, or `{ action: 'decline' }`: ```ts source="../examples/guides/clientGuide.examples.ts#elicitation_handler" client.setRequestHandler('elicitation/create', async request => { @@ -496,12 +514,13 @@ client.setRequestHandler('elicitation/create', async request => { }); ``` -For a full form-based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/interactiveReplClient.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/elicitationUrlClient.ts). +For a full form-based elicitation handler with AJV validation, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). For URL elicitation mode, see +[`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts). ### Roots -> [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. +> [!WARNING] Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> passing paths via tool parameters, resource URIs, or configuration. Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: @@ -522,7 +541,7 @@ When the available roots change, notify the server with {@linkcode @modelcontext ### Tool errors vs protocol errors -{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can *run but report failure* via `isError: true` in the result, or the *request itself can fail* and throw an exception. Always check both: +{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can _run but report failure_ via `isError: true` in the result, or the _request itself can fail_ and throw an exception. Always check both: ```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_toolErrors" try { @@ -550,11 +569,14 @@ try { } ``` -{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | CAPABILITY_NOT_SUPPORTED}, and others. +{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode +@modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | +CAPABILITY_NOT_SUPPORTED}, and others. ### Connection lifecycle -Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: +Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the +connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: ```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_lifecycle" // Out-of-band errors (SSE disconnects, parse errors) @@ -570,7 +592,8 @@ client.onclose = () => { ### Timeouts -All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: +All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | +SdkErrorCode.RequestTimeout}: ```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_timeout" try { @@ -588,7 +611,8 @@ try { ## Client middleware -Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: +Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` +call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: ```ts source="../examples/guides/clientGuide.examples.ts#middleware_basic" const authMiddleware = createMiddleware(async (next, input, init) => { @@ -604,7 +628,9 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 ## Trace context propagation -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When +present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry +context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. Attach trace context to a single request via `_meta`: @@ -689,9 +715,9 @@ For an end-to-end example of server-initiated SSE disconnection and automatic cl ### Additional examples -| Feature | Description | Example | -|---------|-------------|---------| -| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | -| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | -| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | -| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/elicitationUrlClient.ts) | +| Feature | Description | Example | +| ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts) | diff --git a/docs/server.md b/docs/server.md index 3fdea8393f..c08132de91 100644 --- a/docs/server.md +++ b/docs/server.md @@ -50,7 +50,7 @@ await server.connect(transport); **Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. -For a complete server with sessions, logging, and CORS mounted on Express, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts). +For a complete server with sessions, logging, and CORS, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). ### stdio @@ -64,8 +64,8 @@ await server.connect(transport); #### Serving the 2026-07-28 draft revision on stdio -A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` -for long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: +A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` for +long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: ```typescript import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -77,13 +77,14 @@ serveStdio(() => { }); ``` -Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to -refuse 2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for -details). A runnable example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.ts`. +Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to refuse +2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for details). A runnable +example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.ts`. ## Server instructions -Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions. +Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to +the system prompt. Instructions should not duplicate information already in tool descriptions. ```ts source="../examples/guides/serverGuide.examples.ts#instructions_basic" const server = new McpServer( @@ -123,12 +124,13 @@ server.registerTool( ); ``` -> [!NOTE] -> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: +> [!NOTE] When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: > > ```ts -> type BmiResult = { bmi: number }; // assignable -> interface BmiResult { bmi: number } // type error +> type BmiResult = { bmi: number }; // assignable +> interface BmiResult { +> bmi: number; +> } // type error > ``` > > Alternatively, spread the value: `structuredContent: { ...result }`. @@ -223,7 +225,8 @@ If a handler throws instead of returning `isError`, the SDK catches the exceptio ## Resources -Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike [tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. +Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike +[tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. A static resource at a fixed URI: @@ -271,12 +274,13 @@ server.registerResource( ); ``` -> [!IMPORTANT] -> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. +> [!IMPORTANT] **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within +> the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. ## Prompts -Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it. +Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use +a [tool](#tools) when the LLM should decide when to call it. ```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_basic" server.registerPrompt( @@ -334,8 +338,8 @@ server.registerPrompt( ## Logging -> [!WARNING] -> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to stderr logging (STDIO servers) or OpenTelemetry. +> [!WARNING] MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate +> to stderr logging (STDIO servers) or OpenTelemetry. Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). @@ -405,7 +409,9 @@ server.registerTool( ## Trace context propagation -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When +present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are +exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. Read the caller's trace context from `ctx.mcpReq._meta` in a handler: @@ -433,14 +439,15 @@ To propagate context onward (for example on a server-initiated sampling request, ## Server-initiated requests -MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). +MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). ### Sampling -> [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to calling LLM provider APIs directly from your server. +> [!WARNING] Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> calling LLM provider APIs directly from your server. -Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution. +Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling +when a tool needs the model to generate or transform text mid-execution. Call `ctx.mcpReq.requestSampling(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: @@ -485,8 +492,7 @@ Elicitation lets a tool handler request direct input from the user — form fiel - **Form** (`mode: 'form'`) — collects non-sensitive data via a schema-driven form. - **URL** (`mode: 'url'`) — opens a browser URL for sensitive data or secure flows (API keys, payments, OAuth). -> [!IMPORTANT] -> Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. +> [!IMPORTANT] Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: @@ -530,14 +536,16 @@ server.registerTool( ); ``` -For runnable examples, see [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation-form/server.ts) (form) and [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/elicitationUrlServer.ts) (URL). +For runnable examples, see [`elicitation-form/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation-form/server.ts) (form) and [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) +(URL). ### Roots -> [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. +> [!WARNING] Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> passing paths via tool parameters, resource URIs, or configuration. -Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): +Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode +@modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): ```ts source="../examples/guides/serverGuide.examples.ts#registerTool_roots" server.registerTool( @@ -585,15 +593,17 @@ process.on('SIGINT', async () => { }); ``` -For a complete multi-session server with shutdown handling, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts). +For a complete multi-session server with shutdown handling, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). ## Deployment ### DNS rebinding protection -Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** +Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, +since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** -The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from `@modelcontextprotocol/hono`), which enable Host header validation by default: +The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from +`@modelcontextprotocol/hono`), which enable Host header validation by default: ```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_basic" // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) @@ -617,11 +627,12 @@ const app = createMcpExpressApp({ `createMcpHonoApp()` from `@modelcontextprotocol/hono` provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun). -The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there is no option that disables Origin validation for a localhost-class bind. Requests without -an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework middleware (`originValidation`, `localhostOriginValidation`) can also be mounted -explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. +The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there +is no option that disables Origin validation for a localhost-class bind. Requests without an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework +middleware (`originValidation`, `localhostOriginValidation`) can also be mounted explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. -If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. +If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) +middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. ## See also @@ -633,10 +644,10 @@ If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP frame ### Additional examples -| Feature | Description | Example | -|---------|-------------|---------| -| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | -| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts) | -| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/inMemoryEventStore.ts) | -| CORS | Expose MCP headers for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleStreamableHttpServer.ts) | -| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | +| Feature | Description | Example | +| ---------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/inMemoryEventStore.ts) | +| CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | diff --git a/examples/oauth/README.md b/examples/oauth/README.md index f2941216cd..254ec8601e 100644 --- a/examples/oauth/README.md +++ b/examples/oauth/README.md @@ -1,6 +1,9 @@ # oauth (excluded) -The interactive OAuth set: full browser authorization-code flow, URL-elicitation end-to-end, readline REPL clients, dual-mode auth (host token vs `OAuthClientProvider`), client_credentials / private-key-JWT. Typecheck-only — these need a browser, a callback server on `:8090`, and -(for client_credentials) an Authorization Server that doesn't ship in-repo. +The interactive authorization-code OAuth set, typecheck-only. Excluded from the harness (`package.json#example.excluded`) because the browser flow needs a real browser and a callback server on `:8090`. -Excluded from the harness (`manifest.json#excluded`); revisit after the auth-surface walk. For the headless bearer-token resource-server case see `../bearer-auth/`. +- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — full browser authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server, exchanges the code, then drops into a small `list`/`call` REPL. +- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`. +- `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens. + +For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../mrtr/`; for the interactive readline playground see `../repl/`. diff --git a/examples/oauth/elicitationUrlClient.ts b/examples/oauth/elicitationUrlClient.ts deleted file mode 100644 index 7c5cce2ee2..0000000000 --- a/examples/oauth/elicitationUrlClient.ts +++ /dev/null @@ -1,824 +0,0 @@ -// Run with: pnpm tsx src/elicitationUrlExample.ts -// -// This example demonstrates how to use URL elicitation to securely -// collect user input in a remote (HTTP) server. -// URL elicitation allows servers to prompt the end-user to open a URL in their browser -// to collect sensitive information. - -import { createServer } from 'node:http'; -import { createInterface } from 'node:readline'; - -import type { - ElicitRequest, - ElicitRequestURLParams, - ElicitResult, - ListToolsRequest, - OAuthClientMetadata, - ResourceLink -} from '@modelcontextprotocol/client'; -import { - Client, - getDisplayName, - ProtocolError, - ProtocolErrorCode, - StreamableHTTPClientTransport, - UnauthorizedError, - UrlElicitationRequiredError -} from '@modelcontextprotocol/client'; -import open from 'open'; - -import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; - -// Set up OAuth (required for this example) -const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001) -const OAUTH_CALLBACK_URL = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`; - -console.log('Getting OAuth token...'); -const clientMetadata: OAuthClientMetadata = { - client_name: 'Elicitation MCP Client', - redirect_uris: [OAUTH_CALLBACK_URL], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - scope: 'mcp:tools' -}; -const oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); - openBrowser(redirectUrl.toString()); -}); - -// Create readline interface for user input -const readline = createInterface({ - input: process.stdin, - output: process.stdout -}); -let abortCommand = new AbortController(); - -// Global client and transport for interactive commands -let client: Client | null = null; -let transport: StreamableHTTPClientTransport | null = null; -let serverUrl = 'http://localhost:3000/mcp'; -let sessionId: string | undefined; - -// Elicitation queue management -interface QueuedElicitation { - request: ElicitRequest; - resolve: (result: ElicitResult) => void; - reject: (error: Error) => void; -} - -let isProcessingCommand = false; -let isProcessingElicitations = false; -const elicitationQueue: QueuedElicitation[] = []; -let elicitationQueueSignal: (() => void) | null = null; -let elicitationsCompleteSignal: (() => void) | null = null; - -// Map to track pending URL elicitations waiting for completion notifications -const pendingURLElicitations = new Map< - string, - { - resolve: () => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - } ->(); - -async function main(): Promise { - console.log('MCP Interactive Client'); - console.log('====================='); - - // Connect to server immediately with default settings - await connect(); - - // Start the elicitation loop in the background - elicitationLoop().catch(error => { - console.error('Unexpected error in elicitation loop:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - }); - - // Short delay allowing the server to send any SSE elicitations on connection - await new Promise(resolve => setTimeout(resolve, 200)); - - // Wait until we are done processing any initial elicitations - await waitForElicitationsToComplete(); - - // Print help and start the command loop - printHelp(); - await commandLoop(); -} - -async function waitForElicitationsToComplete(): Promise { - // Wait until the queue is empty and nothing is being processed - while (elicitationQueue.length > 0 || isProcessingElicitations) { - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - -function printHelp(): void { - console.log('\nAvailable commands:'); - console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); - console.log(' disconnect - Disconnect from server'); - console.log(' terminate-session - Terminate the current session'); - console.log(' reconnect - Reconnect to the server'); - console.log(' list-tools - List available tools'); - console.log(' call-tool [args] - Call a tool with optional JSON arguments'); - console.log(' payment-confirm - Test URL elicitation via error response with payment-confirm tool'); - console.log(' third-party-auth - Test tool that requires third-party OAuth credentials'); - console.log(' help - Show this help'); - console.log(' quit - Exit the program'); -} - -async function commandLoop(): Promise { - await new Promise(resolve => { - if (isProcessingElicitations) { - elicitationsCompleteSignal = resolve; - } else { - resolve(); - } - }); - - readline.question('\n> ', { signal: abortCommand.signal }, async input => { - isProcessingCommand = true; - - const args = input.trim().split(/\s+/); - const command = args[0]?.toLowerCase(); - - try { - switch (command) { - case 'connect': { - await connect(args[1]); - break; - } - - case 'disconnect': { - await disconnect(); - break; - } - - case 'terminate-session': { - await terminateSession(); - break; - } - - case 'reconnect': { - await reconnect(); - break; - } - - case 'list-tools': { - await listTools(); - break; - } - - case 'call-tool': { - if (args.length < 2) { - console.log('Usage: call-tool [args]'); - } else { - const toolName = args[1]!; - let toolArgs = {}; - if (args.length > 2) { - try { - toolArgs = JSON.parse(args.slice(2).join(' ')); - } catch { - console.log('Invalid JSON arguments. Using empty args.'); - } - } - await callTool(toolName, toolArgs); - } - break; - } - - case 'payment-confirm': { - await callPaymentConfirmTool(); - break; - } - - case 'third-party-auth': { - await callThirdPartyAuthTool(); - break; - } - - case 'help': { - printHelp(); - break; - } - - case 'quit': - case 'exit': { - await cleanup(); - return; - } - - default: { - if (command) { - console.log(`Unknown command: ${command}`); - } - break; - } - } - } catch (error) { - console.error(`Error executing command: ${error}`); - } finally { - isProcessingCommand = false; - } - - // Process another command after we've processed the this one - await commandLoop(); - }); -} - -async function elicitationLoop(): Promise { - while (true) { - // Wait until we have elicitations to process - await new Promise(resolve => { - if (elicitationQueue.length > 0) { - resolve(); - } else { - elicitationQueueSignal = resolve; - } - }); - - isProcessingElicitations = true; - abortCommand.abort(); // Abort the command loop if it's running - - // Process all queued elicitations - while (elicitationQueue.length > 0) { - const queued = elicitationQueue.shift()!; - console.log(`📤 Processing queued elicitation (${elicitationQueue.length} remaining)`); - - try { - const result = await handleElicitationRequest(queued.request); - queued.resolve(result); - } catch (error) { - queued.reject(error instanceof Error ? error : new Error(String(error))); - } - } - - console.log('✅ All queued elicitations processed. Resuming command loop...\n'); - isProcessingElicitations = false; - - // Reset the abort controller for the next command loop - abortCommand = new AbortController(); - - // Resume the command loop - if (elicitationsCompleteSignal) { - elicitationsCompleteSignal(); - elicitationsCompleteSignal = null; - } - } -} - -const ALLOWED_SCHEMES = new Set(['http:', 'https:']); - -async function openBrowser(url: string): Promise { - try { - const parsed = new URL(url); - if (!ALLOWED_SCHEMES.has(parsed.protocol)) { - console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`); - return; - } - } catch { - console.error(`Invalid URL: ${url}`); - return; - } - - try { - await open(url); - } catch { - console.log(`Please manually open: ${url}`); - } -} - -/** - * Enqueues an elicitation request and returns the result. - * - * This function is used so that our CLI (which can only handle one input request at a time) - * can handle elicitation requests and the command loop. - * - * @param request - The elicitation request to be handled - * @returns The elicitation result - */ -async function elicitationRequestHandler(request: ElicitRequest): Promise { - // If we are processing a command, handle this elicitation immediately - if (isProcessingCommand) { - console.log('📋 Processing elicitation immediately (during command execution)'); - return await handleElicitationRequest(request); - } - - // Otherwise, queue the request to be handled by the elicitation loop - console.log(`📥 Queueing elicitation request (queue size will be: ${elicitationQueue.length + 1})`); - - return new Promise((resolve, reject) => { - elicitationQueue.push({ - request, - resolve, - reject - }); - - // Signal the elicitation loop that there's work to do - if (elicitationQueueSignal) { - elicitationQueueSignal(); - elicitationQueueSignal = null; - } - }); -} - -/** - * Handles an elicitation request. - * - * This function is used to handle the elicitation request and return the result. - * - * @param request - The elicitation request to be handled - * @returns The elicitation result - */ -async function handleElicitationRequest(request: ElicitRequest): Promise { - const mode = request.params.mode; - console.log('\n🔔 Elicitation Request Received:'); - console.log(`Mode: ${mode}`); - - if (mode === 'url') { - return { - action: await handleURLElicitation(request.params as ElicitRequestURLParams) - }; - } else { - // Should not happen because the client declares its capabilities to the server, - // but being defensive is a good practice: - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${mode}`); - } -} - -/** - * Handles a URL elicitation by opening the URL in the browser. - * - * Note: This is a shared code for both request handlers and error handlers. - * As a result of sharing schema, there is no big forking of logic for the client. - * - * @param params - The URL elicitation request parameters - * @returns The action to take (accept, cancel, or decline) - */ -async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - const url = params.url; - const elicitationId = params.elicitationId; - const message = params.message; - console.log(`🆔 Elicitation ID: ${elicitationId}`); // Print for illustration - - // Parse URL to show domain for security - let domain = 'unknown domain'; - try { - const parsedUrl = new URL(url); - domain = parsedUrl.hostname; - } catch { - console.error('Invalid URL provided by server'); - return 'decline'; - } - - // Example security warning to help prevent phishing attacks - console.log('\n⚠️ \u001B[33mSECURITY WARNING\u001B[0m ⚠️'); - console.log('\u001B[33mThe server is requesting you to open an external URL.\u001B[0m'); - console.log('\u001B[33mOnly proceed if you trust this server and understand why it needs this.\u001B[0m\n'); - console.log(`🌐 Target domain: \u001B[36m${domain}\u001B[0m`); - console.log(`🔗 Full URL: \u001B[36m${url}\u001B[0m`); - console.log(`\nℹ️ Server's reason:\n\n\u001B[36m${message}\u001B[0m\n`); - - // 1. Ask for user consent to open the URL - const consent = await new Promise(resolve => { - readline.question('\nDo you want to open this URL in your browser? (y/n): ', input => { - resolve(input.trim().toLowerCase()); - }); - }); - - // 2. If user did not consent, return appropriate result - if (consent === 'no' || consent === 'n') { - console.log('❌ URL navigation declined.'); - return 'decline'; - } else if (consent !== 'yes' && consent !== 'y') { - console.log('🚫 Invalid response. Cancelling elicitation.'); - return 'cancel'; - } - - // 3. Wait for completion notification in the background - const completionPromise = new Promise((resolve, reject) => { - const timeout = setTimeout( - () => { - pendingURLElicitations.delete(elicitationId); - console.log(`\u001B[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\u001B[0m`); - reject(new Error('Elicitation completion timeout')); - }, - 5 * 60 * 1000 - ); // 5 minute timeout - - pendingURLElicitations.set(elicitationId, { - resolve: () => { - clearTimeout(timeout); - resolve(); - }, - reject, - timeout - }); - }); - - completionPromise.catch(error => { - console.error('Background completion wait failed:', error); - }); - - // 4. Open the URL in the browser - console.log(`\n🚀 Opening browser to: ${url}`); - await openBrowser(url); - - console.log('\n⏳ Waiting for you to complete the interaction in your browser...'); - console.log(' The server will send a notification once you complete the action.'); - - // 5. Acknowledge the user accepted the elicitation - return 'accept'; -} - -/** - * Example OAuth callback handler - in production, use a more robust approach - * for handling callbacks and storing tokens - */ -/** - * Starts a temporary HTTP server to receive the OAuth callback - */ -async function waitForOAuthCallback(): Promise { - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - // Ignore favicon requests - if (req.url === '/favicon.ico') { - res.writeHead(404); - res.end(); - return; - } - - console.log(`📥 Received callback: ${req.url}`); - const parsedUrl = new URL(req.url || '', 'http://localhost'); - const code = parsedUrl.searchParams.get('code'); - const error = parsedUrl.searchParams.get('error'); - - if (code) { - console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`); - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authorization Successful!

-

This simulates successful authorization of the MCP client, which now has an access token for the MCP server.

-

This window will close automatically in 10 seconds.

- - - - `); - - resolve(code); - setTimeout(() => server.close(), 15_000); - } else if (error) { - console.log(`❌ Authorization error: ${error}`); - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authorization Failed

-

Error: ${error}

- - - `); - reject(new Error(`OAuth authorization failed: ${error}`)); - } else { - console.log(`❌ No authorization code or error in callback`); - res.writeHead(400); - res.end('Bad request'); - reject(new Error('No authorization code provided')); - } - }); - - server.listen(OAUTH_CALLBACK_PORT, () => { - console.log(`OAuth callback server started on http://localhost:${OAUTH_CALLBACK_PORT}`); - }); - }); -} - -/** - * Attempts to connect to the MCP server with OAuth authentication. - * Handles OAuth flow recursively if authorization is required. - */ -async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { - console.log('🚢 Creating transport with OAuth provider...'); - const baseUrl = new URL(serverUrl); - transport = new StreamableHTTPClientTransport(baseUrl, { - sessionId: sessionId, - authProvider: oauthProvider - }); - console.log('🚢 Transport created'); - - try { - console.log('🔌 Attempting connection (this will trigger OAuth redirect if needed)...'); - await client!.connect(transport); - sessionId = transport.sessionId; - console.log('Transport created with session ID:', sessionId); - console.log('✅ Connected successfully'); - } catch (error) { - if (error instanceof UnauthorizedError) { - console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); - console.log('🔐 Authorization code received:', authCode); - console.log('🔌 Reconnecting with authenticated transport...'); - // Recursively retry connection after OAuth completion - await attemptConnection(oauthProvider); - } else { - console.error('❌ Connection failed with non-auth error:', error); - throw error; - } - } -} - -async function connect(url?: string): Promise { - if (client) { - console.log('Already connected. Disconnect first.'); - return; - } - - if (url) { - serverUrl = url; - } - - console.log(`🔗 Attempting to connect to ${serverUrl}...`); - - // Create a new client with elicitation capability - console.log('👤 Creating MCP client...'); - client = new Client( - { - name: 'example-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - // Only URL elicitation is supported in this demo - // (see server/elicitationExample.ts for a demo of form mode elicitation) - url: {} - } - } - } - ); - console.log('👤 Client created'); - - // Set up elicitation request handler with proper validation - client.setRequestHandler('elicitation/create', elicitationRequestHandler); - - // Set up notification handler for elicitation completion - client.setNotificationHandler('notifications/elicitation/complete', notification => { - const { elicitationId } = notification.params; - const pending = pendingURLElicitations.get(elicitationId); - if (pending) { - clearTimeout(pending.timeout); - pendingURLElicitations.delete(elicitationId); - console.log(`\u001B[32m✅ Elicitation ${elicitationId} completed!\u001B[0m`); - pending.resolve(); - } else { - // Shouldn't happen - discard it! - console.warn(`Received completion notification for unknown elicitation: ${elicitationId}`); - } - }); - - try { - console.log('🔐 Starting OAuth flow...'); - await attemptConnection(oauthProvider!); - console.log('Connected to MCP server'); - - // Set up error handler after connection is established so we don't double log errors - client.onerror = error => { - console.error('\u001B[31mClient error:', error, '\u001B[0m'); - }; - } catch (error) { - console.error('Failed to connect:', error); - client = null; - transport = null; - return; - } -} - -async function disconnect(): Promise { - if (!client || !transport) { - console.log('Not connected.'); - return; - } - - try { - await transport.close(); - console.log('Disconnected from MCP server'); - client = null; - transport = null; - } catch (error) { - console.error('Error disconnecting:', error); - } -} - -async function terminateSession(): Promise { - if (!client || !transport) { - console.log('Not connected.'); - return; - } - - try { - console.log('Terminating session with ID:', transport.sessionId); - await transport.terminateSession(); - console.log('Session terminated successfully'); - - // Check if sessionId was cleared after termination - if (transport.sessionId) { - console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); - console.log('Session ID is still active:', transport.sessionId); - } else { - console.log('Session ID has been cleared'); - sessionId = undefined; - - // Also close the transport and clear client objects - await transport.close(); - console.log('Transport closed after session termination'); - client = null; - transport = null; - } - } catch (error) { - console.error('Error terminating session:', error); - } -} - -async function reconnect(): Promise { - if (client) { - await disconnect(); - } - await connect(); -} - -async function listTools(): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server (${error})`); - } -} - -async function callTool(name: string, args: Record): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - try { - console.log(`Calling tool '${name}' with args:`, args); - const result = await client.callTool({ name, arguments: args }); - - console.log('Tool result:'); - const resourceLinks: ResourceLink[] = []; - - for (const item of result.content) { - switch (item.type) { - case 'text': { - console.log(` ${item.text}`); - - break; - } - case 'resource_link': { - const resourceLink = item as ResourceLink; - resourceLinks.push(resourceLink); - console.log(` 📁 Resource Link: ${resourceLink.name}`); - console.log(` URI: ${resourceLink.uri}`); - if (resourceLink.mimeType) { - console.log(` Type: ${resourceLink.mimeType}`); - } - if (resourceLink.description) { - console.log(` Description: ${resourceLink.description}`); - } - - break; - } - case 'resource': { - console.log(` [Embedded Resource: ${item.resource.uri}]`); - - break; - } - case 'image': { - console.log(` [Image: ${item.mimeType}]`); - - break; - } - case 'audio': { - console.log(` [Audio: ${item.mimeType}]`); - - break; - } - default: { - console.log(` [Unknown content type]:`, item); - } - } - } - - // Offer to read resource links - if (resourceLinks.length > 0) { - console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); - } - } catch (error) { - if (error instanceof UrlElicitationRequiredError) { - console.log('\n🔔 Elicitation Required Error Received:'); - console.log(`Message: ${error.message}`); - for (const e of error.elicitations) { - await handleURLElicitation(e); // For the error handler, we discard the action result because we don't respond to an error response - } - return; - } - console.log(`Error calling tool ${name}: ${error}`); - } -} - -async function cleanup(): Promise { - if (client && transport) { - try { - // First try to terminate the session gracefully - if (transport.sessionId) { - try { - console.log('Terminating session before exit...'); - await transport.terminateSession(); - console.log('Session terminated successfully'); - } catch (error) { - console.error('Error terminating session:', error); - } - } - - // Then close the transport - await transport.close(); - } catch (error) { - console.error('Error closing transport:', error); - } - } - - process.stdin.setRawMode(false); - readline.close(); - console.log('\nGoodbye!'); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -} - -async function callPaymentConfirmTool(): Promise { - console.log('Calling payment-confirm tool...'); - await callTool('payment-confirm', { cartId: 'cart_123' }); -} - -async function callThirdPartyAuthTool(): Promise { - console.log('Calling third-party-auth tool...'); - await callTool('third-party-auth', { param1: 'test' }); -} - -// Set up raw mode for keyboard input to capture Escape key -process.stdin.setRawMode(true); -process.stdin.on('data', async data => { - // Check for Escape key (27) - if (data.length === 1 && data[0] === 27) { - console.log('\nESC key pressed. Disconnecting from server...'); - - // Abort current operation and disconnect from server - if (client && transport) { - await disconnect(); - console.log('Disconnected. Press Enter to continue.'); - } else { - console.log('Not connected to server.'); - } - - // Re-display the prompt - process.stdout.write('> '); - } -}); - -// Handle Ctrl+C -process.on('SIGINT', async () => { - console.log('\nReceived SIGINT. Cleaning up...'); - await cleanup(); -}); - -// Start the interactive client -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/oauth/elicitationUrlServer.ts b/examples/oauth/elicitationUrlServer.ts deleted file mode 100644 index 78718c05fc..0000000000 --- a/examples/oauth/elicitationUrlServer.ts +++ /dev/null @@ -1,738 +0,0 @@ -// Run with: pnpm tsx src/elicitationUrlExample.ts -// -// This example demonstrates how to use URL elicitation to securely collect -// *sensitive* user input in a remote (HTTP) server. -// URL elicitation allows servers to prompt the end-user to open a URL in their browser -// to collect sensitive information. -// Note: See also elicitationFormExample.ts for an example of using form (not URL) elicitation -// to collect *non-sensitive* user input with a structured schema. - -import { randomUUID } from 'node:crypto'; - -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; -import cors from 'cors'; -import type { Request, Response } from 'express'; -import express from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from '../sse-polling/inMemoryEventStore.js'; - -// Create an MCP server with implementation details -const getServer = () => { - const mcpServer = new McpServer( - { - name: 'url-elicitation-http-server', - version: '1.0.0' - }, - { - capabilities: { logging: {} } - } - ); - - mcpServer.registerTool( - 'payment-confirm', - { - description: 'A tool that confirms a payment directly with a user', - inputSchema: z.object({ - cartId: z.string().describe('The ID of the cart to confirm') - }) - }, - async ({ cartId }, ctx): Promise => { - /* - In a real world scenario, there would be some logic here to check if the user has the provided cartId. - For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to confirm payment) - */ - const sessionId = ctx.sessionId; - if (!sessionId) { - throw new Error('Expected a Session ID'); - } - - // Create and track the elicitation - const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) - ); - throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires a payment confirmation. Open the link to confirm payment!', - url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, - elicitationId - } - ]); - } - ); - - mcpServer.registerTool( - 'third-party-auth', - { - description: 'A demo tool that requires third-party OAuth credentials', - inputSchema: z.object({ - param1: z.string().describe('First parameter') - }) - }, - async (_, ctx): Promise => { - /* - In a real world scenario, there would be some logic here to check if we already have a valid access token for the user. - Auth info (with a subject or `sub` claim) can be typically be found in `ctx.http?.authInfo`. - If we do, we can just return the result of the tool call. - If we don't, we can throw an ElicitationRequiredError to request the user to authenticate. - For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to authenticate). - */ - const sessionId = ctx.sessionId; - if (!sessionId) { - throw new Error('Expected a Session ID'); - } - - // Create and track the elicitation - const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) - ); - - // Simulate OAuth callback and token exchange after 5 seconds - // In a real app, this would be called from your OAuth callback handler - setTimeout(() => { - console.log(`Simulating OAuth token received for elicitation ${elicitationId}`); - completeURLElicitation(elicitationId); - }, 5000); - - throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires access to your example.com account. Open the link to authenticate!', - url: 'https://www.example.com/oauth/authorize', - elicitationId - } - ]); - } - ); - - return mcpServer; -}; - -/** - * Elicitation Completion Tracking Utilities - **/ - -interface ElicitationMetadata { - status: 'pending' | 'complete'; - completedPromise: Promise; - completeResolver: () => void; - createdAt: Date; - sessionId: string; - completionNotifier?: () => Promise; -} - -const elicitationsMap = new Map(); - -// Clean up old elicitations after 1 hour to prevent memory leaks -const ELICITATION_TTL_MS = 60 * 60 * 1000; // 1 hour -const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes - -function cleanupOldElicitations() { - const now = new Date(); - for (const [id, metadata] of elicitationsMap.entries()) { - if (now.getTime() - metadata.createdAt.getTime() > ELICITATION_TTL_MS) { - elicitationsMap.delete(id); - console.log(`Cleaned up expired elicitation: ${id}`); - } - } -} - -setInterval(cleanupOldElicitations, CLEANUP_INTERVAL_MS); - -/** - * Elicitation IDs must be unique strings within the MCP session - * UUIDs are used in this example for simplicity - */ -function generateElicitationId(): string { - return randomUUID(); -} - -/** - * Helper function to create and track a new elicitation. - */ -function generateTrackedElicitation(sessionId: string, createCompletionNotifier?: ElicitationCompletionNotifierFactory): string { - const elicitationId = generateElicitationId(); - - // Create a Promise and its resolver for tracking completion - let completeResolver: () => void; - const completedPromise = new Promise(resolve => { - completeResolver = resolve; - }); - - const completionNotifier = createCompletionNotifier ? createCompletionNotifier(elicitationId) : undefined; - - // Store the elicitation in our map - elicitationsMap.set(elicitationId, { - status: 'pending', - completedPromise, - completeResolver: completeResolver!, - createdAt: new Date(), - sessionId, - completionNotifier - }); - - return elicitationId; -} - -/** - * Helper function to complete an elicitation. - */ -function completeURLElicitation(elicitationId: string) { - const elicitation = elicitationsMap.get(elicitationId); - if (!elicitation) { - console.warn(`Attempted to complete unknown elicitation: ${elicitationId}`); - return; - } - - if (elicitation.status === 'complete') { - console.warn(`Elicitation already complete: ${elicitationId}`); - return; - } - - // Update metadata - elicitation.status = 'complete'; - - // Send completion notification to the client - if (elicitation.completionNotifier) { - console.log(`Sending notifications/elicitation/complete notification for elicitation ${elicitationId}`); - - elicitation.completionNotifier().catch(error => { - console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error); - }); - } - - // Resolve the promise to unblock any waiting code - elicitation.completeResolver(); -} - -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// Allow CORS all domains, expose the Mcp-Session-Id header -app.use( - cors({ - origin: '*', // Allow all origins - exposedHeaders: ['Mcp-Session-Id'], - credentials: true // Allow cookies to be sent cross-origin - }) -); - -// Set up OAuth (required for this example) -let authMiddleware = null; -// Create auth middleware for MCP endpoints -const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); -const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - -setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true }); - -// Add protected resource metadata route to the MCP server -// This allows clients to discover the auth server -// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp -app.use(createProtectedResourceMetadataRouter('/mcp')); - -authMiddleware = requireBearerAuth({ - verifier: demoTokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -/** - * API Key Form Handling - * - * Many servers today require an API key to operate, but there's no scalable way to do this dynamically for remote servers within MCP protocol. - * URL-mode elicitation enables the server to host a simple form and get the secret data securely from the user without involving the LLM or client. - **/ - -async function sendApiKeyElicitation( - sessionId: string, - sender: ElicitationSender, - createCompletionNotifier: ElicitationCompletionNotifierFactory -) { - if (!sessionId) { - console.error('No session ID provided'); - throw new Error('Expected a Session ID to track elicitation'); - } - - console.log('🔑 URL elicitation demo: Requesting API key from client...'); - const elicitationId = generateTrackedElicitation(sessionId, createCompletionNotifier); - try { - const result = await sender({ - mode: 'url', - message: 'Please provide your API key to authenticate with this server', - // Host the form on the same server. In a real app, you might coordinate passing these state variables differently. - url: `http://localhost:${MCP_PORT}/api-key-form?session=${sessionId}&elicitation=${elicitationId}`, - elicitationId - }); - - switch (result.action) { - case 'accept': { - console.log('🔑 URL elicitation demo: Client accepted the API key elicitation (now pending form submission)'); - // Wait for the API key to be submitted via the form - // The form submission will complete the elicitation - break; - } - default: { - console.log('🔑 URL elicitation demo: Client declined to provide an API key'); - // In a real app, this might close the connection, but for the demo, we'll continue - break; - } - } - } catch (error) { - console.error('Error during API key elicitation:', error); - } -} - -// API Key Form endpoint - serves a simple HTML form -app.get('/api-key-form', (req: Request, res: Response) => { - const mcpSessionId = req.query.session as string | undefined; - const elicitationId = req.query.elicitation as string | undefined; - if (!mcpSessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie - // In production, this is often handled by some user auth middleware to ensure the user has a valid session - // This session is different from the MCP session. - // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // Serve a simple HTML form - res.send(` - - - - Submit Your API Key - - - -

API Key Required

-
✓ Logged in as: ${userSession.name}
-
- - - - -
-
This is a demo showing how a server can securely elicit sensitive data from a user using a URL.
- - - `); -}); - -// Handle API key form submission -app.post('/api-key-form', express.urlencoded(), (req: Request, res: Response) => { - const { session: sessionId, apiKey, elicitation: elicitationId } = req.body; - if (!sessionId || !apiKey || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie here too - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // A real app might store this API key to be used later for the user. - console.log(`🔑 Received API key \u001B[32m${apiKey}\u001B[0m for session ${sessionId}`); - - // If we have an elicitationId, complete the elicitation - completeURLElicitation(elicitationId); - - // Send a success response - res.send(` - - - - Success - - - -
-

Success ✓

-

API key received.

-
-

You can close this window and return to your MCP client.

- - - `); -}); - -// Helper to get the user session from the demo_session cookie -function getUserSessionCookie(cookieHeader?: string): { userId: string; name: string; timestamp: number } | null { - if (!cookieHeader) return null; - - const cookies = cookieHeader.split(';'); - for (const cookie of cookies) { - const [name, value] = cookie.trim().split('='); - if (name === 'demo_session' && value) { - try { - return JSON.parse(decodeURIComponent(value)); - } catch (error) { - console.error('Failed to parse demo_session cookie:', error); - return null; - } - } - } - return null; -} - -/** - * Payment Confirmation Form Handling - * - * This demonstrates how a server can use URL-mode elicitation to get user confirmation - * for sensitive operations like payment processing. - **/ - -// Payment Confirmation Form endpoint - serves a simple HTML form -app.get('/confirm-payment', (req: Request, res: Response) => { - const mcpSessionId = req.query.session as string | undefined; - const elicitationId = req.query.elicitation as string | undefined; - const cartId = req.query.cartId as string | undefined; - if (!mcpSessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie - // In production, this is often handled by some user auth middleware to ensure the user has a valid session - // This session is different from the MCP session. - // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // Serve a simple HTML form - res.send(` - - - - Confirm Payment - - - -

Confirm Payment

-
✓ Logged in as: ${userSession.name}
- ${cartId ? `
Cart ID: ${cartId}
` : ''} -
- ⚠️ Please review your order before confirming. -
-
- - - ${cartId ? `` : ''} - - -
-
This is a demo showing how a server can securely get user confirmation for sensitive operations using URL-mode elicitation.
- - - `); -}); - -// Handle Payment Confirmation form submission -app.post('/confirm-payment', express.urlencoded(), (req: Request, res: Response) => { - const { session: sessionId, elicitation: elicitationId, cartId, action } = req.body; - if (!sessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie here too - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - if (action === 'confirm') { - // A real app would process the payment here - console.log(`💳 Payment confirmed for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); - - // Complete the elicitation - completeURLElicitation(elicitationId); - - // Send a success response - res.send(` - - - - Payment Confirmed - - - -
-

Payment Confirmed ✓

-

Your payment has been successfully processed.

- ${cartId ? `

Cart ID: ${cartId}

` : ''} -
-

You can close this window and return to your MCP client.

- - - `); - } else if (action === 'cancel') { - console.log(`💳 Payment cancelled for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); - - // The client will still receive a notifications/elicitation/complete notification, - // which indicates that the out-of-band interaction is complete (but not necessarily successful) - completeURLElicitation(elicitationId); - - res.send(` - - - - Payment Cancelled - - - -
-

Payment Cancelled

-

Your payment has been cancelled.

-
-

You can close this window and return to your MCP client.

- - - `); - } else { - res.status(400).send('

Error

Invalid action

'); - } -}); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// Interface for a function that can send an elicitation request -type ElicitationSender = (params: ElicitRequestURLParams) => Promise; -type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise; - -// Track sessions that need an elicitation request to be sent -interface SessionElicitationInfo { - elicitationSender: ElicitationSender; - createCompletionNotifier: ElicitationCompletionNotifierFactory; -} -const sessionsNeedingElicitation: { [sessionId: string]: SessionElicitationInfo } = {}; - -// MCP POST endpoint -const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - console.debug(`Received MCP POST for session: ${sessionId || 'unknown'}`); - - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - const server = getServer(); - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - sessionsNeedingElicitation[sessionId] = { - elicitationSender: params => server.server.elicitInput(params), - createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId) - }; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - delete sessionsNeedingElicitation[sid]; - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}; - -// Set up routes with auth middleware -app.post('/mcp', authMiddleware, mcpPostHandler); - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - - if (sessionsNeedingElicitation[sessionId]) { - const { elicitationSender, createCompletionNotifier } = sessionsNeedingElicitation[sessionId]; - - // Send an elicitation request to the client in the background - sendApiKeyElicitation(sessionId, elicitationSender, createCompletionNotifier) - .then(() => { - // Only delete on successful send for this demo - delete sessionsNeedingElicitation[sessionId]; - console.log(`🔑 URL elicitation demo: Finished sending API key elicitation request for session ${sessionId}`); - }) - .catch(error => { - console.error('Error sending API key elicitation:', error); - // Keep in map to potentially retry on next reconnect - }); - } -}; - -// Set up GET route with conditional auth middleware -app.get('/mcp', authMiddleware, mcpGetHandler); - -// Handle DELETE requests for session termination (according to MCP spec) -const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } -}; - -// Set up DELETE route with auth middleware -app.delete('/mcp', authMiddleware, mcpDeleteHandler); - -app.listen(MCP_PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - delete sessionsNeedingElicitation[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/oauth/package.json b/examples/oauth/package.json index e981eef47a..14638ca832 100644 --- a/examples/oauth/package.json +++ b/examples/oauth/package.json @@ -4,20 +4,12 @@ "type": "module", "dependencies": { "@modelcontextprotocol/client": "workspace:*", - "@mcp-examples/shared": "workspace:*", - "@modelcontextprotocol/express": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", - "@modelcontextprotocol/server": "workspace:*", - "ajv": "catalog:runtimeShared", - "cors": "catalog:runtimeServerOnly", - "express": "catalog:runtimeServerOnly", - "open": "^11.0.0", - "zod": "catalog:runtimeShared" + "open": "^11.0.0" }, "devDependencies": { "tsx": "catalog:devTools" }, "example": { - "excluded": "Interactive authorization-code OAuth flows: browser auth, readline REPLs, callback server on :8090. The machine-to-machine client_credentials grant is covered by ../oauth-client-credentials/. Revisit after the auth-surface walk." + "excluded": "Interactive authorization-code OAuth flow: browser auth + callback server on :8090. Machine-to-machine client_credentials is ../oauth-client-credentials/; the readline playground is ../repl/." } } diff --git a/examples/oauth/simpleClientCredentials.ts b/examples/oauth/simpleClientCredentials.ts deleted file mode 100644 index 58f17e312a..0000000000 --- a/examples/oauth/simpleClientCredentials.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node - -/** - * Example demonstrating client_credentials grant for machine-to-machine authentication. - * - * Supports two authentication methods based on environment variables: - * - * 1. client_secret_basic (default): - * MCP_CLIENT_ID - OAuth client ID (required) - * MCP_CLIENT_SECRET - OAuth client secret (required) - * - * 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set): - * MCP_CLIENT_ID - OAuth client ID (required) - * MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required) - * MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256) - * - * Common: - * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) - */ - -import type { OAuthClientProvider } from '@modelcontextprotocol/client'; -import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; - -function createProvider(): OAuthClientProvider { - const clientId = process.env.MCP_CLIENT_ID; - if (!clientId) { - console.error('MCP_CLIENT_ID environment variable is required'); - process.exit(1); - } - - // If private key is provided, use private_key_jwt authentication - const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM; - if (privateKeyPem) { - const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256'; - console.log('Using private_key_jwt authentication'); - return new PrivateKeyJwtProvider({ - clientId, - privateKey: privateKeyPem, - algorithm - }); - } - - // Otherwise, use client_secret_basic authentication - const clientSecret = process.env.MCP_CLIENT_SECRET; - if (!clientSecret) { - console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required'); - process.exit(1); - } - - console.log('Using client_secret_basic authentication'); - return new ClientCredentialsProvider({ - clientId, - clientSecret - }); -} - -async function main() { - const provider = createProvider(); - - const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} }); - - const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { - authProvider: provider - }); - - await client.connect(transport); - console.log('Connected successfully.'); - - const tools = await client.listTools(); - console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); - - await transport.close(); -} - -try { - await main(); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/oauth/simpleOAuthClient.ts b/examples/oauth/simpleOAuthClient.ts index 1187f8ec1a..cf438fa7d4 100644 --- a/examples/oauth/simpleOAuthClient.ts +++ b/examples/oauth/simpleOAuthClient.ts @@ -15,6 +15,11 @@ const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; const CALLBACK_PORT = 8090; // Use different port than auth server (3001) const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; +/** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */ +function escHtml(s: string): string { + return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + /** * Interactive MCP client with OAuth authentication * Demonstrates the complete OAuth flow with browser-based authorization @@ -109,7 +114,7 @@ class InteractiveOAuthClient {

Authorization Failed

-

Error: ${error}

+

Error: ${escHtml(error)}

`); diff --git a/examples/oauth/simpleStreamableHttpServer.ts b/examples/oauth/simpleStreamableHttpServer.ts deleted file mode 100644 index 6237e7130b..0000000000 --- a/examples/oauth/simpleStreamableHttpServer.ts +++ /dev/null @@ -1,658 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { - CallToolResult, - GetPromptResult, - PrimitiveSchemaDefinition, - ReadResourceResult, - ResourceLink -} from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import cors from 'cors'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from '../sse-polling/inMemoryEventStore.js'; - -// Check for OAuth flag -const useOAuth = process.argv.includes('--oauth'); -const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled'); - -// Create an MCP server with implementation details -const getServer = () => { - const server = new McpServer( - { - name: 'simple-streamable-http-server', - version: '1.0.0', - icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }], - websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' - }, - { - capabilities: { - logging: {} - } - } - ); - - // Register a simple tool that returns a greeting - server.registerTool( - 'greet', - { - title: 'Greeting Tool', // Display name for UI - description: 'A simple greeting tool', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!` - } - ] - }; - } - ); - - // Register a tool that sends multiple greetings with notifications (with annotations) - server.registerTool( - 'multi-greet', - { - description: 'A tool that sends different greetings with delays between them', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }), - annotations: { - title: 'Multiple Greeting Tool', - readOnlyHint: true, - openWorldHint: false - } - }, - async ({ name }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); - - await sleep(1000); // Wait 1 second before first greeting - - await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); - - await sleep(1000); // Wait another second before second greeting - - await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - // Register a tool that demonstrates form elicitation (user input collection with a schema) - // This creates a closure that captures the server instance - server.registerTool( - 'collect-user-info', - { - description: 'A tool that collects user information through form elicitation', - inputSchema: z.object({ - infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') - }) - }, - async ({ infoType }, ctx): Promise => { - let message: string; - let requestedSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; - - switch (infoType) { - case 'contact': { - message = 'Please provide your contact information'; - requestedSchema = { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Your full name' - }, - email: { - type: 'string', - title: 'Email Address', - description: 'Your email address', - format: 'email' - }, - phone: { - type: 'string', - title: 'Phone Number', - description: 'Your phone number (optional)' - } - }, - required: ['name', 'email'] - }; - break; - } - case 'preferences': { - message = 'Please set your preferences'; - requestedSchema = { - type: 'object', - properties: { - theme: { - type: 'string', - title: 'Theme', - description: 'Choose your preferred theme', - enum: ['light', 'dark', 'auto'], - enumNames: ['Light', 'Dark', 'Auto'] - }, - notifications: { - type: 'boolean', - title: 'Enable Notifications', - description: 'Would you like to receive notifications?', - default: true - }, - frequency: { - type: 'string', - title: 'Notification Frequency', - description: 'How often would you like notifications?', - enum: ['daily', 'weekly', 'monthly'], - enumNames: ['Daily', 'Weekly', 'Monthly'] - } - }, - required: ['theme'] - }; - break; - } - case 'feedback': { - message = 'Please provide your feedback'; - requestedSchema = { - type: 'object', - properties: { - rating: { - type: 'integer', - title: 'Rating', - description: 'Rate your experience (1-5)', - minimum: 1, - maximum: 5 - }, - comments: { - type: 'string', - title: 'Comments', - description: 'Additional comments (optional)', - maxLength: 500 - }, - recommend: { - type: 'boolean', - title: 'Would you recommend this?', - description: 'Would you recommend this to others?' - } - }, - required: ['rating', 'recommend'] - }; - break; - } - default: { - throw new Error(`Unknown info type: ${infoType}`); - } - } - - try { - // Use sendRequest through the ctx parameter to elicit input - const result = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - mode: 'form', - message, - requestedSchema - } - }); - - if (result.action === 'accept') { - return { - content: [ - { - type: 'text', - text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [ - { - type: 'text', - text: `No information was collected. User declined ${infoType} information request.` - } - ] - }; - } else { - return { - content: [ - { - type: 'text', - text: `Information collection was cancelled by the user.` - } - ] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error collecting ${infoType} information: ${error}` - } - ] - }; - } - } - ); - - // Register a simple prompt with title - server.registerPrompt( - 'greeting-template', - { - title: 'Greeting Template', // Display name for UI - description: 'A simple greeting prompt template', - argsSchema: z.object({ - name: z.string().describe('Name to include in greeting') - }) - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.` - } - } - ] - }; - } - ); - - // Register a tool specifically for testing resumability - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: z.object({ - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(50) - }) - }, - async ({ interval, count }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - - // Create a simple resource at a fixed URI - server.registerResource( - 'greeting-resource', - 'https://example.com/greetings/default', - { - title: 'Default Greeting', // Display name for UI - description: 'A simple greeting resource', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!' - } - ] - }; - } - ); - - // Create additional resources for ResourceLink demonstration - server.registerResource( - 'example-file-1', - 'file:///example/file1.txt', - { - title: 'Example File 1', - description: 'First example file for ResourceLink demonstration', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'file:///example/file1.txt', - text: 'This is the content of file 1' - } - ] - }; - } - ); - - server.registerResource( - 'example-file-2', - 'file:///example/file2.txt', - { - title: 'Example File 2', - description: 'Second example file for ResourceLink demonstration', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'file:///example/file2.txt', - text: 'This is the content of file 2' - } - ] - }; - } - ); - - // Register a tool that returns ResourceLinks - server.registerTool( - 'list-files', - { - title: 'List Files with ResourceLinks', - description: 'Returns a list of files as ResourceLinks without embedding their content', - inputSchema: z.object({ - includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links') - }) - }, - async ({ includeDescriptions = true }): Promise => { - const resourceLinks: ResourceLink[] = [ - { - type: 'resource_link', - uri: 'https://example.com/greetings/default', - name: 'Default Greeting', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'A simple greeting resource' }) - }, - { - type: 'resource_link', - uri: 'file:///example/file1.txt', - name: 'Example File 1', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) - }, - { - type: 'resource_link', - uri: 'file:///example/file2.txt', - name: 'Example File 2', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) - } - ]; - - return { - content: [ - { - type: 'text', - text: 'Here are the available files as resource links:' - }, - ...resourceLinks, - { - type: 'text', - text: '\nYou can read any of these resources using their URI.' - } - ] - }; - } - ); - - return server; -}; - -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// Enable CORS for browser-based clients (demo only) -// This allows cross-origin requests and exposes WWW-Authenticate header for OAuth -// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself. -app.use( - cors({ - exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], - origin: '*' // WARNING: This allows all origins to access the MCP server. In production, you should restrict this to specific origins. - }) -); - -// Set up OAuth if enabled -let authMiddleware = null; -if (useOAuth) { - // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); - const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - - setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled }); - - // Add protected resource metadata route to the MCP server - // This allows clients to discover the auth server - // Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp - app.use(createProtectedResourceMetadataRouter('/mcp')); - - authMiddleware = requireBearerAuth({ - verifier: demoTokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - }); -} - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// MCP POST endpoint with optional auth -const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId) { - console.log(`Received MCP request for session: ${sessionId}`); - } else { - console.log('Request body:', req.body); - } - - if (useOAuth && req.auth) { - console.log('Authenticated user:', req.auth); - } - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - const server = getServer(); - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}; - -// Set up routes with conditional auth middleware -if (useOAuth && authMiddleware) { - app.post('/mcp', authMiddleware, mcpPostHandler); -} else { - app.post('/mcp', mcpPostHandler); -} - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - if (useOAuth && req.auth) { - console.log('Authenticated SSE connection from user:', req.auth); - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}; - -// Set up GET route with conditional auth middleware -if (useOAuth && authMiddleware) { - app.get('/mcp', authMiddleware, mcpGetHandler); -} else { - app.get('/mcp', mcpGetHandler); -} - -// Handle DELETE requests for session termination (according to MCP spec) -const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } -}; - -// Set up DELETE route with conditional auth middleware -if (useOAuth && authMiddleware) { - app.delete('/mcp', authMiddleware, mcpDeleteHandler); -} else { - app.delete('/mcp', mcpDeleteHandler); -} - -app.listen(MCP_PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); - if (useOAuth) { - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); - } -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/repl/README.md b/examples/repl/README.md new file mode 100644 index 0000000000..7f444bd011 --- /dev/null +++ b/examples/repl/README.md @@ -0,0 +1,13 @@ +# repl (excluded) + +The interactive playground. A fully-featured HTTP server (tools with input/output schemas + annotations, prompts with completion, direct + templated resources, `notifications/message` logging, `resources/list_changed` published via `handler.notify`) paired with a readline REPL +client that can drive every primitive by hand — `list-tools`, `call-tool`, `list-prompts`, `get-prompt`, `list-resources`, `read-resource`, form elicitation, resumable notification streams. + +Excluded from the harness (`package.json#example.excluded`); run manually: + +```sh +pnpm run server # terminal 1 — listens on http://localhost:3000/mcp +pnpm run client # terminal 2 — readline REPL +``` + +Try `multi-greet Ada`, `collect-info contact`, `call-tool add-resource {"name":"n1","text":"hello"}` then `list-resources`, or `start-notifications 500 5`. diff --git a/examples/oauth/interactiveReplClient.ts b/examples/repl/client.ts similarity index 100% rename from examples/oauth/interactiveReplClient.ts rename to examples/repl/client.ts diff --git a/examples/repl/package.json b/examples/repl/package.json new file mode 100644 index 0000000000..069e4887d7 --- /dev/null +++ b/examples/repl/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcp-examples/repl", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "ajv": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "excluded": "interactive REPL — run manually" + } +} diff --git a/examples/repl/server.ts b/examples/repl/server.ts new file mode 100644 index 0000000000..9af5ebea4d --- /dev/null +++ b/examples/repl/server.ts @@ -0,0 +1,259 @@ +/** + * Fully-featured HTTP playground server for the interactive REPL client. + * + * Exposes every primitive the REPL client (`./client.ts`) can drive: tools + * (typed input/output schemas + annotations + form elicitation + + * `ResourceLink`s), prompts (with `completable()` argument completion), + * resources (direct + `ResourceTemplate`), `notifications/message` logging, + * and `notifications/resources/list_changed` published over the handler's + * cross-request {@link ServerNotifier} so the client's `list_changed` handler + * fires after `add-resource`. + * + * HTTP-only — pair with `pnpm run client` in a second terminal. + */ +import { createServer } from 'node:http'; + +import type { + CallToolResult, + PrimitiveSchemaDefinition, + ReadResourceResult, + ResourceLink, + ServerNotifier +} from '@modelcontextprotocol/server'; +import { completable, createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; + +/** Dynamic resources added via the `add-resource` tool (shared across requests). */ +const dynamicResources = new Map(); +/** Publishes `resources/list_changed` to every open subscription. Bound below from `handler.notify`. */ +let publishResourcesChanged: () => void = () => {}; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'repl-playground-server', version: '1.0.0' }, + { capabilities: { logging: {}, resources: { listChanged: true } } } + ); + + // --- Tools ------------------------------------------------------------- + + // Typed input + inferred structured output + read-only annotation. + server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'Returns a greeting for the named subject', + inputSchema: z.object({ name: z.string().describe('Name to greet') }), + outputSchema: z.object({ greeting: z.string() }), + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ name }) => { + const structuredContent = { greeting: `Hello, ${name}!` }; + return { content: [{ type: 'text', text: structuredContent.greeting }], structuredContent }; + } + ); + + // Sends `notifications/message` log lines while it runs (drive with `multi-greet`). + server.registerTool( + 'multi-greet', + { + description: 'Sends several greetings with a delay between each, emitting log notifications as it goes', + inputSchema: z.object({ name: z.string().describe('Name to greet') }), + annotations: { title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false } + }, + async ({ name }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); + await sleep(500); + await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); + await sleep(500); + await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); + return { content: [{ type: 'text', text: `Good morning, ${name}!` }] }; + } + ); + + // Form-mode elicitation (drive with the REPL's `collect-info` command). + server.registerTool( + 'collect-user-info', + { + description: 'Collects user information through form elicitation', + inputSchema: z.object({ + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') + }) + }, + async ({ infoType }, ctx): Promise => { + const schemas: Record< + string, + { message: string; schema: { type: 'object'; properties: Record; required?: string[] } } + > = { + contact: { + message: 'Please provide your contact information', + schema: { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name' }, + email: { type: 'string', title: 'Email Address', format: 'email' } + }, + required: ['name', 'email'] + } + }, + preferences: { + message: 'Please set your preferences', + schema: { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + } + }, + feedback: { + message: 'Please provide your feedback', + schema: { + type: 'object', + properties: { + rating: { type: 'integer', title: 'Rating', minimum: 1, maximum: 5 }, + comments: { type: 'string', title: 'Comments', maxLength: 500 } + }, + required: ['rating'] + } + } + }; + const picked = schemas[infoType]!; + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { mode: 'form', message: picked.message, requestedSchema: picked.schema } + }); + if (result.action === 'accept') { + return { content: [{ type: 'text', text: `Collected ${infoType}: ${JSON.stringify(result.content, null, 2)}` }] }; + } + return { + content: [{ type: 'text', text: `User ${result.action === 'decline' ? 'declined' : 'cancelled'} the ${infoType} request.` }] + }; + } + ); + + // Periodic notifications for testing resumability (`start-notifications` in the REPL). + server.registerTool( + 'start-notification-stream', + { + description: 'Sends periodic log notifications for testing resumability', + inputSchema: z.object({ + interval: z.number().describe('Interval in ms between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(5) + }) + }, + async ({ interval, count }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + for (let i = 1; i <= count; i++) { + await ctx.mcpReq.log('info', `Periodic notification #${i} at ${new Date().toISOString()}`); + await sleep(interval); + } + return { content: [{ type: 'text', text: `Sent ${count} notifications at ${interval}ms intervals` }] }; + } + ); + + // Mutates the resource set and publishes `resources/list_changed` over the + // handler's cross-request bus (handler.notify.resourcesChanged()). + server.registerTool( + 'add-resource', + { + description: 'Add a dynamic note resource and publish resources/list_changed', + inputSchema: z.object({ name: z.string(), text: z.string() }), + annotations: { destructiveHint: false } + }, + async ({ name, text }): Promise => { + dynamicResources.set(name, text); + publishResourcesChanged(); + return { content: [{ type: 'text', text: `Added note://${name}` }] }; + } + ); + + // Returns ResourceLinks (drive with `call-tool list-files`, then `read-resource `). + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: z.object({}) + }, + async (): Promise => { + const links: ResourceLink[] = [ + { type: 'resource_link', uri: 'config://app', name: 'App config', mimeType: 'application/json' }, + ...[...dynamicResources.keys()].map( + (name): ResourceLink => ({ type: 'resource_link', uri: `note://${name}`, name, mimeType: 'text/plain' }) + ) + ]; + return { content: [{ type: 'text', text: 'Available files:' }, ...links] }; + } + ); + + // --- Prompts (with argument completion) -------------------------------- + + const LANGUAGES = ['python', 'typescript', 'rust', 'go']; + server.registerPrompt( + 'greeting-template', + { + title: 'Greeting Template', + description: 'A simple greeting prompt template', + argsSchema: z.object({ + name: z.string().describe('Name to include in greeting'), + language: completable(z.string().describe('Language'), value => LANGUAGES.filter(l => l.startsWith(value))) + }) + }, + async ({ name, language }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Please greet ${name} in ${language}.` } }] + }) + ); + + // --- Resources (direct + template + dynamic) --------------------------- + + server.registerResource( + 'app-config', + 'config://app', + { title: 'App config', mimeType: 'application/json', description: 'Static application config' }, + async (uri): Promise => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] + }) + ); + + server.registerResource( + 'greeting', + new ResourceTemplate('greeting://{name}', { list: undefined }), + { description: 'A greeting for the named subject' }, + async (uri, vars): Promise => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) + ); + + server.registerResource( + 'note', + new ResourceTemplate('note://{name}', { + list: () => ({ + resources: [...dynamicResources.keys()].map(name => ({ uri: `note://${name}`, name, mimeType: 'text/plain' })) + }) + }), + { description: 'A dynamic note added via add-resource', mimeType: 'text/plain' }, + async (uri, vars): Promise => { + const text = dynamicResources.get(String(vars.name)) ?? '(no such note)'; + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text }] }; + } + ); + + return server; +} + +// HTTP-only entry. `createMcpHandler` answers on any path; the REPL client +// connects to `http://localhost:3000/mcp` by default. +const handler = createMcpHandler(buildServer, { onerror: e => console.error('[server] handler error:', e.message) }); +const notify: ServerNotifier = handler.notify; +publishResourcesChanged = () => notify.resourcesChanged(); + +const httpServer = createServer((req, res) => void handler.node(req, res)); +httpServer.listen(PORT, () => console.error(`[server] REPL playground listening on http://localhost:${PORT}/mcp`)); + +process.on('SIGINT', async () => { + await handler.close(); + httpServer.close(); + process.exit(0); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8612b7eca..2f5e3afaa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -532,36 +532,12 @@ importers: examples/oauth: dependencies: - '@mcp-examples/shared': - specifier: workspace:* - version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/express': - specifier: workspace:* - version: link:../../packages/middleware/express - '@modelcontextprotocol/node': - specifier: workspace:* - version: link:../../packages/middleware/node - '@modelcontextprotocol/server': - specifier: workspace:* - version: link:../../packages/server - ajv: - specifier: catalog:runtimeShared - version: 8.18.0 - cors: - specifier: catalog:runtimeServerOnly - version: 2.8.6 - express: - specifier: catalog:runtimeServerOnly - version: 5.2.1 open: specifier: ^11.0.0 version: 11.0.0 - zod: - specifier: catalog:runtimeShared - version: 4.3.6 devDependencies: tsx: specifier: catalog:devTools @@ -618,6 +594,25 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/repl: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/resources: dependencies: '@modelcontextprotocol/server': From 6f2476799a016e1c781de392ea8022d25c3777bf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 20:30:24 +0000 Subject: [PATCH 15/27] docs(examples): annotate runServerFromArgs / connectFromArgs at every call site One comment line above each harness helper call so a reader landing on any story file immediately sees what the helper abstracts and what they'd write in their own server/client (serveStdio/createMcpHandler; new Client + transport.connect). 14 server.ts call sites + 15 client.ts files. (#2325) --- examples/caching/client.ts | 1 + examples/caching/server.ts | 1 + examples/custom-methods/client.ts | 1 + examples/custom-methods/server.ts | 1 + examples/custom-version/client.ts | 1 + examples/custom-version/server.ts | 1 + examples/dual-era/client.ts | 1 + examples/dual-era/server.ts | 1 + examples/elicitation-form/client.ts | 1 + examples/elicitation-form/server.ts | 1 + examples/mrtr/client.ts | 1 + examples/mrtr/server.ts | 1 + examples/parallel-calls/client.ts | 1 + examples/parallel-calls/server.ts | 1 + examples/prompts/client.ts | 1 + examples/prompts/server.ts | 1 + examples/resources/client.ts | 1 + examples/resources/server.ts | 1 + examples/sampling/client.ts | 1 + examples/sampling/server.ts | 1 + examples/schema-validators/client.ts | 1 + examples/schema-validators/server.ts | 1 + examples/stickynotes/client.ts | 1 + examples/stickynotes/server.ts | 1 + examples/streaming/client.ts | 1 + examples/streaming/server.ts | 1 + examples/subscriptions/client.ts | 1 + examples/tools/client.ts | 1 + examples/tools/server.ts | 1 + 29 files changed, 29 insertions(+) diff --git a/examples/caching/client.ts b/examples/caching/client.ts index a95c341fd3..f7137edf89 100644 --- a/examples/caching/client.ts +++ b/examples/caching/client.ts @@ -12,6 +12,7 @@ interface Cacheable { } runClient('caching', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); check.equal(client.getNegotiatedProtocolVersion(), '2026-07-28'); diff --git a/examples/caching/server.ts b/examples/caching/server.ts index bfaf57bbea..4b33f6fec7 100644 --- a/examples/caching/server.ts +++ b/examples/caching/server.ts @@ -49,4 +49,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/custom-methods/client.ts b/examples/custom-methods/client.ts index 4feaf8e7e7..45cd9f8950 100644 --- a/examples/custom-methods/client.ts +++ b/examples/custom-methods/client.ts @@ -16,6 +16,7 @@ runClient('custom-methods', async () => { // Custom methods carry no envelope semantics — connect as a plain 2025 // client so the request reaches the server's setRequestHandler exactly as // a hand-wired stdio client would. + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); const stages: string[] = []; diff --git a/examples/custom-methods/server.ts b/examples/custom-methods/server.ts index 09b9444c5b..7df95fe999 100644 --- a/examples/custom-methods/server.ts +++ b/examples/custom-methods/server.ts @@ -25,4 +25,5 @@ function buildServer(): McpServer { return mcp; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/custom-version/client.ts b/examples/custom-version/client.ts index 99c307ab77..44ee20349a 100644 --- a/examples/custom-version/client.ts +++ b/examples/custom-version/client.ts @@ -7,6 +7,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('custom-version', async () => { // A plain (2025-handshake) client; the server supports the SDK's stock // 2025 version so this negotiates that. + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); // The server should advertise its supportedProtocolVersions in its diff --git a/examples/custom-version/server.ts b/examples/custom-version/server.ts index b0f7783476..6eda353c69 100644 --- a/examples/custom-version/server.ts +++ b/examples/custom-version/server.ts @@ -23,4 +23,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/dual-era/client.ts b/examples/dual-era/client.ts index 0e830f233f..69d47289b3 100644 --- a/examples/dual-era/client.ts +++ b/examples/dual-era/client.ts @@ -16,6 +16,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('dual-era', async () => { // --- leg 1: plain 2025 client (initialize handshake) --- + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const legacy = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); const legacyTools = await legacy.listTools(); check.ok(legacyTools.tools.some(t => t.name === 'greet')); diff --git a/examples/dual-era/server.ts b/examples/dual-era/server.ts index ac2639d63b..94158c35f0 100644 --- a/examples/dual-era/server.ts +++ b/examples/dual-era/server.ts @@ -40,4 +40,5 @@ const buildServer = (ctx: McpRequestContext) => { return server; }; +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/elicitation-form/client.ts b/examples/elicitation-form/client.ts index 5fc5459351..a941ac2daf 100644 --- a/examples/elicitation-form/client.ts +++ b/examples/elicitation-form/client.ts @@ -9,6 +9,7 @@ runClient('elicitation-form', async () => { // multi-round-trip `inputRequired` instead — see ../mrtr/). The harness // pins this story to the legacy era so `ctx.mcpReq.elicitInput` reaches // this handler. + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); let action: 'accept' | 'decline' = 'accept'; diff --git a/examples/elicitation-form/server.ts b/examples/elicitation-form/server.ts index 0027997cc4..4c6e5c3be0 100644 --- a/examples/elicitation-form/server.ts +++ b/examples/elicitation-form/server.ts @@ -37,4 +37,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/mrtr/client.ts b/examples/mrtr/client.ts index 338f88387b..aeb13e3ba6 100644 --- a/examples/mrtr/client.ts +++ b/examples/mrtr/client.ts @@ -21,6 +21,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('mrtr', async () => { // --- auto-fulfilment (the default) --- + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const auto = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } } }); diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts index 3df6cc7d06..db4f81105e 100644 --- a/examples/mrtr/server.ts +++ b/examples/mrtr/server.ts @@ -119,4 +119,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/parallel-calls/client.ts b/examples/parallel-calls/client.ts index 5fee19dafe..2051a38e6a 100644 --- a/examples/parallel-calls/client.ts +++ b/examples/parallel-calls/client.ts @@ -13,6 +13,7 @@ import type { Client } from '@modelcontextprotocol/client'; import { check, connectFromArgs, runClient } from '../harness.js'; async function makeClient(): Promise<{ client: Client; notifications: string[] }> { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); const notifications: string[] = []; client.setNotificationHandler('notifications/message', n => { diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts index 23b24b6f1f..2f11b79ab1 100644 --- a/examples/parallel-calls/server.ts +++ b/examples/parallel-calls/server.ts @@ -33,4 +33,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/prompts/client.ts b/examples/prompts/client.ts index 172fad49fb..99a0ba95e5 100644 --- a/examples/prompts/client.ts +++ b/examples/prompts/client.ts @@ -4,6 +4,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('prompts', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); const list = await client.listPrompts(); diff --git a/examples/prompts/server.ts b/examples/prompts/server.ts index 524454fbaf..856b953cf5 100644 --- a/examples/prompts/server.ts +++ b/examples/prompts/server.ts @@ -38,4 +38,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/resources/client.ts b/examples/resources/client.ts index 20bc19a99c..0823db8345 100644 --- a/examples/resources/client.ts +++ b/examples/resources/client.ts @@ -4,6 +4,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('resources', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); const list = await client.listResources(); diff --git a/examples/resources/server.ts b/examples/resources/server.ts index db353928f1..12633739b7 100644 --- a/examples/resources/server.ts +++ b/examples/resources/server.ts @@ -31,4 +31,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts index de632561c7..5612864f19 100644 --- a/examples/sampling/client.ts +++ b/examples/sampling/client.ts @@ -9,6 +9,7 @@ runClient('sampling', async () => { // Push-style sampling is a 2025-era flow (and is deprecated as of // 2026-07-28). The harness pins this story to the legacy era so the // server's `ctx.mcpReq.requestSampling` reaches this handler. + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { capabilities: { sampling: {} } }); client.setRequestHandler('sampling/createMessage', async () => ({ role: 'assistant', diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts index 048951574e..bea91d019a 100644 --- a/examples/sampling/server.ts +++ b/examples/sampling/server.ts @@ -31,4 +31,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts index 6c9812a943..60bf559258 100644 --- a/examples/schema-validators/client.ts +++ b/examples/schema-validators/client.ts @@ -6,6 +6,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('schema-validators', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); const list = await client.listTools(); diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts index a8a4cd5d5f..bdca9c8c17 100644 --- a/examples/schema-validators/server.ts +++ b/examples/schema-validators/server.ts @@ -51,4 +51,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts index 59f1ac86da..7e4b1cccc6 100644 --- a/examples/stickynotes/client.ts +++ b/examples/stickynotes/client.ts @@ -20,6 +20,7 @@ runClient('stickynotes', async () => { // flow; the harness pins this story to the legacy era so // `ctx.mcpReq.elicitInput` reaches this handler (the 2026-07-28 path uses // multi-round-trip `inputRequired` instead — see ../mrtr/). + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; client.setRequestHandler('elicitation/create', async () => { diff --git a/examples/stickynotes/server.ts b/examples/stickynotes/server.ts index ce9785d8e1..c46ee0d924 100644 --- a/examples/stickynotes/server.ts +++ b/examples/stickynotes/server.ts @@ -111,4 +111,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/streaming/client.ts b/examples/streaming/client.ts index b4384e7497..450445cdd5 100644 --- a/examples/streaming/client.ts +++ b/examples/streaming/client.ts @@ -7,6 +7,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('streaming', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); let logCount = 0; diff --git a/examples/streaming/server.ts b/examples/streaming/server.ts index c948f8f0a3..4c84bcf8d0 100644 --- a/examples/streaming/server.ts +++ b/examples/streaming/server.ts @@ -55,4 +55,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); diff --git a/examples/subscriptions/client.ts b/examples/subscriptions/client.ts index db195f039c..44991257e0 100644 --- a/examples/subscriptions/client.ts +++ b/examples/subscriptions/client.ts @@ -28,6 +28,7 @@ async function until(pred: () => boolean, timeoutMs = 5000): Promise { async function autoOpenLeg(): Promise { let count = 0; + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { listChanged: { tools: { diff --git a/examples/tools/client.ts b/examples/tools/client.ts index 24d46f75ab..0bed08d2fa 100644 --- a/examples/tools/client.ts +++ b/examples/tools/client.ts @@ -5,6 +5,7 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('tools', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname); const list = await client.listTools(); diff --git a/examples/tools/server.ts b/examples/tools/server.ts index 6dc0602482..2987ba6e71 100644 --- a/examples/tools/server.ts +++ b/examples/tools/server.ts @@ -46,4 +46,5 @@ function buildServer(): McpServer { return server; } +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. runServerFromArgs(buildServer); From 46241cb865641ccce013a2119319d6b1af3e103f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 20:55:50 +0000 Subject: [PATCH 16/27] chore(examples): finish the old-layout cleanup; fix SIGKILL backstop, --legacy run commands, multi-node section move --- docs/server.md | 2 +- examples/README.md | 112 ++++++++++++++++++ examples/client/README.md | 52 --------- examples/elicitation-form/README.md | 2 +- examples/sampling/README.md | 2 +- examples/server/README.md | 173 ---------------------------- scripts/run-examples.ts | 4 +- 7 files changed, 118 insertions(+), 229 deletions(-) delete mode 100644 examples/client/README.md delete mode 100644 examples/server/README.md diff --git a/docs/server.md b/docs/server.md index c08132de91..594d168fb3 100644 --- a/docs/server.md +++ b/docs/server.md @@ -650,4 +650,4 @@ middleware source for reference. When mounting a handler bare on a fetch-native | Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | | Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/inMemoryEventStore.ts) | | CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | -| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | +| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | diff --git a/examples/README.md b/examples/README.md index 3ad4310edd..ed5047dfd3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -59,3 +59,115 @@ Some stories mount at a different path (e.g. `/`); check the story's `package.js The interactive OAuth set lives under [`oauth/`](./oauth/README.md) and is excluded from the harness (browser authorization-code flow); the headless machine-to-machine grant is covered by `oauth-client-credentials/`. The [`guides/`](./guides/README.md) directory holds the snippet collections synced into `docs/server.md` and `docs/client.md` — typecheck-only, not runnable. `shared/` is the demo OAuth provider library used by the OAuth examples. The `server-quickstart/` and `client-quickstart/` packages are the website-tutorial sources (external network / API key; typecheck-only). + +## Multi-node deployment patterns + +When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: + +- **Stateless mode** - no need to maintain state between calls. +- **Persistent storage mode** - state stored in a database; any node can handle a session. +- **Local state with message routing** - stateful nodes + pub/sub routing for a session. + +### Stateless mode + +To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: + +```typescript +sessionIdGenerator: undefined; +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + +### Persistent storage mode + +Configure the transport with session management, but use an external event store: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Database (PostgreSQL) │ +│ │ +│ • Session state │ +│ • Event storage for resumability │ +└─────────────────────────────────────────────┘ +``` + +### Streamable HTTP with distributed message routing + +For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │◄───►│ MCP Server #2 │ +│ (Has Session A) │ │ (Has Session B) │ +└─────────────────┘ └─────────────────────┘ + ▲│ ▲│ + │▼ │▼ +┌─────────────────────────────────────────────┐ +│ Message Queue / Pub-Sub │ +│ │ +│ • Session ownership registry │ +│ • Bidirectional message routing │ +│ • Request/response forwarding │ +└─────────────────────────────────────────────┘ +``` + +## Backwards compatibility (Streamable HTTP ↔ legacy SSE) + +Start the server: + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts +``` + +Then run the backwards-compatible client: + +```bash +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/streamableHttpWithSseFallbackClient.ts +``` diff --git a/examples/client/README.md b/examples/client/README.md deleted file mode 100644 index 0879b3b6c0..0000000000 --- a/examples/client/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# MCP TypeScript SDK Examples (Client) - -This directory contains runnable MCP **client** examples built with `@modelcontextprotocol/client`. - -For server examples, see [`../server/README.md`](../server/README.md). For guided docs, see [`../../docs/client.md`](../../docs/client.md). - -## Running examples - -From the repo root: - -```bash -pnpm install -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts -``` - -Or, from within this package: - -```bash -cd examples/client -pnpm tsx src/simpleStreamableHttp.ts -``` - -Most clients expect a server to be running. Start one from [`../server/README.md`](../server/README.md) (for example `src/simpleStreamableHttp.ts` in `examples/server`). - -## Example index - -| Scenario | Description | File | -| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | -| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | -| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | -| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | -| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | -| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | -| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | -| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) | - -## URL elicitation example (server + client) - -Run the server first: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts -``` - -Then run the client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts -``` diff --git a/examples/elicitation-form/README.md b/examples/elicitation-form/README.md index de1a724070..9dcde19ce6 100644 --- a/examples/elicitation-form/README.md +++ b/examples/elicitation-form/README.md @@ -7,5 +7,5 @@ For URL-mode elicitation see `../oauth/` (excluded from the harness; browser flo **stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the stateless per-request `createMcpHandler`. ```bash -pnpm tsx examples/elicitation-form/client.ts +pnpm tsx examples/elicitation-form/client.ts --legacy ``` diff --git a/examples/sampling/README.md b/examples/sampling/README.md index f441a8654e..18855618cd 100644 --- a/examples/sampling/README.md +++ b/examples/sampling/README.md @@ -7,5 +7,5 @@ A tool that requests LLM sampling from the client via `ctx.mcpReq.requestSamplin **stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the per-request `createMcpHandler`, which serves the 2026-07-28 path where sampling is unavailable. ```bash -pnpm tsx examples/sampling/client.ts +pnpm tsx examples/sampling/client.ts --legacy ``` diff --git a/examples/server/README.md b/examples/server/README.md deleted file mode 100644 index bce265104a..0000000000 --- a/examples/server/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# MCP TypeScript SDK Examples (Server) - -This directory contains runnable MCP **server** examples built with `@modelcontextprotocol/server` plus framework adapters: - -- `@modelcontextprotocol/express` -- `@modelcontextprotocol/hono` - -For client examples, see [`../client/README.md`](../client/README.md). For guided docs, see [`../../docs/server.md`](../../docs/server.md). - -## Running examples - -From anywhere in the SDK: - -```bash -pnpm install -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Or, from within this package: - -```bash -cd examples/server -pnpm tsx src/simpleStreamableHttp.ts -``` - -## Example index - -| Scenario | Description | File | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | -| Resource-Server-only auth | Minimal OAuth RS using `mcpAuthMetadataRouter` + `requireBearerAuth` from `@modelcontextprotocol/express` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) | -| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | -| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | -| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | -| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | -| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | -| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | -| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | -| Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) | - -## OAuth demo flags (Streamable HTTP server) - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth -``` - -## URL elicitation example (server + client) - -Run the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts -``` - -Run the client in another terminal: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts -``` - -## Multi-node deployment patterns - -When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: - -- **Stateless mode** - no need to maintain state between calls. -- **Persistent storage mode** - state stored in a database; any node can handle a session. -- **Local state with message routing** - stateful nodes + pub/sub routing for a session. - -### Stateless mode - -To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: - -```typescript -sessionIdGenerator: undefined; -``` - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ -``` - -### Persistent storage mode - -Configure the transport with session management, but use an external event store: - -```typescript -sessionIdGenerator: () => randomUUID(), -eventStore: databaseEventStore -``` - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ - │ │ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────┐ -│ Database (PostgreSQL) │ -│ │ -│ • Session state │ -│ • Event storage for resumability │ -└─────────────────────────────────────────────┘ -``` - -### Streamable HTTP with distributed message routing - -For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │◄───►│ MCP Server #2 │ -│ (Has Session A) │ │ (Has Session B) │ -└─────────────────┘ └─────────────────────┘ - ▲│ ▲│ - │▼ │▼ -┌─────────────────────────────────────────────┐ -│ Message Queue / Pub-Sub │ -│ │ -│ • Session ownership registry │ -│ • Bidirectional message routing │ -│ • Request/response forwarding │ -└─────────────────────────────────────────────┘ -``` - -## Backwards compatibility (Streamable HTTP ↔ legacy SSE) - -Start the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Then run the backwards-compatible client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/streamableHttpWithSseFallbackClient.ts -``` diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts index d00cb58baf..b56db79401 100644 --- a/scripts/run-examples.ts +++ b/scripts/run-examples.ts @@ -161,7 +161,9 @@ async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era } finally { server.kill('SIGTERM'); await new Promise(r => setTimeout(r, 100)); - if (!server.killed) server.kill('SIGKILL'); + // `.killed` flips true the moment kill() is called, so it can't gate + // the backstop; check whether the process actually exited instead. + if (server.exitCode === null && server.signalCode === null) server.kill('SIGKILL'); } } From 393bc07ef9d3f859bb14098041b61ab0b5e28648 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 21:46:49 +0000 Subject: [PATCH 17/27] docs(examples): consumer-facing elicitation/ story (both eras), README coverage fixes - Rename elicitation-form/ -> elicitation/ and rewrite as the dual-era elicitation story: form + URL mode on both protocol eras from one factory. On 2025 (--legacy) the server uses the push-style elicitInput / throw UrlElicitationRequiredError / createElicitationCompletionNotifier; on 2026-07-28 the same tools return inputRequired(...).elicit / .elicitUrl. Adds enumNames to the form schema. stdio x dual-era (2 legs). - repl/server.ts: add Implementation icons + websiteUrl. - examples/README.md: rewrite Excluded as a table; rewrite the broken Backwards-compatibility section (deleted-package commands -> guides snippet pointer); update the elicitation row. - sse-polling: README notes eventStore resumability is 2025-session-only; fix stale "Run with:" header path. - docs/{client,server}.md, oauth/README.md: re-point elicitation links. --- docs/client.md | 6 +- docs/server.md | 4 +- examples/README.md | 26 ++- examples/elicitation-form/README.md | 11 -- examples/elicitation-form/client.ts | 31 ---- examples/elicitation-form/server.ts | 41 ----- examples/elicitation/README.md | 18 ++ examples/elicitation/client.ts | 82 +++++++++ .../package.json | 10 +- examples/elicitation/server.ts | 163 ++++++++++++++++++ examples/oauth/README.md | 2 +- examples/repl/server.ts | 7 +- examples/sse-polling/README.md | 3 +- examples/sse-polling/server.ts | 2 +- pnpm-lock.yaml | 8 +- 15 files changed, 302 insertions(+), 112 deletions(-) delete mode 100644 examples/elicitation-form/README.md delete mode 100644 examples/elicitation-form/client.ts delete mode 100644 examples/elicitation-form/server.ts create mode 100644 examples/elicitation/README.md create mode 100644 examples/elicitation/client.ts rename examples/{elicitation-form => elicitation}/package.json (61%) create mode 100644 examples/elicitation/server.ts diff --git a/docs/client.md b/docs/client.md index 951e6dce89..6f21e70442 100644 --- a/docs/client.md +++ b/docs/client.md @@ -514,8 +514,8 @@ client.setRequestHandler('elicitation/create', async request => { }); ``` -For a full form-based elicitation handler with AJV validation, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). For URL elicitation mode, see -[`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts). +For a full form-based elicitation handler with AJV validation, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). For URL elicitation mode (both the 2025-era push/throw style and the 2026-07-28 `inputRequired` +return), see [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts). ### Roots @@ -720,4 +720,4 @@ For an end-to-end example of server-initiated SSE disconnection and automatic cl | Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | | SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | | Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | -| URL elicitation | Handle sensitive data collection via browser | [`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts) | diff --git a/docs/server.md b/docs/server.md index 594d168fb3..a0a469f480 100644 --- a/docs/server.md +++ b/docs/server.md @@ -536,8 +536,8 @@ server.registerTool( ); ``` -For runnable examples, see [`elicitation-form/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation-form/server.ts) (form) and [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) -(URL). +For runnable examples, see [`elicitation/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/server.ts) (form + URL mode, both protocol eras) and +[`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) (the secure `requestState` round-trip pattern). ### Roots diff --git a/examples/README.md b/examples/README.md index ed5047dfd3..176b3cd661 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,7 +32,7 @@ Some stories mount at a different path (e.g. `/`); check the story's `package.js | [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | | [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | | [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | -| [`elicitation-form/`](./elicitation-form/README.md) | Form-mode elicitation (server requests user input) | stdio | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio | | [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio | | [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | | [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | @@ -56,9 +56,14 @@ Some stories mount at a different path (e.g. `/`); check the story's `package.js ## Excluded -The interactive OAuth set lives under [`oauth/`](./oauth/README.md) and is excluded from the harness (browser authorization-code flow); the headless machine-to-machine grant is covered by `oauth-client-credentials/`. The [`guides/`](./guides/README.md) directory holds the snippet -collections synced into `docs/server.md` and `docs/client.md` — typecheck-only, not runnable. `shared/` is the demo OAuth provider library used by the OAuth examples. The `server-quickstart/` and `client-quickstart/` packages are the website-tutorial sources (external network / -API key; typecheck-only). +| Directory | What it is | Why not in CI | +| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`oauth/`](./oauth/README.md) | Interactive authorization-code OAuth flow (`simpleOAuthClient.ts`, `dualModeAuth.ts`, `simpleTokenProvider.ts`) | Opens a real browser and runs a callback server on `:8090`. The headless machine-to-machine grant is covered by [`oauth-client-credentials/`](./oauth-client-credentials/README.md). | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`sse-polling/`](./sse-polling/README.md), [`standalone-get/`](./standalone-get/README.md) | Legacy sessionful-2025 SSE stories (SEP-1699 reconnect/replay; standalone GET stream) | Kept for reference; long-running reconnect/timer flows that need a longer per-leg readiness wait than the harness default. Self-verifying — flip the `excluded` flag once the harness has bounded-wait knobs. | +| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | +| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | ## Multi-node deployment patterns @@ -160,14 +165,5 @@ For scenarios where local in-memory state must be maintained on specific nodes, ## Backwards compatibility (Streamable HTTP ↔ legacy SSE) -Start the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Then run the backwards-compatible client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/streamableHttpWithSseFallbackClient.ts -``` +A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#backwards-compatibility) recipe in the client guide — try +`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern. diff --git a/examples/elicitation-form/README.md b/examples/elicitation-form/README.md deleted file mode 100644 index 9dcde19ce6..0000000000 --- a/examples/elicitation-form/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# elicitation-form - -Form-mode elicitation: the server requests structured user input via `ctx.mcpReq.elicitInput({ mode: 'form', requestedSchema })`; the client auto-answers the form. Covers accept and decline. - -For URL-mode elicitation see `../oauth/` (excluded from the harness; browser flow). For the 2026-07-28 multi-round-trip return style see `../mrtr/`. - -**stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the stateless per-request `createMcpHandler`. - -```bash -pnpm tsx examples/elicitation-form/client.ts --legacy -``` diff --git a/examples/elicitation-form/client.ts b/examples/elicitation-form/client.ts deleted file mode 100644 index a941ac2daf..0000000000 --- a/examples/elicitation-form/client.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Auto-answers the registration form (accept once, decline once) and asserts - * the tool's text reflects the elicitation outcome. - */ -import { check, connectFromArgs, runClient } from '../harness.js'; - -runClient('elicitation-form', async () => { - // Push-style elicitation is the 2025-era flow (the 2026-07-28 revision uses - // multi-round-trip `inputRequired` instead — see ../mrtr/). The harness - // pins this story to the legacy era so `ctx.mcpReq.elicitInput` reaches - // this handler. - // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. - const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); - - let action: 'accept' | 'decline' = 'accept'; - client.setRequestHandler('elicitation/create', async request => { - const params = request.params as { requestedSchema?: { properties?: Record } }; - check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema'); - if (action === 'decline') return { action: 'decline' }; - return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', newsletter: true } }; - }); - - const accepted = await client.callTool({ name: 'register_user' }); - check.match(accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', /registered alice /); - - action = 'decline'; - const declined = await client.callTool({ name: 'register_user' }); - check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/); - - await client.close(); -}); diff --git a/examples/elicitation-form/server.ts b/examples/elicitation-form/server.ts deleted file mode 100644 index 4c6e5c3be0..0000000000 --- a/examples/elicitation-form/server.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Form-mode elicitation: a tool that collects structured user input via - * `ctx.mcpReq.elicitInput({ mode: 'form', ... })`. The client validates the - * form against `requestedSchema` and answers `accept`/`decline`/`cancel`. - * One binary, either transport. - */ -import { McpServer } from '@modelcontextprotocol/server'; - -import { runServerFromArgs } from '../harness.js'; - -function buildServer(): McpServer { - const server = new McpServer({ name: 'elicitation-form-example', version: '1.0.0' }); - - server.registerTool('register_user', { description: 'Register a new user account by collecting their information' }, async ctx => { - const result = await ctx.mcpReq.elicitInput({ - mode: 'form', - message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { type: 'string', title: 'Username', minLength: 3, maxLength: 20 }, - email: { type: 'string', title: 'Email', format: 'email' }, - newsletter: { type: 'boolean', title: 'Subscribe to newsletter?', default: false } - }, - required: ['username', 'email'] - } - }); - if (result.action !== 'accept' || !result.content) { - return { content: [{ type: 'text', text: `registration ${result.action}` }] }; - } - const { username, email, newsletter } = result.content as { username: string; email: string; newsletter?: boolean }; - return { - content: [{ type: 'text', text: `registered ${username} <${email}> (newsletter: ${newsletter ? 'yes' : 'no'})` }] - }; - }); - - return server; -} - -// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. -runServerFromArgs(buildServer); diff --git a/examples/elicitation/README.md b/examples/elicitation/README.md new file mode 100644 index 0000000000..93fb2f87ff --- /dev/null +++ b/examples/elicitation/README.md @@ -0,0 +1,18 @@ +# elicitation + +Server requests user input. One factory, both protocol eras: elicitation works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries it differently but the user experience is the same. + +| Mode | 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **form** (`register_user`) | `await ctx.mcpReq.elicitInput({ mode: 'form', requestedSchema })` — the server pushes an `elicitation/create` request and awaits the answer in-line | `return inputRequired({ inputRequests: { form: inputRequired.elicit(...) } })` — the client collects the form and retries the same handler with the response attached | +| **url** (`link_account`) | `await ctx.mcpReq.elicitInput({ mode: 'url', url, elicitationId })` + `createElicitationCompletionNotifier(elicitationId)` for the out-of-band `notifications/elicitation/complete` | `return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl(...) } })` — no `elicitationId` / complete notification on this era | +| **url, throw** (`confirm_payment`) | `throw new UrlElicitationRequiredError([...])` — the wire `-32042`; the client catches the typed error and reads `.elicitations` | n/a — a throw on this era fails loudly with a steer to `inputRequired.elicitUrl(...)` | + +The form schema includes an `enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md). + +**stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the stateless per-request `createMcpHandler`. + +```bash +pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired) +pnpm --filter @mcp-examples/elicitation client -- --legacy # 2025 (push-style) +``` diff --git a/examples/elicitation/client.ts b/examples/elicitation/client.ts new file mode 100644 index 0000000000..479e270735 --- /dev/null +++ b/examples/elicitation/client.ts @@ -0,0 +1,82 @@ +/** + * Auto-answers form and URL elicitations on either protocol era and asserts + * the tool's text reflects the elicitation outcome. + * + * On the 2025-era leg (`--legacy`) the server pushes `elicitation/create` + * requests and a `notifications/elicitation/complete` notification, and the + * `confirm_payment` tool throws a typed `UrlElicitationRequiredError` the + * client catches. On the 2026-07-28 leg the same `elicitation/create` + * handler is dispatched by the auto-fulfilment engine for the embedded + * `inputRequired` requests; there is no throw-style or complete-notification + * surface on that era, so those assertions are gated to the legacy leg. + */ +import type { ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/client'; +import { UrlElicitationRequiredError } from '@modelcontextprotocol/client'; + +import { check, connectFromArgs, eraLeg, runClient } from '../harness.js'; + +runClient('elicitation', async () => { + // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. + const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {}, url: {} } } }); + + // URL-mode requests on the 2025 era carry an `elicitationId`; the client + // waits for `notifications/elicitation/complete` with that id (the + // out-of-band "the user finished the URL flow" signal) before answering. + const completed = new Map void>(); + client.setNotificationHandler('notifications/elicitation/complete', notification => { + const id = (notification.params as { elicitationId: string }).elicitationId; + completed.get(id)?.(); + }); + + let formAction: 'accept' | 'decline' = 'accept'; + client.setRequestHandler('elicitation/create', async (request): Promise => { + const params = request.params as { mode?: 'form' | 'url'; requestedSchema?: { properties?: Record } } & Partial< + Pick + >; + if (params.mode === 'url') { + // A real client would open `params.url` in a browser here. On the + // 2025 era it then waits for the matching complete notification + // before resolving; on the 2026 era there is no elicitationId and + // the client answers as soon as the user finishes. + check.ok(params.url?.startsWith('https://example.com/')); + if (params.elicitationId) { + await new Promise(resolve => completed.set(params.elicitationId as string, resolve)); + } + return { action: 'accept' }; + } + check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema'); + if (formAction === 'decline') return { action: 'decline' }; + return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', plan: 'pro' } }; + }); + + // ---- Form mode (accept then decline) ------------------------------------- + const accepted = await client.callTool({ name: 'register_user' }); + check.match( + accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', + /registered alice \(plan: pro\)/ + ); + + formAction = 'decline'; + const declined = await client.callTool({ name: 'register_user' }); + check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/); + + // ---- URL mode (push-style on 2025, inputRequired.elicitUrl on 2026) ------ + const linked = await client.callTool({ name: 'link_account', arguments: { provider: 'github' } }); + check.match(linked.content?.[0]?.type === 'text' ? linked.content[0].text : '', /linked github/); + + // ---- URL mode (throw-style — 2025-era only) ------------------------------ + if (eraLeg() === 'legacy') { + let caught: UrlElicitationRequiredError | undefined; + try { + await client.callTool({ name: 'confirm_payment', arguments: { cartId: 'cart-42' } }); + } catch (error) { + check.ok(error instanceof UrlElicitationRequiredError, 'expected UrlElicitationRequiredError'); + caught = error as UrlElicitationRequiredError; + } + check.ok(caught, 'confirm_payment should throw UrlElicitationRequiredError on the 2025 era'); + check.equal(caught?.elicitations.length, 1); + check.match(caught?.elicitations[0]?.url ?? '', /confirm-payment\?cart=cart-42/); + } + + await client.close(); +}); diff --git a/examples/elicitation-form/package.json b/examples/elicitation/package.json similarity index 61% rename from examples/elicitation-form/package.json rename to examples/elicitation/package.json index 70fc144bcb..0f4a4d8874 100644 --- a/examples/elicitation-form/package.json +++ b/examples/elicitation/package.json @@ -1,5 +1,5 @@ { - "name": "@mcp-examples/elicitation-form", + "name": "@mcp-examples/elicitation", "private": true, "type": "module", "scripts": { @@ -7,7 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { - "@modelcontextprotocol/server": "workspace:*" + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" }, "devDependencies": { "tsx": "catalog:devTools" @@ -16,7 +18,7 @@ "transports": [ "stdio" ], - "era": "legacy", - "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the elicitation capability; createMcpHandler's per-request/stateless posture has neither. The 2026-07-28 path is the multi-round-trip story (../mrtr/)." + "era": "dual", + "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the elicitation capability; createMcpHandler's per-request/stateless posture has neither. The 2026-07-28 multi-round-trip path is the same handler over stdio." } } diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts new file mode 100644 index 0000000000..dc31ee1228 --- /dev/null +++ b/examples/elicitation/server.ts @@ -0,0 +1,163 @@ +/** + * Elicitation — server requests user input. One factory, both protocol eras. + * + * The same tools serve both eras with different APIs: on a 2025-era + * connection (`--legacy`, the `initialize` handshake) the server uses the + * push-style server→client request flow — `ctx.mcpReq.elicitInput(...)` for + * form and URL mode, `UrlElicitationRequiredError` for the throw-style URL + * signal, and `createElicitationCompletionNotifier` for the out-of-band + * `notifications/elicitation/complete`. On a 2026-07-28 connection there is + * no server→client request channel: the same tools instead **return** + * `inputRequired(...)` (multi-round-trip) and the client retries with the + * collected responses. The protocol carries the request differently; the user + * experience is the same. + * + * One binary, either transport (selected by the shared scaffold from argv). + */ +import { randomUUID } from 'node:crypto'; + +import type { + CallToolResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + InputRequiredResult, + McpRequestContext +} from '@modelcontextprotocol/server'; +import { acceptedContent, inputRequired, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +import { runServerFromArgs } from '../harness.js'; + +// The form schema (with `enumNames` display labels for the enum field). +const REGISTRATION_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { + username: { type: 'string', title: 'Username', minLength: 3, maxLength: 20 }, + email: { type: 'string', title: 'Email', format: 'email' }, + plan: { + type: 'string', + title: 'Plan', + enum: ['free', 'pro', 'team'], + enumNames: ['Free tier', 'Pro', 'Team'] + } + }, + required: ['username', 'email'] +}; + +type Registration = { username: string; email: string; plan?: string }; + +function buildServer(reqCtx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'elicitation-example', version: '1.0.0' }); + + // ---- Form-mode elicitation ----------------------------------------------- + server.registerTool( + 'register_user', + { description: 'Register a new user account by collecting their information' }, + async (ctx): Promise => { + if (reqCtx.era === 'legacy') { + // 2025-era: push a server→client `elicitation/create` request and + // await the user's answer in-line. + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: REGISTRATION_SCHEMA + }); + if (result.action !== 'accept' || !result.content) { + return { content: [{ type: 'text', text: `registration ${result.action}` }] }; + } + const { username, email, plan } = result.content as Registration; + return { content: [{ type: 'text', text: `registered ${username} <${email}> (plan: ${plan ?? 'free'})` }] }; + } + // 2026-07-28: return inputRequired — the client collects the form + // and retries this same handler with the response attached. + const response = ctx.mcpReq.inputResponses?.['form'] as { action?: string } | undefined; + if (!response) { + return inputRequired({ + inputRequests: { + form: inputRequired.elicit({ + message: 'Please provide your registration information:', + requestedSchema: REGISTRATION_SCHEMA + }) + } + }); + } + const form = acceptedContent(ctx.mcpReq.inputResponses, 'form'); + if (!form) { + return { content: [{ type: 'text', text: `registration ${response.action}` }] }; + } + return { content: [{ type: 'text', text: `registered ${form.username} <${form.email}> (plan: ${form.plan ?? 'free'})` }] }; + } + ); + + // ---- URL-mode elicitation (push style + completion notification) --------- + server.registerTool( + 'link_account', + { + description: 'Link a third-party account by opening a sign-in URL', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx): Promise => { + if (reqCtx.era === 'legacy') { + // 2025-era push style: send `elicitation/create` (mode: 'url') + // and, in parallel, simulate the out-of-band callback that + // fires when the user finishes the URL flow by sending + // `notifications/elicitation/complete` for the same id. The + // client waits for that notification before answering accept. + const elicitationId = randomUUID(); + const notifyComplete = server.server.createElicitationCompletionNotifier(elicitationId); + setTimeout(() => void notifyComplete().catch(error => console.error('[server] complete notify failed:', error)), 50); + const params: ElicitRequestURLParams = { + mode: 'url', + message: `Sign in to ${provider} to link your account`, + url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize`, + elicitationId + }; + const result = await ctx.mcpReq.elicitInput(params); + return { content: [{ type: 'text', text: result.action === 'accept' ? `linked ${provider}` : `link ${result.action}` }] }; + } + // 2026-07-28: URL elicitation rides the multi-round-trip flow. No + // elicitationId / complete notification — correlation is the + // server's own state across retries. + const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; + if (auth?.action !== 'accept') { + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: `Sign in to ${provider} to link your account`, + url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize` + }) + } + }); + } + return { content: [{ type: 'text', text: `linked ${provider}` }] }; + } + ); + + // ---- URL-mode elicitation (throw style, 2025-era only) ------------------- + // The error-style signal: the tool THROWS `UrlElicitationRequiredError` + // (wire `-32042`); the client catches it as a typed error and reads + // `.elicitations`. There is no 2026-07-28 equivalent — a throw on that era + // fails loudly with a steer to `inputRequired.elicitUrl(...)`. + server.registerTool( + 'confirm_payment', + { + description: 'Confirm a payment via a browser flow (2025-era throw-style URL elicitation)', + inputSchema: z.object({ cartId: z.string() }) + }, + async ({ cartId }): Promise => { + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'Open the link to confirm payment', + url: `https://example.com/confirm-payment?cart=${encodeURIComponent(cartId)}`, + elicitationId: randomUUID() + } + ]); + } + ); + + return server; +} + +// runServerFromArgs is the example harness's transport selector (default stdio, --http for HTTP). In your own server you'd call serveStdio(buildServer) or createMcpHandler(buildServer) directly. +runServerFromArgs(buildServer); diff --git a/examples/oauth/README.md b/examples/oauth/README.md index 254ec8601e..90cffe9ff7 100644 --- a/examples/oauth/README.md +++ b/examples/oauth/README.md @@ -6,4 +6,4 @@ The interactive authorization-code OAuth set, typecheck-only. Excluded from the - `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`. - `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens. -For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../mrtr/`; for the interactive readline playground see `../repl/`. +For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`. diff --git a/examples/repl/server.ts b/examples/repl/server.ts index 9af5ebea4d..fbb7d35a84 100644 --- a/examples/repl/server.ts +++ b/examples/repl/server.ts @@ -32,7 +32,12 @@ let publishResourcesChanged: () => void = () => {}; function buildServer(): McpServer { const server = new McpServer( - { name: 'repl-playground-server', version: '1.0.0' }, + { + name: 'repl-playground-server', + version: '1.0.0', + icons: [{ src: 'https://modelcontextprotocol.io/favicon.svg', sizes: ['any'], mimeType: 'image/svg+xml' }], + websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' + }, { capabilities: { logging: {}, resources: { listChanged: true } } } ); diff --git a/examples/sse-polling/README.md b/examples/sse-polling/README.md index c8dd292bfd..38183b89c8 100644 --- a/examples/sse-polling/README.md +++ b/examples/sse-polling/README.md @@ -1,6 +1,7 @@ # sse-polling -SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). +SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). `eventStore` resumability is a 2025-session concern with no 2026-07-28 +per-request equivalent — this is the only story that configures one. Excluded from the harness for now (the reconnect/replay flow needs a longer, bounded wait than the per-leg default). diff --git a/examples/sse-polling/server.ts b/examples/sse-polling/server.ts index 2675a038ed..1b9a484c09 100644 --- a/examples/sse-polling/server.ts +++ b/examples/sse-polling/server.ts @@ -9,7 +9,7 @@ * - Uses `eventStore` to persist events for replay after reconnection * - Uses `ctx.http?.closeSSE()` callback to gracefully disconnect clients mid-operation * - * Run with: pnpm tsx src/ssePollingExample.ts + * Run with: pnpm tsx examples/sse-polling/server.ts * Test with: curl or the MCP Inspector */ import { randomUUID } from 'node:crypto'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f5e3afaa8..7dac19ea55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,11 +441,17 @@ importers: specifier: catalog:devTools version: 4.21.0 - examples/elicitation-form: + examples/elicitation: dependencies: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 devDependencies: tsx: specifier: catalog:devTools From be71beeb5a968f205500a37a3a31993369f2ef7b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 23:43:51 +0000 Subject: [PATCH 18/27] docs(examples): close review-1 M2-M9 + bot nits; in-repo OAuth-protected server, sessionful repl/, fix docs:check anchor - oauth/server.ts: in-repo authorization-code AS (setupAuthServer) + RS (createMcpHandler behind requireBearerAuth(demoTokenVerifier)) so simpleOAuthClient.ts has an in-repo target (M2). README run-manually section. - legacy-routing/: CORS exposedHeaders recipe (M8), explicit GET/DELETE routes for the sessionful arm (M9), README section on direct WebStandardStreamableHTTPServerTransport construction (M6). - repl/server.ts: re-hosted on sessionful NodeStreamableHTTPServerTransport with an InMemoryEventStore so the REPL client's reconnect/resumability commands work (M7). - elicitation/: plan_trip tool chains two form elicitations inside one tool call on both eras (M4). - oauth-client-credentials/README: PrivateKeyJwtProvider section pointing at the client guide snippet (M3). - examples/README: fix #backwards-compatibility -> #sse-fallback-for-legacy-servers (docs:check broken anchor); update oauth/ row. - CONTRIBUTING + root package.json: drop stale examples-server / oauth/ simpleStreamableHttpServer.ts references. - stickynotes/client.ts: header says 2025-era (matches era pin). --- CONTRIBUTING.md | 13 ++-- examples/README.md | 18 ++--- examples/elicitation/README.md | 3 +- examples/elicitation/client.ts | 10 +++ examples/elicitation/server.ts | 55 +++++++++++++++ examples/legacy-routing/README.md | 21 ++++++ examples/legacy-routing/package.json | 2 + examples/legacy-routing/server.ts | 20 +++++- examples/oauth-client-credentials/README.md | 16 +++++ examples/oauth/README.md | 15 +++- examples/oauth/package.json | 14 +++- examples/oauth/server.ts | 76 ++++++++++++++++++++ examples/repl/README.md | 4 +- examples/repl/package.json | 4 ++ examples/repl/server.ts | 78 +++++++++++++-------- examples/stickynotes/client.ts | 8 +-- package.json | 2 +- pnpm-lock.yaml | 36 ++++++++++ 18 files changed, 341 insertions(+), 54 deletions(-) create mode 100644 examples/oauth/server.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325330c15b..d3d64c4819 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,16 +112,19 @@ Then: ### Running Examples -See [`examples/server/README.md`](examples/server/README.md) and [`examples/client/README.md`](examples/client/README.md) for a full list of runnable examples. +See [`examples/README.md`](examples/README.md) for the full list of runnable examples — one self-verifying client/server pair per directory. Quick start: ```bash -# Run a server example -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts +# Run any story's server +pnpm --filter @mcp-examples/tools server -- --http --port 3000 -# Run a client example (in another terminal) -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts +# Run its client (in another terminal) +pnpm --filter @mcp-examples/tools client -- --http http://127.0.0.1:3000/ + +# Run every story over every transport × era leg +pnpm run:examples ``` ## Releasing v1.x Patches diff --git a/examples/README.md b/examples/README.md index 176b3cd661..cf7b567968 100644 --- a/examples/README.md +++ b/examples/README.md @@ -56,14 +56,14 @@ Some stories mount at a different path (e.g. `/`); check the story's `package.js ## Excluded -| Directory | What it is | Why not in CI | -| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`oauth/`](./oauth/README.md) | Interactive authorization-code OAuth flow (`simpleOAuthClient.ts`, `dualModeAuth.ts`, `simpleTokenProvider.ts`) | Opens a real browser and runs a callback server on `:8090`. The headless machine-to-machine grant is covered by [`oauth-client-credentials/`](./oauth-client-credentials/README.md). | -| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | -| [`sse-polling/`](./sse-polling/README.md), [`standalone-get/`](./standalone-get/README.md) | Legacy sessionful-2025 SSE stories (SEP-1699 reconnect/replay; standalone GET stream) | Kept for reference; long-running reconnect/timer flows that need a longer per-leg readiness wait than the harness default. Self-verifying — flip the `excluded` flag once the harness has bounded-wait knobs. | -| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | -| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | -| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | +| Directory | What it is | Why not in CI | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`oauth/`](./oauth/README.md) | Interactive authorization-code OAuth flow: in-repo protected `server.ts` (demo AS + RS) + browser `simpleOAuthClient.ts` | Opens a real browser and runs a callback server on `:8090`. The headless machine-to-machine grant is covered by [`oauth-client-credentials/`](./oauth-client-credentials/README.md). | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`sse-polling/`](./sse-polling/README.md), [`standalone-get/`](./standalone-get/README.md) | Legacy sessionful-2025 SSE stories (SEP-1699 reconnect/replay; standalone GET stream) | Kept for reference; long-running reconnect/timer flows that need a longer per-leg readiness wait than the harness default. Self-verifying — flip the `excluded` flag once the harness has bounded-wait knobs. | +| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | +| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | ## Multi-node deployment patterns @@ -165,5 +165,5 @@ For scenarios where local in-memory state must be maintained on specific nodes, ## Backwards compatibility (Streamable HTTP ↔ legacy SSE) -A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#backwards-compatibility) recipe in the client guide — try +A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try `StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern. diff --git a/examples/elicitation/README.md b/examples/elicitation/README.md index 93fb2f87ff..fe38058a06 100644 --- a/examples/elicitation/README.md +++ b/examples/elicitation/README.md @@ -8,7 +8,8 @@ Server requests user input. One factory, both protocol eras: elicitation works o | **url** (`link_account`) | `await ctx.mcpReq.elicitInput({ mode: 'url', url, elicitationId })` + `createElicitationCompletionNotifier(elicitationId)` for the out-of-band `notifications/elicitation/complete` | `return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl(...) } })` — no `elicitationId` / complete notification on this era | | **url, throw** (`confirm_payment`) | `throw new UrlElicitationRequiredError([...])` — the wire `-32042`; the client catches the typed error and reads `.elicitations` | n/a — a throw on this era fails loudly with a steer to `inputRequired.elicitUrl(...)` | -The form schema includes an `enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md). +`plan_trip` chains **two** form elicitations inside one tool call (destination → dates for that destination): two sequential `ctx.mcpReq.elicitInput` pushes on 2025, two `inputRequired` rounds with `requestState` carry-over on 2026. The `register_user` form schema includes an +`enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md). **stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the stateless per-request `createMcpHandler`. diff --git a/examples/elicitation/client.ts b/examples/elicitation/client.ts index 479e270735..03bef8614b 100644 --- a/examples/elicitation/client.ts +++ b/examples/elicitation/client.ts @@ -44,6 +44,12 @@ runClient('elicitation', async () => { } return { action: 'accept' }; } + if (params.requestedSchema?.properties?.['destination']) { + return { action: 'accept', content: { destination: 'Tokyo' } }; + } + if (params.requestedSchema?.properties?.['departure']) { + return { action: 'accept', content: { departure: '2026-09-01', nights: 7 } }; + } check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema'); if (formAction === 'decline') return { action: 'decline' }; return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', plan: 'pro' } }; @@ -60,6 +66,10 @@ runClient('elicitation', async () => { const declined = await client.callTool({ name: 'register_user' }); check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/); + // ---- Multi-step form (two chained elicitations inside one tool call) ----- + const trip = await client.callTool({ name: 'plan_trip' }); + check.match(trip.content?.[0]?.type === 'text' ? trip.content[0].text : '', /trip planned: Tokyo on 2026-09-01 for 7 nights/); + // ---- URL mode (push-style on 2025, inputRequired.elicitUrl on 2026) ------ const linked = await client.callTool({ name: 'link_account', arguments: { provider: 'github' } }); check.match(linked.content?.[0]?.type === 'text' ? linked.content[0].text : '', /linked github/); diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index dc31ee1228..3856d1b67f 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -89,6 +89,61 @@ function buildServer(reqCtx: McpRequestContext): McpServer { } ); + // ---- Multi-step / chained form elicitation (two sequential prompts) ------ + server.registerTool( + 'plan_trip', + { description: 'Plan a trip by collecting a destination and then dates for that destination' }, + async (ctx): Promise => { + const DEST: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { destination: { type: 'string', title: 'Destination' } }, + required: ['destination'] + }; + const datesFor = (dest: string): ElicitRequestFormParams['requestedSchema'] => ({ + type: 'object', + properties: { + departure: { type: 'string', title: `Departure date for ${dest}`, format: 'date' }, + nights: { type: 'integer', title: 'Nights', minimum: 1, maximum: 30 } + }, + required: ['departure', 'nights'] + }); + if (reqCtx.era === 'legacy') { + // 2025-era: two sequential `elicitation/create` pushes inside one tool call. + const step1 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Where to?', requestedSchema: DEST }); + if (step1.action !== 'accept' || !step1.content) { + return { content: [{ type: 'text', text: `trip ${step1.action}` }] }; + } + const dest = step1.content.destination as string; + const step2 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'When?', requestedSchema: datesFor(dest) }); + if (step2.action !== 'accept' || !step2.content) { + return { content: [{ type: 'text', text: `trip ${step2.action}` }] }; + } + return { + content: [ + { type: 'text', text: `trip planned: ${dest} on ${step2.content.departure} for ${step2.content.nights} nights` } + ] + }; + } + // 2026-07-28: two `inputRequired` rounds — the second carries the + // first answer back via `requestState` (an opaque server-minted + // string) so the chain survives the stateless retry. See ../mrtr/ + // for integrity-protecting `requestState` in production. + const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates'); + const destination = + ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination; + if (!destination) { + return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } }); + } + if (!dates) { + return inputRequired({ + requestState: destination, + inputRequests: { dates: inputRequired.elicit({ message: 'When?', requestedSchema: datesFor(destination) }) } + }); + } + return { content: [{ type: 'text', text: `trip planned: ${destination} on ${dates.departure} for ${dates.nights} nights` }] }; + } + ); + // ---- URL-mode elicitation (push style + completion notification) --------- server.registerTool( 'link_account', diff --git a/examples/legacy-routing/README.md b/examples/legacy-routing/README.md index 9c26dbead3..21a2a38e9e 100644 --- a/examples/legacy-routing/README.md +++ b/examples/legacy-routing/README.md @@ -2,4 +2,25 @@ `isLegacyRequest` routing: keep an **existing** sessionful 1.x Streamable HTTP deployment serving 2025-era clients, add a strict `createMcpHandler({ legacy: 'reject' })` for 2026-07-28 traffic, on the **same port**. The predicate decides per request which arm handles it. +`server.ts` also shows the browser-client CORS `exposedHeaders` recipe and explicit `GET` (standalone SSE stream) / `DELETE` (session termination per the MCP spec) routes for the sessionful arm. + **HTTP-only** by definition; see also `dual-era/` for the simple case where you don't have a sessionful deployment to keep. + +## Direct transport construction (without `createMcpHandler`) + +If you need full control over the per-request transport on a web-standards runtime (Hono, Cloudflare Workers, …) instead of `createMcpHandler`, construct `WebStandardStreamableHTTPServerTransport` directly: + +```ts +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID() +}); +const server = new McpServer({ name: 'direct-transport', version: '1.0.0' }); +await server.connect(transport); + +// Any Request/Response runtime (fetch handler, Hono `c.req.raw`, …): +export default { fetch: (request: Request) => transport.handleRequest(request) }; +``` + +`NodeStreamableHTTPServerTransport` (used in this story's legacy arm) is the Node.js `IncomingMessage`/`ServerResponse` equivalent. diff --git a/examples/legacy-routing/package.json b/examples/legacy-routing/package.json index 7ba3c52b2c..f4ca257377 100644 --- a/examples/legacy-routing/package.json +++ b/examples/legacy-routing/package.json @@ -11,10 +11,12 @@ "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "zod": "catalog:runtimeShared" }, "devDependencies": { + "@types/cors": "catalog:devTools", "tsx": "catalog:devTools" }, "example": { diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index fb64d431d3..06289dfbe9 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -16,6 +16,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; @@ -56,7 +57,19 @@ const handleLegacy = async (req: Request, res: Response) => { const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' }); const app = createMcpExpressApp(); -app.all('/', async (req: Request, res: Response) => { +// Browser-client CORS recipe: expose the response headers a browser-based MCP +// client must be able to read (`Mcp-Session-Id` for session correlation, +// `WWW-Authenticate` for the auth challenge, `Last-Event-Id` for resumability, +// `Mcp-Protocol-Version` for negotiation). DEMO ONLY — restrict `origin` in +// production. +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version'] + }) +); + +app.post('/', async (req: Request, res: Response) => { // The predicate inspects the same headers + body the entry does. Express // has parsed the JSON body; pass it as `parsedBody` so the predicate need // not re-read the stream. @@ -66,6 +79,11 @@ app.all('/', async (req: Request, res: Response) => { }); await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modern.node(req, res, req.body)); }); +// GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE +// (explicit session termination per the MCP spec) are sessionful-2025-only — +// route them straight to the legacy arm; the transport handles each verb. +app.get('/', (req, res) => void handleLegacy(req, res)); +app.delete('/', (req, res) => void handleLegacy(req, res)); const argv = process.argv.slice(2); const portIdx = argv.indexOf('--port'); diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md index 7bb784f90b..a3ee979d6c 100644 --- a/examples/oauth-client-credentials/README.md +++ b/examples/oauth-client-credentials/README.md @@ -23,3 +23,19 @@ pnpm --filter @mcp-examples/oauth-client-credentials client -- --http http://127 ``` HTTP-only, modern-era only. + +## `private_key_jwt` client authentication + +To authenticate the `client_credentials` grant with a signed JWT assertion (RFC 7523 §2.2) instead of a shared secret, swap `ClientCredentialsProvider` for `PrivateKeyJwtProvider`: + +```ts +import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; + +const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' +}); +``` + +The full snippet lives in the client guide (`docs/client.md` → `auth_privateKeyJwt`). There is no runnable leg for it in this story — the in-repo `client_credentials` AS only implements `client_secret_basic`/`client_secret_post`. diff --git a/examples/oauth/README.md b/examples/oauth/README.md index 90cffe9ff7..f99309bc66 100644 --- a/examples/oauth/README.md +++ b/examples/oauth/README.md @@ -1,9 +1,22 @@ # oauth (excluded) -The interactive authorization-code OAuth set, typecheck-only. Excluded from the harness (`package.json#example.excluded`) because the browser flow needs a real browser and a callback server on `:8090`. +The interactive **authorization-code** OAuth set, typecheck-only. Excluded from the harness (`package.json#example.excluded`) because the browser flow needs a real browser and a callback server on `:8090`. +- `server.ts` — an in-repo OAuth-protected MCP server: `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:3001`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:3000/mcp`, + advertising the AS via `createProtectedResourceMetadataRouter`. DEMO ONLY — the AS auto-signs-in a fixed user. - `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — full browser authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server, exchanges the code, then drops into a small `list`/`call` REPL. - `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`. - `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens. +## Run it manually + +```bash +# terminal 1 — Authorization Server (:3001) + protected MCP Resource Server (:3000/mcp) +pnpm --filter @mcp-examples/oauth server + +# terminal 2 — opens a browser to the demo AS, runs the callback server on :8090, +# exchanges the code, then drops into a list/call REPL against :3000/mcp +pnpm --filter @mcp-examples/oauth client +``` + For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`. diff --git a/examples/oauth/package.json b/examples/oauth/package.json index 14638ca832..0d95ff0d62 100644 --- a/examples/oauth/package.json +++ b/examples/oauth/package.json @@ -2,14 +2,24 @@ "name": "@mcp-examples/oauth", "private": true, "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx simpleOAuthClient.ts" + }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "open": "^11.0.0" + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "open": "^11.0.0", + "zod": "catalog:runtimeShared" }, "devDependencies": { + "@types/cors": "catalog:devTools", "tsx": "catalog:devTools" }, "example": { - "excluded": "Interactive authorization-code OAuth flow: browser auth + callback server on :8090. Machine-to-machine client_credentials is ../oauth-client-credentials/; the readline playground is ../repl/." + "excluded": "Interactive authorization-code OAuth flow: browser auth + callback server on :8090. Run server.ts + simpleOAuthClient.ts manually. Machine-to-machine client_credentials is ../oauth-client-credentials/; the readline playground is ../repl/." } } diff --git a/examples/oauth/server.ts b/examples/oauth/server.ts new file mode 100644 index 0000000000..8ac21cba32 --- /dev/null +++ b/examples/oauth/server.ts @@ -0,0 +1,76 @@ +/** + * In-repo OAuth-protected MCP server for the interactive **authorization-code** + * flow — the demo Resource Server that {@link ./simpleOAuthClient.ts} + * authenticates against. + * + * One process, two listeners: + * + * - `:AUTH_PORT` (default `3001`) — the demo **Authorization Server** + * (`setupAuthServer` from `@mcp-examples/shared`, backed by better-auth's + * OIDC plugin). It implements the `authorization_code` grant only and + * auto-signs-in a fixed demo user. + * - `:MCP_PORT` (default `3000`) — the MCP **Resource Server**: + * `createMcpHandler` behind `requireBearerAuth({ verifier: demoTokenVerifier })`, + * advertising the AS via `createProtectedResourceMetadataRouter` (RFC 9728) + * so the client's discovery from a `401` `WWW-Authenticate` challenge works. + * + * Excluded from the harness (the browser flow needs a real browser); run + * manually — see `./README.md`. + * + * DEMO ONLY — NOT FOR PRODUCTION. The demo AS auto-approves a fixed user; CORS + * allows every origin; tokens are validated in-process against the same demo + * AS instance. + */ +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import * as z from 'zod/v4'; + +const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; +const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); +const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); + +// ---- Authorization Server (better-auth OIDC; authorization_code only) ---- +setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true }); + +// ---- Resource Server (MCP) ---- +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'oauth-protected-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the authenticated subject and granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }] + }) + ); + return server; +}); + +const app = createMcpExpressApp(); +// DEMO ONLY — restrict `origin` in production. `exposedHeaders` lists the +// response headers a browser-based MCP client must be able to read. +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version'] + }) +); +// RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp +// — the client discovers the AS from the 401 challenge → this route → AS metadata. +app.use(createProtectedResourceMetadataRouter('/mcp')); + +const auth = requireBearerAuth({ + verifier: demoTokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it +// to the factory as `ctx.authInfo`. +app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); + +app.listen(MCP_PORT, () => { + console.log(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`); + console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); +}); diff --git a/examples/repl/README.md b/examples/repl/README.md index 7f444bd011..4a6dbf808a 100644 --- a/examples/repl/README.md +++ b/examples/repl/README.md @@ -1,7 +1,7 @@ # repl (excluded) -The interactive playground. A fully-featured HTTP server (tools with input/output schemas + annotations, prompts with completion, direct + templated resources, `notifications/message` logging, `resources/list_changed` published via `handler.notify`) paired with a readline REPL -client that can drive every primitive by hand — `list-tools`, `call-tool`, `list-prompts`, `get-prompt`, `list-resources`, `read-resource`, form elicitation, resumable notification streams. +The interactive playground. A fully-featured **sessionful** HTTP server (tools with input/output schemas + annotations, prompts with completion, direct + templated resources, `notifications/message` logging, `resources/list_changed`, in-memory `eventStore` for resumability) +paired with a readline REPL client that can drive every primitive by hand — `list-tools`, `call-tool`, `list-prompts`, `get-prompt`, `list-resources`, `read-resource`, form elicitation, resumable notification streams (`reconnect`, `run-notifications-tool-with-resumability`). Excluded from the harness (`package.json#example.excluded`); run manually: diff --git a/examples/repl/package.json b/examples/repl/package.json index 069e4887d7..f54332fdd7 100644 --- a/examples/repl/package.json +++ b/examples/repl/package.json @@ -8,11 +8,15 @@ }, "dependencies": { "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "ajv": "catalog:runtimeShared", + "express": "catalog:runtimeServerOnly", "zod": "catalog:runtimeShared" }, "devDependencies": { + "@types/express": "catalog:devTools", "tsx": "catalog:devTools" }, "example": { diff --git a/examples/repl/server.ts b/examples/repl/server.ts index fbb7d35a84..8ba2ddbdfb 100644 --- a/examples/repl/server.ts +++ b/examples/repl/server.ts @@ -1,34 +1,35 @@ /** - * Fully-featured HTTP playground server for the interactive REPL client. + * Fully-featured **sessionful** HTTP playground server for the interactive + * REPL client. * * Exposes every primitive the REPL client (`./client.ts`) can drive: tools * (typed input/output schemas + annotations + form elicitation + * `ResourceLink`s), prompts (with `completable()` argument completion), * resources (direct + `ResourceTemplate`), `notifications/message` logging, - * and `notifications/resources/list_changed` published over the handler's - * cross-request {@link ServerNotifier} so the client's `list_changed` handler - * fires after `add-resource`. + * and `notifications/resources/list_changed`. + * + * Hosted on `NodeStreamableHTTPServerTransport` with an in-memory + * `eventStore` so the REPL client's `reconnect` and + * `run-notifications-tool-with-resumability` commands actually replay missed + * events on reconnect with `Last-Event-ID`. * * HTTP-only — pair with `pnpm run client` in a second terminal. */ -import { createServer } from 'node:http'; +import { randomUUID } from 'node:crypto'; -import type { - CallToolResult, - PrimitiveSchemaDefinition, - ReadResourceResult, - ResourceLink, - ServerNotifier -} from '@modelcontextprotocol/server'; -import { completable, createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server'; +import { completable, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; import * as z from 'zod/v4'; +import { InMemoryEventStore } from '../sse-polling/inMemoryEventStore.js'; + const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; -/** Dynamic resources added via the `add-resource` tool (shared across requests). */ +/** Dynamic resources added via the `add-resource` tool (shared across sessions). */ const dynamicResources = new Map(); -/** Publishes `resources/list_changed` to every open subscription. Bound below from `handler.notify`. */ -let publishResourcesChanged: () => void = () => {}; function buildServer(): McpServer { const server = new McpServer( @@ -160,8 +161,8 @@ function buildServer(): McpServer { } ); - // Mutates the resource set and publishes `resources/list_changed` over the - // handler's cross-request bus (handler.notify.resourcesChanged()). + // Mutates the resource set and publishes `resources/list_changed` on this + // session's standalone SSE stream. server.registerTool( 'add-resource', { @@ -171,7 +172,7 @@ function buildServer(): McpServer { }, async ({ name, text }): Promise => { dynamicResources.set(name, text); - publishResourcesChanged(); + server.sendResourceListChanged(); return { content: [{ type: 'text', text: `Added note://${name}` }] }; } ); @@ -248,17 +249,38 @@ function buildServer(): McpServer { return server; } -// HTTP-only entry. `createMcpHandler` answers on any path; the REPL client -// connects to `http://localhost:3000/mcp` by default. -const handler = createMcpHandler(buildServer, { onerror: e => console.error('[server] handler error:', e.message) }); -const notify: ServerNotifier = handler.notify; -publishResourcesChanged = () => notify.resourcesChanged(); +// Sessionful 2025-era hosting with an in-memory event store so the REPL +// client's resumability commands work (reconnect with `Last-Event-ID` replays +// missed `notifications/message` events). +const sessions = new Map(); +const eventStore = new InMemoryEventStore(); + +const app = createMcpExpressApp(); +app.all('/mcp', async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // resumability — events are persisted for replay on GET reconnect + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}); -const httpServer = createServer((req, res) => void handler.node(req, res)); -httpServer.listen(PORT, () => console.error(`[server] REPL playground listening on http://localhost:${PORT}/mcp`)); +app.listen(PORT, () => console.error(`[server] REPL playground listening on http://localhost:${PORT}/mcp`)); process.on('SIGINT', async () => { - await handler.close(); - httpServer.close(); + for (const t of sessions.values()) await t.close(); process.exit(0); }); diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts index 7e4b1cccc6..30dcd1b43a 100644 --- a/examples/stickynotes/client.ts +++ b/examples/stickynotes/client.ts @@ -1,8 +1,8 @@ /** - * Drives the sticky-notes board end to end on a 2026-07-28 connection: add - * two notes, list/read their resources, remove one, then attempt `remove_all` - * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board - * is cleared only on an explicit confirmation. + * Drives the sticky-notes board end to end on a 2025-era (legacy) connection: + * add two notes, list/read their resources, remove one, then attempt + * `remove_all` three ways (cancel, accept-unchecked, accept-confirmed) to prove + * the board is cleared only on an explicit confirmation. */ import { check, connectFromArgs, runClient, transportLeg } from '../harness.js'; diff --git a/package.json b/package.json index 8c5950040a..ab5510cfa1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", - "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples exec tsx --watch oauth/simpleStreamableHttpServer.ts --oauth", + "examples:oauth-server:w": "pnpm --filter @mcp-examples/oauth exec tsx --watch server.ts", "run:examples": "tsx scripts/run-examples.ts", "docs": "typedoc", "docs:multi": "bash scripts/generate-multidoc.sh", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dac19ea55..896331c2df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,6 +509,9 @@ importers: '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 express: specifier: catalog:runtimeServerOnly version: 5.2.1 @@ -516,6 +519,9 @@ importers: specifier: catalog:runtimeShared version: 4.3.6 devDependencies: + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 tsx: specifier: catalog:devTools version: 4.21.0 @@ -538,13 +544,31 @@ importers: examples/oauth: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 open: specifier: ^11.0.0 version: 11.0.0 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 devDependencies: + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 tsx: specifier: catalog:devTools version: 4.21.0 @@ -605,16 +629,28 @@ importers: '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server ajv: specifier: catalog:runtimeShared version: 8.18.0 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 zod: specifier: catalog:runtimeShared version: 4.3.6 devDependencies: + '@types/express': + specifier: catalog:devTools + version: 5.0.6 tsx: specifier: catalog:devTools version: 4.21.0 From e9b726300bdcbdcfe35415fb06ebed17a860ebd7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 10:42:24 +0000 Subject: [PATCH 19/27] =?UTF-8?q?docs(examples):=20sessionful=20http/legac?= =?UTF-8?q?y=20harness;=20complete=20the=20transport=C3=97era=20matrix=20(?= =?UTF-8?q?47=E2=86=9259=20legs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runServerFromArgs now hosts 2025-era HTTP traffic on a sessionful NodeStreamableHTTPServerTransport (one transport+instance per session) behind isLegacyRequest, with createMcpHandler({legacy:'reject'}) for the modern arm — the documented composition. That removes the structural reason elicitation/sampling/stickynotes couldn't run http/legacy. Per-story matrix changes: - elicitation, custom-methods, stickynotes: full stdio+http × dual - sampling: stdio+http × legacy (deprecated 2026-07-28; no modern equiv) - bearer-auth, hono, oauth-client-credentials: http × dual via negotiationFromArgs() (era-agnostic HTTP-layer concerns) - standalone-get: tool-triggered (add_resource) instead of 5s timer; un-excluded, http × legacy - sse-polling: --port/--http aware, self-verifying client, shorter sleeps + retryInterval; un-excluded, http × legacy - legacy-routing: add path:'/' so package.json#example.path is discoverable per the README sentence - json-response: stays modern-only (responseMode shapes the modern path only); replace boilerplate '//' with the principled reason - oauth: drop from NON_STORY, add stub client.ts so the SKIP reason surfaces in the run-examples summary like repl's README: add an Era column to the coverage tables; flip the 'different path' sentence to match the harness default (/); drop the sse-polling/standalone-get row from Excluded. run:examples: 23 run / 2 excluded; 59 legs passed / 0 failed (was 47). --- examples/README.md | 67 +++--- examples/bearer-auth/client.ts | 7 +- examples/bearer-auth/package.json | 4 +- examples/custom-methods/client.ts | 8 +- examples/custom-methods/package.json | 4 +- examples/elicitation/README.md | 3 +- examples/elicitation/package.json | 5 +- examples/elicitation/server.ts | 7 +- examples/harness.ts | 88 ++++++- examples/hono/client.ts | 6 +- examples/hono/package.json | 4 +- examples/json-response/package.json | 2 +- examples/legacy-routing/package.json | 3 +- examples/oauth-client-credentials/client.ts | 4 +- .../oauth-client-credentials/package.json | 4 +- examples/oauth/client.ts | 10 + examples/sampling/README.md | 4 +- examples/sampling/client.ts | 4 +- examples/sampling/package.json | 5 +- examples/sse-polling/README.md | 7 +- examples/sse-polling/client.ts | 136 +++-------- examples/sse-polling/package.json | 5 +- examples/sse-polling/server.ts | 37 ++- examples/standalone-get/README.md | 5 +- examples/standalone-get/client.ts | 31 ++- examples/standalone-get/package.json | 6 +- examples/standalone-get/server.ts | 220 ++++++------------ examples/stickynotes/README.md | 4 +- examples/stickynotes/client.ts | 26 +-- examples/stickynotes/package.json | 4 +- pnpm-lock.yaml | 3 + scripts/run-examples.ts | 2 +- 32 files changed, 349 insertions(+), 376 deletions(-) create mode 100644 examples/oauth/client.ts diff --git a/examples/README.md b/examples/README.md index cf7b567968..cba4a2cd8d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,10 +11,10 @@ pnpm --filter @mcp-examples/ client # Streamable HTTP (two terminals): pnpm --filter @mcp-examples/ server -- --http --port 3000 -pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/mcp +pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/ ``` -Some stories mount at a different path (e.g. `/`); check the story's `package.json#example.path` or its README for the exact URL. +Add `-- --legacy` to the client command for the 2025-era handshake. Some stories mount at a different path (e.g. `/mcp`); check the story's `package.json#example.path` or its README for the exact URL. ## Start here @@ -27,43 +27,44 @@ Some stories mount at a different path (e.g. `/`); check the story's `package.js ## Feature stories -| Story | What it teaches | Transports | -| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | -| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | -| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | -| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio | -| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio | -| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | -| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | -| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | -| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | -| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | -| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | -| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | -| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | -| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | +| Story | What it teaches | Transports | Era | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | ------ | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio + http | legacy | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | both¹ | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | ## HTTP hosting variants -| Story | What it teaches | Transports | -| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | -| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | -| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | -| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | -| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | -| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | +| Story | What it teaches | Transports | Era | +| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | ------ | +| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | both¹ | +| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | modern | +| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | dual | +| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy | +| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy | + +¹ The story body drives both eras inside one client run, so the harness pins it to a single leg. ## Excluded -| Directory | What it is | Why not in CI | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`oauth/`](./oauth/README.md) | Interactive authorization-code OAuth flow: in-repo protected `server.ts` (demo AS + RS) + browser `simpleOAuthClient.ts` | Opens a real browser and runs a callback server on `:8090`. The headless machine-to-machine grant is covered by [`oauth-client-credentials/`](./oauth-client-credentials/README.md). | -| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | -| [`sse-polling/`](./sse-polling/README.md), [`standalone-get/`](./standalone-get/README.md) | Legacy sessionful-2025 SSE stories (SEP-1699 reconnect/replay; standalone GET stream) | Kept for reference; long-running reconnect/timer flows that need a longer per-leg readiness wait than the harness default. Self-verifying — flip the `excluded` flag once the harness has bounded-wait knobs. | -| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | -| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | -| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | +| Directory | What it is | Why not in CI | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`oauth/`](./oauth/README.md) | Interactive authorization-code OAuth flow: in-repo protected `server.ts` (demo AS + RS) + browser `simpleOAuthClient.ts` | Opens a real browser and runs a callback server on `:8090`. The headless machine-to-machine grant is covered by [`oauth-client-credentials/`](./oauth-client-credentials/README.md). | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | +| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | ## Multi-node deployment patterns diff --git a/examples/bearer-auth/client.ts b/examples/bearer-auth/client.ts index 47dddfd10e..f8f3fc9280 100644 --- a/examples/bearer-auth/client.ts +++ b/examples/bearer-auth/client.ts @@ -5,7 +5,7 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, negotiationFromArgs, runClient } from '../harness.js'; const argv = process.argv.slice(2); const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; @@ -20,8 +20,9 @@ runClient('bearer-auth', async () => { check.equal(unauth.status, 401); check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); - // Authenticated → 200 and the tool sees the authInfo. - const client = new Client({ name: 'bearer-auth-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + // Authenticated → 200 and the tool sees the authInfo. Bearer auth is + // HTTP-layer and era-agnostic; `negotiationFromArgs()` honours `--legacy`. + const client = new Client({ name: 'bearer-auth-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL), { authProvider: { token: async () => 'demo-token' } })); const result = await client.callTool({ name: 'whoami', arguments: {} }); check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client'); diff --git a/examples/bearer-auth/package.json b/examples/bearer-auth/package.json index d6b48aa1bc..67e0a83eec 100644 --- a/examples/bearer-auth/package.json +++ b/examples/bearer-auth/package.json @@ -19,8 +19,8 @@ "transports": [ "http" ], - "era": "modern", + "era": "dual", "path": "/mcp", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + "//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs." } } diff --git a/examples/custom-methods/client.ts b/examples/custom-methods/client.ts index 45cd9f8950..c92a4080a3 100644 --- a/examples/custom-methods/client.ts +++ b/examples/custom-methods/client.ts @@ -13,11 +13,11 @@ const SearchResult = z.object({ items: z.array(z.string()) }); const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); runClient('custom-methods', async () => { - // Custom methods carry no envelope semantics — connect as a plain 2025 - // client so the request reaches the server's setRequestHandler exactly as - // a hand-wired stdio client would. + // Vendor-prefixed methods route through both serving entries unchanged: a + // 2025 client sends the bare JSON-RPC request, a 2026-07-28 client sends it + // with the per-request envelope; `setRequestHandler` receives either. // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. - const client = await connectFromArgs(import.meta.dirname, { versionNegotiation: undefined }); + const client = await connectFromArgs(import.meta.dirname); const stages: string[] = []; client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { diff --git a/examples/custom-methods/package.json b/examples/custom-methods/package.json index 260123d877..2ceb27e0db 100644 --- a/examples/custom-methods/package.json +++ b/examples/custom-methods/package.json @@ -14,7 +14,7 @@ "tsx": "catalog:devTools" }, "example": { - "era": "legacy", - "//": "Custom methods carry no envelope semantics; the example connects as a plain 2025 client so the request reaches setRequestHandler exactly as a hand-wired client would." + "era": "dual", + "//": "Vendor-prefixed methods route through both serving entries unchanged: a 2025 client sends the bare JSON-RPC request, a 2026-07-28 client sends it with the per-request envelope; setRequestHandler receives either." } } diff --git a/examples/elicitation/README.md b/examples/elicitation/README.md index fe38058a06..98d2c362a4 100644 --- a/examples/elicitation/README.md +++ b/examples/elicitation/README.md @@ -11,7 +11,8 @@ Server requests user input. One factory, both protocol eras: elicitation works o `plan_trip` chains **two** form elicitations inside one tool call (destination → dates for that destination): two sequential `ctx.mcpReq.elicitInput` pushes on 2025, two `inputRequired` rounds with `requestState` carry-over on 2026. The `register_user` form schema includes an `enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md). -**stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the stateless per-request `createMcpHandler`. +Runs the full transport × era matrix: the harness's `--http` arm hosts 2025 traffic on a sessionful `NodeStreamableHTTPServerTransport` (the same `isLegacyRequest` composition `../legacy-routing/` shows by hand), so push server→client requests reach the client over either +transport. ```bash pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired) diff --git a/examples/elicitation/package.json b/examples/elicitation/package.json index 0f4a4d8874..437f49a350 100644 --- a/examples/elicitation/package.json +++ b/examples/elicitation/package.json @@ -15,10 +15,7 @@ "tsx": "catalog:devTools" }, "example": { - "transports": [ - "stdio" - ], "era": "dual", - "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the elicitation capability; createMcpHandler's per-request/stateless posture has neither. The 2026-07-28 multi-round-trip path is the same handler over stdio." + "//": "2025-era push-style runs over the harness's sessionful http/legacy arm; 2026-07-28 inputRequired runs over the per-request modern arm. Full transport × era matrix." } } diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index 3856d1b67f..7c60e9be06 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -159,7 +159,12 @@ function buildServer(reqCtx: McpRequestContext): McpServer { // `notifications/elicitation/complete` for the same id. The // client waits for that notification before answering accept. const elicitationId = randomUUID(); - const notifyComplete = server.server.createElicitationCompletionNotifier(elicitationId); + // Tie the completion notification to the in-flight request so on + // sessionful HTTP it travels over this POST's SSE response stream + // (rather than the standalone GET stream). + const notifyComplete = server.server.createElicitationCompletionNotifier(elicitationId, { + relatedRequestId: ctx.mcpReq.id + }); setTimeout(() => void notifyComplete().catch(error => console.error('[server] complete notify failed:', error)), 50); const params: ElicitRequestURLParams = { mode: 'url', diff --git a/examples/harness.ts b/examples/harness.ts index b25dd18d76..65b8c97052 100644 --- a/examples/harness.ts +++ b/examples/harness.ts @@ -15,14 +15,17 @@ * Re-exported `check` is `node:assert/strict` for readable inline assertions. */ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer } from 'node:http'; import path from 'node:path'; import type { ClientOptions } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { McpServerFactory } from '@modelcontextprotocol/server'; -import { createMcpHandler } from '@modelcontextprotocol/server'; +import { createMcpHandler, isInitializeRequest, isLegacyRequest } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; export { strict as check } from 'node:assert'; @@ -32,8 +35,12 @@ export { strict as check } from 'node:assert'; * * - default: `serveStdio(factory)` — the deployable shape; the client spawns * this binary and speaks JSON-RPC over the pipe. - * - `--http [--port N]`: `createMcpHandler(factory)` mounted on `node:http` - * at `/` (so the harness's readiness poll and the client's URL agree). + * - `--http [--port N]`: the documented {@linkcode isLegacyRequest} composition + * on `node:http` at `/` — modern (2026-07-28) traffic via a strict + * `createMcpHandler(factory, { legacy: 'reject' })`, 2025-era traffic via a + * sessionful `NodeStreamableHTTPServerTransport` (one transport+instance per + * session, the way you would actually deploy a 2025 server). The same + * factory backs both arms. * * Logs go to **stderr** so stdio's stdout JSON-RPC stream stays clean. */ @@ -42,11 +49,68 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000) if (argv.includes('--http')) { const portIdx = argv.indexOf('--port'); const port = portIdx === -1 ? Number(process.env.PORT ?? defaultPort) : Number(argv[portIdx + 1]); - const handler = createMcpHandler(factory, { onerror: e => console.error('[server] handler error:', e.message) }); - const server = createServer((req, res) => void handler.node(req, res)); + + // --- modern (2026-07-28): per-request, strict so the sessionful arm owns ALL legacy traffic --- + const modern = createMcpHandler(factory, { + legacy: 'reject', + onerror: e => console.error('[server] handler error:', e.message) + }); + + // --- legacy (2025): sessionful streamable HTTP — the deployable shape --- + const sessions = new Map(); + const handleLegacy = async (req: IncomingMessage, res: ServerResponse, body: unknown): Promise => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, body); + } else if (!sid && isInitializeRequest(body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + const instance = await factory({ era: 'legacy' }); + await instance.connect(transport); + await transport.handleRequest(req, res, body); + } else if (sid) { + res.writeHead(404, { 'content-type': 'application/json' }).end( + JSON.stringify({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }) + ); + } else { + res.writeHead(400, { 'content-type': 'application/json' }).end( + JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }) + ); + } + }; + + const server = createServer((req, res) => { + void (async () => { + // Read the body once for the predicate and pass it forward. + let body: unknown; + if (req.method === 'POST') { + let raw = ''; + for await (const chunk of req) raw += String(chunk); + try { + body = raw ? JSON.parse(raw) : undefined; + } catch { + body = undefined; + } + } + const probe = new globalThis.Request(`http://localhost${req.url ?? '/'}`, { + method: req.method, + headers: req.headers as Record + }); + await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern.node(req, res, body)); + })().catch(error => { + console.error('[server] request error:', error instanceof Error ? error.message : error); + if (!res.headersSent) res.writeHead(500).end(); + }); + }); server.listen(port, () => console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`)); const exit = async () => { - await handler.close(); + await modern.close(); + for (const t of sessions.values()) await t.close().catch(() => {}); server.close(); process.exit(0); }; @@ -82,7 +146,7 @@ export async function connectFromArgs(siblingDir: string, options: ClientOptions const argv = process.argv.slice(2); const client = new Client( { name: `${path.basename(siblingDir)}-example-client`, version: '1.0.0' }, - { versionNegotiation: { mode: argv.includes('--legacy') ? 'legacy' : 'auto' }, ...options } + { versionNegotiation: negotiationFromArgs(), ...options } ); const httpIdx = argv.indexOf('--http'); if (httpIdx === -1) { @@ -105,6 +169,16 @@ export function eraLeg(): 'modern' | 'legacy' { return process.argv.includes('--legacy') ? 'legacy' : 'modern'; } +/** + * The `versionNegotiation` ClientOption derived from `process.argv` — the same + * value {@linkcode connectFromArgs} applies. Use it from stories that + * construct their own {@link Client} so the harness's `--legacy` flag still + * selects the era. + */ +export function negotiationFromArgs(): NonNullable { + return { mode: process.argv.includes('--legacy') ? 'legacy' : 'auto' }; +} + /** * Run a self-verifying client scenario. Any thrown error (including * `node:assert/strict` failures) prints a `FAIL:` line to stderr and exits diff --git a/examples/hono/client.ts b/examples/hono/client.ts index d0e6ce8593..5994400920 100644 --- a/examples/hono/client.ts +++ b/examples/hono/client.ts @@ -3,13 +3,15 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, negotiationFromArgs, runClient } from '../harness.js'; const argv = process.argv.slice(2); const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; runClient('hono', async () => { - const client = new Client({ name: 'hono-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + // `createMcpHandler.fetch` serves both eras (default `'stateless'` posture); + // `negotiationFromArgs()` honours `--legacy` so the harness runs both. + const client = new Client({ name: 'hono-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); const tools = await client.listTools(); check.ok(tools.tools.some(t => t.name === 'greet')); diff --git a/examples/hono/package.json b/examples/hono/package.json index cd233ad23d..e8bcbbfcc3 100644 --- a/examples/hono/package.json +++ b/examples/hono/package.json @@ -20,8 +20,8 @@ "transports": [ "http" ], - "era": "modern", + "era": "dual", "path": "/mcp", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + "//": "createMcpHandler.fetch hosting is era-agnostic (default 'stateless' posture serves both); the client honours --legacy via negotiationFromArgs." } } diff --git a/examples/json-response/package.json b/examples/json-response/package.json index d878e2b932..242ccce148 100644 --- a/examples/json-response/package.json +++ b/examples/json-response/package.json @@ -19,6 +19,6 @@ "http" ], "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + "//": "responseMode shapes the modern (2026-07-28) per-request path only; 2025-era traffic goes through the stateless legacy fallback unaffected, so a legacy leg would not exercise the option." } } diff --git a/examples/legacy-routing/package.json b/examples/legacy-routing/package.json index f4ca257377..188995ee60 100644 --- a/examples/legacy-routing/package.json +++ b/examples/legacy-routing/package.json @@ -24,6 +24,7 @@ "http" ], "era": "modern", - "//": "The story body manages its own era internally (or does not use connectFromArgs); pinned so the harness runs it once per transport." + "path": "/", + "//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the harness runs it once." } } diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts index 2ebb2e8864..c66ce8e372 100644 --- a/examples/oauth-client-credentials/client.ts +++ b/examples/oauth-client-credentials/client.ts @@ -14,7 +14,7 @@ */ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, negotiationFromArgs, runClient } from '../harness.js'; const argv = process.argv.slice(2); const URL_ARG = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; @@ -36,7 +36,7 @@ runClient('oauth-client-credentials', async () => { clientSecret: 'demo-m2m-secret', scope: 'mcp:tools mcp:read' }); - const client = new Client({ name: 'client-credentials-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const client = new Client({ name: 'client-credentials-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider })); const tokens = provider.tokens(); diff --git a/examples/oauth-client-credentials/package.json b/examples/oauth-client-credentials/package.json index cd03c27584..c0ee598b83 100644 --- a/examples/oauth-client-credentials/package.json +++ b/examples/oauth-client-credentials/package.json @@ -20,8 +20,8 @@ "transports": [ "http" ], - "era": "modern", + "era": "dual", "path": "/mcp", - "//": "OAuth is HTTP-only by definition; the client drives the modern era explicitly." + "//": "OAuth client_credentials is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs." } } diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts new file mode 100644 index 0000000000..0a3e820ab3 --- /dev/null +++ b/examples/oauth/client.ts @@ -0,0 +1,10 @@ +/** + * Interactive authorization-code OAuth flow — see ./README.md. + * + * The harness lists this story as excluded via `package.json#example.excluded`; + * this stub exists so the SKIP reason surfaces in the run-examples summary + * alongside the other excluded stories. Run `./server.ts` and + * `./simpleOAuthClient.ts` manually in two terminals. + */ +console.error('oauth/ is interactive — run server.ts + simpleOAuthClient.ts manually (see README.md)'); +process.exit(1); diff --git a/examples/sampling/README.md b/examples/sampling/README.md index 18855618cd..05063a843a 100644 --- a/examples/sampling/README.md +++ b/examples/sampling/README.md @@ -4,8 +4,8 @@ A tool that requests LLM sampling from the client via `ctx.mcpReq.requestSamplin > Sampling is **deprecated** as of protocol revision 2026-07-28 (SEP-2577) but remains functional during the deprecation window. -**stdio-only** in the harness: push server→client requests need either a stdio connection or a sessionful HTTP transport (see `../legacy-routing/`); the harness's `--http` arm is the per-request `createMcpHandler`, which serves the 2026-07-28 path where sampling is unavailable. +Runs both transports on the **legacy** era — sampling is a 2025-era push-style server→client request and there is no 2026-07-28 equivalent. The harness's `--http` arm hosts 2025 traffic on a sessionful transport, so the request reaches the client over either. ```bash -pnpm tsx examples/sampling/client.ts --legacy +pnpm --filter @mcp-examples/sampling client -- --legacy ``` diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts index 5612864f19..14018fac26 100644 --- a/examples/sampling/client.ts +++ b/examples/sampling/client.ts @@ -7,8 +7,8 @@ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('sampling', async () => { // Push-style sampling is a 2025-era flow (and is deprecated as of - // 2026-07-28). The harness pins this story to the legacy era so the - // server's `ctx.mcpReq.requestSampling` reaches this handler. + // 2026-07-28). The story is pinned to the legacy era so the server's + // `ctx.mcpReq.requestSampling` reaches this handler over either transport. // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { capabilities: { sampling: {} } }); client.setRequestHandler('sampling/createMessage', async () => ({ diff --git a/examples/sampling/package.json b/examples/sampling/package.json index 4374dc07dd..6651c17042 100644 --- a/examples/sampling/package.json +++ b/examples/sampling/package.json @@ -14,10 +14,7 @@ "tsx": "catalog:devTools" }, "example": { - "transports": [ - "stdio" - ], "era": "legacy", - "//": "Push-style server→client requests need a long-lived bidirectional connection AND a 2025 initialize handshake to advertise the sampling capability; createMcpHandler's per-request/stateless posture has neither." + "//": "Sampling is a 2025-era push-style server→client request and is deprecated as of 2026-07-28 (SEP-2577); there is no modern-era equivalent. Both transports — the harness's http arm hosts 2025 traffic on a sessionful transport." } } diff --git a/examples/sse-polling/README.md b/examples/sse-polling/README.md index 38183b89c8..6a594af2b7 100644 --- a/examples/sse-polling/README.md +++ b/examples/sse-polling/README.md @@ -3,9 +3,10 @@ SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). `eventStore` resumability is a 2025-session concern with no 2026-07-28 per-request equivalent — this is the only story that configures one. -Excluded from the harness for now (the reconnect/replay flow needs a longer, bounded wait than the per-leg default). +The `long-operation` tool emits two log notifications, calls `ctx.http?.closeSSE()` mid-stream, emits two more while the client is disconnected, then returns. The client transport reconnects after `retryInterval` (300 ms) with `Last-Event-ID`; the event store replays the buffered +events. The client asserts the result arrived AND the post-disconnect log was delivered. ```bash -pnpm tsx examples/sse-polling/server.ts # term 1 (port 3001) -pnpm tsx examples/sse-polling/client.ts # term 2 +pnpm --filter @mcp-examples/sse-polling server -- --http --port 3001 # term 1 +pnpm --filter @mcp-examples/sse-polling client -- --http http://127.0.0.1:3001/mcp # term 2 ``` diff --git a/examples/sse-polling/client.ts b/examples/sse-polling/client.ts index 2d1115e72a..a71ac18c3c 100644 --- a/examples/sse-polling/client.ts +++ b/examples/sse-polling/client.ts @@ -1,109 +1,49 @@ /** * SSE Polling Example Client (SEP-1699) * - * This example demonstrates client-side behavior during server-initiated - * SSE stream disconnection and automatic reconnection. - * - * Key features demonstrated: - * - Automatic reconnection when server closes SSE stream - * - Event replay via Last-Event-ID header - * - Resumption token tracking via onresumptiontoken callback - * - * Run with: pnpm tsx src/ssePollingClient.ts - * Requires: ssePollingExample.ts server running on port 3001 + * Connects (2025-era), calls `long-operation`, and asserts the result arrives + * AFTER the server's mid-stream `closeSSE()` — i.e. the client transport + * automatically reconnects with `Last-Event-ID` and replays the events the + * `eventStore` buffered while disconnected. Also asserts every progress log + * (including the one emitted while disconnected) was delivered. */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const SERVER_URL = 'http://localhost:3001/mcp'; - -async function main(): Promise { - console.log('SSE Polling Example Client'); - console.log('=========================='); - console.log(`Connecting to ${SERVER_URL}...`); - console.log(''); - - // Create transport with reconnection options - const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { - // Use default reconnection options - SDK handles automatic reconnection - }); - - // Track the last event ID for debugging - let lastEventId: string | undefined; - - // Set up transport error handler to observe disconnections - // Filter out expected errors from SSE reconnection - transport.onerror = error => { - // Skip abort errors during intentional close - if (error.message.includes('AbortError')) return; - // Show SSE disconnect (expected when server closes stream) - if (error.message.includes('Unexpected end of JSON')) { - console.log('[Transport] SSE stream disconnected - client will auto-reconnect'); - return; - } - console.log(`[Transport] Error: ${error.message}`); - }; +import { check, runClient } from '../harness.js'; - // Set up transport close handler - transport.onclose = () => { - console.log('[Transport] Connection closed'); - }; +const argv = process.argv.slice(2); +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3001/mcp'; - // Create and connect client - const client = new Client({ - name: 'sse-polling-client', - version: '1.0.0' - }); +runClient('sse-polling', async () => { + const transport = new StreamableHTTPClientTransport(new globalThis.URL(URL)); + // The mid-stream disconnect surfaces as a transport error before the + // automatic reconnect; that is the EXPECTED flow, not a failure. + transport.onerror = () => {}; - // Set up notification handler to receive progress updates - client.setNotificationHandler('notifications/message', notification => { - const data = notification.params.data; - console.log(`[Notification] ${data}`); + const client = new Client({ name: 'sse-polling-client', version: '1.0.0' }); + const logs: string[] = []; + client.setNotificationHandler('notifications/message', n => { + logs.push(String(n.params.data)); }); + await client.connect(transport); - try { - await client.connect(transport); - console.log('[Client] Connected successfully'); - console.log(''); - - // Call the long-operation tool - console.log('[Client] Calling long-operation tool...'); - console.log('[Client] Server will disconnect mid-operation to demonstrate polling'); - console.log(''); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'long-operation', - arguments: {} - } - }, - { - // Track resumption tokens for debugging - onresumptiontoken: token => { - lastEventId = token; - console.log(`[Event ID] ${token}`); - } - } - ); - - console.log(''); - console.log('[Client] Tool completed!'); - console.log(`[Result] ${JSON.stringify(result.content, null, 2)}`); - console.log(''); - console.log(`[Debug] Final event ID: ${lastEventId}`); - } catch (error) { - console.error('[Error]', error); - } finally { - await transport.close(); - console.log('[Client] Disconnected'); - } -} - -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} + let lastEventId: string | undefined; + const result = await client.request( + { method: 'tools/call', params: { name: 'long-operation', arguments: {} } }, + { onresumptiontoken: token => (lastEventId = token) } + ); + + const text = (result as { content?: Array<{ type: string; text?: string }> }).content?.[0]?.text ?? ''; + check.match(text, /completed successfully/); + check.ok(lastEventId, 'resumption tokens should have been observed'); + // The 75% line is emitted WHILE the client is disconnected; receiving it + // proves the event store replayed it on reconnect. (Replay ordering relative + // to the terminal result is not asserted — the result resolving is the + // signal the disconnect was survived.) + check.ok( + logs.some(l => l.includes('75%')), + `events emitted while disconnected should be replayed (got: ${logs.join(' | ')})` + ); + + await client.close(); +}); diff --git a/examples/sse-polling/package.json b/examples/sse-polling/package.json index 32a5b77355..2500bffe11 100644 --- a/examples/sse-polling/package.json +++ b/examples/sse-polling/package.json @@ -21,6 +21,9 @@ "transports": [ "http" ], - "excluded": "SEP-1699 SSE polling/resumption story is sessionful 2025 with server-initiated disconnect; the client's reconnect-then-replay flow is a long (~5s) wait that the harness doesn't bound well. Typecheck-only for now; revisit after the harness gets per-leg timeouts." + "era": "legacy", + "path": "/mcp", + "timeoutMs": 20000, + "//": "SEP-1699 closeSSE/eventStore/retryInterval live on the sessionful-2025 transport; the client is era-blind so dual would duplicate." } } diff --git a/examples/sse-polling/server.ts b/examples/sse-polling/server.ts index 1b9a484c09..64b711deee 100644 --- a/examples/sse-polling/server.ts +++ b/examples/sse-polling/server.ts @@ -9,8 +9,7 @@ * - Uses `eventStore` to persist events for replay after reconnection * - Uses `ctx.http?.closeSSE()` callback to gracefully disconnect clients mid-operation * - * Run with: pnpm tsx examples/sse-polling/server.ts - * Test with: curl or the MCP Inspector + * HTTP-only, sessionful 2025 by definition. */ import { randomUUID } from 'node:crypto'; @@ -44,33 +43,33 @@ const getServer = () => { async (ctx): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - console.log(`[${ctx.sessionId}] Starting long-operation...`); + console.error(`[${ctx.sessionId}] Starting long-operation...`); // Send first progress notification await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...'); - await sleep(1000); + await sleep(200); // Send second progress notification await ctx.mcpReq.log('info', 'Progress: 50% - Halfway there...'); - await sleep(1000); + await sleep(200); // Server decides to disconnect the client to free resources // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval // Use ctx.http?.closeSSE callback - available when eventStore is configured if (ctx.http?.closeSSE) { - console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); + console.error(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); ctx.http?.closeSSE(); } // Continue processing while client is disconnected // Events are stored in eventStore and will be replayed on reconnect - await sleep(500); + await sleep(200); await ctx.mcpReq.log('info', 'Progress: 75% - Almost done (sent while client disconnected)...'); - await sleep(500); + await sleep(200); await ctx.mcpReq.log('info', 'Progress: 100% - Complete!'); - console.log(`[${ctx.sessionId}] Operation complete`); + console.error(`[${ctx.sessionId}] Operation complete`); return { content: [ @@ -107,9 +106,9 @@ app.all('/mcp', async (req: Request, res: Response) => { transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, - retryInterval: 2000, // Default retry interval for priming events + retryInterval: 300, // Default retry interval for priming events onsessioninitialized: id => { - console.log(`[${id}] Session initialized`); + console.error(`[${id}] Session initialized`); transports.set(id, transport!); } }); @@ -123,13 +122,13 @@ app.all('/mcp', async (req: Request, res: Response) => { }); // Start the server -const PORT = 3001; +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const PORT = portIdx === -1 ? Number(process.env.PORT ?? 3001) : Number(argv[portIdx + 1]); app.listen(PORT, () => { - console.log(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); - console.log(''); - console.log('This server demonstrates SEP-1699 SSE polling:'); - console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)'); - console.log('- eventStore: InMemoryEventStore (events are persisted for replay)'); - console.log(''); - console.log('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); + console.error(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); + console.error('This server demonstrates SEP-1699 SSE polling:'); + console.error('- retryInterval: 300ms (client waits before reconnecting)'); + console.error('- eventStore: InMemoryEventStore (events are persisted for replay)'); + console.error('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); }); diff --git a/examples/standalone-get/README.md b/examples/standalone-get/README.md index f8e2099d46..c915840e19 100644 --- a/examples/standalone-get/README.md +++ b/examples/standalone-get/README.md @@ -1,5 +1,6 @@ # standalone-get -Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The server adds a resource on a timer; the client opens the GET stream via `ClientOptions.listChanged` and asserts a notification arrives. +Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The `add_resource` tool registers a new resource on the session's instance, which emits the notification over the GET stream the client opened via +`ClientOptions.listChanged`; the client calls the tool and asserts the notification arrived. -**HTTP-only**, sessionful 2025 by definition. Excluded from the harness for now (timer-driven, long-running). +**HTTP-only**, sessionful 2025 by definition. diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts index 790f8ca126..2ad65d6d76 100644 --- a/examples/standalone-get/client.ts +++ b/examples/standalone-get/client.ts @@ -1,7 +1,8 @@ /** - * Connects, opens the standalone GET stream by registering a `listChanged` - * handler, and asserts at least one `notifications/resources/list_changed` - * arrives within the bound (the server adds a resource on a timer). + * Connects (2025-era), opens the standalone GET stream by registering a + * `listChanged` handler, calls `add_resource` to trigger a + * `notifications/resources/list_changed` over that stream, and asserts it + * arrived. */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -12,18 +13,26 @@ const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; runClient('standalone-get', async () => { let received = 0; - let done!: () => void; - const finished = new Promise(resolve => { - done = resolve; - }); const client = new Client( { name: 'standalone-get-client', version: '1.0.0' }, - { listChanged: { resources: { autoRefresh: false, onChanged: () => (++received >= 1 ? done() : undefined) } } } + { listChanged: { resources: { autoRefresh: false, debounceMs: 0, onChanged: () => void received++ } } } ); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - const list = await client.listResources(); - check.ok(list.resources.length > 0); - await Promise.race([finished, new Promise((_, reject) => setTimeout(() => reject(new Error('no listChanged within 8s')), 8000))]); + + const before = await client.listResources(); + check.ok(before.resources.length > 0); + + // Mutate on demand → server emits list_changed over the standalone GET stream. + await client.callTool({ name: 'add_resource', arguments: { content: 'hello' } }); + const deadline = Date.now() + 5000; + while (received < 1) { + if (Date.now() > deadline) throw new Error('no listChanged within 5s'); + await new Promise(r => setTimeout(r, 25)); + } check.ok(received >= 1); + + const after = await client.listResources(); + check.ok(after.resources.length > before.resources.length); + await client.close(); }); diff --git a/examples/standalone-get/package.json b/examples/standalone-get/package.json index 75a999a647..5ae0d1af2f 100644 --- a/examples/standalone-get/package.json +++ b/examples/standalone-get/package.json @@ -11,7 +11,8 @@ "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", - "express": "catalog:runtimeServerOnly" + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" }, "devDependencies": { "tsx": "catalog:devTools" @@ -20,7 +21,8 @@ "transports": [ "http" ], + "era": "legacy", "path": "/mcp", - "excluded": "Sessionful 2025 timer-driven listChanged push over the standalone GET stream — long-running by design. Typecheck-only for now; revisit alongside the sse-polling re-host." + "//": "The standalone GET stream is a sessionful-2025 transport feature; the client is era-blind so dual would duplicate." } } diff --git a/examples/standalone-get/server.ts b/examples/standalone-get/server.ts index 7e133f6d2e..061342b555 100644 --- a/examples/standalone-get/server.ts +++ b/examples/standalone-get/server.ts @@ -1,168 +1,96 @@ +/** + * Standalone GET stream + `notifications/resources/list_changed` (sessionful + * 2025). + * + * One `NodeStreamableHTTPServerTransport` + `McpServer` per session, the way + * you would deploy a sessionful 2025 server. The `add_resource` tool registers + * a new resource on the session's instance — `McpServer.registerResource` emits + * `notifications/resources/list_changed`, which on a sessionful transport + * travels over the **standalone GET** SSE stream the client opened. The client + * decides when to mutate (no timer race in the harness). + * + * **HTTP-only**, sessionful 2025 by definition. + */ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { ReadResourceResult } from '@modelcontextprotocol/server'; import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; -// Helper to register a dynamic resource on a given server instance -const addResource = (server: McpServer, name: string, content: string) => { - const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; - server.registerResource( - name, - uri, - { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, - async (): Promise => { - return { - contents: [{ uri, text: content }] - }; +const buildServer = () => { + const server = new McpServer( + { name: 'standalone-get-example', version: '1.0.0' }, + { capabilities: { resources: { listChanged: true } } } + ); + let nextId = 1; + const register = (name: string, content: string) => + server.registerResource( + name, + `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`, + { mimeType: 'text/plain' }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: content }] + }) + ); + register('initial', 'Initial content'); + + server.registerTool( + 'add_resource', + { + description: + 'Register a new resource on this session — emits notifications/resources/list_changed over the standalone GET stream.', + inputSchema: z.object({ content: z.string() }) + }, + async ({ content }) => { + const name = `note-${nextId++}`; + register(name, content); + return { content: [{ type: 'text', text: `registered ${name}` }] }; } ); -}; - -// Create a fresh MCP server per client connection to avoid shared state between clients -const getServer = () => { - const server = new McpServer({ - name: 'resource-list-changed-notification-server', - version: '1.0.0' - }); - - addResource(server, 'example-resource', 'Initial content for example-resource'); - return server; }; -// Store transports and their associated servers by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; -const servers: { [sessionId: string]: McpServer } = {}; - -// Periodically add a new resource to all active server instances for testing -const resourceChangeInterval = setInterval(() => { - const name = randomUUID(); - for (const sessionId in servers) { - addResource(servers[sessionId]!, name, `Content for ${name}`); - } -}, 5000); // Change resources every 5 seconds for testing - +const sessions = new Map(); const app = createMcpExpressApp(); app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - create a fresh server for this client - const server = getServer(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport and server by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - servers[sessionId] = server; - } - }); - - // Clean up both maps when the transport closes - transport.onclose = () => { - const sid = transport.sessionId; - if (sid) { - delete transports[sid]; - delete servers[sid]; - } - }; - - // Connect the fresh MCP server to the transport - await server.connect(transport); - - // Handle the request - the onsessioninitialized callback will store the transport - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer().connect(transport); await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } + } else if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); } }); -// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); +// The standalone GET stream (the point of this story) and DELETE (explicit +// session termination per the MCP spec) route to the session's transport. +const sessionVerb = async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + const t = sid ? sessions.get(sid) : undefined; + if (!t) { + res.status(sid ? 404 : 400).send(sid ? 'Session not found' : 'Missing session ID'); return; } + await t.handleRequest(req, res); +}; +app.get('/mcp', sessionVerb); +app.delete('/mcp', sessionVerb); - console.log(`Establishing SSE stream for session ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - clearInterval(resourceChangeInterval); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - delete servers[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const port = portIdx === -1 ? Number(process.env.PORT ?? 3000) : Number(argv[portIdx + 1]); +app.listen(port, () => console.error(`standalone-get example server listening on http://127.0.0.1:${port}/mcp`)); diff --git a/examples/stickynotes/README.md b/examples/stickynotes/README.md index b759f1ae19..886e5e113f 100644 --- a/examples/stickynotes/README.md +++ b/examples/stickynotes/README.md @@ -3,5 +3,5 @@ The "real app" capstone: a sticky-notes board where tools mutate state, each note is a resource, the resource list changes on add/remove, and a destructive `remove_all` blocks on a form-mode elicitation. The client adds, lists, reads, removes, and proves `remove_all` only clears the board on an explicit confirm. -The harness runs both transports on the **legacy** era. The `remove_all` confirmation is a push server→client elicitation, which needs a long-lived bidirectional connection (stdio, or a sessionful HTTP transport — see `../legacy-routing/`); the http leg exercises the -add/list/read/remove path and skips the elicitation-confirmed clear. +Runs the full transport × era matrix. The `remove_all` confirmation is a push server→client elicitation (2025-era only — there is no server→client request channel on 2026-07-28; the equivalent is multi-round-trip `inputRequired`, see `../elicitation/`). The legacy legs exercise +the full cancel / unchecked / confirm flow over both stdio and the harness's sessionful http arm; the modern legs exercise add / list / read / remove and skip `remove_all`. diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts index 30dcd1b43a..ab9cd76841 100644 --- a/examples/stickynotes/client.ts +++ b/examples/stickynotes/client.ts @@ -1,10 +1,10 @@ /** - * Drives the sticky-notes board end to end on a 2025-era (legacy) connection: - * add two notes, list/read their resources, remove one, then attempt - * `remove_all` three ways (cancel, accept-unchecked, accept-confirmed) to prove - * the board is cleared only on an explicit confirmation. + * Drives the sticky-notes board end to end: add two notes, list/read their + * resources, remove one, then — on the 2025-era leg — attempt `remove_all` + * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board is + * cleared only on an explicit confirmation. */ -import { check, connectFromArgs, runClient, transportLeg } from '../harness.js'; +import { check, connectFromArgs, eraLeg, runClient } from '../harness.js'; interface AddResult { id: string; @@ -16,10 +16,6 @@ interface RemoveAllResult { } runClient('stickynotes', async () => { - // Push-style elicitation (the `remove_all` confirmation) is a 2025-era - // flow; the harness pins this story to the legacy era so - // `ctx.mcpReq.elicitInput` reaches this handler (the 2026-07-28 path uses - // multi-round-trip `inputRequired` instead — see ../mrtr/). // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { capabilities: { elicitation: { form: {} } } }); let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; @@ -50,11 +46,13 @@ runClient('stickynotes', async () => { const after = await client.listResources(); check.ok(!after.resources.some(r => r.uri === firstNote.uri)); - // The elicitation-confirmed `remove_all` path is stdio-only: push-style - // server→client requests need a long-lived bidirectional connection; - // `createMcpHandler`'s per-request/stateless posture has neither a durable - // client-capability record nor a return path for the elicitation response. - if (transportLeg() === 'http') { + // The elicitation-confirmed `remove_all` path is 2025-era only: push-style + // server→client requests need the `initialize` handshake to advertise the + // elicitation capability and a long-lived bidirectional connection (stdio, + // or the harness's sessionful http/legacy arm). On a 2026-07-28 connection + // there is no server→client request channel — the equivalent is + // multi-round-trip `inputRequired` (see ../elicitation/). + if (eraLeg() === 'modern') { const removedSecond = await client.callTool({ name: 'remove_note', arguments: { id: secondNote.id } }); check.equal((removedSecond.structuredContent as { removed?: boolean } | undefined)?.removed, true); const afterClear = await client.listResources(); diff --git a/examples/stickynotes/package.json b/examples/stickynotes/package.json index 7211ec9d9b..8e10cc8529 100644 --- a/examples/stickynotes/package.json +++ b/examples/stickynotes/package.json @@ -14,7 +14,7 @@ "tsx": "catalog:devTools" }, "example": { - "era": "legacy", - "//": "The elicitation-confirmed remove_all path needs a 2025 initialize handshake to advertise the elicitation capability; the http leg additionally skips that path (no return path on per-request HTTP)." + "era": "dual", + "//": "Full transport × era matrix. The elicitation-confirmed remove_all path is 2025-era only (push-style server→client request); the modern legs exercise add/list/read/remove and skip remove_all." } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 896331c2df..793a0b515c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -834,6 +834,9 @@ importers: express: specifier: catalog:runtimeServerOnly version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 devDependencies: tsx: specifier: catalog:devTools diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts index b56db79401..698fc408a7 100644 --- a/scripts/run-examples.ts +++ b/scripts/run-examples.ts @@ -50,7 +50,7 @@ const EXAMPLES = join(ROOT, 'examples'); const TSX = join(ROOT, 'node_modules', '.bin', 'tsx'); /** Directories that are never stories. */ -const NON_STORY = new Set(['shared', 'guides', 'oauth', 'server-quickstart', 'client-quickstart', 'node_modules']); +const NON_STORY = new Set(['shared', 'guides', 'server-quickstart', 'client-quickstart', 'node_modules']); /** Distinct per-story HTTP ports so the servers never collide. */ let nextPort = 8530; From b09b3201cb9943f0f0d5c146ca674fedfbfee54f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 11:53:18 +0000 Subject: [PATCH 20/27] =?UTF-8?q?docs(examples):=20un-exclude=20oauth/=20v?= =?UTF-8?q?ia=20demo-AS=20autoConsent=20+=20headless=20authorization-code?= =?UTF-8?q?=20client=20(59=E2=86=9261=20legs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo Authorization Server (`setupAuthServer`) gains an `autoConsent` option: when set, a tiny middleware strips the OIDC `prompt` param from `/api/auth/mcp/authorize` before it reaches better-auth, so the authorize handler 302s straight back to `redirect_uri?code=...` — what clicking Approve would do. Combined with the existing `/sign-in` auto-sign-in, the whole authorization-code browser dance becomes a deterministic 302 chain. `oauth/server.ts` now honours `--port` (AS on PORT+1, 127.0.0.1) and wires `autoConsent` from `OAUTH_DEMO_AUTO_CONSENT=1`. `oauth/client.ts` is rewritten as the CI-runnable headless flow: drives the SDK auth driver via `InMemoryOAuthClientProvider`, captures the authorization URL it would `open()`, follows it with `fetch({redirect:'manual'})` + a small cookie jar, reads `?code` off the final Location, `transport.finishAuth(code)`, reconnects, and asserts `ctx.authInfo` round-trips. `simpleOAuthClient.ts` stays as the manual real-browser flow (`pnpm client:browser`). `package.json#example`: excluded → http × dual, env `OAUTH_DEMO_AUTO_CONSENT=1`. READMEs updated; oauth moves from Excluded to Feature stories. Also: `sse-polling` and `standalone-get` clients now pass `versionNegotiation: { mode: 'legacy' }` explicitly (were era-blind, reaching 2025 by fallback — make the leg honest). --- examples/README.md | 14 +-- examples/oauth/README.md | 28 +++--- examples/oauth/client.ts | 156 ++++++++++++++++++++++++++++-- examples/oauth/package.json | 13 ++- examples/oauth/server.ts | 50 +++++----- examples/shared/src/authServer.ts | 45 ++++++++- examples/sse-polling/client.ts | 5 +- examples/standalone-get/client.ts | 8 +- 8 files changed, 266 insertions(+), 53 deletions(-) diff --git a/examples/README.md b/examples/README.md index cba4a2cd8d..23566f2ced 100644 --- a/examples/README.md +++ b/examples/README.md @@ -42,6 +42,7 @@ Add `-- --legacy` to the client command for the 2025-era handshake. Some stories | [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | | [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | both¹ | | [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | | [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | ## HTTP hosting variants @@ -58,13 +59,12 @@ Add `-- --legacy` to the client command for the 2025-era handshake. Some stories ## Excluded -| Directory | What it is | Why not in CI | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`oauth/`](./oauth/README.md) | Interactive authorization-code OAuth flow: in-repo protected `server.ts` (demo AS + RS) + browser `simpleOAuthClient.ts` | Opens a real browser and runs a callback server on `:8090`. The headless machine-to-machine grant is covered by [`oauth-client-credentials/`](./oauth-client-credentials/README.md). | -| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | -| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | -| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | -| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | +| Directory | What it is | Why not in CI | +| ------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | +| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. | ## Multi-node deployment patterns diff --git a/examples/oauth/README.md b/examples/oauth/README.md index f99309bc66..3ec20ce3c9 100644 --- a/examples/oauth/README.md +++ b/examples/oauth/README.md @@ -1,22 +1,28 @@ -# oauth (excluded) +# oauth -The interactive **authorization-code** OAuth set, typecheck-only. Excluded from the harness (`package.json#example.excluded`) because the browser flow needs a real browser and a callback server on `:8090`. +The **authorization-code** OAuth grant — the interactive "user signs in and approves" flow — against an in-repo OAuth-protected MCP server. -- `server.ts` — an in-repo OAuth-protected MCP server: `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:3001`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:3000/mcp`, - advertising the AS via `createProtectedResourceMetadataRouter`. DEMO ONLY — the AS auto-signs-in a fixed user. -- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — full browser authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server, exchanges the code, then drops into a small `list`/`call` REPL. +- `server.ts` — `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:PORT+1`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:PORT/mcp`, advertising the AS via + `createProtectedResourceMetadataRouter` (RFC 9728). DEMO ONLY — the AS auto-signs-in a fixed user, and with `OAUTH_DEMO_AUTO_CONSENT=1` it also auto-approves the consent screen. +- `client.ts` — **CI-runnable headless flow.** Drives the same SDK auth machinery as the browser client, but instead of `open()`ing the authorization URL it follows the 302 chain itself with `fetch(..., { redirect: 'manual' })` (the demo AS's auto-sign-in + auto-consent collapse + every interactive step into a redirect), reads the `code` off the final `Location` header, calls `transport.finishAuth(code)`, reconnects, and asserts `ctx.authInfo` round-trips. This is what the harness runs. +- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — **manual real-browser flow.** Full authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server on `:8090`, exchanges the code, then drops into a small `list`/`call` + REPL. Run this when you want to see the consent page. - `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`. - `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens. -## Run it manually +## Run it ```bash -# terminal 1 — Authorization Server (:3001) + protected MCP Resource Server (:3000/mcp) -pnpm --filter @mcp-examples/oauth server +# headless (what CI does) — terminal 1: AS (:3001) + protected RS (:3000/mcp), auto-consent on +OAUTH_DEMO_AUTO_CONSENT=1 pnpm --filter @mcp-examples/oauth server +# terminal 2: follows the 302 chain, exchanges the code, asserts whoami +pnpm --filter @mcp-examples/oauth client -- --http http://127.0.0.1:3000/mcp -# terminal 2 — opens a browser to the demo AS, runs the callback server on :8090, -# exchanges the code, then drops into a list/call REPL against :3000/mcp -pnpm --filter @mcp-examples/oauth client +# manual real-browser flow — terminal 1: same server (auto-consent optional) +pnpm --filter @mcp-examples/oauth server +# terminal 2: opens a browser to the demo AS, callback server on :8090, then a list/call REPL +pnpm --filter @mcp-examples/oauth client:browser ``` For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`. diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts index 0a3e820ab3..bbe95bf079 100644 --- a/examples/oauth/client.ts +++ b/examples/oauth/client.ts @@ -1,10 +1,152 @@ /** - * Interactive authorization-code OAuth flow — see ./README.md. + * Self-verifying **authorization-code** OAuth client — the CI-runnable headless + * twin of {@link ./simpleOAuthClient.ts}. * - * The harness lists this story as excluded via `package.json#example.excluded`; - * this stub exists so the SKIP reason surfaces in the run-examples summary - * alongside the other excluded stories. Run `./server.ts` and - * `./simpleOAuthClient.ts` manually in two terminals. + * `simpleOAuthClient.ts` is the manual real-user example: it `open()`s the + * authorization URL in a real browser, the user signs in and clicks **Approve** + * on the consent screen, the browser is redirected to a local callback server, + * and the example reads the `code` off that callback. THIS file drives the + * exact same SDK auth machinery but follows the redirect chain itself — which + * only works because the demo Authorization Server is started with + * `OAUTH_DEMO_AUTO_CONSENT=1` so its `/sign-in` page auto-signs-in a fixed + * demo user and its `/authorize` endpoint auto-consents (skips the Approve + * screen and 302s straight back to `redirect_uri?code=...`). + * + * Flow: + * 1. Connect with an {@linkcode InMemoryOAuthClientProvider} → 401 → SDK auth + * driver discovers PRM → AS metadata → registers a client (DCR) → builds + * the authorization URL → calls our `redirectToAuthorization` hook (we + * capture the URL) → throws {@linkcode UnauthorizedError}. + * 2. Follow that URL with `fetch(..., { redirect: 'manual' })`, forwarding + * `Set-Cookie` → `Cookie` across hops, until the AS 302s to our + * `redirect_uri` with `?code=...`. No callback server is bound — the code + * is read straight off the `Location` header. + * 3. `transport.finishAuth(code)` → SDK exchanges the code (+ PKCE verifier) + * for tokens at the AS `/token` endpoint and saves them on the provider. + * 4. Reconnect with a fresh transport (same provider, now holding tokens) → + * Bearer header → 200. Call `whoami` and assert `ctx.authInfo` round-trips. + */ +import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; + +import { check, negotiationFromArgs, runClient } from '../harness.js'; +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; + +const argv = process.argv.slice(2); +const URL_ARG = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; + +// The redirect target the AS will 302 back to with `?code=...`. In the real +// browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the +// browser has somewhere to land; headlessly we never bind it — we read the +// `code` off the final 302's `Location` header instead. +const CALLBACK_URL = 'http://127.0.0.1:8090/callback'; + +/** + * Follow an authorization URL through the demo AS's redirect chain + * (authorize → /sign-in → authorize → redirect_uri?code=...) and return the + * `code`. This is the headless stand-in for "the user's browser navigates the + * login + consent pages": cookies are forwarded hop-to-hop the way a browser + * would, and the demo AS's auto-sign-in + `autoConsent` collapse every + * interactive step into a 302. */ -console.error('oauth/ is interactive — run server.ts + simpleOAuthClient.ts manually (see README.md)'); -process.exit(1); +async function followAuthorizationRedirects(authorizationUrl: URL): Promise { + let next = authorizationUrl.href; + // Crude cookie jar — enough for a single-origin demo AS. + const jar = new Map(); + for (let hop = 0; hop < 10; hop++) { + const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; '); + // In a real client this is `open(authorizationUrl)` — we follow the redirect + // chain headlessly because the demo AS auto-signs-in and auto-approves. + const res = await fetch(next, { redirect: 'manual', headers: cookie ? { cookie } : {} }); + for (const sc of res.headers.getSetCookie()) { + const pair = sc.split(';', 1)[0] ?? ''; + const eq = pair.indexOf('='); + if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + const location = res.headers.get('location'); + if (!location || res.status < 300 || res.status >= 400) { + const body = await res.text().catch(() => ''); + throw new Error(`expected a redirect at hop ${hop} (${next}); got ${res.status}\n${body.slice(0, 400)}`); + } + const resolved = new globalThis.URL(location, next); + // In a real deployment, the browser would render the consent page here and + // the user would click Approve; the demo AS's `autoConsent` flag simulates + // that approval, so the chain ends in a 302 straight to `redirect_uri`. + if (resolved.href.startsWith(CALLBACK_URL)) { + const code = resolved.searchParams.get('code'); + const error = resolved.searchParams.get('error'); + if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`); + if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`); + return code; + } + next = resolved.href; + } + throw new Error('authorization redirect chain did not terminate at the callback within 10 hops'); +} + +runClient('oauth', async () => { + // ---- 1. Kick off the SDK auth driver -------------------------------------- + // The SDK builds the authorization URL and hands it to + // `redirectToAuthorization` — in `simpleOAuthClient.ts` that opens a browser; + // here we just capture it. + let capturedAuthorizationUrl: URL | undefined; + const clientMetadata: OAuthClientMetadata = { + client_name: 'Headless OAuth MCP Client (CI)', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }; + const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { + capturedAuthorizationUrl = url; + }); + + const client = new Client({ name: 'oauth-headless-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); + const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); + let challenged = false; + try { + await client.connect(firstTransport); + } catch (error) { + // Under `--legacy` the transport surfaces `UnauthorizedError` directly; + // under `mode: 'auto'` the version-negotiation probe is what got 401'd + // and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause` + // is the original `UnauthorizedError`. Either way the auth driver has + // already run by the time we land here — DCR done, auth URL captured. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + challenged = true; + } + check.ok(challenged, 'first connect must 401 and throw UnauthorizedError'); + check.ok(capturedAuthorizationUrl, 'SDK auth driver should have produced an authorization URL'); + check.ok(provider.clientInformation()?.client_id, 'dynamic client registration should have run'); + + // ---- 2. Follow the authorization URL headlessly --------------------------- + // (the browser-and-user stand-in; see `followAuthorizationRedirects`). + const code = await followAuthorizationRedirects(capturedAuthorizationUrl!); + + // ---- 3. Exchange the code for tokens -------------------------------------- + // In the browser flow the local callback server hands this `code` to + // `transport.finishAuth`; we read it off the `Location` header instead. The + // SDK now POSTs `grant_type=authorization_code` (+ PKCE `code_verifier`) to + // the AS `/token` endpoint and saves the tokens on `provider`. + await firstTransport.finishAuth(code); + const tokens = provider.tokens(); + check.ok(tokens?.access_token, 'token exchange should have yielded an access_token'); + check.equal(tokens?.token_type, 'Bearer'); + + // ---- 4. Reconnect with the now-populated provider ------------------------- + // A fresh transport reads the saved Bearer token from `provider` and the + // protected `/mcp` endpoint lets us through. + const transport = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); + await client.connect(transport); + + const result = await client.callTool({ name: 'whoami', arguments: {} }); + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; + const seen = JSON.parse(text) as { clientId?: string; scopes?: string[] }; + // `ctx.authInfo` round-trips: the clientId the AS minted at DCR time is the + // one the Resource Server's verifier sees on the Bearer token. + check.equal(seen.clientId, provider.clientInformation()?.client_id, 'ctx.authInfo.clientId round-trips the DCR client_id'); + check.ok(seen.scopes?.includes('openid'), 'ctx.authInfo.scopes carries a granted scope'); + + await client.close(); +}); diff --git a/examples/oauth/package.json b/examples/oauth/package.json index 0d95ff0d62..ab1fc2ea1f 100644 --- a/examples/oauth/package.json +++ b/examples/oauth/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "server": "tsx server.ts", - "client": "tsx simpleOAuthClient.ts" + "client": "tsx client.ts", + "client:browser": "tsx simpleOAuthClient.ts" }, "dependencies": { "@mcp-examples/shared": "workspace:*", @@ -20,6 +21,14 @@ "tsx": "catalog:devTools" }, "example": { - "excluded": "Interactive authorization-code OAuth flow: browser auth + callback server on :8090. Run server.ts + simpleOAuthClient.ts manually. Machine-to-machine client_credentials is ../oauth-client-credentials/; the readline playground is ../repl/." + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "env": { + "OAUTH_DEMO_AUTO_CONSENT": "1" + }, + "//": "client.ts drives the full authorization-code flow headlessly because OAUTH_DEMO_AUTO_CONSENT=1 makes the demo AS auto-sign-in + auto-approve, collapsing the browser dance into a 302 chain. simpleOAuthClient.ts is the manual real-browser flow — run via `pnpm client:browser`." } } diff --git a/examples/oauth/server.ts b/examples/oauth/server.ts index 8ac21cba32..cf380a39f2 100644 --- a/examples/oauth/server.ts +++ b/examples/oauth/server.ts @@ -1,21 +1,21 @@ /** - * In-repo OAuth-protected MCP server for the interactive **authorization-code** - * flow — the demo Resource Server that {@link ./simpleOAuthClient.ts} - * authenticates against. + * In-repo OAuth-protected MCP server for the **authorization-code** flow — the + * demo Resource Server that {@link ./client.ts} (headless, CI) and + * {@link ./simpleOAuthClient.ts} (manual, real browser) authenticate against. * - * One process, two listeners: + * One process, two listeners on adjacent ports: * - * - `:AUTH_PORT` (default `3001`) — the demo **Authorization Server** - * (`setupAuthServer` from `@mcp-examples/shared`, backed by better-auth's - * OIDC plugin). It implements the `authorization_code` grant only and - * auto-signs-in a fixed demo user. - * - `:MCP_PORT` (default `3000`) — the MCP **Resource Server**: - * `createMcpHandler` behind `requireBearerAuth({ verifier: demoTokenVerifier })`, - * advertising the AS via `createProtectedResourceMetadataRouter` (RFC 9728) - * so the client's discovery from a `401` `WWW-Authenticate` challenge works. - * - * Excluded from the harness (the browser flow needs a real browser); run - * manually — see `./README.md`. + * - `:PORT+1` — the demo **Authorization Server** (`setupAuthServer` from + * `@mcp-examples/shared`, backed by better-auth's OIDC plugin). It + * implements the `authorization_code` grant only and auto-signs-in a fixed + * demo user. With `OAUTH_DEMO_AUTO_CONSENT=1` it also **auto-consents** — + * the `/authorize` endpoint skips the consent UI and 302s straight back to + * `redirect_uri?code=...`, so the whole browser dance becomes a chain of + * redirects a headless client can follow. + * - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth({ verifier: demoTokenVerifier })`, advertising the AS + * via `createProtectedResourceMetadataRouter` (RFC 9728) so the client's + * discovery from a `401` `WWW-Authenticate` challenge works. * * DEMO ONLY — NOT FOR PRODUCTION. The demo AS auto-approves a fixed user; CORS * allows every origin; tokens are validated in-process against the same demo @@ -27,13 +27,19 @@ import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import * as z from 'zod/v4'; -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; -const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); -const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); +const argv = process.argv.slice(2); +const portIdx = argv.indexOf('--port'); +const MCP_PORT = portIdx === -1 ? Number(process.env.MCP_PORT ?? 3000) : Number(argv[portIdx + 1]); +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : MCP_PORT + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// harness passes the client byte-for-byte — the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${MCP_PORT}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}`); // ---- Authorization Server (better-auth OIDC; authorization_code only) ---- -setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true }); +// `autoConsent` is the demo-only switch that turns the consent screen into an +// immediate 302 — set by the harness so `./client.ts` can run without a browser. +setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, autoConsent: process.env.OAUTH_DEMO_AUTO_CONSENT === '1' }); // ---- Resource Server (MCP) ---- const handler = createMcpHandler(ctx => { @@ -71,6 +77,6 @@ const auth = requireBearerAuth({ app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body)); app.listen(MCP_PORT, () => { - console.log(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`); - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); + console.error(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`); + console.error(` Protected Resource Metadata: http://127.0.0.1:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); }); diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts index 995fedc7d9..5f895087e0 100644 --- a/examples/shared/src/authServer.ts +++ b/examples/shared/src/authServer.ts @@ -15,7 +15,7 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import { toNodeHandler } from 'better-auth/node'; import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins'; import cors from 'cors'; -import type { Request, Response as ExpressResponse, Router } from 'express'; +import type { NextFunction, Request, Response as ExpressResponse, Router } from 'express'; import express from 'express'; import type { DemoAuth } from './auth.js'; @@ -34,6 +34,22 @@ export interface SetupAuthServerOptions { * Only use for debugging purposes. */ dangerousLoggingEnabled?: boolean; + /** + * DEMO ONLY. When `true`, the `/api/auth/mcp/authorize` endpoint skips the + * consent screen entirely and immediately 302s back to the client's + * `redirect_uri` with an authorization `code` — exactly what would happen + * after a real user clicked **Approve**. Mechanically this strips the OIDC + * `prompt` parameter from the request before it reaches better-auth, so the + * MCP plugin's authorize handler takes its no-consent fast path. Combined + * with the `/sign-in` page that auto-signs-in the demo user, the entire + * authorization-code flow becomes a deterministic chain of 302s a headless + * client can follow with `fetch(..., { redirect: 'manual' })`. + * + * The `examples/oauth/` server enables this when + * `OAUTH_DEMO_AUTO_CONSENT=1` so the CI client (`client.ts`) can drive the + * full browser flow without a browser. NEVER enable in production. + */ + autoConsent?: boolean; } // Store auth instance globally so it can be used for token verification @@ -88,7 +104,7 @@ async function ensureDemoUserExists(auth: DemoAuth): Promise { * @param options - Server configuration */ export function setupAuthServer(options: SetupAuthServerOptions): void { - const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options; + const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false, autoConsent = false } = options; // Create better-auth instance with MCP plugin const auth = createDemoAuth({ @@ -116,6 +132,31 @@ export function setupAuthServer(options: SetupAuthServerOptions): void { // toNodeHandler bypasses Express methods const betterAuthHandler = toNodeHandler(auth); + // DEMO ONLY: simulate the user clicking "Approve" on the consent screen. + // The SDK auth driver appends `prompt=consent` whenever it requests the + // `offline_access` scope (per OIDC §11). With a real user, better-auth + // would render a consent UI and wait for an explicit Approve; here we drop + // `prompt` from the query before it reaches better-auth so its authorize + // handler takes the no-consent fast path and 302s straight back to + // `redirect_uri?code=...`. See {@link SetupAuthServerOptions.autoConsent}. + if (autoConsent) { + authApp.use((req: Request, _res: ExpressResponse, next: NextFunction) => { + const qmark = req.url.indexOf('?'); + if (req.path === '/api/auth/mcp/authorize' && qmark !== -1) { + const search = new URLSearchParams(req.url.slice(qmark + 1)); + if (search.has('prompt')) { + search.delete('prompt'); + const qs = search.toString(); + // toNodeHandler reconstructs the Fetch Request from req.url + // (req.baseUrl is empty at the app level), so rewriting it + // here is what better-auth's handler will see. + req.url = `/api/auth/mcp/authorize${qs ? `?${qs}` : ''}`; + } + } + next(); + }); + } + // Mount better-auth handler BEFORE body parsers // toNodeHandler reads the raw request body, so Express must not consume it first if (dangerousLoggingEnabled) { diff --git a/examples/sse-polling/client.ts b/examples/sse-polling/client.ts index a71ac18c3c..d7c623bc4b 100644 --- a/examples/sse-polling/client.ts +++ b/examples/sse-polling/client.ts @@ -20,7 +20,10 @@ runClient('sse-polling', async () => { // automatic reconnect; that is the EXPECTED flow, not a failure. transport.onerror = () => {}; - const client = new Client({ name: 'sse-polling-client', version: '1.0.0' }); + // Explicitly the 2025 `initialize` handshake — `closeSSE`/`eventStore` live + // on the sessionful-2025 transport, so this story is legacy-only by design + // (it was previously reaching 2025 by negotiation fallback; pin it). + const client = new Client({ name: 'sse-polling-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); const logs: string[] = []; client.setNotificationHandler('notifications/message', n => { logs.push(String(n.params.data)); diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts index 2ad65d6d76..434b8abd79 100644 --- a/examples/standalone-get/client.ts +++ b/examples/standalone-get/client.ts @@ -15,7 +15,13 @@ runClient('standalone-get', async () => { let received = 0; const client = new Client( { name: 'standalone-get-client', version: '1.0.0' }, - { listChanged: { resources: { autoRefresh: false, debounceMs: 0, onChanged: () => void received++ } } } + { + // Explicitly the 2025 `initialize` handshake — the standalone GET + // stream is a sessionful-2025 transport feature, so this story is + // legacy-only by design (was reaching 2025 by fallback; pin it). + versionNegotiation: { mode: 'legacy' }, + listChanged: { resources: { autoRefresh: false, debounceMs: 0, onChanged: () => void received++ } } + } ); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); From 3a569960d69dcb6f06cfda8438e21421eea71525 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:00:44 +0000 Subject: [PATCH 21/27] docs(examples): fix sse-polling sessionful 404/400 routing; align README era/transport claims with package.json#example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sse-polling/server.ts: standard sessionful routing (unknown sid → 404 -32001, missing sid → 400, only build a transport on no-sid + isInitializeRequest); drop the orphan-transport path. Mirrors repl/, legacy-routing/, standalone-get/. - docs/server.md: drop the "logging" claim against legacy-routing/server.ts (it has sessions + CORS only); re-point the shutdown-handling sentence at repl/server.ts (which actually closes transports on SIGINT). - streaming/README.md: name ctx.mcpReq.notify (request-tied notifications/message) to match server.ts; note the per-request-HTTP caveat for ctx.mcpReq.log. - oauth-client-credentials/README.md: era is dual, not modern-only; oauth/ is now harness-run (headless via OAUTH_DEMO_AUTO_CONSENT), not excluded. - bearer-auth/README.md: same stale "oauth excluded from the harness" wording. Sweep of examples/*/README.md era/transport claims vs package.json#example found no other mismatches. --- docs/server.md | 4 +-- examples/bearer-auth/README.md | 2 +- examples/oauth-client-credentials/README.md | 4 +-- examples/sse-polling/server.ts | 33 +++++++++++---------- examples/streaming/README.md | 4 +-- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/server.md b/docs/server.md index a0a469f480..5f36d7f871 100644 --- a/docs/server.md +++ b/docs/server.md @@ -50,7 +50,7 @@ await server.connect(transport); **Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. -For a complete server with sessions, logging, and CORS, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). +For a complete server with sessions and the browser-client CORS recipe, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). ### stdio @@ -593,7 +593,7 @@ process.on('SIGINT', async () => { }); ``` -For a complete multi-session server with shutdown handling, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). +For a complete multi-session server with shutdown handling, see [`repl/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/server.ts). ## Deployment diff --git a/examples/bearer-auth/README.md b/examples/bearer-auth/README.md index 61eb749511..834b1c8931 100644 --- a/examples/bearer-auth/README.md +++ b/examples/bearer-auth/README.md @@ -3,4 +3,4 @@ Resource-server-only auth: `requireBearerAuth` + `mcpAuthMetadataRouter` from `@modelcontextprotocol/express` in front of `createMcpHandler`. The client asserts `401` + `WWW-Authenticate` without a token, and that the verified `authInfo` reaches the factory (`ctx.authInfo`) with one. -**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (excluded from the harness). +**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly by the harness via the demo AS's auto-consent mode). diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md index a3ee979d6c..c99a850477 100644 --- a/examples/oauth-client-credentials/README.md +++ b/examples/oauth-client-credentials/README.md @@ -5,7 +5,7 @@ OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, fully `client_credentials` is the grant a backend service uses to authenticate **as itself** (not on behalf of a user): it presents a pre-registered `client_id`/`client_secret` directly to the Authorization Server's token endpoint and receives a Bearer access token. There is no redirect, no authorization code, no user consent screen. -The interactive **authorization-code** flow (the one that opens a browser and asks a human to sign in) lives under [`../oauth/`](../oauth/README.md) and is excluded from the harness for that reason. +The interactive **authorization-code** flow (the one that opens a browser and asks a human to sign in) lives under [`../oauth/`](../oauth/README.md); the harness runs it headlessly via the demo AS's `OAUTH_DEMO_AUTO_CONSENT=1` auto-approve mode. ## What runs @@ -22,7 +22,7 @@ pnpm --filter @mcp-examples/oauth-client-credentials server -- --http --port 300 pnpm --filter @mcp-examples/oauth-client-credentials client -- --http http://127.0.0.1:3000/mcp ``` -HTTP-only, modern-era only. +HTTP-only; runs on both protocol eras (the client honours `--legacy` via `negotiationFromArgs()`). ## `private_key_jwt` client authentication diff --git a/examples/sse-polling/server.ts b/examples/sse-polling/server.ts index 64b711deee..eb592db769 100644 --- a/examples/sse-polling/server.ts +++ b/examples/sse-polling/server.ts @@ -16,7 +16,7 @@ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; @@ -95,30 +95,31 @@ const eventStore = new InMemoryEventStore(); // Track transports by session ID for session reuse const transports = new Map(); -// Handle all MCP requests +// Handle all MCP requests (standard sessionful routing: known sid → reuse; +// no sid + initialize → new session; unknown sid → 404; otherwise → 400). app.all('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - // Reuse existing transport or create new one - let transport = sessionId ? transports.get(sessionId) : undefined; - - if (!transport) { - transport = new NodeStreamableHTTPServerTransport({ + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && transports.has(sid)) { + await transports.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, retryInterval: 300, // Default retry interval for priming events onsessioninitialized: id => { console.error(`[${id}] Session initialized`); - transports.set(id, transport!); + transports.set(id, transport); } }); - - // Connect a fresh MCP server to the transport - const server = getServer(); - await server.connect(transport); + transport.onclose = () => transport.sessionId && transports.delete(transport.sessionId); + await getServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + // Unknown/expired session ID → 404 so the client knows to re-initialize. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); } - - await transport.handleRequest(req, res, req.body); }); // Start the server diff --git a/examples/streaming/README.md b/examples/streaming/README.md index 4da4cd893f..a62b1f1496 100644 --- a/examples/streaming/README.md +++ b/examples/streaming/README.md @@ -1,7 +1,7 @@ # streaming -The three in-flight channels: progress (via `_meta.progressToken` → `notifications/progress` → the client's `onprogress` callback), logging (`ctx.mcpReq.log(level, data)` → `notifications/message`), and cancellation (the client's `AbortSignal` → `ctx.mcpReq.signal.aborted` -server-side). +The three in-flight channels: progress (via `_meta.progressToken` → `notifications/progress` → the client's `onprogress` callback), logging (`ctx.mcpReq.notify({ method: 'notifications/message', … })` — request-tied so it rides the same response stream as progress; the +connection-level `ctx.mcpReq.log` shorthand sends an unrelated notification a per-request HTTP entry cannot deliver mid-call), and cancellation (the client's `AbortSignal` → `ctx.mcpReq.signal.aborted` server-side). ```bash pnpm tsx examples/streaming/client.ts From 6e51b50586a15ee205a9ad7a98188b75684a4391 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:05:22 +0000 Subject: [PATCH 22/27] docs: update CLAUDE.md examples section to the per-story layout --- CLAUDE.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5a188676a..ed4f5623b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,11 +121,14 @@ Pluggable JSON Schema validation (`packages/core/src/validators/`): ### Examples -Runnable examples in `examples/`: - -- `examples/server/src/` - Various server configurations (stateful, stateless, OAuth, etc.) -- `examples/client/src/` - Client examples (basic, OAuth, parallel calls, etc.) -- `examples/shared/src/` - Shared utilities (OAuth demo provider, etc.) +Runnable examples in `examples//{server.ts,client.ts}` — each story is its own +`@mcp-examples/` workspace package and a self-verifying e2e test (the client connects, +asserts results, exits non-zero on mismatch). `pnpm run:examples` runs every story over its +configured transport×era legs; the `examples (build + e2e)` CI job is part of the per-PR gate +basket. See `examples/README.md` for the full story matrix. + +- `examples/shared/` — shared scaffolding (`connectFromArgs`, `runServerFromArgs`, demo OAuth provider) +- `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md` ## Message Flow (Bidirectional Protocol) From 65ca0b1285789bb0434cb6d2478c1d27846efacb Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:21:15 +0000 Subject: [PATCH 23/27] =?UTF-8?q?docs(examples):=20make=20sampling/=20dual?= =?UTF-8?q?-era=20via=20inputRequired.createMessage;=20mount=20legacy-rout?= =?UTF-8?q?ing=20at=20/mcp;=20clarify=20'dual=20(in-body)'=20notation=20(6?= =?UTF-8?q?1=E2=86=9263=20legs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/README.md | 54 +++++++++++++------------- examples/legacy-routing/client.ts | 2 +- examples/legacy-routing/package.json | 1 - examples/legacy-routing/server.ts | 8 ++-- examples/sampling/README.md | 16 ++++++-- examples/sampling/client.ts | 10 +++-- examples/sampling/package.json | 4 +- examples/sampling/server.ts | 58 +++++++++++++++++++++------- scripts/run-examples.ts | 4 +- 9 files changed, 99 insertions(+), 58 deletions(-) diff --git a/examples/README.md b/examples/README.md index 23566f2ced..d41cd73a2e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,10 +11,10 @@ pnpm --filter @mcp-examples/ client # Streamable HTTP (two terminals): pnpm --filter @mcp-examples/ server -- --http --port 3000 -pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/ +pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/mcp ``` -Add `-- --legacy` to the client command for the 2025-era handshake. Some stories mount at a different path (e.g. `/mcp`); check the story's `package.json#example.path` or its README for the exact URL. +Add `-- --legacy` to the client command for the 2025-era handshake. ## Start here @@ -27,35 +27,35 @@ Add `-- --legacy` to the client command for the 2025-era handshake. Some stories ## Feature stories -| Story | What it teaches | Transports | Era | -| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | ------ | -| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | -| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | -| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | -| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | -| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client | stdio + http | legacy | -| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | -| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | -| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | -| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | -| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | -| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | -| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | both¹ | -| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | -| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | -| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | +| Story | What it teaches | Transports | Era | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -------------- | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | ## HTTP hosting variants -| Story | What it teaches | Transports | Era | -| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | ------ | -| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | both¹ | -| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | modern | -| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | dual | -| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy | -| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy | +| Story | What it teaches | Transports | Era | +| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | -------------- | +| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | dual (in-body) | +| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | modern | +| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | dual | +| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy | +| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy | -¹ The story body drives both eras inside one client run, so the harness pins it to a single leg. +`dual (in-body)` = the client connects to both eras inside one harness run; the story demonstrates one server serving both side by side. ## Excluded diff --git a/examples/legacy-routing/client.ts b/examples/legacy-routing/client.ts index dc43dcebd3..5bee84d390 100644 --- a/examples/legacy-routing/client.ts +++ b/examples/legacy-routing/client.ts @@ -8,7 +8,7 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli import { check, runClient } from '../harness.js'; const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; +const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; runClient('legacy-routing', async () => { // 2025 client → routed to the existing sessionful deployment. diff --git a/examples/legacy-routing/package.json b/examples/legacy-routing/package.json index 188995ee60..2edf1cfe91 100644 --- a/examples/legacy-routing/package.json +++ b/examples/legacy-routing/package.json @@ -24,7 +24,6 @@ "http" ], "era": "modern", - "path": "/", "//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the harness runs it once." } } diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index 06289dfbe9..2e9d5a583f 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -69,7 +69,7 @@ app.use( }) ); -app.post('/', async (req: Request, res: Response) => { +app.post('/mcp', async (req: Request, res: Response) => { // The predicate inspects the same headers + body the entry does. Express // has parsed the JSON body; pass it as `parsedBody` so the predicate need // not re-read the stream. @@ -82,12 +82,12 @@ app.post('/', async (req: Request, res: Response) => { // GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE // (explicit session termination per the MCP spec) are sessionful-2025-only — // route them straight to the legacy arm; the transport handles each verb. -app.get('/', (req, res) => void handleLegacy(req, res)); -app.delete('/', (req, res) => void handleLegacy(req, res)); +app.get('/mcp', (req, res) => void handleLegacy(req, res)); +app.delete('/mcp', (req, res) => void handleLegacy(req, res)); const argv = process.argv.slice(2); const portIdx = argv.indexOf('--port'); const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); app.listen(port, () => { - console.error(`legacy-routing example server listening on http://127.0.0.1:${port}/`); + console.error(`legacy-routing example server listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/sampling/README.md b/examples/sampling/README.md index 05063a843a..c2f9f8e2ff 100644 --- a/examples/sampling/README.md +++ b/examples/sampling/README.md @@ -1,11 +1,19 @@ # sampling -A tool that requests LLM sampling from the client via `ctx.mcpReq.requestSampling(...)`. The client advertises `sampling` and registers a `sampling/createMessage` handler returning a canned response. +A tool that asks the host LLM for a completion. One factory, both protocol eras: sampling works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries the `sampling/createMessage` request differently but the user experience is the +same. -> Sampling is **deprecated** as of protocol revision 2026-07-28 (SEP-2577) but remains functional during the deprecation window. +| 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.requestSampling({ messages, maxTokens })` — the server pushes a `sampling/createMessage` request and awaits the answer in-line | `return inputRequired({ inputRequests: { summary: inputRequired.createMessage({ messages, maxTokens }) } })` — the client fulfils the embedded request and retries with the response attached | -Runs both transports on the **legacy** era — sampling is a 2025-era push-style server→client request and there is no 2026-07-28 equivalent. The harness's `--http` arm hosts 2025 traffic on a sessionful transport, so the request reaches the client over either. +The client registers **one** `sampling/createMessage` handler; on the 2026-07-28 leg the auto-fulfilment driver dispatches the embedded request to that same handler. + +> Push-style sampling is **deprecated** as of protocol revision 2026-07-28 (SEP-2577) but remains functional during the deprecation window. + +Runs the full transport × era matrix. ```bash -pnpm --filter @mcp-examples/sampling client -- --legacy +pnpm --filter @mcp-examples/sampling client # 2026-07-28 (inputRequired) +pnpm --filter @mcp-examples/sampling client -- --legacy # 2025 (push-style) ``` diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts index 14018fac26..f5e986e6b0 100644 --- a/examples/sampling/client.ts +++ b/examples/sampling/client.ts @@ -2,13 +2,17 @@ * Advertises the sampling capability, registers a `sampling/createMessage` * handler that returns a canned summary, then calls the `summarize` tool and * asserts the canned text round-tripped. + * + * The same handler serves both protocol eras: on the 2025-era leg + * (`--legacy`) the server pushes `sampling/createMessage` and this handler + * answers it directly; on the 2026-07-28 leg the auto-fulfilment driver + * dispatches the embedded `sampling/createMessage` from the server's + * `inputRequired` result to this same handler, then retries the tool call + * with the response attached. */ import { check, connectFromArgs, runClient } from '../harness.js'; runClient('sampling', async () => { - // Push-style sampling is a 2025-era flow (and is deprecated as of - // 2026-07-28). The story is pinned to the legacy era so the server's - // `ctx.mcpReq.requestSampling` reaches this handler over either transport. // connectFromArgs picks transport (default: spawn ./server.ts over stdio; --http ) and era (--legacy) from argv. Your code would construct a Client and connect over your chosen transport directly. const client = await connectFromArgs(import.meta.dirname, { capabilities: { sampling: {} } }); client.setRequestHandler('sampling/createMessage', async () => ({ diff --git a/examples/sampling/package.json b/examples/sampling/package.json index 6651c17042..9baf2dac97 100644 --- a/examples/sampling/package.json +++ b/examples/sampling/package.json @@ -14,7 +14,7 @@ "tsx": "catalog:devTools" }, "example": { - "era": "legacy", - "//": "Sampling is a 2025-era push-style server→client request and is deprecated as of 2026-07-28 (SEP-2577); there is no modern-era equivalent. Both transports — the harness's http arm hosts 2025 traffic on a sessionful transport." + "era": "dual", + "//": "2025-era push-style ctx.mcpReq.requestSampling runs over the harness's sessionful http/legacy arm; 2026-07-28 inputRequired.createMessage runs over the per-request modern arm. Full transport × era matrix." } } diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts index bea91d019a..614c4ca508 100644 --- a/examples/sampling/server.ts +++ b/examples/sampling/server.ts @@ -1,29 +1,59 @@ /** - * A tool that requests LLM sampling from the client via - * `ctx.mcpReq.requestSampling(...)` (the request-context idiom — replaces the - * older `mcpServer.server.createMessage(...)`). One binary, either transport. + * Sampling — a tool that asks the host LLM for a completion. One factory, + * both protocol eras. * - * Logs go to stderr only — stdio's stdout is the JSON-RPC stream. + * The same tool serves both eras with different APIs: on a 2025-era + * connection (`--legacy`, the `initialize` handshake) the server uses the + * push-style server→client request flow — `ctx.mcpReq.requestSampling(...)` + * sends `sampling/createMessage` and awaits the answer in-line. On a + * 2026-07-28 connection there is no server→client request channel: the same + * tool instead **returns** `inputRequired(...)` with an embedded + * `sampling/createMessage`, and the client retries with the model's response + * attached. The protocol carries the request differently; the user + * experience is the same. + * + * One binary, either transport. Logs go to stderr only — stdio's stdout is + * the JSON-RPC stream. */ -import { McpServer } from '@modelcontextprotocol/server'; +import type { CallToolResult, InputRequiredResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { inputRequired, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; import { runServerFromArgs } from '../harness.js'; -function buildServer(): McpServer { +function buildServer(reqCtx: McpRequestContext): McpServer { const server = new McpServer({ name: 'sampling-example', version: '1.0.0' }); server.registerTool( 'summarize', { description: 'Summarize text using the host LLM', inputSchema: z.object({ text: z.string() }) }, - async ({ text }, ctx) => { - const response = await ctx.mcpReq.requestSampling({ - messages: [{ role: 'user', content: { type: 'text', text: `Please summarize the following text concisely:\n\n${text}` } }], - maxTokens: 500 - }); - // `content` is a single block when no tools were passed. - const content = response.content; - const summary = !Array.isArray(content) && content.type === 'text' ? content.text : 'Unable to generate summary'; + async ({ text }, ctx): Promise => { + const messages = [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Please summarize the following text concisely:\n\n${text}` } + } + ]; + if (reqCtx.era === 'legacy') { + // 2025-era: push a server→client `sampling/createMessage` request + // and await the model's answer in-line. + const response = await ctx.mcpReq.requestSampling({ messages, maxTokens: 500 }); + // `content` is a single block when no tools were passed. + const content = response.content; + const summary = !Array.isArray(content) && content.type === 'text' ? content.text : 'Unable to generate summary'; + return { content: [{ type: 'text', text: summary }] }; + } + // 2026-07-28: return inputRequired with an embedded + // `sampling/createMessage` — the client's auto-fulfilment driver + // dispatches it to the same `sampling/createMessage` handler and + // retries this call with the model's response attached. + const response = ctx.mcpReq.inputResponses?.['summary'] as { content?: { type: string; text?: string } } | undefined; + if (!response) { + return inputRequired({ + inputRequests: { summary: inputRequired.createMessage({ messages, maxTokens: 500 }) } + }); + } + const summary = response.content?.type === 'text' ? (response.content.text ?? '') : 'Unable to generate summary'; return { content: [{ type: 'text', text: summary }] }; } ); diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts index 698fc408a7..71833f02c8 100644 --- a/scripts/run-examples.ts +++ b/scripts/run-examples.ts @@ -33,7 +33,7 @@ interface ExampleConfig { era?: 'dual' | Era; /** HTTP port (default: a per-story port assigned below). */ port?: number; - /** Endpoint path (default: `'/'`). */ + /** Endpoint path (default: `'/mcp'`). */ path?: string; /** Extra environment for the server process. */ env?: Record; @@ -134,7 +134,7 @@ async function runStdioLeg(story: string, dir: string, config: ExampleConfig, er async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { const timeoutMs = config.timeoutMs ?? 30_000; const port = assignPort(story, config); - const path = config.path ?? '/'; + const path = config.path ?? '/mcp'; const url = `http://127.0.0.1:${port}${path}`; let serverStderr = ''; const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], { From d0d33165fcfceb8e1c850f48b7cfd3e54ddb5d65 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:37:43 +0000 Subject: [PATCH 24/27] docs(examples): restore [!TYPE] alert markers (proseWrap: preserve for *.md); harness Buffer.concat for UTF-8 chunk-safe body decode; lift InMemoryEventStore into @mcp-examples/shared --- .prettierrc.json | 3 +- docs/client.md | 15 ++++++--- docs/server.md | 32 +++++++++++-------- examples/harness.ts | 7 ++-- examples/repl/package.json | 1 + examples/repl/server.ts | 3 +- .../src}/inMemoryEventStore.ts | 0 examples/shared/src/index.ts | 3 ++ examples/sse-polling/package.json | 1 + examples/sse-polling/server.ts | 3 +- pnpm-lock.yaml | 6 ++++ 11 files changed, 49 insertions(+), 25 deletions(-) rename examples/{sse-polling => shared/src}/inMemoryEventStore.ts (100%) diff --git a/.prettierrc.json b/.prettierrc.json index 840a2c6b0b..b9a90b2951 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -13,7 +13,8 @@ { "files": "**/*.md", "options": { - "printWidth": 280 + "printWidth": 280, + "proseWrap": "preserve" } } ] diff --git a/docs/client.md b/docs/client.md index 6f21e70442..a5cfe2b6cb 100644 --- a/docs/client.md +++ b/docs/client.md @@ -243,7 +243,8 @@ For manual control over the token exchange steps, use the Layer 2 utilities from - `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition - `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server -> [!NOTE] See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth +> [!NOTE] +> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth > standards. ## Tools @@ -443,7 +444,8 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = }); ``` -> [!WARNING] MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the +> [!WARNING] +> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the > [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: @@ -452,7 +454,8 @@ To control the minimum severity of log messages the server sends, use {@linkcode await client.setLoggingLevel('warning'); ``` -> [!WARNING] `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. +> [!WARNING] +> `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. ## Handling server-initiated requests @@ -473,7 +476,8 @@ const client = new Client( ### Sampling -> [!WARNING] Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers +> [!WARNING] +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers > should migrate to calling LLM provider APIs directly. When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: @@ -519,7 +523,8 @@ return), see [`elicitation/client.ts`](https://github.com/modelcontextprotocol/t ### Roots -> [!WARNING] Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> [!WARNING] +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to > passing paths via tool parameters, resource URIs, or configuration. Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: diff --git a/docs/server.md b/docs/server.md index 5f36d7f871..7eb82f40ba 100644 --- a/docs/server.md +++ b/docs/server.md @@ -124,7 +124,8 @@ server.registerTool( ); ``` -> [!NOTE] When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: +> [!NOTE] +> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: > > ```ts > type BmiResult = { bmi: number }; // assignable @@ -274,7 +275,8 @@ server.registerResource( ); ``` -> [!IMPORTANT] **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within +> [!IMPORTANT] +> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within > the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. ## Prompts @@ -338,7 +340,8 @@ server.registerPrompt( ## Logging -> [!WARNING] MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate +> [!WARNING] +> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate > to stderr logging (STDIO servers) or OpenTelemetry. Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). @@ -443,7 +446,8 @@ MCP is bidirectional — servers can send requests _to_ the client during tool e ### Sampling -> [!WARNING] Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> [!WARNING] +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to > calling LLM provider APIs directly from your server. Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling @@ -492,7 +496,8 @@ Elicitation lets a tool handler request direct input from the user — form fiel - **Form** (`mode: 'form'`) — collects non-sensitive data via a schema-driven form. - **URL** (`mode: 'url'`) — opens a browser URL for sensitive data or secure flows (API keys, payments, OAuth). -> [!IMPORTANT] Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. +> [!IMPORTANT] +> Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: @@ -541,7 +546,8 @@ For runnable examples, see [`elicitation/server.ts`](https://github.com/modelcon ### Roots -> [!WARNING] Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> [!WARNING] +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to > passing paths via tool parameters, resource URIs, or configuration. Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @@ -644,10 +650,10 @@ middleware source for reference. When mounting a handler bare on a fetch-native ### Additional examples -| Feature | Description | Example | -| ---------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | -| Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | -| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/inMemoryEventStore.ts) | -| CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | -| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | +| Feature | Description | Example | +| ---------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/shared/src/inMemoryEventStore.ts) | +| CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | diff --git a/examples/harness.ts b/examples/harness.ts index 65b8c97052..5116108cba 100644 --- a/examples/harness.ts +++ b/examples/harness.ts @@ -89,8 +89,11 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000) // Read the body once for the predicate and pass it forward. let body: unknown; if (req.method === 'POST') { - let raw = ''; - for await (const chunk of req) raw += String(chunk); + // Collect Buffers and decode once so multi-byte UTF-8 sequences split across chunk + // boundaries (>~16 KiB bodies) aren't mojibaked into U+FFFD by per-chunk String(). + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); try { body = raw ? JSON.parse(raw) : undefined; } catch { diff --git a/examples/repl/package.json b/examples/repl/package.json index f54332fdd7..01c68d4d19 100644 --- a/examples/repl/package.json +++ b/examples/repl/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", diff --git a/examples/repl/server.ts b/examples/repl/server.ts index 8ba2ddbdfb..cf6c41de84 100644 --- a/examples/repl/server.ts +++ b/examples/repl/server.ts @@ -17,6 +17,7 @@ */ import { randomUUID } from 'node:crypto'; +import { InMemoryEventStore } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server'; @@ -24,8 +25,6 @@ import { completable, isInitializeRequest, McpServer, ResourceTemplate } from '@ import type { Request, Response } from 'express'; import * as z from 'zod/v4'; -import { InMemoryEventStore } from '../sse-polling/inMemoryEventStore.js'; - const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; /** Dynamic resources added via the `add-resource` tool (shared across sessions). */ diff --git a/examples/sse-polling/inMemoryEventStore.ts b/examples/shared/src/inMemoryEventStore.ts similarity index 100% rename from examples/sse-polling/inMemoryEventStore.ts rename to examples/shared/src/inMemoryEventStore.ts diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 6b014cee1d..62b0f7ecd7 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -6,6 +6,9 @@ export { createDemoAuth } from './auth.js'; export type { SetupAuthServerOptions } from './authServer.js'; export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js'; +// In-memory EventStore for resumability examples (sse-polling, repl) +export { InMemoryEventStore } from './inMemoryEventStore.js'; + // Minimal client_credentials-only AS (machine-to-machine; no browser) export type { ClientCredentialsAuthServer, ClientCredentialsAuthServerOptions, RegisteredClient } from './clientCredentialsAuthServer.js'; export { clientCredentialsTokenVerifier, createClientCredentialsAuthServer } from './clientCredentialsAuthServer.js'; diff --git a/examples/sse-polling/package.json b/examples/sse-polling/package.json index 2500bffe11..b66b6cad33 100644 --- a/examples/sse-polling/package.json +++ b/examples/sse-polling/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", diff --git a/examples/sse-polling/server.ts b/examples/sse-polling/server.ts index eb592db769..70af622c95 100644 --- a/examples/sse-polling/server.ts +++ b/examples/sse-polling/server.ts @@ -13,6 +13,7 @@ */ import { randomUUID } from 'node:crypto'; +import { InMemoryEventStore } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; @@ -20,8 +21,6 @@ import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; - // Create a fresh MCP server per client connection to avoid shared state between clients const getServer = () => { const server = new McpServer( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 793a0b515c..28443dbaaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,6 +626,9 @@ importers: examples/repl: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -794,6 +797,9 @@ importers: examples/sse-polling: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client From a59063a19d2c2675ecfdc9db38ab566c9e7e3cde Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:51:14 +0000 Subject: [PATCH 25/27] =?UTF-8?q?fix(examples):=20httpUrlFromArgs=20helper?= =?UTF-8?q?=20for=20HTTP-only=20story=20clients=20(indexOf=20-1=20?= =?UTF-8?q?=E2=86=92=20argv[0]=20bug);=20CLAUDE.md=20harness=20path;=20sta?= =?UTF-8?q?ndalone-get/sse-polling=20README=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 3 ++- examples/bearer-auth/client.ts | 5 ++--- examples/harness.ts | 14 ++++++++++++++ examples/hono/client.ts | 5 ++--- examples/json-response/client.ts | 5 ++--- examples/legacy-routing/client.ts | 5 ++--- examples/oauth-client-credentials/client.ts | 5 ++--- examples/oauth/client.ts | 5 ++--- examples/sse-polling/README.md | 2 +- examples/sse-polling/client.ts | 5 ++--- examples/standalone-get/README.md | 2 ++ examples/standalone-get/client.ts | 5 ++--- examples/stateless-legacy/client.ts | 5 ++--- 13 files changed, 37 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed4f5623b6..88514bb58c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,8 @@ asserts results, exits non-zero on mismatch). `pnpm run:examples` runs every sto configured transport×era legs; the `examples (build + e2e)` CI job is part of the per-PR gate basket. See `examples/README.md` for the full story matrix. -- `examples/shared/` — shared scaffolding (`connectFromArgs`, `runServerFromArgs`, demo OAuth provider) +- `examples/harness.ts` — dual-transport scaffold (`connectFromArgs`, `runServerFromArgs`, `httpUrlFromArgs`, `runClient`) +- `examples/shared/` — `@mcp-examples/shared` package (demo OAuth provider, `InMemoryEventStore`) - `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md` ## Message Flow (Bidirectional Protocol) diff --git a/examples/bearer-auth/client.ts b/examples/bearer-auth/client.ts index f8f3fc9280..54554243af 100644 --- a/examples/bearer-auth/client.ts +++ b/examples/bearer-auth/client.ts @@ -5,10 +5,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, negotiationFromArgs, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); runClient('bearer-auth', async () => { // Unauthenticated → 401 + WWW-Authenticate. diff --git a/examples/harness.ts b/examples/harness.ts index 5116108cba..c32db17911 100644 --- a/examples/harness.ts +++ b/examples/harness.ts @@ -182,6 +182,20 @@ export function negotiationFromArgs(): NonNullable` argument from `process.argv`, or `defaultUrl` when the + * flag (or its value) is absent. HTTP-only stories that construct their own + * transport call this instead of {@linkcode connectFromArgs}. (A bare + * `argv[argv.indexOf('--http') + 1]` reads `argv[0]` — the script path — when + * the flag is missing, so the `?? default` never applies.) + */ +export function httpUrlFromArgs(defaultUrl: string): string { + const argv = process.argv.slice(2); + const i = argv.indexOf('--http'); + if (i === -1) return defaultUrl; + return argv[i + 1] ?? defaultUrl; +} + /** * Run a self-verifying client scenario. Any thrown error (including * `node:assert/strict` failures) prints a `FAIL:` line to stderr and exits diff --git a/examples/hono/client.ts b/examples/hono/client.ts index 5994400920..6991003bc7 100644 --- a/examples/hono/client.ts +++ b/examples/hono/client.ts @@ -3,10 +3,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, negotiationFromArgs, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); runClient('hono', async () => { // `createMcpHandler.fetch` serves both eras (default `'stateless'` posture); diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 360889d9bb..147d91f097 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -5,10 +5,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; +const URL = httpUrlFromArgs('http://127.0.0.1:3000/'); runClient('json-response', async () => { // Low-level: a 2026-07-28 (envelope) request should come back as plain diff --git a/examples/legacy-routing/client.ts b/examples/legacy-routing/client.ts index 5bee84d390..33c71794ce 100644 --- a/examples/legacy-routing/client.ts +++ b/examples/legacy-routing/client.ts @@ -5,10 +5,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); runClient('legacy-routing', async () => { // 2025 client → routed to the existing sessionful deployment. diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts index c66ce8e372..5136efcf71 100644 --- a/examples/oauth-client-credentials/client.ts +++ b/examples/oauth-client-credentials/client.ts @@ -14,10 +14,9 @@ */ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, negotiationFromArgs, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL_ARG = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; +const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); runClient('oauth-client-credentials', async () => { // Unauthenticated → 401 + WWW-Authenticate naming the PRM URL. diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts index bbe95bf079..8486d9e6ea 100644 --- a/examples/oauth/client.ts +++ b/examples/oauth/client.ts @@ -29,11 +29,10 @@ import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; -import { check, negotiationFromArgs, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; -const argv = process.argv.slice(2); -const URL_ARG = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; +const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); // The redirect target the AS will 302 back to with `?code=...`. In the real // browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the diff --git a/examples/sse-polling/README.md b/examples/sse-polling/README.md index 6a594af2b7..fef5261ba8 100644 --- a/examples/sse-polling/README.md +++ b/examples/sse-polling/README.md @@ -1,7 +1,7 @@ # sse-polling SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). `eventStore` resumability is a 2025-session concern with no 2026-07-28 -per-request equivalent — this is the only story that configures one. +per-request equivalent. The `long-operation` tool emits two log notifications, calls `ctx.http?.closeSSE()` mid-stream, emits two more while the client is disconnected, then returns. The client transport reconnects after `retryInterval` (300 ms) with `Last-Event-ID`; the event store replays the buffered events. The client asserts the result arrived AND the post-disconnect log was delivered. diff --git a/examples/sse-polling/client.ts b/examples/sse-polling/client.ts index d7c623bc4b..934503505f 100644 --- a/examples/sse-polling/client.ts +++ b/examples/sse-polling/client.ts @@ -9,10 +9,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3001/mcp'; +const URL = httpUrlFromArgs('http://127.0.0.1:3001/mcp'); runClient('sse-polling', async () => { const transport = new StreamableHTTPClientTransport(new globalThis.URL(URL)); diff --git a/examples/standalone-get/README.md b/examples/standalone-get/README.md index c915840e19..085ec69799 100644 --- a/examples/standalone-get/README.md +++ b/examples/standalone-get/README.md @@ -3,4 +3,6 @@ Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The `add_resource` tool registers a new resource on the session's instance, which emits the notification over the GET stream the client opened via `ClientOptions.listChanged`; the client calls the tool and asserts the notification arrived. +The original timer-driven unsolicited push (server emits on its own schedule) was traded for this tool-triggered approach for CI determinism — the `list_changed`-over-standalone-GET behaviour is still demonstrated; "server pushes on its own schedule" is no longer shown. + **HTTP-only**, sessionful 2025 by definition. diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts index 434b8abd79..433aaeac0b 100644 --- a/examples/standalone-get/client.ts +++ b/examples/standalone-get/client.ts @@ -6,10 +6,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/mcp'; +const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); runClient('standalone-get', async () => { let received = 0; diff --git a/examples/stateless-legacy/client.ts b/examples/stateless-legacy/client.ts index e5b5962973..0057e27a22 100644 --- a/examples/stateless-legacy/client.ts +++ b/examples/stateless-legacy/client.ts @@ -6,10 +6,9 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, runClient } from '../harness.js'; +import { check, httpUrlFromArgs, runClient } from '../harness.js'; -const argv = process.argv.slice(2); -const URL = argv[argv.indexOf('--http') + 1] ?? 'http://127.0.0.1:3000/'; +const URL = httpUrlFromArgs('http://127.0.0.1:3000/'); runClient('stateless-legacy', async () => { for (const mode of [undefined, { mode: 'auto' as const }]) { From e17ffb72bf772acb5684a8e46458040a6545cb51 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 13:16:27 +0000 Subject: [PATCH 26/27] fix(examples/oauth): align browser-client default URL with server's advertised PRM resource (127.0.0.1) --- examples/oauth/dualModeAuth.ts | 2 +- examples/oauth/simpleOAuthClient.ts | 2 +- examples/oauth/simpleTokenProvider.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/oauth/dualModeAuth.ts b/examples/oauth/dualModeAuth.ts index 4dd1eaded4..ac4dcc1e82 100644 --- a/examples/oauth/dualModeAuth.ts +++ b/examples/oauth/dualModeAuth.ts @@ -79,7 +79,7 @@ async function connectAndList(transport: StreamableHTTPClientTransport): Promise // --- Driver ---------------------------------------------------------------- async function main() { - const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'); + const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'); const mode = process.argv[2] || 'host'; let transport: StreamableHTTPClientTransport; diff --git a/examples/oauth/simpleOAuthClient.ts b/examples/oauth/simpleOAuthClient.ts index cf438fa7d4..8ce6798608 100644 --- a/examples/oauth/simpleOAuthClient.ts +++ b/examples/oauth/simpleOAuthClient.ts @@ -11,7 +11,7 @@ import open from 'open'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration -const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp'; const CALLBACK_PORT = 8090; // Use different port than auth server (3001) const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; diff --git a/examples/oauth/simpleTokenProvider.ts b/examples/oauth/simpleTokenProvider.ts index ce68fde5a1..f8996760a6 100644 --- a/examples/oauth/simpleTokenProvider.ts +++ b/examples/oauth/simpleTokenProvider.ts @@ -11,14 +11,14 @@ * providers which implement both `token()` and `onUnauthorized()`. * * Environment variables: - * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) + * MCP_SERVER_URL - Server URL (default: http://127.0.0.1:3000/mcp) * MCP_TOKEN - Bearer token to use for authentication (required) */ import type { AuthProvider } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'; async function main() { const token = process.env.MCP_TOKEN; From 9ab402d9f612a4b570c438acdaf9fc2e1f2efac1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 13:25:18 +0000 Subject: [PATCH 27/27] =?UTF-8?q?docs(examples):=20prose-vs-code=20sweep?= =?UTF-8?q?=20=E2=80=94=20align=20guide/example-README=20claims=20with=20a?= =?UTF-8?q?ctual=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/client.md: oauth-client-credentials link no longer claims env-var switching between auth methods (the example only runs ClientCredentialsProvider; private_key_jwt is README-note only) - docs/client.md, docs/server.md: drop pre-restructure filenames from link text (toolWithSampleServer.ts, ssePollingClient.ts, parallelToolCallsClient.ts, multipleClientsParallel.ts, examples/server/) — files were renamed to the per-story /{server,client}.ts layout - examples/caching/README.md: 'three layers' → 'two layers' (server.ts only declares per-registration + server-level hints; handler-return precedence noted but not exercised) --- docs/client.md | 11 ++++++----- docs/server.md | 4 ++-- examples/caching/README.md | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/client.md b/docs/client.md index a5cfe2b6cb..6b7c0df110 100644 --- a/docs/client.md +++ b/docs/client.md @@ -186,7 +186,8 @@ const authProvider = new PrivateKeyJwtProvider({ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -For a runnable example supporting both auth methods via environment variables, see [`oauth-client-credentials/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth-client-credentials/client.ts). +For a runnable `client_credentials` example, see [`oauth-client-credentials/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth-client-credentials/client.ts) — its README shows the `private_key_jwt` swap (the in-repo demo Authorization +Server only implements `client_secret_basic`/`client_secret_post`, so there is no runnable `private_key_jwt` leg). ### Full OAuth with user authorization @@ -708,7 +709,7 @@ const result = await client.request( console.log(result); ``` -For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts). +For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts). ## See also @@ -722,7 +723,7 @@ For an end-to-end example of server-initiated SSE disconnection and automatic cl | Feature | Description | Example | | ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | -| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | -| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | | URL elicitation | Handle sensitive data collection via browser | [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts) | diff --git a/docs/server.md b/docs/server.md index 7eb82f40ba..a66bffd079 100644 --- a/docs/server.md +++ b/docs/server.md @@ -487,7 +487,7 @@ server.registerTool( ); ``` -For a full runnable example, see [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sampling/server.ts). +For a full runnable example, see [`sampling/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sampling/server.ts). ### Elicitation @@ -642,7 +642,7 @@ middleware source for reference. When mounting a handler bare on a fetch-native ## See also -- [`examples/server/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Full runnable server examples +- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable server examples - [Client guide](./client.md) — Building MCP clients with this SDK - [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives - [Migration guide](./migration.md) — Upgrading from previous SDK versions diff --git a/examples/caching/README.md b/examples/caching/README.md index 13e2a0600d..665ee3cc7e 100644 --- a/examples/caching/README.md +++ b/examples/caching/README.md @@ -1,7 +1,7 @@ # caching -`CacheableResult` freshness hints (protocol revision 2026-07-28). The server declares hints at three layers (handler return → per-registration `cacheHint` → server-level `ServerOptions.cacheHints`); the SDK resolves most-specific-author-first and stamps `ttlMs`/`cacheScope` on -the wire toward modern clients only. The client reads the stamped values back. +`CacheableResult` freshness hints (protocol revision 2026-07-28). The server declares hints at two layers — a per-registration `cacheHint` on the resource and server-level `ServerOptions.cacheHints` — and the SDK resolves most-specific-author-first (handler-return fields would +take precedence over both) and stamps `ttlMs`/`cacheScope` on the wire toward modern clients only. The client reads the stamped values back. > Full client-side cache **honouring** (re-using a still-fresh result instead of re-requesting) is a follow-up; this example reads what the server emits today.