diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 9426afd78f..34ec6b152c 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -44,4 +44,4 @@ jobs: - run: pnpm run build:all - name: Run all example pairs (transport × era) - run: pnpm tsx scripts/run-examples.ts + run: pnpm tsx scripts/examples/run-examples.ts diff --git a/CLAUDE.md b/CLAUDE.md index e70bb6b4cb..c2c664b970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,8 +128,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/harness.ts` — dual-transport scaffold (`connectFromArgs`, `runServerFromArgs`, `httpUrlFromArgs`, `runClient`) -- `examples/shared/` — `@mcp-examples/shared` package (demo OAuth provider, `InMemoryEventStore`) +- `examples/shared/` — `@mcp-examples/shared` package. Root export is args-only (`parseExampleArgs`, `check`, `siblingPath`); the demo OAuth provider and `InMemoryEventStore` live at the `@mcp-examples/shared/auth` subpath so non-auth stories don't eagerly evaluate better-auth/express/better-sqlite3. Stories import only this plumbing and inline the SDK transport setup themselves — see `examples/CONTRIBUTING.md`. +- `scripts/examples/` — runner (`run-examples.ts`) - `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md` ## Message Flow (Bidirectional Protocol) diff --git a/examples/CONTRIBUTING.md b/examples/CONTRIBUTING.md new file mode 100644 index 0000000000..a0ad23e880 --- /dev/null +++ b/examples/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing an example + +Each `examples//` directory is a tiny `@mcp-examples/` workspace +package containing a `server.ts` / `client.ts` pair. The pair is a +self-verifying e2e test: the client connects, asserts results, and exits +non-zero on any mismatch. `pnpm run:examples` runs every story over its +configured transport × era legs and is part of the per-PR CI gate. + +## Typical shape + +Examples are **compiled documentation**. Every story shows the SDK transport +setup **inline** — no helper hides `serveStdio`, `createMcpHandler`, `Client`, +or transport construction. The duplication is the feature: when the public API +changes, 25 compile errors flag 25 doc pages. + +Only the part a reader is _not_ here to learn — argv parsing — is shared, via +`parseExampleArgs` / `check` / `siblingPath` from `@mcp-examples/shared` (a +workspace package, so it reads as scaffolding, not part of the example). The +demo OAuth provider and `InMemoryEventStore` live at the +`@mcp-examples/shared/auth` subpath so the args-only root barrel does not pull +better-auth/express/better-sqlite3 into every story. + +Most stories follow the skeleton below; deviate freely when the story calls for +it (HTTP-only auth, sessionful transports, framework adapters, etc.). + +### `server.ts` + +```ts +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +function buildServer(): McpServer { + const server = new McpServer({ name: '-example', version: '1.0.0' }); + // … register tools / resources / prompts here … + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} +``` + +### `client.ts` + +```ts +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client({ name: '-example-client', version: '1.0.0' }, { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }); + +await client.connect(transport === 'stdio' ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) : new StreamableHTTPClientTransport(new URL(url))); + +// … example body — drive the server and assert with `check.*` … + +await client.close(); +``` + +The body uses top-level `await`. A `check.*` failure throws, Node prints the +error and exits 1; on success `client.close()` releases the last handle and +Node exits 0. `pnpm run:examples` reports PASS/FAIL from the exit code (a +timeout is reported as a hang — investigate it as a possible unclosed handle). + +## Import rules (lint-enforced) + +Stories may import from: + +- `@modelcontextprotocol/{server,client,node,express,hono}` and their published + subpath exports (e.g. `@modelcontextprotocol/server/stdio`) +- `@mcp-examples/shared` (args/assert) and `@mcp-examples/shared/auth` (demo OAuth + `InMemoryEventStore`) +- third-party packages a consumer would `npm install` + +Stories may **not** import from: + +- `@modelcontextprotocol/core` or `@modelcontextprotocol/core/*` (internal barrel) +- `@modelcontextprotocol/*/src/*` or `@modelcontextprotocol/*/dist/*` (deep paths) +- `@modelcontextprotocol/test-helpers` +- any relative path that hides the SDK transport setup behind a shared helper + +`@mcp-examples/shared` itself must never import from a story package (one-way). diff --git a/examples/README.md b/examples/README.md index d15ae5cc15..9e1441fff2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,7 +1,7 @@ # 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. +pair over every transport it supports (`scripts/examples/run-examples.ts`); a non-zero exit fails the build. Each story is its own private workspace package (`@mcp-examples/`). Run any pair from the repo root: @@ -57,16 +57,16 @@ Add `-- --legacy` to the client command for the 2025-era handshake. | [`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 | -`dual (in-body)` = the client connects to both eras inside one harness run; the story demonstrates one server serving both side by side. +`dual (in-body)` = the client connects to both eras inside one runner invocation; the story demonstrates one server serving both side by side. ## Excluded -| 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. | +| 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/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. | ## Multi-node deployment patterns diff --git a/examples/bearer-auth/README.md b/examples/bearer-auth/README.md index 834b1c8931..8142dc2107 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/` (run headlessly by the harness via the demo AS's auto-consent mode). +**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly in CI via the demo AS's auto-consent mode). diff --git a/examples/bearer-auth/client.ts b/examples/bearer-auth/client.ts index 54554243af..334036cdb3 100644 --- a/examples/bearer-auth/client.ts +++ b/examples/bearer-auth/client.ts @@ -3,27 +3,29 @@ * a request with `Authorization: Bearer demo-token` reaches the `whoami` tool * with the verified `authInfo`. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; +const { url, era } = parseExampleArgs(); -const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); +// 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/); -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. Bearer auth is +// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`. +const client = new Client( + { name: 'bearer-auth-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } })); - // 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'); - await client.close(); -}); +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/package.json b/examples/bearer-auth/package.json index 56cc25e165..88012bc865 100644 --- a/examples/bearer-auth/package.json +++ b/examples/bearer-auth/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", @@ -22,6 +23,6 @@ ], "era": "dual", "path": "/mcp", - "//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs." + "//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via the inline versionNegotiation branch." } } diff --git a/examples/bearer-auth/server.ts b/examples/bearer-auth/server.ts index 543e5dfc11..86863462b0 100644 --- a/examples/bearer-auth/server.ts +++ b/examples/bearer-auth/server.ts @@ -7,6 +7,7 @@ * endpoint is hosted on `createMcpHandler` with the verified `authInfo` passed * through to the factory (`ctx.authInfo`). HTTP-only by definition. */ +import { parseExampleArgs } from '@mcp-examples/shared'; import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; import { createMcpExpressApp, @@ -15,14 +16,20 @@ import { requireBearerAuth } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; -import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import type { AuthInfo, McpServerFactory, 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 buildServer: McpServerFactory = 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 { port } = parseExampleArgs(); +const mcpServerUrl = new URL(`http://localhost:${port}/mcp`); const oauthMetadata: OAuthMetadata = { issuer: 'https://auth.example.com', @@ -41,13 +48,10 @@ const staticTokenVerifier: OAuthTokenVerifier = { } }; -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; -}); +// Bearer auth is HTTP-layer (no stdio arm). The MCP handler is the canonical +// `createMcpHandler(buildServer)`; the Express auth middleware in front of it +// is the point of this story. +const handler = createMcpHandler(buildServer); const app = createMcpExpressApp(); app.use( @@ -67,6 +71,6 @@ const auth = requireBearerAuth({ const node = toNodeHandler(handler); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); -app.listen(PORT, () => { - console.error(`bearer-auth example server on http://127.0.0.1:${PORT}/mcp`); +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/caching/client.ts b/examples/caching/client.ts index 6354dbe48d..6a3c42cb60 100644 --- a/examples/caching/client.ts +++ b/examples/caching/client.ts @@ -3,67 +3,76 @@ * only) and asserts the client honours them: a still-fresh cached entry is * served without a round trip. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; interface Cacheable { ttlMs?: number; cacheScope?: 'public' | 'private'; } -async function callCount(client: Awaited>, name: 'read-count' | 'request-count'): Promise { +async function callCount(client: Client, name: 'read-count' | 'request-count'): Promise { const r = await client.callTool({ name }); return Number((r.content[0] as { text: string }).text); } -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'); +const { transport, url, era } = parseExampleArgs(); - // The server stamps `tools/list` with `ttlMs: 30_000, cacheScope: 'public'`. - const tools = (await client.listTools()) as Cacheable & Awaited>; - check.equal(tools.ttlMs, 30_000); - check.equal(tools.cacheScope, 'public'); - // `request-count` proves the wire was reached exactly once. - check.equal(await callCount(client, 'request-count'), 1); +const client = new Client( + { name: 'caching-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); - // The second call is served from the response cache: the server-side - // `tools/list` counter is unchanged, and the result is a fresh copy of the - // held entry (so mutating it cannot reach the cache). - const toolsAgain = await client.listTools(); - check.deepEqual( - toolsAgain.tools.map(t => t.name), - tools.tools.map(t => t.name) - ); - check.equal(await callCount(client, 'request-count'), 1); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); - // `cacheMode: 'refresh'` always fetches and re-stores: the counter moves. - await client.listTools(undefined, { cacheMode: 'refresh' }); - check.equal(await callCount(client, 'request-count'), 2); +check.equal(client.getNegotiatedProtocolVersion(), '2026-07-28'); - const resources = (await client.listResources()) as Cacheable & Awaited>; - check.equal(resources.ttlMs, 5000); - check.equal(resources.cacheScope, 'public'); +// The server stamps `tools/list` with `ttlMs: 30_000, cacheScope: 'public'`. +const tools = (await client.listTools()) as Cacheable & Awaited>; +check.equal(tools.ttlMs, 30_000); +check.equal(tools.cacheScope, 'public'); +// `request-count` proves the wire was reached exactly once. +check.equal(await callCount(client, 'request-count'), 1); - // `readResource`: the resource handler counts how many times it ran, and - // the `read-count` tool exposes that counter. - const read = (await client.readResource({ uri: 'config://app' })) as Cacheable & Awaited>; - check.equal(read.ttlMs, 60_000); - check.equal(read.cacheScope, 'private'); - check.equal(await callCount(client, 'read-count'), 1); +// The second call is served from the response cache: the server-side +// `tools/list` counter is unchanged, and the result is a fresh copy of the +// held entry (so mutating it cannot reach the cache). +const toolsAgain = await client.listTools(); +check.deepEqual( + toolsAgain.tools.map(t => t.name), + tools.tools.map(t => t.name) +); +check.equal(await callCount(client, 'request-count'), 1); - // Within TTL, default `cacheMode: 'use'` → served from cache; the server - // handler does not run. - await client.readResource({ uri: 'config://app' }); - check.equal(await callCount(client, 'read-count'), 1); +// `cacheMode: 'refresh'` always fetches and re-stores: the counter moves. +await client.listTools(undefined, { cacheMode: 'refresh' }); +check.equal(await callCount(client, 'request-count'), 2); - // `cacheMode: 'refresh'` always fetches and re-stores. - await client.readResource({ uri: 'config://app' }, { cacheMode: 'refresh' }); - check.equal(await callCount(client, 'read-count'), 2); +const resources = (await client.listResources()) as Cacheable & Awaited>; +check.equal(resources.ttlMs, 5000); +check.equal(resources.cacheScope, 'public'); - // After the refresh the entry is fresh again — back to cache-served. - await client.readResource({ uri: 'config://app' }); - check.equal(await callCount(client, 'read-count'), 2); +// `readResource`: the resource handler counts how many times it ran, and +// the `read-count` tool exposes that counter. +const read = (await client.readResource({ uri: 'config://app' })) as Cacheable & Awaited>; +check.equal(read.ttlMs, 60_000); +check.equal(read.cacheScope, 'private'); +check.equal(await callCount(client, 'read-count'), 1); - await client.close(); -}); +// Within TTL, default `cacheMode: 'use'` → served from cache; the server +// handler does not run. +await client.readResource({ uri: 'config://app' }); +check.equal(await callCount(client, 'read-count'), 1); + +// `cacheMode: 'refresh'` always fetches and re-stores. +await client.readResource({ uri: 'config://app' }, { cacheMode: 'refresh' }); +check.equal(await callCount(client, 'read-count'), 2); + +// After the refresh the entry is fresh again — back to cache-served. +await client.readResource({ uri: 'config://app' }); +check.equal(await callCount(client, 'read-count'), 2); + +await client.close(); diff --git a/examples/caching/package.json b/examples/caching/package.json index 9b4623f1a3..173bbe05eb 100644 --- a/examples/caching/package.json +++ b/examples/caching/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/caching/server.ts b/examples/caching/server.ts index b680355736..fe93e66e2c 100644 --- a/examples/caching/server.ts +++ b/examples/caching/server.ts @@ -13,13 +13,16 @@ * 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 { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; -// Module-level (process-wide) counters so the values survive the harness's -// stateless HTTP leg (fresh `buildServer()` per request) as well as stdio's -// single per-connection instance. The client asserts against these to prove a +// Module-level (process-wide) counters so the values survive the stateless +// HTTP leg (fresh `buildServer()` per request) as well as stdio's single +// per-connection instance. The client asserts against these to prove a // cache-served call never reached the server. let readCount = 0; let listCount = 0; @@ -86,5 +89,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/custom-methods/client.ts b/examples/custom-methods/client.ts index c92a4080a3..e4ca552013 100644 --- a/examples/custom-methods/client.ts +++ b/examples/custom-methods/client.ts @@ -2,31 +2,41 @@ * 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 `. + * Spawns the sibling `server.ts` over stdio by default, or connects to a + * running endpoint under `--http `. See `examples/CONTRIBUTING.md` for + * the canonical shape. */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; 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 () => { - // 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); +const { transport, url, era } = parseExampleArgs(); - const stages: string[] = []; - client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { - stages.push(params.stage); - }); +// 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. +const client = new Client( + { name: 'custom-methods-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); - 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.connect( + transport === 'stdio' + ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) + : new StreamableHTTPClientTransport(new URL(url)) +); - await client.close(); +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/package.json b/examples/custom-methods/package.json index 2ceb27e0db..3d5761985b 100644 --- a/examples/custom-methods/package.json +++ b/examples/custom-methods/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/custom-methods/server.ts b/examples/custom-methods/server.ts index 7df95fe999..b21e8e4963 100644 --- a/examples/custom-methods/server.ts +++ b/examples/custom-methods/server.ts @@ -2,12 +2,16 @@ * 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). + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. */ -import { McpServer } from '@modelcontextprotocol/server'; -import { z } from 'zod/v4'; +import { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } 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()) }); @@ -25,5 +29,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/custom-version/client.ts b/examples/custom-version/client.ts index 44ee20349a..e25ccb53a9 100644 --- a/examples/custom-version/client.ts +++ b/examples/custom-version/client.ts @@ -2,21 +2,26 @@ * 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'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -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 }); +const { transport, url } = parseExampleArgs(); - // 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); +// A plain (2025-handshake) client; the server supports the SDK's stock +// 2025 version so this negotiates that. +const client = new Client({ name: 'custom-version-example-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); - await client.close(); -}); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +// 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/package.json b/examples/custom-version/package.json index 60bb5ae272..d4f51326d9 100644 --- a/examples/custom-version/package.json +++ b/examples/custom-version/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/custom-version/server.ts b/examples/custom-version/server.ts index 6eda353c69..ae31799dea 100644 --- a/examples/custom-version/server.ts +++ b/examples/custom-version/server.ts @@ -3,9 +3,12 @@ * 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 { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; // Add support for a newer protocol version (first in list is fallback). const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; @@ -23,5 +26,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/dual-era/client.ts b/examples/dual-era/client.ts index 69d47289b3..c3a41c5094 100644 --- a/examples/dual-era/client.ts +++ b/examples/dual-era/client.ts @@ -2,36 +2,48 @@ * 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'`); + * 1. a plain 2025 client (`versionNegotiation: { mode: 'legacy' }`) — 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. + * `scripts/examples/run-examples.ts` over stdio AND http. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -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')); - 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(); +// The story body drives BOTH eras itself, so the argv `era` flag is unused; +// only the transport leg is read from argv. +const { transport, url } = parseExampleArgs(); - // --- 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(); +const connect = async (mode: 'legacy' | 'auto'): Promise => { + const client = new Client({ name: 'dual-era-example-client', version: '1.0.0' }, { versionNegotiation: { mode } }); + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + return client; +}; - console.log('both eras served by the same factory over the same transport.'); -}); +// --- leg 1: plain 2025 client (initialize handshake) --- +const legacy = await connect('legacy'); +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 connect('auto'); +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/package.json b/examples/dual-era/package.json index 9851ad1369..a9b0fe8737 100644 --- a/examples/dual-era/package.json +++ b/examples/dual-era/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, @@ -15,6 +18,6 @@ }, "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." + "//": "The story body drives BOTH eras itself (legacy via { mode: 'legacy' }, modern via { mode: 'auto' }); pinned so the runner runs it once per transport." } } diff --git a/examples/dual-era/server.ts b/examples/dual-era/server.ts index 94158c35f0..9313acb436 100644 --- a/examples/dual-era/server.ts +++ b/examples/dual-era/server.ts @@ -8,19 +8,21 @@ * `_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). + * One binary, either transport (selected from argv): stdio by default + * (`serveStdio(buildServer)`), or HTTP under `--http --port ` + * (`createMcpHandler(buildServer)` on its default posture — modern served per + * request, 2025-era traffic served stateless from the same factory). */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -import { runServerFromArgs } from '../harness.js'; - -const buildServer = (ctx: McpRequestContext) => { +const buildServer = (ctx: McpRequestContext): McpServer => { const server = new McpServer( { name: 'dual-era-server', version: '1.0.0' }, { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } @@ -40,5 +42,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/elicitation/README.md b/examples/elicitation/README.md index 98d2c362a4..de3443bd68 100644 --- a/examples/elicitation/README.md +++ b/examples/elicitation/README.md @@ -11,8 +11,7 @@ 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). -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. +Runs all four transport/era legs: `server.ts` inlines a sessionful `NodeStreamableHTTPServerTransport` arm for 2025 traffic (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/client.ts b/examples/elicitation/client.ts index 03bef8614b..66236d446d 100644 --- a/examples/elicitation/client.ts +++ b/examples/elicitation/client.ts @@ -10,83 +10,89 @@ * `inputRequired` requests; there is no throw-style or complete-notification * surface on that era, so those assertions are gated to the legacy leg. */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; import type { ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/client'; -import { UrlElicitationRequiredError } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UrlElicitationRequiredError } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { check, connectFromArgs, eraLeg, runClient } from '../harness.js'; +const { transport, url, era } = parseExampleArgs(); -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: {} } } }); +const client = new Client( + { name: 'elicitation-example-client', version: '1.0.0' }, + { + versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, + 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)?.(); - }); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); - 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' }; - } - 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 } }; +// 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)); } - 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' } }; - }); + 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' } }; +}); - // ---- 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\)/ - ); +// ---- 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/); +formAction = 'decline'; +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/); +// ---- 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/); +// ---- 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/); +// ---- URL mode (throw-style — 2025-era only) ------------------------------ +if (era === '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(); -}); +await client.close(); diff --git a/examples/elicitation/package.json b/examples/elicitation/package.json index 437f49a350..05635c8ddc 100644 --- a/examples/elicitation/package.json +++ b/examples/elicitation/package.json @@ -7,7 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, @@ -16,6 +18,6 @@ }, "example": { "era": "dual", - "//": "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." + "//": "2025-era push-style runs over the sessionful legacy arm inlined in server.ts; 2026-07-28 inputRequired runs over the per-request modern arm. All four transport/era legs." } } diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index 7c60e9be06..f0f1d7faa1 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -12,10 +12,18 @@ * 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). + * One binary, either transport (selected from argv). On HTTP the 2025-era arm + * is **sessionful** (`NodeStreamableHTTPServerTransport`): push-style + * `elicitation/create` needs the `initialize`-declared client capabilities and + * the bidirectional SSE stream of a session, neither of which the per-request + * stateless legacy fallback can provide. */ import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; import type { CallToolResult, ElicitRequestFormParams, @@ -23,11 +31,18 @@ import type { InputRequiredResult, McpRequestContext } from '@modelcontextprotocol/server'; -import { acceptedContent, inputRequired, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import { + acceptedContent, + createMcpHandler, + inputRequired, + isInitializeRequest, + isLegacyRequest, + McpServer, + UrlElicitationRequiredError +} from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; 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', @@ -219,5 +234,69 @@ function buildServer(reqCtx: McpRequestContext): 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + // --- modern (2026-07-28): per-request, strict so the sessionful arm owns ALL legacy traffic --- + const modern = toNodeHandler(createMcpHandler(buildServer, { legacy: 'reject' })); + + // --- legacy (2025): sessionful Streamable HTTP — push-style elicitation + // requires the session (client capabilities + bidirectional SSE stream) --- + 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 t = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, t); + } + }); + t.onclose = () => t.sessionId && sessions.delete(t.sessionId); + await buildServer({ era: 'legacy' } as McpRequestContext).connect(t); + await t.handleRequest(req, res, body); + } else { + res.writeHead(sid ? 404 : 400, { 'content-type': 'application/json' }).end( + JSON.stringify({ + jsonrpc: '2.0', + error: sid + ? { code: -32_001, message: 'Session not found' } + : { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }) + ); + } + }; + + createServer((req, res) => { + void (async () => { + // Read the body once for the predicate and pass it forward. + let body: unknown; + if (req.method === 'POST') { + 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 { + 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(req, res, body)); + })().catch(error => { + console.error('[server] request error:', error instanceof Error ? error.message : error); + if (!res.headersSent) res.writeHead(500).end(); + }); + }).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs index 807cb09106..08e956e733 100644 --- a/examples/eslint.config.mjs +++ b/examples/eslint.config.mjs @@ -6,6 +6,8 @@ export default [ ...baseConfig, { // The nested workspace packages (shared, *-quickstart) are linted by their own configs. + // The one-way "@mcp-examples/shared must not import from stories" rule lives in + // shared/eslint.config.mjs so it fires under that package's own lint. ignores: ['shared/**', 'server-quickstart/**', 'client-quickstart/**'] }, { @@ -13,23 +15,24 @@ export default [ 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. + // public package entry points and the @mcp-examples/shared scaffold. 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: ['@modelcontextprotocol/*/src/*', '@modelcontextprotocol/*/dist/*'], + message: 'Examples must import only public package entry points (no /src/ or /dist/ deep paths).' + }, { 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.' + message: 'Examples must import from @modelcontextprotocol/{server,client}, not the internal core barrel.' }, { group: ['@modelcontextprotocol/test-helpers', '@modelcontextprotocol/test-helpers/*'], diff --git a/examples/gateway/client.ts b/examples/gateway/client.ts index 441bf4d250..b7824cb85a 100644 --- a/examples/gateway/client.ts +++ b/examples/gateway/client.ts @@ -18,73 +18,70 @@ * unauthenticated endpoint, so the constraint holds trivially. Do not share a * `DiscoverResult` across principals. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import type { DiscoverResult } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, runClient } from '../harness.js'; - async function requestCount(client: Client): Promise { const r = await client.callTool({ name: 'request_count' }); return Number((r.content?.[0] as { text: string }).text); } -runClient('gateway', async () => { - const url = new globalThis.URL(httpUrlFromArgs('http://127.0.0.1:3000/')); +const { url } = parseExampleArgs(); - // --------------------------------------------------------------------- - // Step 1: bootstrap — one server/discover probe. - // --------------------------------------------------------------------- - const bootstrap = new Client({ name: 'gateway-bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await bootstrap.connect(new StreamableHTTPClientTransport(url)); - check.equal(bootstrap.getNegotiatedProtocolVersion(), '2026-07-28'); +// --------------------------------------------------------------------- +// Step 1: bootstrap — one server/discover probe. +// --------------------------------------------------------------------- +const bootstrap = new Client({ name: 'gateway-bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(new URL(url))); +check.equal(bootstrap.getNegotiatedProtocolVersion(), '2026-07-28'); - const discovered = bootstrap.getDiscoverResult(); - check.ok(discovered, 'bootstrap connect populated getDiscoverResult()'); - check.deepEqual(discovered?.serverInfo, { name: 'gateway-target', version: '1.0.0' }); +const discovered = bootstrap.getDiscoverResult(); +check.ok(discovered, 'bootstrap connect populated getDiscoverResult()'); +check.deepEqual(discovered?.serverInfo, { name: 'gateway-target', version: '1.0.0' }); - // The probe was the only request so far; the request_count call is the - // second. (createMcpHandler builds one server instance per request.) - check.equal(await requestCount(bootstrap), 2); +// The probe was the only request so far; the request_count call is the +// second. (createMcpHandler builds one server instance per request.) +check.equal(await requestCount(bootstrap), 2); - // --------------------------------------------------------------------- - // Step 2: persist. In a real gateway you'd write this to Redis / a config - // map / a process-local cache here. JSON round-trips by design. - // --------------------------------------------------------------------- - const persisted: string = JSON.stringify(discovered); - await bootstrap.close(); +// --------------------------------------------------------------------- +// Step 2: persist. In a real gateway you'd write this to Redis / a config +// map / a process-local cache here. JSON round-trips by design. +// --------------------------------------------------------------------- +const persisted: string = JSON.stringify(discovered); +await bootstrap.close(); - // --------------------------------------------------------------------- - // Step 3: three fresh workers connect from the persisted blob — zero - // round trips each. Every worker presents the same authorization context - // as the bootstrap (unauthenticated here), so reuse is safe. - // --------------------------------------------------------------------- - const prior: DiscoverResult = JSON.parse(persisted) as DiscoverResult; - const workers = await Promise.all( - ['worker-a', 'worker-b', 'worker-c'].map(async name => { - const worker = new Client({ name, version: '1.0.0' }); - await worker.connect(new StreamableHTTPClientTransport(url), { prior }); - // Adopted directly from prior — no probe, no initialize. - check.equal(worker.getNegotiatedProtocolVersion(), '2026-07-28'); - check.deepEqual(worker.getServerVersion(), { name: 'gateway-target', version: '1.0.0' }); - return worker; - }) - ); +// --------------------------------------------------------------------- +// Step 3: three fresh workers connect from the persisted blob — zero +// round trips each. Every worker presents the same authorization context +// as the bootstrap (unauthenticated here), so reuse is safe. +// --------------------------------------------------------------------- +const prior: DiscoverResult = JSON.parse(persisted) as DiscoverResult; +const workers = await Promise.all( + ['worker-a', 'worker-b', 'worker-c'].map(async name => { + const worker = new Client({ name, version: '1.0.0' }); + await worker.connect(new StreamableHTTPClientTransport(new URL(url)), { prior }); + // Adopted directly from prior — no probe, no initialize. + check.equal(worker.getNegotiatedProtocolVersion(), '2026-07-28'); + check.deepEqual(worker.getServerVersion(), { name: 'gateway-target', version: '1.0.0' }); + return worker; + }) +); - // --------------------------------------------------------------------- - // Step 4: prove it. Three connect() calls and the count is unchanged - // (still 2 from the bootstrap leg + this request_count call = 3). Had - // each worker probed/initialized, this would read 6. - // --------------------------------------------------------------------- - check.equal(await requestCount(workers[0]!), 3); +// --------------------------------------------------------------------- +// Step 4: prove it. Three connect() calls and the count is unchanged +// (still 2 from the bootstrap leg + this request_count call = 3). Had +// each worker probed/initialized, this would read 6. +// --------------------------------------------------------------------- +check.equal(await requestCount(workers[0]!), 3); - // Each worker can callTool immediately. - for (const [i, worker] of workers.entries()) { - const echoed = await worker.callTool({ name: 'echo', arguments: { text: `hello from ${i}` } }); - check.equal((echoed.content?.[0] as { text: string }).text, `hello from ${i}`); - } +// Each worker can callTool immediately. +for (const [i, worker] of workers.entries()) { + const echoed = await worker.callTool({ name: 'echo', arguments: { text: `hello from ${i}` } }); + check.equal((echoed.content?.[0] as { text: string }).text, `hello from ${i}`); +} - // 3 (above) + 3 echo calls + this request_count call = 7. - check.equal(await requestCount(workers[0]!), 7); +// 3 (above) + 3 echo calls + this request_count call = 7. +check.equal(await requestCount(workers[0]!), 7); - for (const worker of workers) await worker.close(); -}); +for (const worker of workers) await worker.close(); diff --git a/examples/gateway/package.json b/examples/gateway/package.json index 0f696e72a5..676b5313e6 100644 --- a/examples/gateway/package.json +++ b/examples/gateway/package.json @@ -7,7 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/gateway/server.ts b/examples/gateway/server.ts index b069c24399..ec0d657d27 100644 --- a/examples/gateway/server.ts +++ b/examples/gateway/server.ts @@ -6,10 +6,12 @@ * number of MCP requests served (server/discover, tools/call, …). The client * asserts against it to PROVE that `connect({ prior })` sent nothing. */ -import { McpServer } from '@modelcontextprotocol/server'; -import * as z from 'zod/v4'; +import { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; let requestCount = 0; @@ -36,5 +38,12 @@ 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); +// HTTP-only — the request_count proof depends on `createMcpHandler`'s +// per-request factory; on stdio the factory is per-connection and the 2/3/7 +// assertions would not hold. +const { port } = parseExampleArgs(); + +const handler = createMcpHandler(buildServer); +createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/harness.ts b/examples/harness.ts deleted file mode 100644 index 1a2d216a1f..0000000000 --- a/examples/harness.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * 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 `, 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. - */ - -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, toNodeHandler } from '@modelcontextprotocol/node'; -import type { McpServerFactory } from '@modelcontextprotocol/server'; -import { createMcpHandler, isInitializeRequest, isLegacyRequest } 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]`: 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. - */ -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]); - - // --- 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) - }); - const modernNode = toNodeHandler(modern); - - // --- 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') { - // 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 { - 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) : modernNode(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 modern.close(); - for (const t of sessions.values()) await t.close().catch(() => {}); - 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 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 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: negotiationFromArgs(), ...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'; -} - -/** Protocol-era leg the client is running on this invocation. */ -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' }; -} - -/** - * The `--http ` 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 - * 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 () => { - const leg = `${transportLeg()}/${eraLeg()}`; - try { - await scenario(); - 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} (${leg}): ${message}`); - process.exit(1); - } - })(); -} diff --git a/examples/hono/client.ts b/examples/hono/client.ts index 6991003bc7..24dd8dd42f 100644 --- a/examples/hono/client.ts +++ b/examples/hono/client.ts @@ -1,20 +1,25 @@ /** * Connects to the Hono-hosted server, lists tools and calls `greet`. + * + * HTTP-only — the point is the Hono adapter; a stdio leg would bypass it. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; +const { url, era } = parseExampleArgs(); -const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); +// `createMcpHandler.fetch` serves both eras (default `'stateless'` posture); +// the runner drives `--legacy` to exercise the legacy negotiation path too. +const client = new Client( + { name: 'hono-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); -runClient('hono', async () => { - // `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')); - 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(); -}); +await client.connect(new StreamableHTTPClientTransport(new 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/package.json b/examples/hono/package.json index e8bcbbfcc3..139c26e13d 100644 --- a/examples/hono/package.json +++ b/examples/hono/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", @@ -22,6 +23,6 @@ ], "era": "dual", "path": "/mcp", - "//": "createMcpHandler.fetch hosting is era-agnostic (default 'stateless' posture serves both); the client honours --legacy via negotiationFromArgs." + "//": "createMcpHandler.fetch hosting is era-agnostic (default 'stateless' posture serves both)." } } diff --git a/examples/hono/server.ts b/examples/hono/server.ts index f5cf1ab3bd..88d9e69d35 100644 --- a/examples/hono/server.ts +++ b/examples/hono/server.ts @@ -6,13 +6,16 @@ * `Request` and return the `Response`. The `@modelcontextprotocol/hono` * package adds the same DNS-rebinding / origin protection middleware the * Express adapter ships. + * + * HTTP-only — the point is the Hono adapter; a stdio leg would bypass it. */ import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; -const handler = createMcpHandler(() => { +function buildServer(): McpServer { const server = new McpServer({ name: 'hono-example', version: '1.0.0' }); server.registerTool( 'greet', @@ -20,15 +23,15 @@ const handler = createMcpHandler(() => { async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (from Hono + createMcpHandler.fetch)` }] }) ); return server; -}); +} + +const { port } = parseExampleArgs(); +const handler = createMcpHandler(buildServer); // `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 }); +serve({ fetch: app.fetch, port }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index d047a0a59d..72189fe9c2 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -2,45 +2,48 @@ * 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. + * + * HTTP-only, modern-only — `responseMode` shapes the 2026-07-28 per-request + * HTTP path; there is no stdio equivalent and 2025-era traffic goes through + * the stateless legacy fallback unaffected. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, runClient } from '../harness.js'; +const { url } = parseExampleArgs(); -const URL = httpUrlFromArgs('http://127.0.0.1:3000/'); +const client = new Client({ name: 'json-response-example-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); -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', - 'mcp-method': 'tools/list' - }, - 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); +await client.connect(new StreamableHTTPClientTransport(new URL(url))); - // 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(); +// Low-level: a 2026-07-28 (envelope) request should come back as plain JSON — +// the JSON content-type assertion is the point of the story. +const probe = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' + }, + 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 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/package.json b/examples/json-response/package.json index 18837eef62..4ec6e1c857 100644 --- a/examples/json-response/package.json +++ b/examples/json-response/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", diff --git a/examples/json-response/server.ts b/examples/json-response/server.ts index bb5cad2be8..d954db09d1 100644 --- a/examples/json-response/server.ts +++ b/examples/json-response/server.ts @@ -3,29 +3,32 @@ * 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). + * + * HTTP-only — `responseMode` shapes the HTTP response body; there is no stdio + * equivalent and a stdio leg would not exercise the option. */ import { createServer } from 'node:http'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { toNodeHandler } from '@modelcontextprotocol/node'; 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' } -); +function buildServer(): McpServer { + 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; +} -const argv = process.argv.slice(2); -const portIdx = argv.indexOf('--port'); -const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const { port } = parseExampleArgs(); + +// `responseMode: 'json'` is the point of this story — applies to the modern +// (2026-07-28) per-request HTTP path. +const handler = createMcpHandler(buildServer, { responseMode: 'json' }); createServer(toNodeHandler(handler)).listen(port, () => { - console.error(`json-response example server listening on http://127.0.0.1:${port}/`); + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/legacy-routing/client.ts b/examples/legacy-routing/client.ts index 33c71794ce..2df6a8e107 100644 --- a/examples/legacy-routing/client.ts +++ b/examples/legacy-routing/client.ts @@ -3,25 +3,22 @@ * existing sessionful transport, `era=legacy`) and a 2026-capable client * (lands on the strict modern entry, `era=modern`). */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, runClient } from '../harness.js'; +const { url } = parseExampleArgs(); -const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); +// 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 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(); -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(); -}); +// 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 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/package.json b/examples/legacy-routing/package.json index 2edf1cfe91..408cc248a2 100644 --- a/examples/legacy-routing/package.json +++ b/examples/legacy-routing/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", @@ -24,6 +25,6 @@ "http" ], "era": "modern", - "//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the harness runs it once." + "//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the runner invokes it once." } } diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index dae1c46c7d..efc0f88e02 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -12,6 +12,7 @@ */ import { randomUUID } from 'node:crypto'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; import type { McpRequestContext } from '@modelcontextprotocol/server'; @@ -86,9 +87,7 @@ app.post('/mcp', async (req: Request, res: Response) => { 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]); +const { port } = parseExampleArgs(); app.listen(port, () => { - console.error(`legacy-routing example server listening on http://127.0.0.1:${port}/mcp`); + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/mrtr/client.ts b/examples/mrtr/client.ts index aeb13e3ba6..1938f61174 100644 --- a/examples/mrtr/client.ts +++ b/examples/mrtr/client.ts @@ -14,65 +14,76 @@ * * Asserts both flows reach `deployed to …` and exits 0. */ -import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/client'; -import { isInputRequiredResult } from '@modelcontextprotocol/client'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import type { CallToolResult, ClientOptions, InputRequiredResult } from '@modelcontextprotocol/client'; +import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { check, connectFromArgs, runClient } from '../harness.js'; +const { transport, url, era } = parseExampleArgs(); -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: {} } } - }); - // The SAME handler a 2025-flow client registers: the auto-fulfilment - // engine dispatches embedded form and URL elicitations through it. - auto.setRequestHandler('elicitation/create', async request => { - const params = request.params as { mode?: string; message: string; url?: string }; - if (params.mode === 'url') return { action: 'accept' }; - return { action: 'accept', content: { confirm: true } }; - }); - // callTool returns a plain CallToolResult — the interactive rounds happen - // inside the call. - 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(); +// Both halves connect identically and differ only in ClientOptions; the +// local helper keeps the SDK transport setup visible in THIS file (the +// canonical shape) while avoiding duplicating it for each half. +const connect = async (options: ClientOptions): Promise => { + const client = new Client( + { name: 'mrtr-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, ...options } + ); + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + return client; +}; - // --- manual mode (autoFulfill: false + allowInputRequired) --- - const manual = await connectFromArgs(import.meta.dirname, { - capabilities: { elicitation: { form: {}, url: {} } }, - inputRequired: { autoFulfill: false } - }); - let inputResponses: Record | undefined; - let requestState: string | undefined; - let final: CallToolResult | undefined; - for (let round = 0; round < 10; round++) { - const value = (await manual.request( - { - method: 'tools/call', - params: { - name: 'deploy', - arguments: { env: 'staging' }, - ...(inputResponses && { inputResponses }), - ...(requestState && { requestState }) - } - }, - { allowInputRequired: true } - )) as CallToolResult | InputRequiredResult; - if (!isInputRequiredResult(value)) { - final = value; - break; - } - // Collect responses and echo requestState byte-exact. - inputResponses = {}; - for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { - inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; - } - requestState = value.requestState; - } - 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(); +// --- auto-fulfilment (the default) --- +const auto = await connect({ capabilities: { elicitation: { form: {}, url: {} } } }); +// The SAME handler a 2025-flow client registers: the auto-fulfilment +// engine dispatches embedded form and URL elicitations through it. +auto.setRequestHandler('elicitation/create', async request => { + const params = request.params as { mode?: string; message: string; url?: string }; + if (params.mode === 'url') return { action: 'accept' }; + return { action: 'accept', content: { confirm: true } }; +}); +// callTool returns a plain CallToolResult — the interactive rounds happen +// inside the call. +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(); + +// --- manual mode (autoFulfill: false + allowInputRequired) --- +const manual = await connect({ + capabilities: { elicitation: { form: {}, url: {} } }, + inputRequired: { autoFulfill: false } }); +let inputResponses: Record | undefined; +let requestState: string | undefined; +let final: CallToolResult | undefined; +for (let round = 0; round < 10; round++) { + const value = (await manual.request( + { + method: 'tools/call', + params: { + name: 'deploy', + arguments: { env: 'staging' }, + ...(inputResponses && { inputResponses }), + ...(requestState && { requestState }) + } + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + if (!isInputRequiredResult(value)) { + final = value; + break; + } + // Collect responses and echo requestState byte-exact. + inputResponses = {}; + for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { + inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; + } + requestState = value.requestState; +} +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/mrtr/package.json b/examples/mrtr/package.json index 59e7ebc322..eca6285a84 100644 --- a/examples/mrtr/package.json +++ b/examples/mrtr/package.json @@ -7,7 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts index 1907bc7fcb..c06568241f 100644 --- a/examples/mrtr/server.ts +++ b/examples/mrtr/server.ts @@ -18,14 +18,18 @@ * expired state with a wire-level `-32602` Invalid Params error before the * handler runs. * - * One binary, either transport (selected by the shared scaffold from argv). + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; -import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { acceptedContent, createMcpHandler, createRequestStateCodec, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; 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'] }; type DeployState = { step: 'confirm' | 'signed-in'; env: string }; @@ -102,5 +106,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md index c99a850477..5910ed00e0 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); the harness runs it headlessly via the demo AS's `OAUTH_DEMO_AUTO_CONSENT=1` auto-approve mode. +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 runner drives 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; runs on both protocol eras (the client honours `--legacy` via `negotiationFromArgs()`). +HTTP-only; runs on both protocol eras (the client honours `--legacy` via `parseExampleArgs().era`). ## `private_key_jwt` client authentication diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts index 5136efcf71..3d40654fac 100644 --- a/examples/oauth-client-credentials/client.ts +++ b/examples/oauth-client-credentials/client.ts @@ -12,47 +12,47 @@ * 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 { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; +const { url, era } = parseExampleArgs(); -const URL_ARG = httpUrlFromArgs('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: negotiationFromArgs() }); - 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(); +// Unauthenticated → 401 + WWW-Authenticate naming the PRM URL. +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, '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: era === 'modern' ? 'auto' : 'legacy' } } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url), { 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 index a9cf773f33..cd61ebf69c 100644 --- a/examples/oauth-client-credentials/package.json +++ b/examples/oauth-client-credentials/package.json @@ -23,6 +23,6 @@ ], "era": "dual", "path": "/mcp", - "//": "OAuth client_credentials is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs." + "//": "OAuth client_credentials is HTTP-layer and era-agnostic; the client honours --legacy via parseExampleArgs().era." } } diff --git a/examples/oauth-client-credentials/server.ts b/examples/oauth-client-credentials/server.ts index 88ec54ebc1..f986645d68 100644 --- a/examples/oauth-client-credentials/server.ts +++ b/examples/oauth-client-credentials/server.ts @@ -16,7 +16,8 @@ * 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 { parseExampleArgs } from '@mcp-examples/shared'; +import { createClientCredentialsAuthServer } from '@mcp-examples/shared/auth'; import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, @@ -27,13 +28,11 @@ import { toNodeHandler } from '@modelcontextprotocol/node'; 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; +const { port } = parseExampleArgs(); +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`); +// runner 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. @@ -75,4 +74,4 @@ const auth = requireBearerAuth({ const node = toNodeHandler(handler); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); -app.listen(PORT, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`)); +app.listen(port, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`)); diff --git a/examples/oauth/README.md b/examples/oauth/README.md index 97c0a628c4..0514c0c477 100644 --- a/examples/oauth/README.md +++ b/examples/oauth/README.md @@ -5,7 +5,7 @@ The **authorization-code** OAuth grant — the interactive "user signs in and ap - `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 callback query off the final `Location` header, calls `transport.finishAuth(url.searchParams)` (so the SDK reads `code` + `iss` per RFC 9207), reconnects, and asserts `ctx.authInfo` round-trips. This is what the harness runs. + every interactive step into a redirect), reads the callback query off the final `Location` header, calls `transport.finishAuth(url.searchParams)` (so the SDK reads `code` + `iss` per RFC 9207), reconnects, and asserts `ctx.authInfo` round-trips. This is what `pnpm run:examples` 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`. diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts index 14cad32ad6..330ae0b247 100644 --- a/examples/oauth/client.ts +++ b/examples/oauth/client.ts @@ -25,15 +25,16 @@ * 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. + * + * HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the + * canonical stdio branch does not apply. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; -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 // browser has somewhere to land; headlessly we never bind it — we read the @@ -83,72 +84,76 @@ async function followAuthorizationRedirects(authorizationUrl: URL): Promise { - // ---- 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'], - application_type: 'native', - token_endpoint_auth_method: 'client_secret_post' - }; - const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { - capturedAuthorizationUrl = url; - }); +const { url, era } = parseExampleArgs(); - 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'); +const client = new Client( + { name: 'oauth-headless-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +// ---- 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'], + application_type: 'native', + token_endpoint_auth_method: 'client_secret_post' +}; +const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => { + capturedAuthorizationUrl = authUrl; +}); + +const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(url), { 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 callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!); +// ---- 2. Follow the authorization URL headlessly --------------------------- +// (the browser-and-user stand-in; see `followAuthorizationRedirects`). +const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!); - // ---- 3. Exchange the code for tokens -------------------------------------- - // In the browser flow the local callback server hands the redirect query to - // `transport.finishAuth`; we read it off the final `Location` header instead. - // The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss` - // against the recorded issuer, then POSTs `grant_type=authorization_code` - // (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens - // on `provider`. - await firstTransport.finishAuth(callbackParams); - const tokens = provider.tokens(); - check.ok(tokens?.access_token, 'token exchange should have yielded an access_token'); - check.equal(tokens?.token_type, 'Bearer'); +// ---- 3. Exchange the code for tokens -------------------------------------- +// In the browser flow the local callback server hands the redirect query to +// `transport.finishAuth`; we read it off the final `Location` header instead. +// The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss` +// against the recorded issuer, then POSTs `grant_type=authorization_code` +// (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens +// on `provider`. +await firstTransport.finishAuth(callbackParams); +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); +// ---- 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), { 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'); +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(); -}); +await client.close(); diff --git a/examples/oauth/server.ts b/examples/oauth/server.ts index d67f2c6e5c..f83d8d8343 100644 --- a/examples/oauth/server.ts +++ b/examples/oauth/server.ts @@ -20,30 +20,20 @@ * 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. + * + * HTTP-only (Bearer auth has no stdio equivalent), so the canonical + * `if (transport === 'stdio')` branch does not apply. */ -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared/auth'; import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import * as z from 'zod/v4'; -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) ---- -// `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 => { +function buildServer(ctx: McpRequestContext): McpServer { const server = new McpServer({ name: 'oauth-protected-example', version: '1.0.0' }); server.registerTool( 'whoami', @@ -53,7 +43,22 @@ const handler = createMcpHandler(ctx => { }) ); return server; -}); +} + +const { port } = parseExampleArgs(); +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : port + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// runner 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}`); + +// ---- Authorization Server (better-auth OIDC; authorization_code only) ---- +// `autoConsent` is the demo-only switch that turns the consent screen into an +// immediate 302 — set by the runner 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(buildServer); const app = createMcpExpressApp(); // DEMO ONLY — restrict `origin` in production. `exposedHeaders` lists the @@ -78,7 +83,7 @@ const auth = requireBearerAuth({ const node = toNodeHandler(handler); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); -app.listen(MCP_PORT, () => { +app.listen(port, () => { 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`); + console.error(` Protected Resource Metadata: http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp`); }); diff --git a/examples/parallel-calls/client.ts b/examples/parallel-calls/client.ts index 2051a38e6a..41b1b6fe75 100644 --- a/examples/parallel-calls/client.ts +++ b/examples/parallel-calls/client.ts @@ -4,17 +4,26 @@ * 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 + * each `makeClient` 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 type { Client } from '@modelcontextprotocol/client'; - -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; 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 { transport, url, era } = parseExampleArgs(); + + const client = new Client( + { name: 'parallel-calls-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } + ); + + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + const notifications: string[] = []; client.setNotificationHandler('notifications/message', n => { notifications.push(String(n.params.data)); @@ -22,28 +31,26 @@ async function makeClient(): Promise<{ client: Client; notifications: string[] } return { client, notifications }; } -runClient('parallel-calls', async () => { - // --- multiple clients, one call each --- - 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 } }) - ]); - 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(); +// --- multiple clients, one call each --- +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 } }) +]); +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(); - 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(); -}); +// --- one client, parallel tool calls --- +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 } }) +]); +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/package.json b/examples/parallel-calls/package.json index 3047392f58..7bf6831f81 100644 --- a/examples/parallel-calls/package.json +++ b/examples/parallel-calls/package.json @@ -7,7 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts index 2f11b79ab1..c2b390b32b 100644 --- a/examples/parallel-calls/server.ts +++ b/examples/parallel-calls/server.ts @@ -4,10 +4,13 @@ * calls (both transports), asserting in-flight notifications are attributed * back to the right caller. One binary, either transport. */ -import { McpServer } from '@modelcontextprotocol/server'; -import * as z from 'zod/v4'; +import { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; function buildServer(): McpServer { const server = new McpServer({ name: 'parallel-calls-example', version: '1.0.0' }, { capabilities: { logging: {} } }); @@ -33,5 +36,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/prompts/client.ts b/examples/prompts/client.ts index 99a0ba95e5..90ed76847e 100644 --- a/examples/prompts/client.ts +++ b/examples/prompts/client.ts @@ -1,25 +1,33 @@ /** * Drives the prompts example: list, complete an argument, get a prompt. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -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 { transport, url, era } = parseExampleArgs(); - const list = await client.listPrompts(); - check.ok(list.prompts.some(p => p.name === 'review-code')); +const client = new Client( + { name: 'prompts-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); - const completion = await client.complete({ - ref: { type: 'ref/prompt', name: 'review-code' }, - argument: { name: 'language', value: 'ty' } - }); - check.ok(completion.completion.values.includes('typescript')); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); - 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/); +const list = await client.listPrompts(); +check.ok(list.prompts.some(p => p.name === 'review-code')); - await client.close(); +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/package.json b/examples/prompts/package.json index 148c977c15..4f46b52436 100644 --- a/examples/prompts/package.json +++ b/examples/prompts/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/prompts/server.ts b/examples/prompts/server.ts index 856b953cf5..8732d3d748 100644 --- a/examples/prompts/server.ts +++ b/examples/prompts/server.ts @@ -5,10 +5,13 @@ * `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 { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { completable, createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; const LANGUAGES = ['python', 'typescript', 'rust', 'go']; @@ -38,5 +41,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/repl/README.md b/examples/repl/README.md index 4a6dbf808a..beb492a733 100644 --- a/examples/repl/README.md +++ b/examples/repl/README.md @@ -3,7 +3,7 @@ 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: +Excluded from the runner (`package.json#example.excluded`); run manually: ```sh pnpm run server # terminal 1 — listens on http://localhost:3000/mcp diff --git a/examples/repl/client.ts b/examples/repl/client.ts index 6c8be12610..ccb1ca2c0c 100644 --- a/examples/repl/client.ts +++ b/examples/repl/client.ts @@ -1,5 +1,17 @@ +/** + * Interactive readline REPL for driving an MCP server by hand. + * + * The canonical top-level-await connect→assert→close shape does not apply: + * this is an interactive command loop (stdin is the readline + * prompt, so there is no stdio-transport arm) and the `connect`/`disconnect`/ + * `reconnect`/`terminate-session` commands deliberately tear the transport up + * and down repeatedly. The explicit `new Client(...)` / + * `new StreamableHTTPClientTransport(...)` / `client.connect(...)` calls live + * inline in `connect()` below so the SDK surface is still visible. + */ import { createInterface } from 'node:readline'; +import { parseExampleArgs } from '@mcp-examples/shared'; import type { GetPromptRequest, ListPromptsRequest, @@ -23,7 +35,7 @@ let notificationCount = 0; // Global client and transport for interactive commands let client: Client | null = null; let transport: StreamableHTTPClientTransport | null = null; -let serverUrl = 'http://localhost:3000/mcp'; +let serverUrl = parseExampleArgs().url; let notificationsToolLastEventId: string | undefined; let sessionId: string | undefined; diff --git a/examples/repl/server.ts b/examples/repl/server.ts index cf6c41de84..33f70bf631 100644 --- a/examples/repl/server.ts +++ b/examples/repl/server.ts @@ -8,16 +8,20 @@ * resources (direct + `ResourceTemplate`), `notifications/message` logging, * and `notifications/resources/list_changed`. * - * Hosted on `NodeStreamableHTTPServerTransport` with an in-memory - * `eventStore` so the REPL client's `reconnect` and + * HTTP-only and sessionful by design: hosted on + * `NodeStreamableHTTPServerTransport` with an in-memory `eventStore` so the + * REPL client's `reconnect`, `terminate-session`, and * `run-notifications-tool-with-resumability` commands actually replay missed - * events on reconnect with `Last-Event-ID`. + * events on reconnect with `Last-Event-ID`. The canonical + * `serveStdio` / `createMcpHandler` arms cannot express that, and the REPL + * client uses stdin for the readline command loop. * - * HTTP-only — pair with `pnpm run client` in a second terminal. + * Pair with `pnpm run client` in a second terminal. */ import { randomUUID } from 'node:crypto'; -import { InMemoryEventStore } from '@mcp-examples/shared'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { InMemoryEventStore } from '@mcp-examples/shared/auth'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server'; @@ -25,8 +29,6 @@ import { completable, isInitializeRequest, McpServer, ResourceTemplate } from '@ import type { Request, Response } from 'express'; 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 sessions). */ const dynamicResources = new Map(); @@ -248,6 +250,8 @@ function buildServer(): McpServer { return server; } +const { port } = parseExampleArgs(); + // 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). @@ -277,7 +281,7 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); -app.listen(PORT, () => console.error(`[server] REPL playground listening on http://localhost:${PORT}/mcp`)); +app.listen(port, () => console.error(`[server] REPL playground listening on http://127.0.0.1:${port}/mcp`)); process.on('SIGINT', async () => { for (const t of sessions.values()) await t.close(); diff --git a/examples/resources/client.ts b/examples/resources/client.ts index 0823db8345..c81fdf4bce 100644 --- a/examples/resources/client.ts +++ b/examples/resources/client.ts @@ -1,25 +1,33 @@ /** * Drives the resources example: list, list templates, read direct + templated. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -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 { transport, url, era } = parseExampleArgs(); - const list = await client.listResources(); - check.ok(list.resources.some(r => r.uri === 'config://app')); +const client = new Client( + { name: 'resources-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); - const templates = await client.listResourceTemplates(); - check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}')); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); - const config = await client.readResource({ uri: 'config://app' }); - const configContent = config.contents[0]; - check.equal(configContent && 'text' in configContent ? configContent.text : '', '{"feature":true}'); +const list = await client.listResources(); +check.ok(list.resources.some(r => r.uri === 'config://app')); - const hello = await client.readResource({ uri: 'greeting://world' }); - const helloContent = hello.contents[0]; - check.equal(helloContent && 'text' in helloContent ? helloContent.text : '', 'Hello, world!'); +const templates = await client.listResourceTemplates(); +check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}')); - await client.close(); -}); +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/package.json b/examples/resources/package.json index 4b907b0f00..5fb5df3b83 100644 --- a/examples/resources/package.json +++ b/examples/resources/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/resources/server.ts b/examples/resources/server.ts index 12633739b7..069be5769a 100644 --- a/examples/resources/server.ts +++ b/examples/resources/server.ts @@ -3,11 +3,14 @@ * * `McpServer.registerResource` accepts either a fixed URI string (direct * resource) or a `ResourceTemplate` (URI template with substitution). One - * binary, either transport. + * binary, either transport — selected from argv below. */ -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; function buildServer(): McpServer { const server = new McpServer({ name: 'resources-example', version: '1.0.0' }); @@ -31,5 +34,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/sampling/README.md b/examples/sampling/README.md index c2f9f8e2ff..ad9ad86f6b 100644 --- a/examples/sampling/README.md +++ b/examples/sampling/README.md @@ -11,7 +11,8 @@ The client registers **one** `sampling/createMessage` handler; on the 2026-07-28 > 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. +Push-style sampling is exercised on **stdio/legacy** (`createMcpHandler`'s stateless-legacy posture has no return path for the client's response POST — see `../legacy-routing/` for the sessionful composition); the http/legacy leg only verifies the initialize handshake. +2026-07-28 `inputRequired.createMessage` runs on both transports. ```bash pnpm --filter @mcp-examples/sampling client # 2026-07-28 (inputRequired) diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts index f5e986e6b0..21c323732b 100644 --- a/examples/sampling/client.ts +++ b/examples/sampling/client.ts @@ -10,20 +10,43 @@ * `inputRequired` result to this same handler, then retries the tool call * with the response attached. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -runClient('sampling', 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: { sampling: {} } }); - client.setRequestHandler('sampling/createMessage', async () => ({ - role: 'assistant', - content: { type: 'text', text: '[canned summary]' }, - model: 'stub', - stopReason: 'endTurn' - })); +const { transport, url, era } = parseExampleArgs(); +const client = new Client( + { name: 'sampling-example-client', version: '1.0.0' }, + { + versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, + capabilities: { sampling: {} } + } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: '[canned summary]' }, + model: 'stub', + stopReason: 'endTurn' +})); + +if (transport === 'http' && era === 'legacy') { + // Push-style `ctx.mcpReq.requestSampling` needs a sessionful return + // path: the client's response to `sampling/createMessage` is a separate + // POST that must reach the SAME server instance that sent the request. + // `createMcpHandler`'s default stateless-legacy posture has no such + // path — see `../legacy-routing/` for the sessionful `isLegacyRequest` + // composition. The push-style flow is exercised on stdio/legacy; this + // leg only verifies the 2025 `initialize` handshake succeeded. + check.ok(client.getServerCapabilities()?.tools); +} else { 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(); -}); +await client.close(); diff --git a/examples/sampling/package.json b/examples/sampling/package.json index 9baf2dac97..f4981410af 100644 --- a/examples/sampling/package.json +++ b/examples/sampling/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, @@ -15,6 +18,6 @@ }, "example": { "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." + "//": "2025-era push-style ctx.mcpReq.requestSampling is exercised on stdio/legacy (createMcpHandler's stateless-legacy posture has no return path for the client's response POST — see ../legacy-routing/ for the sessionful composition); 2026-07-28 inputRequired.createMessage runs on both transports." } } diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts index 614c4ca508..cf45c3a356 100644 --- a/examples/sampling/server.ts +++ b/examples/sampling/server.ts @@ -15,12 +15,15 @@ * One binary, either transport. Logs go to stderr only — stdio's stdout is * the JSON-RPC stream. */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import type { CallToolResult, InputRequiredResult, McpRequestContext } from '@modelcontextprotocol/server'; -import { inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -import { runServerFromArgs } from '../harness.js'; - function buildServer(reqCtx: McpRequestContext): McpServer { const server = new McpServer({ name: 'sampling-example', version: '1.0.0' }); @@ -61,5 +64,14 @@ function buildServer(reqCtx: McpRequestContext): 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts index 86730c3eeb..f2df6371ca 100644 --- a/examples/schema-validators/client.ts +++ b/examples/schema-validators/client.ts @@ -3,52 +3,60 @@ * Schema with a required `name` string; calls `get-weather` and asserts the * structured output matches. */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -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 { transport, url, era } = parseExampleArgs(); - 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 client = new Client( + { name: 'schema-validators-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); - // structuredContent is typed `unknown` (SEP-2106). The SDK has already - // runtime-validated it against the server's outputSchema. This client is - // written FOR the paired server above, so the shape is known and a cast is - // the honest known-server idiom (same as C# `.Deserialize()` or Go - // `json.Unmarshal`). A generic host that connects to arbitrary servers - // would not cast; it would render the JSON or narrow at runtime. - const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); - const w = weather.structuredContent as { city: string; conditions: string; celsius: number }; - check.equal(w.city, 'Tokyo'); - check.equal(w.conditions, 'sunny'); - check.equal(w.celsius, 21); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); - // SEP-2106: array structuredContent. The SDK auto-injects a serialized - // JSON text block alongside it. On the legacy era the array is wrapped as - // `{result: }` (the 2025 wire shape only carries object - // structuredContent), so the natural value is at `.result`. - const forecasts = await client.callTool({ name: 'list-forecasts', arguments: { city: 'Tokyo' } }); - const text = forecasts.content?.find(c => c.type === 'text'); - check.ok(text, 'auto-injected TextContent fallback present'); - check.match(text.text, /"hour":"09:00"/); - type Forecast = { hour: string; celsius: number }; - if (process.argv.includes('--legacy')) { - const sc = forecasts.structuredContent as { result: Forecast[] }; - check.equal(sc.result.length, 2); - check.equal(sc.result[0]?.hour, '09:00'); - } else { - const sc = forecasts.structuredContent as Forecast[]; - check.equal(sc.length, 2); - check.equal(sc[0]?.hour, '09:00'); - } +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!/); +} - await client.close(); -}); +// structuredContent is typed `unknown` (SEP-2106). The SDK has already +// runtime-validated it against the server's outputSchema. This client is +// written FOR the paired server above, so the shape is known and a cast is +// the honest known-server idiom (same as C# `.Deserialize()` or Go +// `json.Unmarshal`). A generic host that connects to arbitrary servers +// would not cast; it would render the JSON or narrow at runtime. +const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); +const w = weather.structuredContent as { city: string; conditions: string; celsius: number }; +check.equal(w.city, 'Tokyo'); +check.equal(w.conditions, 'sunny'); +check.equal(w.celsius, 21); + +// SEP-2106: array structuredContent. The SDK auto-injects a serialized +// JSON text block alongside it. On the legacy era the array is wrapped as +// `{result: }` (the 2025 wire shape only carries object +// structuredContent), so the natural value is at `.result`. +const forecasts = await client.callTool({ name: 'list-forecasts', arguments: { city: 'Tokyo' } }); +const text = forecasts.content?.find(c => c.type === 'text'); +check.ok(text, 'auto-injected TextContent fallback present'); +check.match(text.text, /"hour":"09:00"/); +type Forecast = { hour: string; celsius: number }; +if (era === 'legacy') { + const sc = forecasts.structuredContent as { result: Forecast[] }; + check.equal(sc.result.length, 2); + check.equal(sc.result[0]?.hour, '09:00'); +} else { + const sc = forecasts.structuredContent as Forecast[]; + check.equal(sc.length, 2); + check.equal(sc[0]?.hour, '09:00'); +} + +await client.close(); diff --git a/examples/schema-validators/package.json b/examples/schema-validators/package.json index e6eafa6cd6..cc94d3b645 100644 --- a/examples/schema-validators/package.json +++ b/examples/schema-validators/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "@valibot/to-json-schema": "catalog:devTools", "arktype": "catalog:devTools", diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts index 1a04f40f78..95408951ce 100644 --- a/examples/schema-validators/server.ts +++ b/examples/schema-validators/server.ts @@ -5,14 +5,17 @@ * Valibot needs the `@valibot/to-json-schema` wrapper to expose JSON Schema * conversion. One binary, either transport. */ -import { McpServer } from '@modelcontextprotocol/server'; +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; 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' }); @@ -71,5 +74,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/scoped-tools/client.ts b/examples/scoped-tools/client.ts index 328c884869..357b7273c5 100644 --- a/examples/scoped-tools/client.ts +++ b/examples/scoped-tools/client.ts @@ -9,14 +9,16 @@ * dedicated e2e scenario (`test/e2e/scenarios/client-auth.test.ts`); this * example demonstrates the recommended server-side pattern of enforcing scope * inside the tool handler that needs it. + * + * HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the + * canonical stdio branch does not apply. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js'; import { InMemoryOAuthClientProvider } from '../oauth/simpleOAuthClientProvider.js'; -const URL_ARG = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); const CALLBACK_URL = 'http://127.0.0.1:8091/callback'; /** Follow the demo AS's auto-consent 302 and return the `code`. */ @@ -29,53 +31,56 @@ async function followAuthorize(authorizationUrl: URL): Promise { return code; } -runClient('scoped-tools', async () => { - const captured: URL[] = []; - const clientMetadata: OAuthClientMetadata = { - client_name: 'Scoped-Tools Step-Up Client', - redirect_uris: [CALLBACK_URL], - application_type: 'native', - grant_types: ['authorization_code'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - scope: 'files:read' - }; - const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => { - captured.push(url); - }); +const { url } = parseExampleArgs(); + +// Modern-only — authInfo plumbing through ServerContext is the feature under +// demonstration; the legacy era does not exercise it. +const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +const captured: URL[] = []; +const clientMetadata: OAuthClientMetadata = { + client_name: 'Scoped-Tools Step-Up Client', + redirect_uris: [CALLBACK_URL], + application_type: 'native', + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'files:read' +}; +const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => { + captured.push(authUrl); +}); - // ---- 1. Initial authorization for files:read ------------------------------ - const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() }); - const t1 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); - let challenged = false; - try { - await client.connect(t1); - } catch (error) { - 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'); - check.equal(captured.length, 1, 'authorize URL captured'); - check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/); - await t1.finishAuth(await followAuthorize(captured[0]!)); - check.equal(provider.tokens()?.scope, 'files:read'); +// ---- 1. Initial authorization for files:read ------------------------------ +const t1 = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider }); +let challenged = false; +try { + await client.connect(t1); +} catch (error) { + 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'); +check.equal(captured.length, 1, 'authorize URL captured'); +check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/); +await t1.finishAuth(await followAuthorize(captured[0]!)); +check.equal(provider.tokens()?.scope, 'files:read'); - // ---- 2. Reconnect with files:read; list-files works ----------------------- - const t2 = new StreamableHTTPClientTransport(new globalThis.URL(URL_ARG), { authProvider: provider }); - await client.connect(t2); - const listed = await client.callTool({ name: 'list-files', arguments: {} }); - check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/); +// ---- 2. Reconnect with files:read; list-files works ----------------------- +const t2 = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider }); +await client.connect(t2); +const listed = await client.callTool({ name: 'list-files', arguments: {} }); +check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/); - // ---- 3. write-file → handler-level insufficient_scope --------------------- - // Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo), - // so an under-scoped call surfaces as a tool-result `isError`, not an HTTP - // 403. The transport's automatic step-up (SEP-2350) applies only when the - // RS responds 403 at the HTTP layer. - const denied = await client.callTool({ name: 'write-file', arguments: {} }); - check.equal(denied.isError, true, 'write-file must isError under files:read-only token'); - check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/); - check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler'); +// ---- 3. write-file → handler-level insufficient_scope --------------------- +// Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo), +// so an under-scoped call surfaces as a tool-result `isError`, not an HTTP +// 403. The transport's automatic step-up (SEP-2350) applies only when the +// RS responds 403 at the HTTP layer. +const denied = await client.callTool({ name: 'write-file', arguments: {} }); +check.equal(denied.isError, true, 'write-file must isError under files:read-only token'); +check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/); +check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler'); - await client.close(); -}); +await client.close(); diff --git a/examples/scoped-tools/package.json b/examples/scoped-tools/package.json index 62f0dd7d44..90a6cc9f8b 100644 --- a/examples/scoped-tools/package.json +++ b/examples/scoped-tools/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@mcp-examples/oauth": "workspace:*", + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", "@modelcontextprotocol/node": "workspace:*", diff --git a/examples/scoped-tools/server.ts b/examples/scoped-tools/server.ts index d42d822663..5eee7f24d6 100644 --- a/examples/scoped-tools/server.ts +++ b/examples/scoped-tools/server.ts @@ -17,21 +17,23 @@ * * DEMO ONLY — NOT FOR PRODUCTION. The AS auto-approves and issues whatever * scope is asked for; tokens are validated in-process against the same AS. + * + * HTTP-only by definition (the OAuth dance is HTTP redirects + Bearer headers), + * so the canonical stdio branch does not apply. */ import { randomUUID } from 'node:crypto'; import { createServer } from 'node:http'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { AuthInfo } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; -const argv = process.argv.slice(2); -const portIdx = argv.indexOf('--port'); -const MCP_PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); -const AS_PORT = MCP_PORT + 1; -const MCP_URL = `http://127.0.0.1:${MCP_PORT}/mcp`; +const { port } = parseExampleArgs(); +const AS_PORT = port + 1; +const MCP_URL = `http://127.0.0.1:${port}/mcp`; const AS_ISSUER = `http://127.0.0.1:${AS_PORT}`; // --------------------------------------------------------------------------- @@ -136,7 +138,7 @@ const asServer = createServer((req, res) => { } json(404, { error: 'not_found' }); }); -asServer.listen(AS_PORT, '127.0.0.1', () => console.error(`[scoped-tools] demo AS listening on ${AS_ISSUER}`)); +asServer.listen(AS_PORT, '127.0.0.1', () => console.error(`[server] demo AS listening on ${AS_ISSUER}`)); // --------------------------------------------------------------------------- // Resource Server (MCP) — bearer-verify at the gate, per-tool scope in handlers @@ -163,7 +165,7 @@ function requireScope( return { isError: true, content: [{ type: 'text', text: `insufficient_scope: requires ${scope}` }] }; } -const handler = createMcpHandler(() => { +function buildServer(): McpServer { const server = new McpServer({ name: 'scoped-tools', version: '1.0.0' }); server.registerTool('list-files', { description: 'Requires files:read.', inputSchema: z.object({}) }, (_args, ctx) => { const auth = ctx.http?.authInfo; @@ -182,20 +184,22 @@ const handler = createMcpHandler(() => { ); }); return server; -}); +} + +const handler = createMcpHandler(buildServer); +const node = toNodeHandler(handler); const app = createMcpExpressApp(); // RFC 9728 PRM: the client discovers the AS from the 401 challenge → this route → AS metadata. app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => { res.json({ resource: MCP_URL, authorization_servers: [AS_ISSUER], scopes_supported: ['files:read', 'files:write'] }); }); -const node = toNodeHandler(handler); app.all('/mcp', (req, res) => { const authInfo = verifyBearer(req.headers.authorization ?? null); if (!authInfo) { res.set( 'www-authenticate', - `Bearer resource_metadata="http://127.0.0.1:${MCP_PORT}/.well-known/oauth-protected-resource/mcp", scope="files:read"` + `Bearer resource_metadata="http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp", scope="files:read"` ); res.status(401).json({ error: 'invalid_token' }); return; @@ -206,4 +210,4 @@ app.all('/mcp', (req, res) => { void node(req, res, req.body); }); -app.listen(MCP_PORT, '127.0.0.1', () => console.error(`[scoped-tools] MCP RS listening on ${MCP_URL}`)); +app.listen(port, '127.0.0.1', () => console.error(`[server] MCP RS listening on ${MCP_URL}`)); diff --git a/examples/shared/eslint.config.mjs b/examples/shared/eslint.config.mjs index 83b79879f6..03c6acb755 100644 --- a/examples/shared/eslint.config.mjs +++ b/examples/shared/eslint.config.mjs @@ -8,7 +8,20 @@ export default [ files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], rules: { // Allow console statements in examples only - 'no-console': 'off' + 'no-console': 'off', + // One-way dependency: @mcp-examples/shared is scaffolding consumed BY + // stories; it must never import FROM a story package. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@mcp-examples/*', '!@mcp-examples/shared', '../../*/**'], + message: '@mcp-examples/shared must not import from story packages (one-way dependency).' + } + ] + } + ] } } ]; diff --git a/examples/shared/package.json b/examples/shared/package.json index 4b99310e9d..7ecfc11157 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -9,7 +9,8 @@ "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./auth": "./src/indexAuth.ts" }, "repository": { "type": "git", diff --git a/examples/shared/src/args.ts b/examples/shared/src/args.ts new file mode 100644 index 0000000000..65045fc400 --- /dev/null +++ b/examples/shared/src/args.ts @@ -0,0 +1,61 @@ +/** + * Argv-parsing scaffold shared by every `examples//` pair. + * + * Intentionally **zero SDK API calls** in this module — it is pure + * `process.argv` plumbing plus an assert wrapper. Each story's + * `server.ts`/`client.ts` shows the real `@modelcontextprotocol/*` calls + * inline (the canonical shape; see `examples/CONTRIBUTING.md`). This module + * only DRYs the parts a reader is not here to learn: flag parsing and + * sibling-path resolution. + * + * Re-exported `check` is `node:assert/strict` for readable inline assertions. + */ + +import { fileURLToPath } from 'node:url'; + +export { strict as check } from 'node:assert'; + +/** + * Resolve a sibling of the calling module to an absolute filesystem path + * (`fileURLToPath` handles Windows drive letters and percent-encoded segments, + * which `new URL(...).pathname` does not). Used by every story's stdio leg to + * spawn its companion `server.ts` — the path-resolution part of that line is + * scaffolding, so it lives here rather than being repeated in each `client.ts`. + */ +export function siblingPath(importMetaUrl: string | URL, name: string): string { + return fileURLToPath(new URL(name, importMetaUrl)); +} + +export type ExampleTransport = 'stdio' | 'http'; +export type ExampleEra = 'modern' | 'legacy'; + +export interface ExampleArgs { + /** `'http'` under `--http`, otherwise `'stdio'`. */ + transport: ExampleTransport; + /** `--port ` (or `$PORT`, or 3000) — meaningful on the server side. */ + port: number; + /** `--http ` (or `http://127.0.0.1:/mcp`) — meaningful on the client side. */ + url: string; + /** `'legacy'` under `--legacy`, otherwise `'modern'` (negotiates 2026-07-28). */ + era: ExampleEra; +} + +/** + * Parse `process.argv` into the four knobs every example branches on. + * + * The example runner (`scripts/examples/run-examples.ts`) drives the same + * binary over each transport/era combination by passing `--http`, `--port`, + * `--http ` and `--legacy`; manual runs use the same flags. + */ +export function parseExampleArgs(defaultPort = 3000): ExampleArgs { + const argv = process.argv.slice(2); + const transport: ExampleTransport = argv.includes('--http') ? 'http' : 'stdio'; + const era: ExampleEra = argv.includes('--legacy') ? 'legacy' : 'modern'; + const portIdx = argv.indexOf('--port'); + const port = portIdx === -1 ? Number(process.env.PORT ?? defaultPort) : Number(argv[portIdx + 1]); + const httpIdx = argv.indexOf('--http'); + // A bare `argv[indexOf('--http') + 1]` reads `argv[0]` (the script path) + // when the flag is absent, so guard with `httpIdx === -1` first. + const url = httpIdx === -1 ? `http://127.0.0.1:${port}/mcp` : (argv[httpIdx + 1] ?? `http://127.0.0.1:${port}/mcp`); + return { transport, port, url, era }; +} diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 62b0f7ecd7..3753ae4e4f 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -1,14 +1,7 @@ -// Auth configuration -export type { CreateDemoAuthOptions, DemoAuth } from './auth.js'; -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'; - -// 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'; +// Argv-parse + assert scaffold (NO SDK calls — those go inline in each story). +// This barrel is intentionally args-only so that the ~25 non-auth stories do +// not eagerly evaluate better-auth/express/cors/better-sqlite3 just by +// importing `parseExampleArgs`. The OAuth scaffolding lives at the `./auth` +// subpath — see `./indexAuth.ts`. +export type { ExampleArgs, ExampleEra, ExampleTransport } from './args.js'; +export { check, parseExampleArgs, siblingPath } from './args.js'; diff --git a/examples/shared/src/indexAuth.ts b/examples/shared/src/indexAuth.ts new file mode 100644 index 0000000000..0e9ec62c51 --- /dev/null +++ b/examples/shared/src/indexAuth.ts @@ -0,0 +1,19 @@ +// Auth + resumability scaffolding for the handful of stories that need it +// (`oauth`, `oauth-client-credentials`, `sse-polling`, `repl`). Kept off the +// root barrel so the other ~25 stories do not eagerly evaluate +// better-auth/express/cors/better-sqlite3 via `parseExampleArgs`. + +// Auth configuration +export type { CreateDemoAuthOptions, DemoAuth } from './auth.js'; +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'; + +// 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/client.ts b/examples/sse-polling/client.ts index 934503505f..7de08b147d 100644 --- a/examples/sse-polling/client.ts +++ b/examples/sse-polling/client.ts @@ -7,45 +7,42 @@ * `eventStore` buffered while disconnected. Also asserts every progress log * (including the one emitted while disconnected) was delivered. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, runClient } from '../harness.js'; +const { url } = parseExampleArgs(); -const URL = httpUrlFromArgs('http://127.0.0.1:3001/mcp'); - -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 = () => {}; +// `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)); +}); - // 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)); - }); - await client.connect(transport); +const transport = new StreamableHTTPClientTransport(new 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 = () => {}; +await client.connect(transport); - let lastEventId: string | undefined; - const result = await client.request( - { method: 'tools/call', params: { name: 'long-operation', arguments: {} } }, - { onresumptiontoken: token => (lastEventId = token) } - ); +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(' | ')})` - ); +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(); -}); +await client.close(); diff --git a/examples/sse-polling/server.ts b/examples/sse-polling/server.ts index 70af622c95..dbc011a84b 100644 --- a/examples/sse-polling/server.ts +++ b/examples/sse-polling/server.ts @@ -9,11 +9,14 @@ * - Uses `eventStore` to persist events for replay after reconnection * - Uses `ctx.http?.closeSSE()` callback to gracefully disconnect clients mid-operation * - * HTTP-only, sessionful 2025 by definition. + * HTTP-only, sessionful 2025 by definition — `closeSSE`/`eventStore`/`retryInterval` + * live on `NodeStreamableHTTPServerTransport`, so this story wires that transport + * directly instead of the canonical `createMcpHandler` entry. */ import { randomUUID } from 'node:crypto'; -import { InMemoryEventStore } from '@mcp-examples/shared'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { InMemoryEventStore } from '@mcp-examples/shared/auth'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; @@ -21,8 +24,7 @@ import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; -// Create a fresh MCP server per client connection to avoid shared state between clients -const getServer = () => { +function buildServer(): McpServer { const server = new McpServer( { name: 'sse-polling-example', @@ -82,7 +84,7 @@ const getServer = () => { ); return server; -}; +} // Set up Express app const app = createMcpExpressApp(); @@ -111,7 +113,7 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); transport.onclose = () => transport.sessionId && transports.delete(transport.sessionId); - await getServer().connect(transport); + await buildServer().connect(transport); await transport.handleRequest(req, res, req.body); } else if (sid) { // Unknown/expired session ID → 404 so the client knows to re-initialize. @@ -121,12 +123,9 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); -// Start the server -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.error(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); +const { port } = parseExampleArgs(); +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${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)'); diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts index 433aaeac0b..b62bf51d58 100644 --- a/examples/standalone-get/client.ts +++ b/examples/standalone-get/client.ts @@ -4,40 +4,37 @@ * `notifications/resources/list_changed` over that stream, and asserts it * arrived. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, runClient } from '../harness.js'; +const { url } = parseExampleArgs(); -const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp'); - -runClient('standalone-get', async () => { - let received = 0; - const client = new Client( - { name: 'standalone-get-client', version: '1.0.0' }, - { - // 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))); +let received = 0; +const client = new Client( + { name: 'standalone-get-client', version: '1.0.0' }, + { + // 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 URL(url))); - const before = await client.listResources(); - check.ok(before.resources.length > 0); +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); +// 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); +const after = await client.listResources(); +check.ok(after.resources.length > before.resources.length); - await client.close(); -}); +await client.close(); diff --git a/examples/standalone-get/package.json b/examples/standalone-get/package.json index 5ae0d1af2f..5a55605d77 100644 --- a/examples/standalone-get/package.json +++ b/examples/standalone-get/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/standalone-get/server.ts b/examples/standalone-get/server.ts index 061342b555..5852cb40e8 100644 --- a/examples/standalone-get/server.ts +++ b/examples/standalone-get/server.ts @@ -7,19 +7,22 @@ * 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). + * decides when to mutate (no timer race with the runner). * - * **HTTP-only**, sessionful 2025 by definition. + * **HTTP-only**, sessionful 2025 by definition — so the canonical + * `serveStdio` / `createMcpHandler` shape does not apply (per-request stateless + * has no GET stream). */ import { randomUUID } from 'node:crypto'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; -const buildServer = () => { +function buildServer(): McpServer { const server = new McpServer( { name: 'standalone-get-example', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } } @@ -50,7 +53,7 @@ const buildServer = () => { } ); return server; -}; +} const sessions = new Map(); const app = createMcpExpressApp(); @@ -90,7 +93,7 @@ const sessionVerb = async (req: Request, res: Response) => { app.get('/mcp', sessionVerb); app.delete('/mcp', sessionVerb); -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`)); +const { port } = parseExampleArgs(); +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/stateless-legacy/client.ts b/examples/stateless-legacy/client.ts index 0057e27a22..2daca7df32 100644 --- a/examples/stateless-legacy/client.ts +++ b/examples/stateless-legacy/client.ts @@ -1,23 +1,25 @@ /** * 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. + * client (`versionNegotiation: { mode: 'legacy' }` — 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. + * + * HTTP-only — `createMcpHandler`'s `legacy: 'stateless'` posture is an HTTP + * hosting concern; a stdio leg would bypass it. The story body drives BOTH + * eras itself, so only `url` is read from argv. */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { check, httpUrlFromArgs, runClient } from '../harness.js'; +const { url } = parseExampleArgs(); -const URL = httpUrlFromArgs('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(); - } -}); +for (const mode of ['legacy', 'auto'] as const) { + const client = new Client({ name: 'stateless-legacy-client', version: '1.0.0' }, { versionNegotiation: { mode } }); + await client.connect(new StreamableHTTPClientTransport(new 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/package.json b/examples/stateless-legacy/package.json index 2f5a75c72e..8052f36367 100644 --- a/examples/stateless-legacy/package.json +++ b/examples/stateless-legacy/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", @@ -20,6 +21,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." + "//": "The story body manages its own era internally; pinned so the runner runs it once per transport." } } diff --git a/examples/stateless-legacy/server.ts b/examples/stateless-legacy/server.ts index 2183fff03e..a51ee5ab48 100644 --- a/examples/stateless-legacy/server.ts +++ b/examples/stateless-legacy/server.ts @@ -6,14 +6,19 @@ * (`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. + * + * HTTP-only — `createMcpHandler`'s `legacy: 'stateless'` posture is an HTTP + * hosting concern; a stdio leg would bypass it. See `dual-era/` for the stdio + * analogue. */ import { createServer } from 'node:http'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; -const handler = createMcpHandler(() => { +function buildServer(): McpServer { const server = new McpServer({ name: 'stateless-legacy-example', version: '1.0.0' }, { capabilities: { logging: {} } }); server.registerTool( 'greet', @@ -21,11 +26,11 @@ const handler = createMcpHandler(() => { async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) ); return server; -}); +} + +const { port } = parseExampleArgs(); -const argv = process.argv.slice(2); -const portIdx = argv.indexOf('--port'); -const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]); +const handler = createMcpHandler(buildServer); createServer(toNodeHandler(handler)).listen(port, () => { - console.error(`stateless-legacy example server listening on http://127.0.0.1:${port}/`); + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/stickynotes/README.md b/examples/stickynotes/README.md index 886e5e113f..13699fdd34 100644 --- a/examples/stickynotes/README.md +++ b/examples/stickynotes/README.md @@ -3,5 +3,6 @@ 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. -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`. +Runs all four transport/era legs. 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 cancel / unchecked / +confirm flow is exercised on **stdio/legacy only** — `server.ts` hosts HTTP via a plain stateless `createMcpHandler`, whose per-request legacy fallback has no return path for the client's elicitation response — so the modern and http legs exercise add / list / read / remove and +skip `remove_all`. diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts index ab9cd76841..e6a8fac9dc 100644 --- a/examples/stickynotes/client.ts +++ b/examples/stickynotes/client.ts @@ -4,7 +4,9 @@ * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board is * cleared only on an explicit confirmation. */ -import { check, connectFromArgs, eraLeg, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; interface AddResult { id: string; @@ -15,52 +17,63 @@ interface RemoveAllResult { removed: number; } -runClient('stickynotes', 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: {} } } }); - let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; - client.setRequestHandler('elicitation/create', async () => { - if (elicitAnswer === 'cancel') return { action: 'cancel' }; - return { action: 'accept', content: { confirm: elicitAnswer === 'confirm' } }; - }); +const { transport, url, era } = parseExampleArgs(); - // 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); +const client = new Client( + { name: 'stickynotes-example-client', version: '1.0.0' }, + { + versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, + capabilities: { elicitation: { form: {} } } + } +); - // 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'); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); - // 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)); +let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; +client.setRequestHandler('elicitation/create', async () => { + if (elicitAnswer === 'cancel') return { action: 'cancel' }; + return { action: 'accept', content: { confirm: elicitAnswer === 'confirm' } }; +}); - // 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(); - check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); - await client.close(); - return; - } +// 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)); +// The elicitation-confirmed `remove_all` path is 2025-era stdio only: +// push-style server→client requests need a long-lived bidirectional +// connection that saw the `initialize` handshake (so the client's +// elicitation capability is advertised and the response can route back to +// the same server instance). On a 2026-07-28 connection there is no +// server→client request channel, and over `createMcpHandler`'s default +// stateless legacy fallback each HTTP request is a fresh per-request +// server — the equivalent is multi-round-trip `inputRequired` (see +// ../elicitation/). +if (era === 'modern' || transport === '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); +} else { // CANCEL — board untouched. elicitAnswer = 'cancel'; const cancelled = await client.callTool({ name: 'remove_all' }); @@ -84,6 +97,6 @@ runClient('stickynotes', async () => { // 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(); -}); +await client.close(); diff --git a/examples/stickynotes/package.json b/examples/stickynotes/package.json index 8e10cc8529..2e5ce67e09 100644 --- a/examples/stickynotes/package.json +++ b/examples/stickynotes/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, @@ -15,6 +18,6 @@ }, "example": { "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." + "//": "All four transport/era legs. The elicitation-confirmed remove_all path is exercised on stdio/legacy only (push-style server→client requests need a bidirectional connection that saw initialize; createMcpHandler's stateless legacy fallback has none); the modern and http legs exercise add/list/read/remove and skip remove_all." } } diff --git a/examples/stickynotes/server.ts b/examples/stickynotes/server.ts index c46ee0d924..c3baf45b48 100644 --- a/examples/stickynotes/server.ts +++ b/examples/stickynotes/server.ts @@ -17,12 +17,15 @@ * * One binary, either transport. */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import type { RegisteredResource } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -import { runServerFromArgs } from '../harness.js'; - const notes = new Map(); let nextId = 1; const uriFor = (id: string) => `note:///${id}`; @@ -111,5 +114,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/streaming/client.ts b/examples/streaming/client.ts index 450445cdd5..d1cc249aca 100644 --- a/examples/streaming/client.ts +++ b/examples/streaming/client.ts @@ -4,43 +4,51 @@ * (asserts log messages arrived), and a cancelled call (asserts the cancel * propagated). */ -import { check, connectFromArgs, runClient } from '../harness.js'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -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); +const { transport, url, era } = parseExampleArgs(); - let logCount = 0; - client.setNotificationHandler('notifications/message', () => { - logCount++; - }); +const client = new Client( + { name: 'streaming-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); - // --- 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}`); +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +let logCount = 0; +client.setNotificationHandler('notifications/message', () => { + 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; +// --- 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.ok(cancelled, 'a client-side abort should reject the in-flight callTool'); +); +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}`); - await client.close(); -}); +// --- 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/package.json b/examples/streaming/package.json index df70e5b96b..381e19a7e6 100644 --- a/examples/streaming/package.json +++ b/examples/streaming/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/streaming/server.ts b/examples/streaming/server.ts index 4c84bcf8d0..0e45e320a7 100644 --- a/examples/streaming/server.ts +++ b/examples/streaming/server.ts @@ -6,10 +6,13 @@ * (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 { createServer } from 'node:http'; -import { runServerFromArgs } from '../harness.js'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; function buildServer(): McpServer { const server = new McpServer({ name: 'streaming-example', version: '1.0.0' }, { capabilities: { logging: {} } }); @@ -55,5 +58,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/subscriptions/client.ts b/examples/subscriptions/client.ts index 44991257e0..9ac1a6fbbb 100644 --- a/examples/subscriptions/client.ts +++ b/examples/subscriptions/client.ts @@ -13,9 +13,10 @@ * 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'; +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import type { ClientOptions, McpSubscription } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; /** Wait until `pred()` is true or `timeoutMs` elapses. */ async function until(pred: () => boolean, timeoutMs = 5000): Promise { @@ -26,10 +27,27 @@ async function until(pred: () => boolean, timeoutMs = 5000): Promise { } } -async function autoOpenLeg(): Promise { +const { transport, url } = parseExampleArgs(); + +// Both legs connect identically and differ only in ClientOptions; the local +// helper keeps the SDK transport setup visible in THIS file (the canonical +// shape) while avoiding duplicating it for each leg. Modern-only — +// `subscriptions/listen` is a 2026-07-28 protocol feature. +const connect = async (options?: ClientOptions): Promise => { + const client = new Client( + { name: 'subscriptions-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, ...options } + ); + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + return client; +}; + +// --- auto-open via ClientOptions.listChanged --- +{ 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, { + const client = await connect({ listChanged: { tools: { autoRefresh: false, @@ -53,8 +71,9 @@ async function autoOpenLeg(): Promise { 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); +// --- manual client.listen() --- +{ + const client = await connect(); let count = 0; client.setNotificationHandler('notifications/tools/list_changed', () => void count++); const sub: McpSubscription = await client.listen({ toolsListChanged: true }); @@ -69,8 +88,3 @@ async function manualLeg(): Promise { 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/package.json b/examples/subscriptions/package.json index 0e0517902d..880618ea28 100644 --- a/examples/subscriptions/package.json +++ b/examples/subscriptions/package.json @@ -7,6 +7,7 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", diff --git a/examples/subscriptions/server.ts b/examples/subscriptions/server.ts index e2bc5cb23a..4681de1693 100644 --- a/examples/subscriptions/server.ts +++ b/examples/subscriptions/server.ts @@ -13,12 +13,12 @@ * 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). + * so the client decides when to mutate (no timer race with the runner). The + * canonical-shape transport branch below assigns `publish` per entry. */ import { createServer } from 'node:http'; +import { parseExampleArgs } from '@mcp-examples/shared'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; @@ -67,10 +67,14 @@ function buildServer(): McpServer { 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]); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + // Over stdio the per-instance `farewell.update` inside `flip_tools` IS the + // publish, so `publish` stays a no-op here. + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { // 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. @@ -80,11 +84,6 @@ if (argv.includes('--http')) { void bus; // (the typed publish facade `notify` wraps `bus.publish`) publish = () => notify.toolsChanged(); createServer(toNodeHandler(handler)).listen(port, () => { - console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`); + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); -} 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'); } diff --git a/examples/tools/client.ts b/examples/tools/client.ts index 8c401c0169..f546d19e14 100644 --- a/examples/tools/client.ts +++ b/examples/tools/client.ts @@ -2,40 +2,48 @@ * 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 () => { - // 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(); - 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'); - check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire'); - - 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(); -}); +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'tools-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +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'); +check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire'); + +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/package.json b/examples/tools/package.json index 7c0791890d..d54e05fc22 100644 --- a/examples/tools/package.json +++ b/examples/tools/package.json @@ -7,6 +7,9 @@ "client": "tsx client.ts" }, "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/tools/server.ts b/examples/tools/server.ts index 762570231e..3456aa1027 100644 --- a/examples/tools/server.ts +++ b/examples/tools/server.ts @@ -6,12 +6,15 @@ * `structuredContent` from `outputSchema`, `annotations` for behavioral hints * (`readOnlyHint`, `destructiveHint`). One binary, either transport. */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; 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' }); @@ -49,5 +52,14 @@ 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); +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/package.json b/package.json index d247408de1..261ad9b92d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", "examples:oauth-server:w": "pnpm --filter @mcp-examples/oauth exec tsx --watch server.ts", - "run:examples": "tsx scripts/run-examples.ts", + "run:examples": "tsx scripts/examples/run-examples.ts", "docs": "typedoc", "docs:multi": "bash scripts/generate-multidoc.sh", "docs:check": "typedoc", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 288c295032..ab9c55a696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: examples/bearer-auth: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -384,6 +387,15 @@ importers: examples/caching: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -410,6 +422,15 @@ importers: examples/custom-methods: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -423,6 +444,15 @@ importers: examples/custom-version: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -433,6 +463,15 @@ importers: examples/dual-era: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -446,9 +485,15 @@ importers: examples/elicitation: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -462,9 +507,15 @@ importers: examples/gateway: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -481,6 +532,9 @@ 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 @@ -500,6 +554,9 @@ importers: examples/json-response: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -519,6 +576,9 @@ importers: examples/legacy-routing: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -550,9 +610,15 @@ importers: examples/mrtr: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -625,9 +691,15 @@ importers: examples/parallel-calls: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -641,6 +713,15 @@ importers: examples/prompts: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -688,6 +769,15 @@ importers: examples/resources: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -698,6 +788,15 @@ importers: examples/sampling: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -711,6 +810,15 @@ importers: examples/schema-validators: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -736,6 +844,9 @@ importers: '@mcp-examples/oauth': specifier: workspace:* version: link:../oauth + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -878,6 +989,9 @@ importers: examples/standalone-get: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -903,6 +1017,9 @@ importers: examples/stateless-legacy: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -922,6 +1039,15 @@ importers: examples/stickynotes: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -935,6 +1061,15 @@ importers: examples/streaming: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -948,6 +1083,9 @@ importers: examples/subscriptions: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client @@ -967,6 +1105,15 @@ importers: examples/tools: dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server diff --git a/scripts/run-examples.ts b/scripts/examples/run-examples.ts similarity index 82% rename from scripts/run-examples.ts rename to scripts/examples/run-examples.ts index 71833f02c8..db3f8fd546 100644 --- a/scripts/run-examples.ts +++ b/scripts/examples/run-examples.ts @@ -1,8 +1,11 @@ #!/usr/bin/env tsx /** * 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). + * leg it supports. Each story's `client.ts` is a self-verifying top-level-await + * script: a `check.*` failure throws, Node prints the error and exits 1; on + * success `client.close()` releases the last handle and Node exits 0. The + * harness reports PASS/FAIL from the child's exit code (a timeout is a FAIL + * with "hung — possible unclosed handle"). * * - **stdio** (default for dual-transport stories): run `client.ts` with no * transport flag; it spawns the sibling server binary itself and speaks @@ -45,7 +48,7 @@ interface ExampleConfig { excluded?: string; } -const ROOT = resolve(import.meta.dirname, '..'); +const ROOT = resolve(import.meta.dirname, '../..'); const EXAMPLES = join(ROOT, 'examples'); const TSX = join(ROOT, 'node_modules', '.bin', 'tsx'); @@ -72,7 +75,7 @@ function run( cmd: string, args: string[], opts: { cwd: string; env?: Record; timeoutMs: number } -): Promise<{ code: number; stdout: string; stderr: string }> { +): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> { return new Promise(resolvePromise => { const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } }); let stdout = ''; @@ -81,19 +84,48 @@ function run( child.stderr.on('data', d => (stderr += String(d))); const timer = setTimeout(() => { child.kill('SIGKILL'); - resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out' }); + resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out', timedOut: true }); }, opts.timeoutMs); child.on('close', code => { clearTimeout(timer); - resolvePromise({ code: code ?? 1, stdout, stderr }); + resolvePromise({ code: code ?? 1, stdout, stderr, timedOut: false }); }); child.on('error', err => { clearTimeout(timer); - resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}` }); + resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}`, timedOut: false }); }); }); } +/** + * Story `client.ts` files are top-level-await scripts: a thrown `check.*` + * propagates as an unhandled rejection (Node prints + exits 1); a clean run + * exits 0 once `client.close()` releases the last handle. The harness prints + * PASS/FAIL itself from the child's exit code — there is no in-band OK/FAIL + * line. A timeout means the process never exited on its own and is reported as + * a hang (possible unclosed handle). + */ +function toLegResult( + story: string, + leg: string, + result: { code: number; stdout: string; stderr: string; timedOut: boolean }, + config: ExampleConfig, + serverLog?: string +): LegResult { + if (result.timedOut) { + return { story, leg, ok: false, detail: `(hung — possible unclosed handle)\n${result.stderr || result.stdout}${serverLog ?? ''}` }; + } + const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); + return { + story, + leg, + ok, + detail: ok + ? (result.stdout.trim().split('\n').pop() ?? '') + : `exit ${result.code}\n${result.stderr || result.stdout}${serverLog ?? ''}` + }; +} + async function waitForPort(port: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -122,13 +154,7 @@ const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []); 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 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); - return { - story, - leg: `stdio/${era}`, - ok, - detail: ok ? (result.stdout.trim().split('\n').pop() ?? '') : `exit ${result.code}\n${result.stderr || result.stdout}` - }; + return toLegResult(story, `stdio/${era}`, result, config); } async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { @@ -149,15 +175,7 @@ async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era 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 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); - return { - story, - leg: `http/${era}`, - ok, - detail: ok - ? (result.stdout.trim().split('\n').pop() ?? '') - : `exit ${result.code}\n${result.stderr || result.stdout}\n--- server log ---\n${serverStderr}` - }; + return toLegResult(story, `http/${era}`, result, config, `\n--- server log ---\n${serverStderr}`); } finally { server.kill('SIGTERM'); await new Promise(r => setTimeout(r, 100));