docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
9 changed files with 11884 additions and 4971 deletions
84
examples/guides/serving/authorization.examples.ts
Normal file
84
examples/guides/serving/authorization.examples.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// docs: typecheck-only
|
||||
/**
|
||||
* Companion example for `docs/serving/authorization.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The Express middleware needs a listening
|
||||
* HTTP server and a real authorization server to exercise, so this file only
|
||||
* typechecks:
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
//#region requireBearerAuth_basic
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import {
|
||||
createMcpExpressApp,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter,
|
||||
requireBearerAuth
|
||||
} from '@modelcontextprotocol/express';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const mcpServerUrl = new URL('https://api.example.com/mcp');
|
||||
const verifier: OAuthTokenVerifier = { verifyAccessToken };
|
||||
|
||||
const auth = requireBearerAuth({
|
||||
verifier,
|
||||
requiredScopes: ['mcp'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
|
||||
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
|
||||
const node = toNodeHandler(createMcpHandler(buildServer));
|
||||
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
|
||||
//#endregion requireBearerAuth_basic
|
||||
|
||||
//#region tokenVerifier_basic
|
||||
async function verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const payload = await verifyJwt(token);
|
||||
return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp };
|
||||
}
|
||||
//#endregion tokenVerifier_basic
|
||||
|
||||
// Stand-in for your JWT library or RFC 7662 introspection call.
|
||||
declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>;
|
||||
|
||||
// Your authorization server's RFC 8414 metadata document — fetch it from the AS
|
||||
// at startup or embed it.
|
||||
const oauthMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
//#region metadataRouter_basic
|
||||
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
|
||||
//#endregion metadataRouter_basic
|
||||
|
||||
// The per-request factory from the Express recipe; the lead block mounts it behind `auth`.
|
||||
function buildServer(): McpServer {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
|
||||
//#region authInfo_handler
|
||||
server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => {
|
||||
const caller = ctx.http?.authInfo;
|
||||
return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] };
|
||||
});
|
||||
//#endregion authInfo_handler
|
||||
|
||||
//#region perToolScopes_handler
|
||||
server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => {
|
||||
if (!ctx.http?.authInfo?.scopes.includes('notes:write')) {
|
||||
return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'All notes deleted' }] };
|
||||
});
|
||||
//#endregion perToolScopes_handler
|
||||
|
||||
return server;
|
||||
}
|
||||
92
examples/guides/serving/express.examples.ts
Normal file
92
examples/guides/serving/express.examples.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/serving/express.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The
|
||||
* `createMcpExpressApp_listen` region lives in a wrapper function that is never
|
||||
* invoked, so running this file never binds a port; the harness below the
|
||||
* regions produces the response the page's "Run it and verify" section quotes
|
||||
* verbatim and exits non-zero if it drifts.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/serving/express.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
|
||||
//#region createMcpExpressApp_mount
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
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: 'notes', version: '1.0.0' });
|
||||
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
|
||||
content: [{ type: 'text', text: `Saved: ${text}` }]
|
||||
}));
|
||||
return server;
|
||||
});
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
const node = toNodeHandler(handler);
|
||||
app.all('/mcp', (req, res) => void node(req, res, req.body));
|
||||
//#endregion createMcpExpressApp_mount
|
||||
|
||||
//#region createMcpExpressApp_allowedHosts
|
||||
const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
|
||||
//#endregion createMcpExpressApp_allowedHosts
|
||||
|
||||
// `verifier` stands in for your deployment's token verification (JWT
|
||||
// validation, RFC 7662 introspection, a call to your IdP). The page points at
|
||||
// docs/serving/authorization.md for the real thing.
|
||||
const verifier: OAuthTokenVerifier = {
|
||||
async verifyAccessToken(token) {
|
||||
return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
};
|
||||
|
||||
//#region requireBearerAuth_mount
|
||||
import { requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
|
||||
const auth = requireBearerAuth({ verifier });
|
||||
publicApp.all('/mcp', auth, (req, res) => void node(req, res, req.body));
|
||||
//#endregion requireBearerAuth_mount
|
||||
|
||||
// "Run it and verify" — the listen line. Never invoked: docs companions must
|
||||
// terminate on their own and never bind a port.
|
||||
function createMcpExpressApp_listen(): void {
|
||||
//#region createMcpExpressApp_listen
|
||||
app.listen(3000);
|
||||
//#endregion createMcpExpressApp_listen
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). The page quotes the response to its curl
|
||||
// command verbatim; this produces it. The Express route is
|
||||
// `(req, res) => void node(req, res, req.body)` with `node = toNodeHandler(handler)`,
|
||||
// and `toNodeHandler` copies `handler.fetch(request)`'s status, headers, and
|
||||
// body onto `res` unchanged — so `handler.fetch` given the curl command's exact
|
||||
// request yields the bytes curl prints. (Driving the Express stack itself needs
|
||||
// a listening socket, which a docs companion never opens.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const response = await handler.fetch(
|
||||
new Request('http://127.0.0.1:3000/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
|
||||
})
|
||||
);
|
||||
const text = await response.text();
|
||||
console.log(text);
|
||||
|
||||
const quotedOnPage =
|
||||
'event: message\n' +
|
||||
'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' +
|
||||
'"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}';
|
||||
if (response.status !== 200 || text.trimEnd() !== quotedOnPage) {
|
||||
throw new Error(`express.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(text)}`);
|
||||
}
|
||||
89
examples/guides/serving/fastify.examples.ts
Normal file
89
examples/guides/serving/fastify.examples.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/serving/fastify.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The
|
||||
* `createMcpFastifyApp_listen` region lives in a wrapper function that is never
|
||||
* invoked, so running this file never binds a port; the harness below the
|
||||
* regions drives the real Fastify app in process with `app.inject()`, produces
|
||||
* the response the page's "Run it and verify" section quotes verbatim, and
|
||||
* exits non-zero if it drifts.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/serving/fastify.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
|
||||
//#region createMcpFastifyApp_mount
|
||||
import { createMcpFastifyApp } from '@modelcontextprotocol/fastify';
|
||||
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: 'notes', version: '1.0.0' });
|
||||
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
|
||||
content: [{ type: 'text', text: `Saved: ${text}` }]
|
||||
}));
|
||||
return server;
|
||||
});
|
||||
|
||||
const app = createMcpFastifyApp();
|
||||
const node = toNodeHandler(handler);
|
||||
app.all('/mcp', (request, reply) => node(request.raw, reply.raw, request.body));
|
||||
//#endregion createMcpFastifyApp_mount
|
||||
|
||||
//#region createMcpFastifyApp_allowedHosts
|
||||
const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
|
||||
//#endregion createMcpFastifyApp_allowedHosts
|
||||
|
||||
// `verifyToken` stands in for your deployment's token verification (JWT
|
||||
// validation, RFC 7662 introspection, a call to your IdP). The page points at
|
||||
// docs/serving/authorization.md for the real thing.
|
||||
async function verifyToken(authorization: string | undefined): Promise<AuthInfo> {
|
||||
const token = authorization?.replace(/^Bearer /, '') ?? '';
|
||||
return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
|
||||
//#region toNodeHandler_authInfo
|
||||
publicApp.all('/mcp', async (request, reply) => {
|
||||
const auth = await verifyToken(request.headers.authorization);
|
||||
return node(Object.assign(request.raw, { auth }), reply.raw, request.body);
|
||||
});
|
||||
//#endregion toNodeHandler_authInfo
|
||||
|
||||
// "Run it and verify" — the listen line. Never invoked: docs companions must
|
||||
// terminate on their own and never bind a port.
|
||||
async function createMcpFastifyApp_listen(): Promise<void> {
|
||||
//#region createMcpFastifyApp_listen
|
||||
await app.listen({ port: 3000 });
|
||||
//#endregion createMcpFastifyApp_listen
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). `app.inject()` runs the real Fastify app —
|
||||
// the Host/Origin validation hooks, the JSON body parser, and the `/mcp` route
|
||||
// — entirely in process, no socket. The page quotes its payload verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const injected = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/mcp',
|
||||
headers: { host: '127.0.0.1:3000', 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
payload: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
|
||||
});
|
||||
console.log(injected.payload);
|
||||
|
||||
const quotedOnPage =
|
||||
'event: message\n' +
|
||||
'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' +
|
||||
'"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}';
|
||||
if (injected.statusCode !== 200 || injected.payload.trimEnd() !== quotedOnPage) {
|
||||
throw new Error(`fastify.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(injected.payload)}`);
|
||||
}
|
||||
|
||||
await app.close();
|
||||
await publicApp.close();
|
||||
77
examples/guides/serving/hono.examples.ts
Normal file
77
examples/guides/serving/hono.examples.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/serving/hono.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The harness
|
||||
* below the regions drives the real Hono app in process with `app.request()`,
|
||||
* produces the response the page's "Run it and verify" section quotes verbatim,
|
||||
* and exits non-zero if it drifts. No port is ever bound.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/serving/hono.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
|
||||
//#region createMcpHonoApp_mount
|
||||
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import type { Context } from 'hono';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
|
||||
content: [{ type: 'text', text: `Saved: ${text}` }]
|
||||
}));
|
||||
return server;
|
||||
});
|
||||
|
||||
const app = createMcpHonoApp();
|
||||
app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') }));
|
||||
|
||||
export default app;
|
||||
//#endregion createMcpHonoApp_mount
|
||||
|
||||
//#region createMcpHonoApp_allowedHosts
|
||||
const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
|
||||
//#endregion createMcpHonoApp_allowedHosts
|
||||
|
||||
// `verifyToken` stands in for your deployment's token verification (JWT
|
||||
// validation, RFC 7662 introspection, a call to your IdP). The page points at
|
||||
// docs/serving/authorization.md for the real thing.
|
||||
async function verifyToken(request: Request): Promise<AuthInfo> {
|
||||
const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? '';
|
||||
return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
|
||||
//#region McpHttpHandler_fetch_authInfo
|
||||
publicApp.all('/mcp', async (c: Context) => {
|
||||
const authInfo = await verifyToken(c.req.raw);
|
||||
return handler.fetch(c.req.raw, { authInfo, parsedBody: c.get('parsedBody') });
|
||||
});
|
||||
//#endregion McpHttpHandler_fetch_authInfo
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). `app.request()` runs the real Hono app —
|
||||
// the JSON body-parsing middleware, the Host/Origin validation, and the `/mcp`
|
||||
// route — entirely in process. The page quotes its body verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const response = await app.request('/mcp', {
|
||||
method: 'POST',
|
||||
headers: { host: '127.0.0.1:8787', 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
|
||||
});
|
||||
const text = await response.text();
|
||||
console.log(text);
|
||||
|
||||
const quotedOnPage =
|
||||
'event: message\n' +
|
||||
'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' +
|
||||
'"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}';
|
||||
if (response.status !== 200 || text.trimEnd() !== quotedOnPage) {
|
||||
throw new Error(`hono.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(text)}`);
|
||||
}
|
||||
139
examples/guides/serving/http.examples.ts
Normal file
139
examples/guides/serving/http.examples.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Companion example for `docs/serving/http.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions drives `handler.fetch` in process — no port, no socket — and
|
||||
* produces the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/serving/http.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { McpServerFactory } from '@modelcontextprotocol/server';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Create a handler"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region createHandler
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
server.registerTool(
|
||||
'add-note',
|
||||
{
|
||||
description: 'Save a note',
|
||||
inputSchema: z.object({ text: z.string() })
|
||||
},
|
||||
async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })
|
||||
);
|
||||
return server;
|
||||
});
|
||||
//#endregion createHandler
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Understand the per-request factory" — the factory reads the request context.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region factoryContext
|
||||
const perCaller = createMcpHandler(({ authInfo }) => {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
server.registerTool('whoami', { description: 'Name the authenticated caller' }, async () => ({
|
||||
content: [{ type: 'text', text: authInfo?.clientId ?? 'anonymous' }]
|
||||
}));
|
||||
return server;
|
||||
});
|
||||
//#endregion factoryContext
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Mount it on your runtime" — never invoked: binding a port is the reader's
|
||||
// deployment step, not this program's. The region typechecks against the real
|
||||
// module-scope `handler` above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: mounting the handler on plain `node:http`. */
|
||||
function mountNode(): void {
|
||||
//#region mountNode
|
||||
createServer(toNodeHandler(handler)).listen(3000);
|
||||
//#endregion mountNode
|
||||
}
|
||||
void mountNode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Validate Host and Origin in front of it" / "Pass authentication through" —
|
||||
// no regions: http.md states the contract and links to the serving recipes
|
||||
// (web-standard, express, hono, fastify) that build each mount. The harness
|
||||
// below produces the `alice` result the auth section quotes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Shape the response stream" — never invoked: `responseMode: 'json'` warns at
|
||||
// construction by design, and the page quotes nothing from it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: pin the response shape to plain JSON. */
|
||||
function shapeResponse(factory: McpServerFactory) {
|
||||
//#region shapeResponse
|
||||
const jsonOnly = createMcpHandler(factory, { responseMode: 'json' });
|
||||
//#endregion shapeResponse
|
||||
return jsonOnly;
|
||||
}
|
||||
void shapeResponse;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Shut down" — never invoked: this program exits on its own.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: tear down in-flight modern exchanges on SIGINT. */
|
||||
function shutDown(): void {
|
||||
//#region shutDown
|
||||
process.on('SIGINT', async () => {
|
||||
await handler.close();
|
||||
process.exit(0);
|
||||
});
|
||||
//#endregion shutDown
|
||||
}
|
||||
void shutDown;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). A real `Client` drives each handler's
|
||||
// `fetch` in process — the URL is never dialed (docs/testing.md owns that
|
||||
// wiring). It produces the two tool results the page quotes verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client');
|
||||
|
||||
// "Create a handler" — the add-note result the page quotes.
|
||||
const client = new Client({ name: 'http-docs-harness', version: '1.0.0' });
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), {
|
||||
fetch: (url, init) => handler.fetch(new Request(url, init))
|
||||
})
|
||||
);
|
||||
const saved = await client.callTool({ name: 'add-note', arguments: { text: 'ship the release notes' } });
|
||||
console.log(saved.content);
|
||||
await client.close();
|
||||
|
||||
// "Pass authentication through" — the caller hands `fetch` a verified
|
||||
// `AuthInfo`; the factory above reads it back as `ctx.authInfo`.
|
||||
const authInfo = { token: 'verified-elsewhere', clientId: 'alice', scopes: ['notes'] };
|
||||
const alice = new Client({ name: 'http-docs-harness', version: '1.0.0' });
|
||||
await alice.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), {
|
||||
fetch: (url, init) => perCaller.fetch(new Request(url, init), { authInfo })
|
||||
})
|
||||
);
|
||||
const who = await alice.callTool({ name: 'whoami' });
|
||||
console.log(who.content);
|
||||
await alice.close();
|
||||
|
||||
await handler.close();
|
||||
await perCaller.close();
|
||||
107
examples/guides/serving/legacy-clients.examples.ts
Normal file
107
examples/guides/serving/legacy-clients.examples.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Companion example for `docs/serving/legacy-clients.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions drives `handler.fetch` in process — no port, no socket — and
|
||||
* produces the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/serving/legacy-clients.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Choose a legacy posture"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region createMcpHandler_legacyReject
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
|
||||
const strict = createMcpHandler(buildServer, { legacy: 'reject' });
|
||||
//#endregion createMcpHandler_legacyReject
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Choose the same posture on stdio" — never invoked: `serveStdio` over real
|
||||
// stdio would hold this program open on stdin. The posture is the point.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: the same posture on the stdio entry. */
|
||||
function rejectOnStdio(): void {
|
||||
//#region serveStdio_legacyReject
|
||||
serveStdio(buildServer, { legacy: 'reject' });
|
||||
//#endregion serveStdio_legacyReject
|
||||
}
|
||||
void rejectOnStdio;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Keep a sessionful 2025 deployment running"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region isLegacyRequest_route
|
||||
import { isLegacyRequest, legacyStatelessFallback } from '@modelcontextprotocol/server';
|
||||
|
||||
const legacy = legacyStatelessFallback(buildServer);
|
||||
|
||||
async function serve(request: Request): Promise<Response> {
|
||||
if (await isLegacyRequest(request)) {
|
||||
return legacy(request);
|
||||
}
|
||||
return strict.fetch(request);
|
||||
}
|
||||
//#endregion isLegacyRequest_route
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). A 2025-era client opens with a claim-less
|
||||
// `initialize` POST; build that request twice and send it to the strict
|
||||
// handler, then through the `isLegacyRequest` branch. The page quotes both
|
||||
// outputs verbatim; the self-checks at the bottom exit non-zero if either
|
||||
// claim stops being observable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const legacyInitialize = () =>
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy-host', version: '1.0.0' } }
|
||||
})
|
||||
});
|
||||
|
||||
// "Choose a legacy posture" — the strict rejection the page quotes.
|
||||
const rejected = await strict.fetch(legacyInitialize());
|
||||
const rejection = (await rejected.json()) as { error: { code: number; data: { supported: string[]; requested: string } } };
|
||||
console.log(rejected.status);
|
||||
console.log(JSON.stringify(rejection, null, 2));
|
||||
|
||||
// "Keep a sessionful 2025 deployment running" — the same request through the
|
||||
// branch reaches the legacy leg and completes the 2025 handshake over SSE.
|
||||
const served = await serve(legacyInitialize());
|
||||
const sse = await served.text();
|
||||
const dataLine = sse.split('\n').find(line => line.startsWith('data: '));
|
||||
const initialized = JSON.parse(dataLine?.slice('data: '.length) ?? '{}') as {
|
||||
result: { protocolVersion: string; serverInfo: { name: string; version: string } };
|
||||
};
|
||||
console.log(served.status);
|
||||
console.log(initialized.result);
|
||||
|
||||
// Self-verification — the page's claims must stay observable.
|
||||
if (rejected.status !== 400 || rejection.error.code !== -32_022) {
|
||||
throw new Error(`expected the 400 / -32022 strict rejection, got ${rejected.status} ${JSON.stringify(rejection)}`);
|
||||
}
|
||||
if (rejection.error.data.supported[0] !== '2026-07-28' || rejection.error.data.requested !== '2025-06-18') {
|
||||
throw new Error(`expected the supported/requested revisions in the error data, got ${JSON.stringify(rejection.error.data)}`);
|
||||
}
|
||||
if (served.status !== 200 || initialized.result.protocolVersion !== '2025-06-18') {
|
||||
throw new Error(`expected the legacy leg to complete the 2025 handshake, got ${served.status} ${JSON.stringify(initialized)}`);
|
||||
}
|
||||
|
||||
await strict.close();
|
||||
95
examples/guides/serving/sessions-state-scaling.examples.ts
Normal file
95
examples/guides/serving/sessions-state-scaling.examples.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// docs: typecheck-only
|
||||
/**
|
||||
* Companion example for `docs/serving/sessions-state-scaling.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The regions are fragments of an HTTP
|
||||
* deployment — transport options, an Express route, a handler option — and
|
||||
* none of them may bind a port, so the file is typecheck-only.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// "Sessions" lead block — sessionIdGenerator turns sessions on for the
|
||||
// hand-wired 2025-era Streamable HTTP transport.
|
||||
//#region sessions_stateful
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID()
|
||||
});
|
||||
//#endregion sessions_stateful
|
||||
void transport;
|
||||
|
||||
// Imports for the function-wrapped regions below, kept out of the page's lead block.
|
||||
import type { EventStore, McpServer, ServerEventBus } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, isInitializeRequest } from '@modelcontextprotocol/server';
|
||||
import type { Express, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* "Sessions" follow-up — one transport per session, routed by `Mcp-Session-Id`
|
||||
* (mined from `examples/legacy-routing/server.ts`).
|
||||
*/
|
||||
function sessions_routing(app: Express, buildServer: () => McpServer) {
|
||||
//#region sessions_routing
|
||||
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
|
||||
|
||||
const route = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
await sessions.get(sessionId)!.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
if (!sessionId && isInitializeRequest(req.body)) {
|
||||
const transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: id => {
|
||||
sessions.set(id, transport);
|
||||
}
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) sessions.delete(transport.sessionId);
|
||||
};
|
||||
await buildServer().connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
if (sessionId) {
|
||||
// Unknown session id: the client should start a new session.
|
||||
res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null });
|
||||
return;
|
||||
}
|
||||
// No session header on a non-initialize request: the request is malformed.
|
||||
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Session ID required' }, id: null });
|
||||
};
|
||||
|
||||
app.post('/mcp', route);
|
||||
app.get('/mcp', route);
|
||||
app.delete('/mcp', route);
|
||||
//#endregion sessions_routing
|
||||
}
|
||||
void sessions_routing;
|
||||
|
||||
/** "Resumability" — an EventStore implementation next to sessionIdGenerator. */
|
||||
function resumability_eventStore(databaseEventStore: EventStore) {
|
||||
//#region resumability_eventStore
|
||||
const transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore: databaseEventStore
|
||||
});
|
||||
//#endregion resumability_eventStore
|
||||
return transport;
|
||||
}
|
||||
void resumability_eventStore;
|
||||
|
||||
/** "Multi-node" — every node hands the same pub/sub-backed bus to createMcpHandler. */
|
||||
function multiNode_bus(buildServer: () => McpServer, redisBus: ServerEventBus) {
|
||||
//#region multiNode_bus
|
||||
const handler = createMcpHandler(buildServer, { bus: redisBus });
|
||||
//#endregion multiNode_bus
|
||||
return handler;
|
||||
}
|
||||
void multiNode_bus;
|
||||
116
examples/guides/serving/stdio.examples.ts
Normal file
116
examples/guides/serving/stdio.examples.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/serving/stdio.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's `ts` fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The regions
|
||||
* are one linear stdio server program. The file runs in two modes:
|
||||
*
|
||||
* - `node --import tsx stdio.examples.ts --serve` — be that stdio server, plus
|
||||
* the one deliberate `console.log` the page's gotcha section describes, and
|
||||
* stay alive on stdin like any stdio server.
|
||||
* - `node --import tsx stdio.examples.ts` (default) — the harness: spawn this
|
||||
* file with `--serve`, send it an `initialize` request, and print every line
|
||||
* the child wrote to each of its streams. The page quotes that output
|
||||
* verbatim; the harness exits non-zero if the corruption it demonstrates
|
||||
* ever stops being observable.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
|
||||
//#region serveStdio_basic
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
const handle = serveStdio(() => {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
// server.registerTool(...) — one factory builds the instance that serves the connection
|
||||
return server;
|
||||
});
|
||||
//#endregion serveStdio_basic
|
||||
|
||||
//#region serveStdio_logStderr
|
||||
console.error('notes server is listening on stdio');
|
||||
//#endregion serveStdio_logStderr
|
||||
|
||||
//#region serveStdio_shutdown
|
||||
process.on('SIGINT', () => {
|
||||
void handle.close();
|
||||
});
|
||||
//#endregion serveStdio_shutdown
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page).
|
||||
//
|
||||
// `--serve` mode: the regions above already made this process a real stdio
|
||||
// server. Inject the page's gotcha — one `console.log` on the protocol channel
|
||||
// — and stay alive on stdin (the spawning harness kills the child when done).
|
||||
//
|
||||
// Default mode: spawn this file with `--serve`, write a JSON-RPC `initialize`
|
||||
// request to the child's stdin, then print every line the child wrote to each
|
||||
// stream. "Log to stderr, never stdout" quotes this output verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (process.argv.includes('--serve')) {
|
||||
// The bug the page demonstrates: one log line written to stdout.
|
||||
console.log('debug: starting the notes server');
|
||||
} else {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
|
||||
const selfPath = fileURLToPath(import.meta.url);
|
||||
const child = spawn(process.execPath, ['--no-warnings', '--import', 'tsx', selfPath, '--serve'], {
|
||||
cwd: dirname(selfPath)
|
||||
});
|
||||
|
||||
let childStdout = '';
|
||||
let childStderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
childStdout += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
childStderr += chunk;
|
||||
});
|
||||
|
||||
// What an MCP host writes first: the `initialize` request, one JSON line on stdin.
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'host', version: '1.0.0' } }
|
||||
})}\n`
|
||||
);
|
||||
|
||||
// Wait for the `initialize` response to reach the child's stdout, then stop the child.
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (!childStdout.includes('"id":1') && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
child.kill();
|
||||
await new Promise(resolve => child.once('exit', resolve));
|
||||
|
||||
const stdoutLines = childStdout.trimEnd().split('\n');
|
||||
const stderrLines = childStderr.trimEnd().split('\n');
|
||||
for (const line of stdoutLines) console.log(`[stdout] ${line}`);
|
||||
for (const line of stderrLines) console.log(`[stderr] ${line}`);
|
||||
|
||||
// Self-verification — the page's claims must stay observable, or this exits non-zero.
|
||||
if (stdoutLines[0] !== 'debug: starting the notes server') {
|
||||
throw new Error(`expected the stray console.log first on stdout, got ${JSON.stringify(stdoutLines)}`);
|
||||
}
|
||||
if (!stdoutLines[1]?.includes('"jsonrpc":"2.0"')) {
|
||||
throw new Error(`expected the initialize response next on stdout, got ${JSON.stringify(stdoutLines)}`);
|
||||
}
|
||||
if (!stderrLines.includes('notes server is listening on stdio')) {
|
||||
throw new Error(`expected the console.error banner on stderr, got ${JSON.stringify(stderrLines)}`);
|
||||
}
|
||||
|
||||
await handle.close();
|
||||
// The regions above also started a real stdio server on this process; end it here.
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
101
examples/guides/serving/webStandard.examples.ts
Normal file
101
examples/guides/serving/webStandard.examples.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/serving/web-standard.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). On a
|
||||
* web-standard runtime the default export's `fetch` IS the request path, so the
|
||||
* harness below the regions calls it directly with the page's curl request,
|
||||
* prints the response the page quotes verbatim, and exits non-zero if it (or
|
||||
* the `guarded` / `secured` wrappers' behavior) drifts. No port is ever bound.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/serving/webStandard.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
|
||||
//#region createMcpHandler_exportDefault
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
|
||||
content: [{ type: 'text', text: `Saved: ${text}` }]
|
||||
}));
|
||||
return server;
|
||||
});
|
||||
|
||||
export default handler;
|
||||
//#endregion createMcpHandler_exportDefault
|
||||
|
||||
//#region hostHeaderValidationResponse_guard
|
||||
import { hostHeaderValidationResponse, originValidationResponse } from '@modelcontextprotocol/server';
|
||||
|
||||
const guarded = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const rejected =
|
||||
hostHeaderValidationResponse(request, ['api.example.com']) ?? originValidationResponse(request, ['app.example.com']);
|
||||
return rejected ?? handler.fetch(request);
|
||||
}
|
||||
};
|
||||
//#endregion hostHeaderValidationResponse_guard
|
||||
|
||||
// `verifyToken` stands in for your deployment's token verification (JWT
|
||||
// validation, RFC 7662 introspection, a call to your IdP). The page points at
|
||||
// docs/serving/authorization.md for the real thing.
|
||||
async function verifyToken(request: Request): Promise<AuthInfo> {
|
||||
const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? '';
|
||||
return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
|
||||
//#region McpHttpHandler_fetch_authInfo
|
||||
const secured = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const authInfo = await verifyToken(request);
|
||||
return handler.fetch(request, { authInfo });
|
||||
}
|
||||
};
|
||||
//#endregion McpHttpHandler_fetch_authInfo
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). `handler.fetch` is exactly what a
|
||||
// web-standard runtime calls on the default export, so calling it with the
|
||||
// page's curl request IS the deployment path. The page quotes the body
|
||||
// verbatim. The two wrapper exports the page describes are exercised too:
|
||||
// `guarded` must answer 403 for a Host outside its allowlist, and `secured`
|
||||
// must still serve the request.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const curlRequest = (): Request =>
|
||||
new Request('http://127.0.0.1:8787/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
|
||||
});
|
||||
|
||||
const response = await handler.fetch(curlRequest());
|
||||
const text = await response.text();
|
||||
console.log(text);
|
||||
|
||||
const quotedOnPage =
|
||||
'event: message\n' +
|
||||
'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' +
|
||||
'"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}';
|
||||
if (response.status !== 200 || text.trimEnd() !== quotedOnPage) {
|
||||
throw new Error(`web-standard.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(text)}`);
|
||||
}
|
||||
|
||||
// "Protect against DNS rebinding": a Host outside the allowlist never reaches `fetch`.
|
||||
const guardedResponse = await guarded.fetch(curlRequest());
|
||||
if (guardedResponse.status !== 403) {
|
||||
throw new Error(`web-standard.md guard claim failed: expected 403, got ${guardedResponse.status}`);
|
||||
}
|
||||
|
||||
// "Forward auth and the parsed body": the secured wrapper still serves the request.
|
||||
const securedResponse = await secured.fetch(curlRequest());
|
||||
if (securedResponse.status !== 200) {
|
||||
throw new Error(`web-standard.md auth claim failed: expected 200, got ${securedResponse.status}`);
|
||||
}
|
||||
Loading…
Reference in a new issue