diff --git a/.changeset/expose-auth-server-discovery.md b/.changeset/expose-auth-server-discovery.md new file mode 100644 index 000000000..443dce893 --- /dev/null +++ b/.changeset/expose-auth-server-discovery.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `discoverOAuthServerInfo()` function and unified discovery state caching for OAuth + +- New `discoverOAuthServerInfo(serverUrl)` export that performs RFC 9728 protected resource metadata discovery followed by authorization server metadata discovery in a single call. Use this for operations like token refresh and revocation that need the authorization server URL outside of `auth()`. +- New `OAuthDiscoveryState` type and optional `OAuthClientProvider` methods `saveDiscoveryState()` / `discoveryState()` allow providers to persist all discovery results (auth server URL, resource metadata URL, resource metadata, auth server metadata) across sessions. This avoids redundant discovery requests and handles browser redirect scenarios where discovery state would otherwise be lost. +- New `'discovery'` scope for `invalidateCredentials()` to clear cached discovery state. +- New `OAuthServerInfo` type exported for the return value of `discoverOAuthServerInfo()`. diff --git a/CLAUDE.md b/CLAUDE.md index 67601a319..dea46e8e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,12 @@ Include what changed, why, and how to migrate. Search for related sections and g - **Testing**: Co-locate tests with source files, use descriptive test names - **Comments**: JSDoc for public APIs, inline comments for complex logic +### JSDoc `@example` Code Snippets + +JSDoc `@example` tags should pull type-checked code from companion `.examples.ts` files (e.g., `client.ts` → `client.examples.ts`). Use `` ```ts source="./file.examples.ts#regionName" `` fences referencing `//#region regionName` blocks; region names follow `exportedName_variant` or `ClassName_methodName_variant` pattern (e.g., `applyMiddlewares_basicUsage`, `Client_connect_basicUsage`). For whole-file inclusion (any file type), omit the `#regionName`. + +Run `pnpm sync:snippets` to sync example content into JSDoc comments and markdown files. + ## Architecture Overview ### Core Layers diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index 6e55630cf..32aad9275 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -86,6 +86,14 @@ export default defineConfig( 'unicorn/consistent-function-scoping': 'off' } }, + { + // Example files contain intentionally unused functions (one per region) + files: ['**/*.examples.ts'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'no-console': 'off' + } + }, { // Ignore generated protocol types everywhere ignores: ['**/spec.types.ts'] diff --git a/docs/client.md b/docs/client.md index 41f91656a..ae49d759d 100644 --- a/docs/client.md +++ b/docs/client.md @@ -4,61 +4,377 @@ title: Client ## Client overview -The SDK provides a high-level `Client` class that connects to MCP servers over different transports: +This guide covers SDK usage for building MCP clients in TypeScript. For protocol-level details and message formats, see the [MCP specification](https://modelcontextprotocol.io/specification/latest/). -- `StdioClientTransport` – for local processes you spawn. -- `StreamableHTTPClientTransport` – for remote HTTP servers. -- `SSEClientTransport` – for legacy HTTP+SSE servers (deprecated). +The SDK provides a {@linkcode @modelcontextprotocol/client!client/client.Client | Client} class from `@modelcontextprotocol/client` that connects to MCP servers over different transports: -Runnable client examples live under: +- **Streamable HTTP** – for remote HTTP servers. +- **stdio** – for local processes you spawn. +- **SSE** – for legacy HTTP+SSE servers (deprecated). -- [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts) -- [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts) -- [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts) -- [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/multipleClientsParallel.ts) -- [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/parallelToolCallsClient.ts) +For a feature‑rich starting point, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). -## Connecting and basic operations +## Connecting to a server -A typical flow: +Construct a `Client` with a name and version, create a transport, and call {@linkcode @modelcontextprotocol/client!client/client.Client#connect | client.connect(transport)}. The client automatically performs the MCP initialization handshake. -1. Construct a `Client` with name, version and capabilities. -2. Create a transport and call `client.connect(transport)`. -3. Use high-level helpers: - - `listTools`, `callTool` - - `listPrompts`, `getPrompt` - - `listResources`, `readResource` +### Streamable HTTP -See [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts) for an interactive CLI client that exercises these methods and shows how to handle notifications, elicitation and tasks. +For remote HTTP servers, use {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}: -## Transports and backwards compatibility +```ts source="../examples/client/src/clientGuide.examples.ts#connect_streamableHttp" +const client = new Client({ name: 'my-client', version: '1.0.0' }); -To support both modern Streamable HTTP and legacy SSE servers, use a client that: +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); -1. Tries `StreamableHTTPClientTransport`. -2. Falls back to `SSEClientTransport` on a 4xx response. +await client.connect(transport); +``` -Runnable example: +> [!NOTE] +> For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). -- [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts) +### stdio -## OAuth client authentication helpers +For local, process‑spawned servers (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/client!client/stdio.StdioClientTransport | StdioClientTransport}. The transport spawns the server process and communicates over stdin/stdout: -For OAuth-secured MCP servers, the client `auth` module exposes: +```ts source="../examples/client/src/clientGuide.examples.ts#connect_stdio" +const client = new Client({ name: 'my-client', version: '1.0.0' }); -- `ClientCredentialsProvider` -- `PrivateKeyJwtProvider` -- `StaticPrivateKeyJwtProvider` +const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'] +}); -Examples: +await client.connect(transport); +``` -- [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) -- [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts) -- [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleClientCredentials.ts) -- Server-side auth demo: [`demoInMemoryOAuthProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/shared/src/demoInMemoryOAuthProvider.ts) (tests live under `examples/shared/test/demoInMemoryOAuthProvider.test.ts`) +### SSE fallback for legacy servers -These examples show how to: +To support both modern Streamable HTTP and legacy SSE servers, try `StreamableHTTPClientTransport` first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: -- Perform dynamic client registration if needed. -- Acquire access tokens. -- Attach OAuth credentials to Streamable HTTP requests. +```ts source="../examples/client/src/clientGuide.examples.ts#connect_sseFallback" +const baseUrl = new URL(url); + +try { + // Try modern Streamable HTTP transport first + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; +} catch { + // Fall back to legacy SSE transport + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new SSEClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; +} +``` + +> [!NOTE] +> For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). + +## Authentication + +For OAuth‑secured MCP servers, pass an `authProvider` to `StreamableHTTPClientTransport`. The SDK provides built‑in providers for common machine‑to‑machine flows, or you can implement the full {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface for user‑facing OAuth. + +### Client credentials + +{@linkcode @modelcontextprotocol/client!client/authExtensions.ClientCredentialsProvider | ClientCredentialsProvider} handles the `client_credentials` grant flow for service‑to‑service communication: + +```ts source="../examples/client/src/clientGuide.examples.ts#auth_clientCredentials" +const authProvider = new ClientCredentialsProvider({ + clientId: 'my-service', + clientSecret: 'my-secret' +}); + +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + +await client.connect(transport); +``` + +### Private key JWT + +{@linkcode @modelcontextprotocol/client!client/authExtensions.PrivateKeyJwtProvider | PrivateKeyJwtProvider} signs JWT assertions for the `private_key_jwt` token endpoint auth method, avoiding a shared client secret: + +```ts source="../examples/client/src/clientGuide.examples.ts#auth_privateKeyJwt" +const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); +``` + +> [!NOTE] +> For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleClientCredentials.ts). + +### Full OAuth with user authorization + +For user‑facing applications, implement the `OAuthClientProvider` interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The `connect()` call will throw `UnauthorizedError` when authorization is needed — catch it, complete the browser flow, call `transport.finishAuth(code)`, and reconnect. + +> [!NOTE] +> For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). +> +> For protocol details, see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification. + +## Using server features + +Once connected, the `Client` provides high‑level helpers for the three core MCP primitives: tools, resources, and prompts. These handle JSON‑RPC request/response encoding automatically. + +> [!NOTE] +> For a full runnable client exercising tools, resources, and prompts, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). + +### Tools + +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one: + +```ts source="../examples/client/src/clientGuide.examples.ts#callTool_basic" +const { tools } = await client.listTools(); +console.log( + 'Available tools:', + tools.map(t => t.name) +); + +const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } +}); +console.log(result.content); +``` + +> [!NOTE] +> See [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) in the MCP specification for the full protocol details. + +### Resources + +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server‑provided data: + +```ts source="../examples/client/src/clientGuide.examples.ts#readResource_basic" +const { resources } = await client.listResources(); +console.log( + 'Available resources:', + resources.map(r => r.name) +); + +const { contents } = await client.readResource({ uri: 'config://app' }); +for (const item of contents) { + console.log(item); +} +``` + +> [!NOTE] +> See [Resources](https://modelcontextprotocol.io/specification/latest/server/resources) in the MCP specification for the full protocol details. + +### Prompts + +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to retrieve prompt templates from the server: + +```ts source="../examples/client/src/clientGuide.examples.ts#getPrompt_basic" +const { prompts } = await client.listPrompts(); +console.log( + 'Available prompts:', + prompts.map(p => p.name) +); + +const { messages } = await client.getPrompt({ + name: 'review-code', + arguments: { code: 'console.log("hello")' } +}); +console.log(messages); +``` + +> [!NOTE] +> See [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts) in the MCP specification for the full protocol details. + +### Completions + +If a server supports argument completions on prompts or resources, use {@linkcode @modelcontextprotocol/client!client/client.Client#complete | complete()} to request suggestions. This is the client‑side counterpart to {@linkcode @modelcontextprotocol/server!server/completable.completable | completable()} on the server: + +```ts source="../examples/client/src/clientGuide.examples.ts#complete_basic" +const { completion } = await client.complete({ + ref: { + type: 'ref/prompt', + name: 'review-code' + }, + argument: { + name: 'language', + value: 'type' + } +}); +console.log(completion.values); // e.g. ['typescript'] +``` + +## Notifications + +### Automatic list‑change tracking + +The `listChanged` client option keeps a local cache of tools, prompts, or resources in sync with the server. Compared to manually handling notifications, it provides automatic server capability gating, debouncing (300 ms by default), auto‑refresh, and error‑first callbacks: + +```ts source="../examples/client/src/clientGuide.examples.ts#listChanged_basic" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Failed to refresh tools:', error); + return; + } + console.log('Tools updated:', tools); + } + }, + prompts: { + onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + } + } + } +); +``` + +### Manual notification handlers + +For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}: + +```ts source="../examples/client/src/clientGuide.examples.ts#notificationHandler_basic" +// Server log messages (e.g. from ctx.mcpReq.log() in tool handlers) +client.setNotificationHandler('notifications/message', notification => { + const { level, data } = notification.params; + console.log(`[${level}]`, data); +}); + +// Server's resource list changed — re-fetch the list +client.setNotificationHandler('notifications/resources/list_changed', async () => { + const { resources } = await client.listResources(); + console.log('Resources changed:', resources.length); +}); +``` + +Note that `listChanged` and `setNotificationHandler` are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. + +## Handling server‑initiated requests + +MCP is bidirectional — servers can also send requests *to* the client. To handle these, declare the corresponding capability when constructing the `Client` and register a request handler. The two main server‑initiated request types are **sampling** (LLM completions) and **elicitation** (user input). + +### Declaring capabilities + +Pass a {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | `capabilities`} object when constructing the `Client`. The server reads these during initialization and will only send requests your client has declared support for: + +```ts source="../examples/client/src/clientGuide.examples.ts#capabilities_declaration" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {} } + } + } +); +``` + +### Sampling + +When a server calls `server.createMessage(...)` inside a tool handler, the request is routed to the client. Register a handler for `sampling/createMessage` to fulfill it: + +```ts source="../examples/client/src/clientGuide.examples.ts#sampling_handler" +client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { + type: 'text' as const, + text: 'Response from the model' + } + }; +}); +``` + +> [!NOTE] +> See [Sampling](https://modelcontextprotocol.io/specification/latest/client/sampling) in the MCP specification for the full protocol details. + +### Elicitation + +When a server calls `server.elicitInput(...)`, the request arrives at the client as an `elicitation/create` request. The client should present the form to the user and return the collected data, or `{ action: 'decline' }`: + +```ts source="../examples/client/src/clientGuide.examples.ts#elicitation_handler" +client.setRequestHandler('elicitation/create', async request => { + console.log('Server asks:', request.params.message); + + if (request.params.mode === 'form') { + // Present the schema-driven form to the user + console.log('Schema:', request.params.requestedSchema); + return { action: 'accept', content: { confirm: true } }; + } + + return { action: 'decline' }; +}); +``` + +> [!NOTE] +> For a full form‑based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts) and the [Capabilities guide](capabilities.md#elicitation). +> +> For protocol details, see [Elicitation](https://modelcontextprotocol.io/specification/latest/client/elicitation) in the MCP specification. + +## Advanced patterns + +### Client middleware + +Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: + +```ts source="../examples/client/src/clientGuide.examples.ts#middleware_basic" +const authMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Header', 'my-value'); + return next(input, { ...init, headers }); +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(authMiddleware)(fetch) +}); +``` + +### Resumption tokens + +When using SSE‑based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: + +```ts source="../examples/client/src/clientGuide.examples.ts#resumptionToken_basic" +let lastToken: string | undefined; + +const result = await client.request( + { + method: 'tools/call', + params: { name: 'long-running-task', arguments: {} } + }, + CallToolResultSchema, + { + resumptionToken: lastToken, + onresumptiontoken: (token: string) => { + lastToken = token; + // Persist token to survive restarts + } + } +); +console.log(result); +``` + +> [!NOTE] +> For an end‑to‑end example of server‑initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts). + +## More client features + +The sections above cover the essentials. The table below links to additional capabilities. + +| Feature | Description | Reference | +|---------|-------------|-----------| +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/parallelToolCallsClient.ts) | +| SSE disconnect / reconnection | Server‑initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/multipleClientsParallel.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts) | +| Tasks (experimental) | Long‑running tool calls with status streaming | [`simpleTaskInteractiveClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTaskInteractiveClient.ts), [Capabilities guide](capabilities.md#task-based-execution-experimental) | diff --git a/docs/server.md b/docs/server.md index 105871bc1..941ca8740 100644 --- a/docs/server.md +++ b/docs/server.md @@ -4,93 +4,112 @@ title: Server ## Server overview -This SDK lets you build MCP servers in TypeScript and connect them to different transports. For most use cases you will use `McpServer` from `@modelcontextprotocol/server` and choose one of: +This guide covers SDK usage for building MCP servers in TypeScript. For protocol-level details and message formats, see the [MCP specification](https://modelcontextprotocol.io/specification/latest/). -- **Streamable HTTP** (recommended for remote servers) -- **HTTP + SSE** (deprecated, for backwards compatibility only) -- **stdio** (for local, process‑spawned integrations) +Building a server takes three steps: -For a complete, runnable example server, see: +1. Create an {@linkcode @modelcontextprotocol/server!server/mcp.McpServer | McpServer} and register your [tools, resources, and prompts](#tools-resources-and-prompts). +2. Create a transport — [Streamable HTTP](#streamable-http) for remote servers or [stdio](#stdio) for local, process‑spawned integrations. +3. Wire the transport into your HTTP framework (or use stdio directly) and call `server.connect(transport)`. -- [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) – feature‑rich Streamable HTTP server -- [`jsonResponseStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/jsonResponseStreamableHttp.ts) – Streamable HTTP with JSON response mode -- [`simpleStatelessStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStatelessStreamableHttp.ts) – stateless Streamable HTTP server -- [`simpleSseServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleSseServer.ts) – deprecated HTTP+SSE transport -- [`sseAndStreamableHttpCompatibleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/sseAndStreamableHttpCompatibleServer.ts) – backwards‑compatible server for old and new clients +The sections below cover each of these. For a feature‑rich starting point, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) — remove what you don't need and register your own tools, resources, and prompts. For stateless or JSON‑response‑mode alternatives, see the examples linked in [Transports](#transports) below. ## Transports ### Streamable HTTP -Streamable HTTP is the modern, fully featured transport. It supports: +Streamable HTTP is the HTTP‑based transport. It supports: - Request/response over HTTP POST - Server‑to‑client notifications over SSE (when enabled) - Optional JSON‑only response mode with no SSE - Session management and resumability -Key examples: +A minimal stateful setup: -- [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) – sessions, logging, tasks, elicitation, auth hooks -- [`jsonResponseStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/jsonResponseStreamableHttp.ts) – `enableJsonResponse: true`, no SSE -- [`standaloneSseWithGetStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/standaloneSseWithGetStreamableHttp.ts) – notifications with Streamable HTTP GET + SSE +```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_stateful" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -See the MCP spec for full transport details: `https://modelcontextprotocol.io/specification/2025-11-25/basic/transports` +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() +}); + +await server.connect(transport); +``` -### Stateless vs stateful sessions +> [!NOTE] +> For full runnable examples, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) (sessions, logging, tasks, elicitation, auth hooks), [`jsonResponseStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/jsonResponseStreamableHttp.ts) (`enableJsonResponse: true`, no SSE), and [`standaloneSseWithGetStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/standaloneSseWithGetStreamableHttp.ts) (notifications with Streamable HTTP GET + SSE). +> +> For protocol details, see [Transports](https://modelcontextprotocol.io/specification/latest/basic/transports) in the MCP specification. + +#### Stateless vs stateful sessions Streamable HTTP can run: - **Stateless** – no session tracking, ideal for simple API‑style servers. - **Stateful** – sessions have IDs, and you can enable resumability and advanced features. -Examples: +The key difference is the `sessionIdGenerator` option. Pass `undefined` for stateless mode: -- Stateless Streamable HTTP: [`simpleStatelessStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStatelessStreamableHttp.ts) -- Stateful with resumability: [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) +```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_stateless" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -### Deprecated HTTP + SSE +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined +}); -The older HTTP+SSE transport (protocol version 2024‑11‑05) is supported only for backwards compatibility. New implementations should prefer Streamable HTTP. +await server.connect(transport); +``` -Examples: +> [!NOTE] +> For full runnable examples, see [`simpleStatelessStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStatelessStreamableHttp.ts) (stateless) and [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) (stateful with resumability). -- Legacy SSE server: [`simpleSseServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleSseServer.ts) -- Backwards‑compatible server (Streamable HTTP + SSE): - [`sseAndStreamableHttpCompatibleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/sseAndStreamableHttpCompatibleServer.ts) +#### JSON response mode -## Running your server +If you do not need SSE streaming, set `enableJsonResponse: true`. The server will return plain JSON responses to every POST and reject GET requests with `405`: -For a minimal “getting started” experience: +```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_jsonResponse" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -1. Start from [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). -2. Remove features you do not need (tasks, advanced logging, OAuth, etc.). -3. Register your own tools, resources and prompts. +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true +}); -For more detailed patterns (stateless vs stateful, JSON response mode, CORS, DNS rebind protection), see the examples above and the MCP spec sections on transports. +await server.connect(transport); +``` -## DNS rebinding protection +> [!NOTE] +> For a full runnable example, see [`jsonResponseStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/jsonResponseStreamableHttp.ts). -MCP servers running on localhost are vulnerable to DNS rebinding attacks. Use `createMcpExpressApp()` to create an Express app with DNS rebinding protection enabled by default: +### stdio -```typescript -import { createMcpExpressApp } from '@modelcontextprotocol/express'; +For local, process‑spawned integrations (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/server!server/stdio.StdioServerTransport | StdioServerTransport}: -// Protection auto-enabled (default host is 127.0.0.1) +```ts source="../examples/server/src/serverGuide.examples.ts#stdio_basic" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +## DNS rebinding protection + +MCP servers running on localhost are vulnerable to DNS rebinding attacks. Use `createMcpExpressApp()` from `@modelcontextprotocol/express` to create an Express app with DNS rebinding protection enabled by default: + +```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_basic" +// Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) const app = createMcpExpressApp(); -// Protection auto-enabled for localhost -const app = createMcpExpressApp({ host: 'localhost' }); +// DNS rebinding protection also auto-enabled for localhost +const appLocal = createMcpExpressApp({ host: 'localhost' }); -// No auto protection when binding to all interfaces, unless you provide allowedHosts -const app = createMcpExpressApp({ host: '0.0.0.0' }); +// No automatic protection when binding to all interfaces +const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); ``` When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: -```typescript -import { createMcpExpressApp } from '@modelcontextprotocol/express'; - +```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_allowedHosts" const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] @@ -103,19 +122,19 @@ const app = createMcpExpressApp({ Tools let MCP clients ask your server to take actions. They are usually the main way that LLMs call into your application. -A typical registration with `registerTool` looks like this: +A typical registration with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}: -```typescript +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_basic" server.registerTool( 'calculate-bmi', { title: 'BMI Calculator', description: 'Calculate Body Mass Index', - inputSchema: { + inputSchema: z.object({ weightKg: z.number(), heightM: z.number() - }, - outputSchema: { bmi: z.number() } + }), + outputSchema: z.object({ bmi: z.number() }) }, async ({ weightKg, heightM }) => { const output = { bmi: weightKg / (heightM * heightM) }; @@ -127,24 +146,84 @@ server.registerTool( ); ``` -This snippet is illustrative only; for runnable servers that expose tools, see: +> [!NOTE] +> For full runnable examples, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) and [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts). +> +> For protocol details, see [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) in the MCP specification. + +#### `ResourceLink` outputs + +Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_resourceLink" +server.registerTool( + 'list-files', + { + title: 'List Files', + description: 'Returns files as resource links without embedding content' + }, + async (): Promise => { + const links: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'file:///projects/readme.md', + name: 'README', + mimeType: 'text/markdown' + }, + { + type: 'resource_link', + uri: 'file:///projects/config.json', + name: 'Config', + mimeType: 'application/json' + } + ]; + return { content: links }; + } +); +``` + +> [!NOTE] +> For a full runnable example with `ResourceLink` outputs, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). + +#### Logging + +Use `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler to send structured log messages to the client. The server must declare the `logging` capability: -- [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) -- [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts) +```ts source="../examples/server/src/serverGuide.examples.ts#logging_capability" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); +``` -#### ResourceLink outputs +Then log from any tool callback: -Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need. +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_logging" +server.registerTool( + 'fetch-data', + { + description: 'Fetch data from an API', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }, ctx): Promise => { + await ctx.mcpReq.log('info', `Fetching ${url}`); + const res = await fetch(url); + await ctx.mcpReq.log('debug', `Response status: ${res.status}`); + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } +); +``` -The README’s `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `examples/server/src`. +> [!NOTE] +> For logging in a full server, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) and [`jsonResponseStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/jsonResponseStreamableHttp.ts). +> +> For protocol details, see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification. ### Resources Resources expose data to clients, but should not perform heavy computation or side‑effects. They are ideal for configuration, documents, or other reference data. -Conceptually, you might register resources like: +A static resource at a fixed URI: -```typescript +```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_static" server.registerResource( 'config', 'config://app', @@ -159,9 +238,39 @@ server.registerResource( ); ``` -Dynamic resources use `ResourceTemplate` and can support completions on path parameters. For full runnable examples of resources: +Dynamic resources use {@linkcode @modelcontextprotocol/server!server/mcp.ResourceTemplate | ResourceTemplate} and can support completions on path parameters: -- [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) +```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_template" +server.registerResource( + 'user-profile', + new ResourceTemplate('user://{userId}/profile', { + list: async () => ({ + resources: [ + { uri: 'user://123/profile', name: 'Alice' }, + { uri: 'user://456/profile', name: 'Bob' } + ] + }) + }), + { + title: 'User Profile', + description: 'User profile data', + mimeType: 'application/json' + }, + async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify({ userId, name: 'Example User' }) + } + ] + }) +); +``` + +> [!NOTE] +> For full runnable examples of resources, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +> +> For protocol details, see [Resources](https://modelcontextprotocol.io/specification/latest/server/resources) in the MCP specification. ### Prompts @@ -169,20 +278,22 @@ Prompts are reusable templates that help humans (or client UIs) talk to models i A minimal prompt: -```typescript +```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_basic" server.registerPrompt( 'review-code', { title: 'Code Review', description: 'Review code for best practices and potential issues', - argsSchema: { code: z.string() } + argsSchema: z.object({ + code: z.string() + }) }, ({ code }) => ({ messages: [ { - role: 'user', + role: 'user' as const, content: { - type: 'text', + type: 'text' as const, text: `Please review this code:\n\n${code}` } } @@ -191,35 +302,55 @@ server.registerPrompt( ); ``` -For prompts integrated into a full server, see: - -- [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) +> [!NOTE] +> For prompts integrated into a full server, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +> +> For protocol details, see [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts) in the MCP specification. ### Completions -Both prompts and resources can support argument completions. On the client side, you use `client.complete()` with a reference to the prompt or resource and the partially‑typed argument. - -See the MCP spec sections on prompts and resources for complete details, and [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts) for client‑side usage patterns. - -### Display names and metadata - -Tools, resources and prompts support a `title` field for human‑readable names. Older APIs can also attach `annotations.title`. To compute the correct display name on the client, use: +Both prompts and resources can support argument completions. Wrap a field in the `argsSchema` with {@linkcode @modelcontextprotocol/server!server/completable.completable | completable()} to provide autocompletion suggestions: -- `getDisplayName` from `@modelcontextprotocol/client` - -## Multi‑node deployment patterns - -The SDK supports multi‑node deployments using Streamable HTTP. The high‑level patterns and diagrams live with the runnable server examples: - -- [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) +```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_completion" +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${language} code for best practices.` + } + } + ] + }) +); +``` -## Backwards compatibility +For client-side completion usage, see the [Client guide](client.md). -To handle both modern and legacy clients: +## More server features -- Run a backwards‑compatible server: - - [`sseAndStreamableHttpCompatibleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/sseAndStreamableHttpCompatibleServer.ts) -- Use a client that falls back from Streamable HTTP to SSE: - - [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts) +The sections above cover the essentials. The SDK supports several additional capabilities — each is demonstrated in the runnable examples and covered in more detail in the linked references. -For the detailed protocol rules, see the “Backwards compatibility” section of the MCP spec. +| Feature | Description | Reference | +|---------|-------------|-----------| +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`honoWebStandardStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/honoWebStandardStreamableHttp.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/inMemoryEventStore.ts) | +| CORS | Expose MCP headers (`mcp-session-id`, etc.) for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Tool annotations | Hint whether tools are read-only, destructive, etc. | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Elicitation | Request user input (forms or URLs) during tool execution | [Capabilities guide](capabilities.md#elicitation), [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationFormExample.ts) | +| Sampling | Request LLM completions from the connected client | [Capabilities guide](capabilities.md#sampling), [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts) | +| Tasks (experimental) | Long-running operations with polling and resumption | [Capabilities guide](capabilities.md#task-based-execution-experimental), [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Multi‑node deployment | Stateless, persistent‑storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts new file mode 100644 index 000000000..8cefcf224 --- /dev/null +++ b/examples/client/src/clientGuide.examples.ts @@ -0,0 +1,340 @@ +/** + * Type-checked examples for docs/client.md. + * + * Regions are synced into markdown code fences via `pnpm sync:snippets`. + * Each function wraps a single region. The function name matches the region name. + * + * @module + */ + +import { + applyMiddlewares, + CallToolResultSchema, + Client, + ClientCredentialsProvider, + createMiddleware, + PrivateKeyJwtProvider, + SSEClientTransport, + StdioClientTransport, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; + +// --------------------------------------------------------------------------- +// Connecting to a server +// --------------------------------------------------------------------------- + +/** Example: Streamable HTTP transport. */ +async function connect_streamableHttp() { + //#region connect_streamableHttp + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + + await client.connect(transport); + //#endregion connect_streamableHttp +} + +/** Example: stdio transport for local process-spawned servers. */ +async function connect_stdio() { + //#region connect_stdio + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'] + }); + + await client.connect(transport); + //#endregion connect_stdio +} + +/** Example: Try Streamable HTTP, fall back to legacy SSE. */ +async function connect_sseFallback(url: string) { + //#region connect_sseFallback + const baseUrl = new URL(url); + + try { + // Try modern Streamable HTTP transport first + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; + } catch { + // Fall back to legacy SSE transport + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new SSEClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; + } + //#endregion connect_sseFallback +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +/** Example: Client credentials auth for service-to-service communication. */ +async function auth_clientCredentials() { + //#region auth_clientCredentials + const authProvider = new ClientCredentialsProvider({ + clientId: 'my-service', + clientSecret: 'my-secret' + }); + + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + + await client.connect(transport); + //#endregion auth_clientCredentials +} + +/** Example: Private key JWT auth. */ +async function auth_privateKeyJwt(pemEncodedKey: string) { + //#region auth_privateKeyJwt + const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' + }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + //#endregion auth_privateKeyJwt + return transport; +} + +// --------------------------------------------------------------------------- +// Using server features +// --------------------------------------------------------------------------- + +/** Example: List and call tools. */ +async function callTool_basic(client: Client) { + //#region callTool_basic + const { tools } = await client.listTools(); + console.log( + 'Available tools:', + tools.map(t => t.name) + ); + + const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } + }); + console.log(result.content); + //#endregion callTool_basic +} + +/** Example: List and read resources. */ +async function readResource_basic(client: Client) { + //#region readResource_basic + const { resources } = await client.listResources(); + console.log( + 'Available resources:', + resources.map(r => r.name) + ); + + const { contents } = await client.readResource({ uri: 'config://app' }); + for (const item of contents) { + console.log(item); + } + //#endregion readResource_basic +} + +/** Example: List and get prompts. */ +async function getPrompt_basic(client: Client) { + //#region getPrompt_basic + const { prompts } = await client.listPrompts(); + console.log( + 'Available prompts:', + prompts.map(p => p.name) + ); + + const { messages } = await client.getPrompt({ + name: 'review-code', + arguments: { code: 'console.log("hello")' } + }); + console.log(messages); + //#endregion getPrompt_basic +} + +/** Example: Request argument completions. */ +async function complete_basic(client: Client) { + //#region complete_basic + const { completion } = await client.complete({ + ref: { + type: 'ref/prompt', + name: 'review-code' + }, + argument: { + name: 'language', + value: 'type' + } + }); + console.log(completion.values); // e.g. ['typescript'] + //#endregion complete_basic +} + +// --------------------------------------------------------------------------- +// Notifications +// --------------------------------------------------------------------------- + +/** Example: Handle log messages and list-change notifications. */ +function notificationHandler_basic(client: Client) { + //#region notificationHandler_basic + // Server log messages (e.g. from ctx.mcpReq.log() in tool handlers) + client.setNotificationHandler('notifications/message', notification => { + const { level, data } = notification.params; + console.log(`[${level}]`, data); + }); + + // Server's resource list changed — re-fetch the list + client.setNotificationHandler('notifications/resources/list_changed', async () => { + const { resources } = await client.listResources(); + console.log('Resources changed:', resources.length); + }); + //#endregion notificationHandler_basic +} + +/** Example: Automatic list-change tracking via the listChanged option. */ +async function listChanged_basic() { + //#region listChanged_basic + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Failed to refresh tools:', error); + return; + } + console.log('Tools updated:', tools); + } + }, + prompts: { + onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + } + } + } + ); + //#endregion listChanged_basic + return client; +} + +// --------------------------------------------------------------------------- +// Handling server-initiated requests +// --------------------------------------------------------------------------- + +/** Example: Declare client capabilities for sampling and elicitation. */ +function capabilities_declaration() { + //#region capabilities_declaration + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {} } + } + } + ); + //#endregion capabilities_declaration + return client; +} + +/** Example: Handle a sampling request from the server. */ +function sampling_handler(client: Client) { + //#region sampling_handler + client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { + type: 'text' as const, + text: 'Response from the model' + } + }; + }); + //#endregion sampling_handler +} + +/** Example: Handle an elicitation request from the server. */ +function elicitation_handler(client: Client) { + //#region elicitation_handler + client.setRequestHandler('elicitation/create', async request => { + console.log('Server asks:', request.params.message); + + if (request.params.mode === 'form') { + // Present the schema-driven form to the user + console.log('Schema:', request.params.requestedSchema); + return { action: 'accept', content: { confirm: true } }; + } + + return { action: 'decline' }; + }); + //#endregion elicitation_handler +} + +// --------------------------------------------------------------------------- +// Advanced patterns +// --------------------------------------------------------------------------- + +/** Example: Client middleware that adds a custom header. */ +async function middleware_basic() { + //#region middleware_basic + const authMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Header', 'my-value'); + return next(input, { ...init, headers }); + }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(authMiddleware)(fetch) + }); + //#endregion middleware_basic + return transport; +} + +/** Example: Track resumption tokens for SSE reconnection. */ +async function resumptionToken_basic(client: Client) { + //#region resumptionToken_basic + let lastToken: string | undefined; + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'long-running-task', arguments: {} } + }, + CallToolResultSchema, + { + resumptionToken: lastToken, + onresumptiontoken: (token: string) => { + lastToken = token; + // Persist token to survive restarts + } + } + ); + console.log(result); + //#endregion resumptionToken_basic +} + +// Suppress unused-function warnings (functions exist solely for type-checking) +void connect_streamableHttp; +void connect_stdio; +void connect_sseFallback; +void auth_clientCredentials; +void auth_privateKeyJwt; +void callTool_basic; +void readResource_basic; +void getPrompt_basic; +void complete_basic; +void notificationHandler_basic; +void listChanged_basic; +void capabilities_declaration; +void sampling_handler; +void elicitation_handler; +void middleware_basic; +void resumptionToken_basic; diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts new file mode 100644 index 000000000..271d0d39b --- /dev/null +++ b/examples/server/src/serverGuide.examples.ts @@ -0,0 +1,306 @@ +/** + * Type-checked examples for docs/server.md. + * + * Regions are synced into markdown code fences via `pnpm sync:snippets`. + * Each function wraps a single region. The function name matches the region name. + * + * @module + */ + +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate, StdioServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// Tools, resources, and prompts +// --------------------------------------------------------------------------- + +/** Example: Registering a tool with inputSchema, outputSchema, and structuredContent. */ +function registerTool_basic(server: McpServer) { + //#region registerTool_basic + server.registerTool( + 'calculate-bmi', + { + title: 'BMI Calculator', + description: 'Calculate Body Mass Index', + inputSchema: z.object({ + weightKg: z.number(), + heightM: z.number() + }), + outputSchema: z.object({ bmi: z.number() }) + }, + async ({ weightKg, heightM }) => { + const output = { bmi: weightKg / (heightM * heightM) }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + //#endregion registerTool_basic +} + +/** Example: Tool returning resource_link content items. */ +function registerTool_resourceLink(server: McpServer) { + //#region registerTool_resourceLink + server.registerTool( + 'list-files', + { + title: 'List Files', + description: 'Returns files as resource links without embedding content' + }, + async (): Promise => { + const links: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'file:///projects/readme.md', + name: 'README', + mimeType: 'text/markdown' + }, + { + type: 'resource_link', + uri: 'file:///projects/config.json', + name: 'Config', + mimeType: 'application/json' + } + ]; + return { content: links }; + } + ); + //#endregion registerTool_resourceLink +} + +/** Example: Registering a static resource at a fixed URI. */ +function registerResource_static(server: McpServer) { + //#region registerResource_static + server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) + ); + //#endregion registerResource_static +} + +/** Example: Dynamic resource with ResourceTemplate and listing. */ +function registerResource_template(server: McpServer) { + //#region registerResource_template + server.registerResource( + 'user-profile', + new ResourceTemplate('user://{userId}/profile', { + list: async () => ({ + resources: [ + { uri: 'user://123/profile', name: 'Alice' }, + { uri: 'user://456/profile', name: 'Bob' } + ] + }) + }), + { + title: 'User Profile', + description: 'User profile data', + mimeType: 'application/json' + }, + async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify({ userId, name: 'Example User' }) + } + ] + }) + ); + //#endregion registerResource_template +} + +/** Example: Registering a prompt with argsSchema. */ +function registerPrompt_basic(server: McpServer) { + //#region registerPrompt_basic + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: z.object({ + code: z.string() + }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Please review this code:\n\n${code}` + } + } + ] + }) + ); + //#endregion registerPrompt_basic +} + +/** Example: Prompt with completable argsSchema for autocompletion. */ +function registerPrompt_completion(server: McpServer) { + //#region registerPrompt_completion + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${language} code for best practices.` + } + } + ] + }) + ); + //#endregion registerPrompt_completion +} + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +/** Example: Server with logging capability + tool that logs progress messages. */ +function registerTool_logging() { + //#region logging_capability + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + //#endregion logging_capability + + //#region registerTool_logging + server.registerTool( + 'fetch-data', + { + description: 'Fetch data from an API', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }, ctx): Promise => { + await ctx.mcpReq.log('info', `Fetching ${url}`); + const res = await fetch(url); + await ctx.mcpReq.log('debug', `Response status: ${res.status}`); + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } + ); + //#endregion registerTool_logging + return server; +} + +// --------------------------------------------------------------------------- +// Transports +// --------------------------------------------------------------------------- + +/** Example: Stateful Streamable HTTP transport with session management. */ +async function streamableHttp_stateful() { + //#region streamableHttp_stateful + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await server.connect(transport); + //#endregion streamableHttp_stateful +} + +/** Example: Stateless Streamable HTTP transport (no session persistence). */ +async function streamableHttp_stateless() { + //#region streamableHttp_stateless + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + + await server.connect(transport); + //#endregion streamableHttp_stateless +} + +/** Example: Streamable HTTP with JSON response mode (no SSE). */ +async function streamableHttp_jsonResponse() { + //#region streamableHttp_jsonResponse + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + + await server.connect(transport); + //#endregion streamableHttp_jsonResponse +} + +/** Example: stdio transport for local process-spawned integrations. */ +async function stdio_basic() { + //#region stdio_basic + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(); + await server.connect(transport); + //#endregion stdio_basic +} + +// --------------------------------------------------------------------------- +// DNS rebinding protection +// --------------------------------------------------------------------------- + +/** Example: createMcpExpressApp with different host bindings. */ +function dnsRebinding_basic() { + //#region dnsRebinding_basic + // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) + const app = createMcpExpressApp(); + + // DNS rebinding protection also auto-enabled for localhost + const appLocal = createMcpExpressApp({ host: 'localhost' }); + + // No automatic protection when binding to all interfaces + const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); + //#endregion dnsRebinding_basic + return { app, appLocal, appOpen }; +} + +/** Example: createMcpExpressApp with allowedHosts for non-localhost binding. */ +function dnsRebinding_allowedHosts() { + //#region dnsRebinding_allowedHosts + const app = createMcpExpressApp({ + host: '0.0.0.0', + allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] + }); + //#endregion dnsRebinding_allowedHosts + return app; +} + +// Suppress unused-function warnings (functions exist solely for type-checking) +void registerTool_basic; +void registerTool_resourceLink; +void registerTool_logging; +void registerResource_static; +void registerResource_template; +void registerPrompt_basic; +void registerPrompt_completion; +void streamableHttp_stateful; +void streamableHttp_stateless; +void streamableHttp_jsonResponse; +void stdio_basic; +void dnsRebinding_basic; +void dnsRebinding_allowedHosts; diff --git a/package.json b/package.json index ff9a9b350..8c756f032 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,16 @@ ], "scripts": { "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", + "sync:snippets": "tsx scripts/sync-snippets.ts", "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples-server exec tsx --watch src/simpleStreamableHttp.ts --oauth", "docs": "typedoc", "docs:check": "typedoc --emit none", "typecheck:all": "pnpm -r typecheck", "build:all": "pnpm -r build", "prepack:all": "pnpm -r prepack", - "lint:all": "pnpm -r lint", - "lint:fix:all": "pnpm -r lint:fix", - "check:all": "pnpm -r typecheck && pnpm -r lint", + "lint:all": "pnpm sync:snippets --check && pnpm -r lint", + "lint:fix:all": "pnpm sync:snippets && pnpm -r lint:fix", + "check:all": "pnpm -r typecheck && pnpm -r lint && pnpm run docs:check", "test:all": "pnpm -r test", "test:conformance:client": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client", "test:conformance:client:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:all", diff --git a/packages/client/package.json b/packages/client/package.json index 3ced4ba20..3f606227c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -55,7 +55,7 @@ "prepack": "npm run build", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "npm run typecheck && npm run lint && pnpm exec -- typedoc --emit none", + "check": "npm run typecheck && npm run lint", "test": "vitest run", "test:watch": "vitest", "start": "npm run server", diff --git a/packages/client/src/client/auth.examples.ts b/packages/client/src/client/auth.examples.ts new file mode 100644 index 000000000..17c04e6a0 --- /dev/null +++ b/packages/client/src/client/auth.examples.ts @@ -0,0 +1,63 @@ +/** + * Type-checked examples for `auth.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { AuthorizationServerMetadata } from '@modelcontextprotocol/core'; + +import type { OAuthClientProvider } from './auth.js'; +import { fetchToken } from './auth.js'; + +/** + * Base class providing no-op implementations of required OAuthClientProvider methods. + * Used as a base for concise examples that focus on specific methods. + */ +abstract class MyProviderBase implements OAuthClientProvider { + get redirectUrl(): URL | undefined { + return; + } + get clientMetadata() { + return { redirect_uris: [] as string[] }; + } + clientInformation(): undefined { + return; + } + tokens(): undefined { + return; + } + saveTokens() { + return Promise.resolve(); + } + redirectToAuthorization() { + return Promise.resolve(); + } + saveCodeVerifier() { + return Promise.resolve(); + } + codeVerifier() { + return Promise.resolve(''); + } +} + +/** + * Example: Using fetchToken with a client_credentials provider. + */ +async function fetchToken_clientCredentials(authServerUrl: URL, metadata: AuthorizationServerMetadata) { + //#region fetchToken_clientCredentials + // Provider for client_credentials: + class MyProvider extends MyProviderBase implements OAuthClientProvider { + prepareTokenRequest(scope?: string) { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } + } + + const tokens = await fetchToken(new MyProvider(), authServerUrl, { metadata }); + //#endregion fetchToken_clientCredentials + return tokens; +} diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 03b38b327..cfba29d85 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -144,7 +144,7 @@ export interface OAuthClientProvider { * credentials, in the case where the server has indicated that they are no longer valid. * This avoids requiring the user to intervene manually. */ - invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; /** * Prepares grant-specific parameters for a token request. @@ -183,6 +183,46 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the OAuth discovery state after RFC 9728 and authorization server metadata + * discovery. Providers can persist this state to avoid redundant discovery requests + * on subsequent {@linkcode auth} calls. + * + * This state can also be provided out-of-band (e.g., from a previous session or + * external configuration) to bootstrap the OAuth flow without discovery. + * + * Called by {@linkcode auth} after successful discovery. + */ + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + + /** + * Returns previously saved discovery state, or `undefined` if none is cached. + * + * When available, {@linkcode auth} restores the discovery state (authorization server + * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing + * latency on subsequent calls. + * + * Providers should clear cached discovery state on repeated authentication failures + * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow + * re-discovery in case the authorization server has changed. + */ + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. + * + * Contains the results of RFC 9728 protected resource metadata discovery and + * authorization server metadata discovery. Persisting this state avoids + * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + */ +// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL +// at which authorization server metadata was discovered. This would require +// `discoverAuthorizationServerMetadata()` to return the successful discovery URL. +export interface OAuthDiscoveryState extends OAuthServerInfo { + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -193,7 +233,7 @@ export class UnauthorizedError extends Error { } } -type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; +export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; function isClientAuthMethod(method: string): method is ClientAuthMethod { return ['client_secret_basic', 'client_secret_post', 'none'].includes(method); @@ -395,32 +435,70 @@ async function authInternal( fetchFn?: FetchLike; } ): Promise { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; + + // If resourceMetadataUrl is not provided, try to load it from cached state + // This handles browser redirects where the URL was saved before navigation + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = + cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); + } catch { + // RFC 9728 not available — selectResourceURL will handle undefined + } } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } - /** - * If we don't get a valid authorization server metadata from protected resource metadata, - * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. - */ - if (!authorizationServerUrl) { - authorizationServerUrl = new URL('/', serverUrl); + // Re-save if we enriched the cached state with missing metadata + if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } + } else { + // Full discovery via RFC 9728 + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // Persist discovery state for future use + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn - }); - // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -941,6 +1019,87 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string; + + /** + * The authorization server metadata (endpoints, capabilities), + * or `undefined` if metadata discovery failed. + */ + authorizationServerMetadata?: AuthorizationServerMetadata; + + /** + * The OAuth 2.0 Protected Resource Metadata from RFC 9728, + * or `undefined` if the server does not support it. + */ + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +/** + * Discovers the authorization server for an MCP server following + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected + * Resource Metadata), with fallback to treating the server URL as the + * authorization server. + * + * This function combines two discovery steps into one call: + * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the + * authorization server URL (RFC 9728). + * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery). + * + * Use this when you need the authorization server metadata for operations outside the + * {@linkcode auth} orchestrator, such as token refresh or token revocation. + * + * @param serverUrl - The MCP resource server URL + * @param opts - Optional configuration + * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint + * @param opts.fetchFn - Custom fetch function for HTTP requests + * @returns Authorization server URL, metadata, and resource metadata (if available) + */ +export async function discoverOAuthServerInfo( + serverUrl: string | URL, + opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: opts?.resourceMetadataUrl }, + opts?.fetchFn + ); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // RFC 9728 not supported -- fall back to treating the server URL as the authorization server + } + + // If we don't get a valid authorization server from protected resource metadata, + // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server + if (!authorizationServerUrl) { + authorizationServerUrl = String(new URL('/', serverUrl)); + } + + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + + return { + authorizationServerUrl, + authorizationServerMetadata, + resourceMetadata + }; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ @@ -1210,18 +1369,17 @@ export async function refreshAuthorization( * @throws {Error} When provider doesn't implement `prepareTokenRequest` or token fetch fails * * @example - * ```typescript + * ```ts source="./auth.examples.ts#fetchToken_clientCredentials" * // Provider for client_credentials: - * class MyProvider implements OAuthClientProvider { - * prepareTokenRequest(scope) { - * const params = new URLSearchParams({ grant_type: 'client_credentials' }); - * if (scope) params.set('scope', scope); - * return params; - * } - * // ... other methods + * class MyProvider extends MyProviderBase implements OAuthClientProvider { + * prepareTokenRequest(scope?: string) { + * const params = new URLSearchParams({ grant_type: 'client_credentials' }); + * if (scope) params.set('scope', scope); + * return params; + * } * } * - * const tokens = await fetchToken(provider, authServerUrl, { metadata }); + * const tokens = await fetchToken(new MyProvider(), authServerUrl, { metadata }); * ``` */ export async function fetchToken( diff --git a/packages/client/src/client/authExtensions.examples.ts b/packages/client/src/client/authExtensions.examples.ts new file mode 100644 index 000000000..bcb26a3d4 --- /dev/null +++ b/packages/client/src/client/authExtensions.examples.ts @@ -0,0 +1,62 @@ +/** + * Type-checked examples for `authExtensions.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { ClientCredentialsProvider, createPrivateKeyJwtAuth, PrivateKeyJwtProvider } from './authExtensions.js'; +import { StreamableHTTPClientTransport } from './streamableHttp.js'; + +/** + * Example: Creating a private key JWT authentication function. + */ +function createPrivateKeyJwtAuth_basicUsage(pemEncodedPrivateKey: string) { + //#region createPrivateKeyJwtAuth_basicUsage + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'my-client', + subject: 'my-client', + privateKey: pemEncodedPrivateKey, + alg: 'RS256' + }); + // pass addClientAuth as provider.addClientAuthentication implementation + //#endregion createPrivateKeyJwtAuth_basicUsage + return addClientAuth; +} + +/** + * Example: Using ClientCredentialsProvider for OAuth client credentials flow. + */ +function ClientCredentialsProvider_basicUsage(serverUrl: URL) { + //#region ClientCredentialsProvider_basicUsage + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret' + }); + + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider: provider + }); + //#endregion ClientCredentialsProvider_basicUsage + return transport; +} + +/** + * Example: Using PrivateKeyJwtProvider for OAuth with private key JWT. + */ +function PrivateKeyJwtProvider_basicUsage(pemEncodedPrivateKey: string, serverUrl: URL) { + //#region PrivateKeyJwtProvider_basicUsage + const provider = new PrivateKeyJwtProvider({ + clientId: 'my-client', + privateKey: pemEncodedPrivateKey, + algorithm: 'RS256' + }); + + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider: provider + }); + //#endregion PrivateKeyJwtProvider_basicUsage + return transport; +} diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index c3c69f00c..c366947be 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -14,8 +14,13 @@ import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; * Helper to produce a `private_key_jwt` client authentication function. * * @example - * ```typescript - * const addClientAuth = createPrivateKeyJwtAuth({ issuer, subject, privateKey, alg, audience? }); + * ```ts source="./authExtensions.examples.ts#createPrivateKeyJwtAuth_basicUsage" + * const addClientAuth = createPrivateKeyJwtAuth({ + * issuer: 'my-client', + * subject: 'my-client', + * privateKey: pemEncodedPrivateKey, + * alg: 'RS256' + * }); * // pass addClientAuth as provider.addClientAuthentication implementation * ``` */ @@ -116,14 +121,14 @@ export interface ClientCredentialsProviderOptions { * the client authenticates using a `client_id` and `client_secret`. * * @example - * ```typescript + * ```ts source="./authExtensions.examples.ts#ClientCredentialsProvider_basicUsage" * const provider = new ClientCredentialsProvider({ - * clientId: 'my-client', - * clientSecret: 'my-secret' + * clientId: 'my-client', + * clientSecret: 'my-secret' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { - * authProvider: provider + * authProvider: provider * }); * ``` */ @@ -227,15 +232,15 @@ export interface PrivateKeyJwtProviderOptions { * ({@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 | RFC 7523 Section 2.2}). * * @example - * ```typescript + * ```ts source="./authExtensions.examples.ts#PrivateKeyJwtProvider_basicUsage" * const provider = new PrivateKeyJwtProvider({ - * clientId: 'my-client', - * privateKey: pemEncodedPrivateKey, - * algorithm: 'RS256' + * clientId: 'my-client', + * privateKey: pemEncodedPrivateKey, + * algorithm: 'RS256' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { - * authProvider: provider + * authProvider: provider * }); * ``` */ diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts new file mode 100644 index 000000000..b18c26701 --- /dev/null +++ b/packages/client/src/client/client.examples.ts @@ -0,0 +1,38 @@ +/** + * Type-checked examples for `client.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { Client } from './client.js'; + +/** + * Example: Using listChanged to automatically track tool and prompt updates. + */ +function ClientOptions_listChanged() { + //#region ClientOptions_listChanged + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Failed to refresh tools:', error); + return; + } + console.log('Tools updated:', tools); + } + }, + prompts: { + onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + } + } + } + ); + //#endregion ClientOptions_listChanged + return client; +} diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b6e83e359..3c434f723 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -161,25 +161,25 @@ export type ClientOptions = ProtocolOptions & { * Configure handlers for list changed notifications (tools, prompts, resources). * * @example - * ```typescript + * ```ts source="./client.examples.ts#ClientOptions_listChanged" * const client = new Client( - * { name: 'my-client', version: '1.0.0' }, - * { - * listChanged: { - * tools: { - * onChanged: (error, tools) => { - * if (error) { - * console.error('Failed to refresh tools:', error); - * return; - * } - * console.log('Tools updated:', tools); + * { name: 'my-client', version: '1.0.0' }, + * { + * listChanged: { + * tools: { + * onChanged: (error, tools) => { + * if (error) { + * console.error('Failed to refresh tools:', error); + * return; + * } + * console.log('Tools updated:', tools); + * } + * }, + * prompts: { + * onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + * } * } - * }, - * prompts: { - * onChanged: (error, prompts) => console.log('Prompts updated:', prompts) - * } * } - * } * ); * ``` */ diff --git a/packages/client/src/client/middleware.examples.ts b/packages/client/src/client/middleware.examples.ts new file mode 100644 index 000000000..9ccea3abc --- /dev/null +++ b/packages/client/src/client/middleware.examples.ts @@ -0,0 +1,89 @@ +/** + * Type-checked examples for `middleware.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { Middleware } from './middleware.js'; +import { applyMiddlewares, createMiddleware } from './middleware.js'; + +// Stubs for hypothetical application middleware +declare function withOAuth(provider: unknown, url: string): Middleware; +declare function withLogging(opts: { statusLevel: number }): Middleware; + +// Stubs for hypothetical application cache +declare function getFromCache(key: string): Promise; +declare function saveToCache(key: string, value: string): Promise; + +/** + * Example: Creating a middleware pipeline for OAuth and logging. + */ +async function applyMiddlewares_basicUsage(oauthProvider: unknown) { + //#region applyMiddlewares_basicUsage + // Create a middleware pipeline that handles both OAuth and logging + const enhancedFetch = applyMiddlewares(withOAuth(oauthProvider, 'https://api.example.com'), withLogging({ statusLevel: 400 }))(fetch); + + // Use the enhanced fetch - it will handle auth and log errors + const response = await enhancedFetch('https://api.example.com/data'); + //#endregion applyMiddlewares_basicUsage + return response; +} + +/** + * Example: Creating various custom middlewares with createMiddleware. + */ +function createMiddleware_examples() { + //#region createMiddleware_examples + // Create custom authentication middleware + const customAuthMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Auth', 'my-token'); + + const response = await next(input, { ...init, headers }); + + if (response.status === 401) { + console.log('Authentication failed'); + } + + return response; + }); + + // Create conditional middleware + const conditionalMiddleware = createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + + // Only add headers for API routes + if (url.includes('/api/')) { + const headers = new Headers(init?.headers); + headers.set('X-API-Version', 'v2'); + return next(input, { ...init, headers }); + } + + // Pass through for non-API routes + return next(input, init); + }); + + // Create caching middleware + const cacheMiddleware = createMiddleware(async (next, input, init) => { + const cacheKey = typeof input === 'string' ? input : input.toString(); + + // Check cache first + const cached = await getFromCache(cacheKey); + if (cached) { + return new Response(cached, { status: 200 }); + } + + // Make request and cache result + const response = await next(input, init); + if (response.ok) { + await saveToCache(cacheKey, await response.clone().text()); + } + + return response; + }); + //#endregion createMiddleware_examples + return { customAuthMiddleware, conditionalMiddleware, cacheMiddleware }; +} diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index 31151b68f..749414441 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -235,12 +235,9 @@ export const withLogging = (options: LoggingOptions = {}): Middleware => { * Middleware are applied in the order they appear, creating a chain of handlers. * * @example - * ```typescript + * ```ts source="./middleware.examples.ts#applyMiddlewares_basicUsage" * // Create a middleware pipeline that handles both OAuth and logging - * const enhancedFetch = applyMiddlewares( - * withOAuth(oauthProvider, 'https://api.example.com'), - * withLogging({ statusLevel: 400 }) - * )(fetch); + * const enhancedFetch = applyMiddlewares(withOAuth(oauthProvider, 'https://api.example.com'), withLogging({ statusLevel: 400 }))(fetch); * * // Use the enhanced fetch - it will handle auth and log errors * const response = await enhancedFetch('https://api.example.com/data'); @@ -264,53 +261,53 @@ export const applyMiddlewares = (...middleware: Middleware[]): Middleware => { * Provides the next handler and request details as separate parameters for easier access. * * @example - * ```typescript + * ```ts source="./middleware.examples.ts#createMiddleware_examples" * // Create custom authentication middleware * const customAuthMiddleware = createMiddleware(async (next, input, init) => { - * const headers = new Headers(init?.headers); - * headers.set('X-Custom-Auth', 'my-token'); + * const headers = new Headers(init?.headers); + * headers.set('X-Custom-Auth', 'my-token'); * - * const response = await next(input, { ...init, headers }); + * const response = await next(input, { ...init, headers }); * - * if (response.status === 401) { - * console.log('Authentication failed'); - * } + * if (response.status === 401) { + * console.log('Authentication failed'); + * } * - * return response; + * return response; * }); * * // Create conditional middleware * const conditionalMiddleware = createMiddleware(async (next, input, init) => { - * const url = typeof input === 'string' ? input : input.toString(); + * const url = typeof input === 'string' ? input : input.toString(); * - * // Only add headers for API routes - * if (url.includes('/api/')) { - * const headers = new Headers(init?.headers); - * headers.set('X-API-Version', 'v2'); - * return next(input, { ...init, headers }); - * } + * // Only add headers for API routes + * if (url.includes('/api/')) { + * const headers = new Headers(init?.headers); + * headers.set('X-API-Version', 'v2'); + * return next(input, { ...init, headers }); + * } * - * // Pass through for non-API routes - * return next(input, init); + * // Pass through for non-API routes + * return next(input, init); * }); * * // Create caching middleware * const cacheMiddleware = createMiddleware(async (next, input, init) => { - * const cacheKey = typeof input === 'string' ? input : input.toString(); + * const cacheKey = typeof input === 'string' ? input : input.toString(); * - * // Check cache first - * const cached = await getFromCache(cacheKey); - * if (cached) { - * return new Response(cached, { status: 200 }); - * } + * // Check cache first + * const cached = await getFromCache(cacheKey); + * if (cached) { + * return new Response(cached, { status: 200 }); + * } * - * // Make request and cache result - * const response = await next(input, init); - * if (response.ok) { - * await saveToCache(cacheKey, await response.clone().text()); - * } + * // Make request and cache result + * const response = await next(input, init); + * if (response.ok) { + * await saveToCache(cacheKey, await response.clone().text()); + * } * - * return response; + * return response; * }); * ``` * diff --git a/packages/client/src/experimental/tasks/client.examples.ts b/packages/client/src/experimental/tasks/client.examples.ts new file mode 100644 index 000000000..d8146edee --- /dev/null +++ b/packages/client/src/experimental/tasks/client.examples.ts @@ -0,0 +1,71 @@ +/** + * Type-checked examples for `client.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { Request, RequestOptions } from '@modelcontextprotocol/core'; +import { CallToolResultSchema } from '@modelcontextprotocol/core'; + +import type { Client } from '../../client/client.js'; + +/** + * Example: Using callToolStream to execute a tool with task lifecycle events. + */ +async function ExperimentalClientTasks_callToolStream(client: Client) { + //#region ExperimentalClientTasks_callToolStream + const stream = client.experimental.tasks.callToolStream({ name: 'myTool', arguments: {} }); + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': { + console.log('Tool execution started:', message.task.taskId); + break; + } + case 'taskStatus': { + console.log('Tool status:', message.task.status); + break; + } + case 'result': { + console.log('Tool result:', message.result); + break; + } + case 'error': { + console.error('Tool error:', message.error); + break; + } + } + } + //#endregion ExperimentalClientTasks_callToolStream +} + +/** + * Example: Using requestStream to consume task lifecycle events for any request type. + */ +async function ExperimentalClientTasks_requestStream(client: Client, request: Request, options: RequestOptions) { + //#region ExperimentalClientTasks_requestStream + const stream = client.experimental.tasks.requestStream(request, CallToolResultSchema, options); + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': { + console.log('Task created:', message.task.taskId); + break; + } + case 'taskStatus': { + console.log('Task status:', message.task.status); + break; + } + case 'result': { + console.log('Final result:', message.result); + break; + } + case 'error': { + console.error('Error:', message.error); + break; + } + } + } + //#endregion ExperimentalClientTasks_requestStream +} diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index 4379fc324..4f2bb7543 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -57,28 +57,31 @@ export class ExperimentalClientTasks { * Automatically validates structured output if the tool has an `outputSchema`. * * @example - * ```typescript + * ```ts source="./client.examples.ts#ExperimentalClientTasks_callToolStream" * const stream = client.experimental.tasks.callToolStream({ name: 'myTool', arguments: {} }); * for await (const message of stream) { - * switch (message.type) { - * case 'taskCreated': - * console.log('Tool execution started:', message.task.taskId); - * break; - * case 'taskStatus': - * console.log('Tool status:', message.task.status); - * break; - * case 'result': - * console.log('Tool result:', message.result); - * break; - * case 'error': - * console.error('Tool error:', message.error); - * break; - * } + * switch (message.type) { + * case 'taskCreated': { + * console.log('Tool execution started:', message.task.taskId); + * break; + * } + * case 'taskStatus': { + * console.log('Tool status:', message.task.status); + * break; + * } + * case 'result': { + * console.log('Tool result:', message.result); + * break; + * } + * case 'error': { + * console.error('Tool error:', message.error); + * break; + * } + * } * } * ``` * * @param params - Tool call parameters (name and arguments) - * @param resultSchema - Zod schema for validating the result (defaults to {@linkcode CallToolResultSchema}) * @param options - Optional request options (timeout, signal, task creation params, etc.) * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects * @@ -240,6 +243,31 @@ export class ExperimentalClientTasks { * This method provides streaming access to request processing, allowing you to * observe intermediate task status updates for task-augmented requests. * + * @example + * ```ts source="./client.examples.ts#ExperimentalClientTasks_requestStream" + * const stream = client.experimental.tasks.requestStream(request, CallToolResultSchema, options); + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': { + * console.log('Task created:', message.task.taskId); + * break; + * } + * case 'taskStatus': { + * console.log('Task status:', message.task.status); + * break; + * } + * case 'result': { + * console.log('Final result:', message.result); + * break; + * } + * case 'error': { + * console.error('Error:', message.error); + * break; + * } + * } + * } + * ``` + * * @param request - The request to send * @param resultSchema - Zod schema for validating the result * @param options - Optional request options (timeout, signal, task creation params, etc.) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 4bbd08859..742dbc143 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -10,6 +10,7 @@ import { discoverAuthorizationServerMetadata, discoverOAuthMetadata, discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, isHttpsUrl, @@ -895,6 +896,369 @@ describe('OAuth Authorization', () => { }); }); + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); + }); + + it('restores full discovery state from cache including resource metadata', async () => { + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); + + // Verify the token request includes the resource parameter from cached metadata + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); + }); + + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should have used the cached PRM URL, not the default well-known path + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); + }); + }); + describe('selectClientAuthMethod', () => { it('selects the correct client authentication method from client information', () => { const clientInfo = { diff --git a/packages/core/src/errors/sdkErrors.examples.ts b/packages/core/src/errors/sdkErrors.examples.ts new file mode 100644 index 000000000..729a879ac --- /dev/null +++ b/packages/core/src/errors/sdkErrors.examples.ts @@ -0,0 +1,27 @@ +/** + * Type-checked examples for `sdkErrors.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { SdkError, SdkErrorCode } from './sdkErrors.js'; + +/** + * Example: Throwing and catching SDK errors. + */ +function SdkError_basicUsage() { + //#region SdkError_basicUsage + try { + // Throwing an SDK error + throw new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected'); + } catch (error) { + // Checking error type by code + if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + // Handle timeout + } + } + //#endregion SdkError_basicUsage +} diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index eac276b0f..f53c07ccf 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -42,13 +42,15 @@ export enum SdkErrorCode { * that are serialized and sent as error responses. * * @example - * ```typescript - * // Throwing an SDK error - * throw new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected'); - * - * // Checking error type by code - * if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { - * // Handle timeout + * ```ts source="./sdkErrors.examples.ts#SdkError_basicUsage" + * try { + * // Throwing an SDK error + * throw new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected'); + * } catch (error) { + * // Checking error type by code + * if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + * // Handle timeout + * } * } * ``` */ diff --git a/packages/core/src/index.examples.ts b/packages/core/src/index.examples.ts new file mode 100644 index 000000000..41952d29e --- /dev/null +++ b/packages/core/src/index.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `index.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { AjvJsonSchemaValidator } from './validation/ajvProvider.js'; +import { CfWorkerJsonSchemaValidator } from './validation/cfWorkerProvider.js'; + +/** + * Example: AJV validator for Node.js. + */ +function validation_ajv() { + //#region validation_ajv + const validator = new AjvJsonSchemaValidator(); + //#endregion validation_ajv + return validator; +} + +/** + * Example: CfWorker validator for edge runtimes. + */ +function validation_cfWorker() { + //#region validation_cfWorker + const validator = new CfWorkerJsonSchemaValidator(); + //#endregion validation_cfWorker + return validator; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7948d6517..917e5c6b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,14 +31,13 @@ export * from './validation/cfWorkerProvider.js'; * Import from: @modelcontextprotocol/sdk/validation/cfworker * Requires peer dependency: @cfworker/json-schema * - * @example - * ```typescript - * // For Node.js with AJV - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * @example For Node.js with AJV + * ```ts source="./index.examples.ts#validation_ajv" * const validator = new AjvJsonSchemaValidator(); + * ``` * - * // For Cloudflare Workers - * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; + * @example For Cloudflare Workers + * ```ts source="./index.examples.ts#validation_cfWorker" * const validator = new CfWorkerJsonSchemaValidator(); * ``` * diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index f241ce730..6c693971f 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1033,27 +1033,6 @@ export abstract class Protocol { * Sends a request and returns an AsyncGenerator that yields response messages. * The generator is guaranteed to end with either a `'result'` or `'error'` message. * - * @example - * ```typescript - * const stream = protocol.requestStream(request, resultSchema, options); - * for await (const message of stream) { - * switch (message.type) { - * case 'taskCreated': - * console.log('Task created:', message.task.taskId); - * break; - * case 'taskStatus': - * console.log('Task status:', message.task.status); - * break; - * case 'result': - * console.log('Final result:', message.result); - * break; - * case 'error': - * console.error('Error:', message.error); - * break; - * } - * } - * ``` - * * @experimental Use `client.experimental.tasks.requestStream()` to access this method. */ protected async *requestStream( diff --git a/packages/core/src/validation/ajvProvider.examples.ts b/packages/core/src/validation/ajvProvider.examples.ts new file mode 100644 index 000000000..eea45bf15 --- /dev/null +++ b/packages/core/src/validation/ajvProvider.examples.ts @@ -0,0 +1,48 @@ +/** + * Type-checked examples for `ajvProvider.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { Ajv } from 'ajv'; +import _addFormats from 'ajv-formats'; + +import { AjvJsonSchemaValidator } from './ajvProvider.js'; + +const addFormats = _addFormats as unknown as typeof _addFormats.default; + +/** + * Example: Default AJV instance. + */ +function AjvJsonSchemaValidator_default() { + //#region AjvJsonSchemaValidator_default + const validator = new AjvJsonSchemaValidator(); + //#endregion AjvJsonSchemaValidator_default + return validator; +} + +/** + * Example: Custom AJV instance. + */ +function AjvJsonSchemaValidator_customInstance() { + //#region AjvJsonSchemaValidator_customInstance + const ajv = new Ajv({ strict: true, allErrors: true }); + const validator = new AjvJsonSchemaValidator(ajv); + //#endregion AjvJsonSchemaValidator_customInstance + return validator; +} + +/** + * Example: Constructor with advanced AJV configuration including formats. + */ +function AjvJsonSchemaValidator_constructor_withFormats() { + //#region AjvJsonSchemaValidator_constructor_withFormats + const ajv = new Ajv({ validateFormats: true }); + addFormats(ajv); + const validator = new AjvJsonSchemaValidator(ajv); + //#endregion AjvJsonSchemaValidator_constructor_withFormats + return validator; +} diff --git a/packages/core/src/validation/ajvProvider.ts b/packages/core/src/validation/ajvProvider.ts index ce9ea125e..ae57e8c7d 100644 --- a/packages/core/src/validation/ajvProvider.ts +++ b/packages/core/src/validation/ajvProvider.ts @@ -22,14 +22,13 @@ function createDefaultAjvInstance(): Ajv { } /** - * @example - * ```typescript - * // Use with default AJV instance (recommended) - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * @example Use with default AJV instance (recommended) + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" * const validator = new AjvJsonSchemaValidator(); + * ``` * - * // Use with custom AJV instance - * import { Ajv } from 'ajv'; + * @example Use with custom AJV instance + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance" * const ajv = new Ajv({ strict: true, allErrors: true }); * const validator = new AjvJsonSchemaValidator(ajv); * ``` @@ -44,16 +43,13 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { * * @param ajv - Optional pre-configured AJV instance. If not provided, a default instance will be created. * - * @example - * ```typescript - * // Use default configuration (recommended for most cases) - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * @example Use default configuration (recommended for most cases) + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" * const validator = new AjvJsonSchemaValidator(); + * ``` * - * // Or provide custom AJV instance for advanced configuration - * import { Ajv } from 'ajv'; - * import addFormats from 'ajv-formats'; - * + * @example Provide custom AJV instance for advanced configuration + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_constructor_withFormats" * const ajv = new Ajv({ validateFormats: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); diff --git a/packages/core/src/validation/cfWorkerProvider.examples.ts b/packages/core/src/validation/cfWorkerProvider.examples.ts new file mode 100644 index 000000000..a347f9b7c --- /dev/null +++ b/packages/core/src/validation/cfWorkerProvider.examples.ts @@ -0,0 +1,33 @@ +/** + * Type-checked examples for `cfWorkerProvider.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { CfWorkerJsonSchemaValidator } from './cfWorkerProvider.js'; + +/** + * Example: Default configuration. + */ +function CfWorkerJsonSchemaValidator_default() { + //#region CfWorkerJsonSchemaValidator_default + const validator = new CfWorkerJsonSchemaValidator(); + //#endregion CfWorkerJsonSchemaValidator_default + return validator; +} + +/** + * Example: Custom configuration with all errors reported. + */ +function CfWorkerJsonSchemaValidator_customConfig() { + //#region CfWorkerJsonSchemaValidator_customConfig + const validator = new CfWorkerJsonSchemaValidator({ + draft: '2020-12', + shortcircuit: false // Report all errors + }); + //#endregion CfWorkerJsonSchemaValidator_customConfig + return validator; +} diff --git a/packages/core/src/validation/cfWorkerProvider.ts b/packages/core/src/validation/cfWorkerProvider.ts index 982d5703b..f2cce37e8 100644 --- a/packages/core/src/validation/cfWorkerProvider.ts +++ b/packages/core/src/validation/cfWorkerProvider.ts @@ -19,15 +19,16 @@ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; /** * - * @example - * ```typescript - * // Use with default configuration (2020-12, shortcircuit) + * @example Use with default configuration (2020-12, shortcircuit) + * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default" * const validator = new CfWorkerJsonSchemaValidator(); + * ``` * - * // Use with custom configuration + * @example Use with custom configuration + * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_customConfig" * const validator = new CfWorkerJsonSchemaValidator({ - * draft: '2020-12', - * shortcircuit: false // Report all errors + * draft: '2020-12', + * shortcircuit: false // Report all errors * }); * ``` */ diff --git a/packages/core/src/validation/types.examples.ts b/packages/core/src/validation/types.examples.ts new file mode 100644 index 000000000..b6cd76069 --- /dev/null +++ b/packages/core/src/validation/types.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `types.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from './types.js'; + +// Stub for hypothetical schema validation function +declare function isValid(schema: JsonSchemaType, input: unknown): boolean; + +/** + * Example: Implementing the jsonSchemaValidator interface. + */ +function jsonSchemaValidator_implementation() { + //#region jsonSchemaValidator_implementation + class MyValidatorProvider implements jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Compile/cache validator from schema + return (input: unknown) => + isValid(schema, input) + ? { valid: true, data: input as T, errorMessage: undefined } + : { valid: false, data: undefined, errorMessage: 'Error details' }; + } + } + //#endregion jsonSchemaValidator_implementation + return MyValidatorProvider; +} diff --git a/packages/core/src/validation/types.ts b/packages/core/src/validation/types.ts index 5864a43f2..e2202b4a6 100644 --- a/packages/core/src/validation/types.ts +++ b/packages/core/src/validation/types.ts @@ -36,19 +36,15 @@ export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResu * - Provide clear error messages on validation failure * * @example - * ```typescript + * ```ts source="./types.examples.ts#jsonSchemaValidator_implementation" * class MyValidatorProvider implements jsonSchemaValidator { - * getValidator(schema: JsonSchemaType): JsonSchemaValidator { - * // Compile/cache validator from schema - * return (input: unknown) => { - * // Validate input against schema - * if (valid) { - * return { valid: true, data: input as T, errorMessage: undefined }; - * } else { - * return { valid: false, data: undefined, errorMessage: 'Error details' }; - * } - * }; - * } + * getValidator(schema: JsonSchemaType): JsonSchemaValidator { + * // Compile/cache validator from schema + * return (input: unknown) => + * isValid(schema, input) + * ? { valid: true, data: input as T, errorMessage: undefined } + * : { valid: false, data: undefined, errorMessage: 'Error details' }; + * } * } * ``` */ diff --git a/packages/middleware/express/src/express.examples.ts b/packages/middleware/express/src/express.examples.ts new file mode 100644 index 000000000..8d3f8e2ff --- /dev/null +++ b/packages/middleware/express/src/express.examples.ts @@ -0,0 +1,41 @@ +/** + * Type-checked examples for `express.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { createMcpExpressApp } from './express.js'; + +/** + * Example: Basic usage with default DNS rebinding protection. + */ +function createMcpExpressApp_default() { + //#region createMcpExpressApp_default + const app = createMcpExpressApp(); + //#endregion createMcpExpressApp_default + return app; +} + +/** + * Example: Custom host binding with and without DNS rebinding protection. + */ +function createMcpExpressApp_customHost() { + //#region createMcpExpressApp_customHost + const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + const appLocal = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + //#endregion createMcpExpressApp_customHost + return { appOpen, appLocal }; +} + +/** + * Example: Custom allowed hosts for non-localhost binding. + */ +function createMcpExpressApp_allowedHosts() { + //#region createMcpExpressApp_allowedHosts + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + //#endregion createMcpExpressApp_allowedHosts + return app; +} diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index 68dbafb76..af156a229 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -34,16 +34,19 @@ export interface CreateMcpExpressAppOptions { * @param options - Configuration options * @returns A configured Express application * - * @example - * ```typescript - * // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * @example Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * ```ts source="./express.examples.ts#createMcpExpressApp_default" * const app = createMcpExpressApp(); + * ``` * - * // Custom host - DNS rebinding protection only applied for localhost hosts - * const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection - * const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + * @example Custom host - DNS rebinding protection only applied for localhost hosts + * ```ts source="./express.examples.ts#createMcpExpressApp_customHost" + * const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + * const appLocal = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + * ``` * - * // Custom allowed hosts for non-localhost binding + * @example Custom allowed hosts for non-localhost binding + * ```ts source="./express.examples.ts#createMcpExpressApp_allowedHosts" * const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); * ``` */ diff --git a/packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts b/packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts new file mode 100644 index 000000000..2e00f48b6 --- /dev/null +++ b/packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `hostHeaderValidation.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { Express } from 'express'; + +import { hostHeaderValidation, localhostHostValidation } from './hostHeaderValidation.js'; + +/** + * Example: Using hostHeaderValidation middleware with custom allowed hosts. + */ +function hostHeaderValidation_basicUsage(app: Express) { + //#region hostHeaderValidation_basicUsage + const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + app.use(middleware); + //#endregion hostHeaderValidation_basicUsage +} + +/** + * Example: Using localhostHostValidation convenience middleware. + */ +function localhostHostValidation_basicUsage(app: Express) { + //#region localhostHostValidation_basicUsage + app.use(localhostHostValidation()); + //#endregion localhostHostValidation_basicUsage +} diff --git a/packages/middleware/express/src/middleware/hostHeaderValidation.ts b/packages/middleware/express/src/middleware/hostHeaderValidation.ts index 47c2b0a94..c22ee6655 100644 --- a/packages/middleware/express/src/middleware/hostHeaderValidation.ts +++ b/packages/middleware/express/src/middleware/hostHeaderValidation.ts @@ -15,7 +15,7 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; * @returns Express middleware function * * @example - * ```typescript + * ```ts source="./hostHeaderValidation.examples.ts#hostHeaderValidation_basicUsage" * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); * app.use(middleware); * ``` @@ -43,7 +43,7 @@ export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler * Allows only `localhost`, `127.0.0.1`, and `[::1]` (IPv6 localhost) hostnames. * * @example - * ```typescript + * ```ts source="./hostHeaderValidation.examples.ts#localhostHostValidation_basicUsage" * app.use(localhostHostValidation()); * ``` */ diff --git a/packages/middleware/node/src/streamableHttp.examples.ts b/packages/middleware/node/src/streamableHttp.examples.ts new file mode 100644 index 000000000..fb4bef841 --- /dev/null +++ b/packages/middleware/node/src/streamableHttp.examples.ts @@ -0,0 +1,56 @@ +/** + * Type-checked examples for `streamableHttp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { McpServer } from '@modelcontextprotocol/server'; + +import { NodeStreamableHTTPServerTransport } from './streamableHttp.js'; + +/** + * Example: Stateful Streamable HTTP transport (Node.js). + */ +async function NodeStreamableHTTPServerTransport_stateful() { + //#region NodeStreamableHTTPServerTransport_stateful + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await server.connect(transport); + //#endregion NodeStreamableHTTPServerTransport_stateful +} + +/** + * Example: Stateless Streamable HTTP transport (Node.js). + */ +async function NodeStreamableHTTPServerTransport_stateless() { + //#region NodeStreamableHTTPServerTransport_stateless + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + //#endregion NodeStreamableHTTPServerTransport_stateless + return transport; +} + +// Stubs for Express-style app +declare const app: { post(path: string, handler: (req: IncomingMessage & { body?: unknown }, res: ServerResponse) => void): void }; + +/** + * Example: Using with a pre-parsed request body (e.g. Express). + */ +function NodeStreamableHTTPServerTransport_express(transport: NodeStreamableHTTPServerTransport) { + //#region NodeStreamableHTTPServerTransport_express + app.post('/mcp', (req, res) => { + transport.handleRequest(req, res, req.body); + }); + //#endregion NodeStreamableHTTPServerTransport_express +} diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 59f8d2b82..68a0c224f 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -28,25 +28,6 @@ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServ * This is a wrapper around {@linkcode WebStandardStreamableHTTPServerTransport} that provides Node.js HTTP compatibility. * It uses the `@hono/node-server` library to convert between Node.js HTTP and Web Standard APIs. * - * Usage example: - * - * ```typescript - * // Stateful mode - server sets the session ID - * const statefulTransport = new StreamableHTTPServerTransport({ - * sessionIdGenerator: () => randomUUID(), - * }); - * - * // Stateless mode - explicitly set session ID to undefined - * const statelessTransport = new StreamableHTTPServerTransport({ - * sessionIdGenerator: undefined, - * }); - * - * // Using with pre-parsed request body - * app.post('/mcp', (req, res) => { - * transport.handleRequest(req, res, req.body); - * }); - * ``` - * * In stateful mode: * - Session ID is generated and included in response headers * - Session ID is always included in initialization responses @@ -57,6 +38,31 @@ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServ * In stateless mode: * - No Session ID is included in any responses * - No session validation is performed + * + * @example Stateful setup + * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_stateful" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * + * const transport = new NodeStreamableHTTPServerTransport({ + * sessionIdGenerator: () => randomUUID() + * }); + * + * await server.connect(transport); + * ``` + * + * @example Stateless setup + * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_stateless" + * const transport = new NodeStreamableHTTPServerTransport({ + * sessionIdGenerator: undefined + * }); + * ``` + * + * @example Using with a pre-parsed request body (e.g. Express) + * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_express" + * app.post('/mcp', (req, res) => { + * transport.handleRequest(req, res, req.body); + * }); + * ``` */ export class NodeStreamableHTTPServerTransport implements Transport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; diff --git a/packages/server/package.json b/packages/server/package.json index 882e5f283..9cebe8aed 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -55,7 +55,7 @@ "prepack": "npm run build", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "npm run typecheck && npm run lint && pnpm exec -- typedoc --emit none", + "check": "npm run typecheck && npm run lint", "test": "vitest run", "test:watch": "vitest", "start": "npm run server", diff --git a/packages/server/src/server/completable.examples.ts b/packages/server/src/server/completable.examples.ts new file mode 100644 index 000000000..b0655d243 --- /dev/null +++ b/packages/server/src/server/completable.examples.ts @@ -0,0 +1,46 @@ +/** + * Type-checked examples for `completable.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import * as z from 'zod/v4'; + +import { completable } from './completable.js'; +import { McpServer } from './mcp.js'; + +/** + * Example: Using completable() in a prompt registration. + */ +function completable_basicUsage() { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + //#region completable_basicUsage + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${language} code.` + } + } + ] + }) + ); + //#endregion completable_basicUsage + return server; +} diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 126f88f55..f80cdad95 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -21,6 +21,32 @@ export type CompletableSchema = T & { /** * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. * + * @example + * ```ts source="./completable.examples.ts#completable_basicUsage" + * server.registerPrompt( + * 'review-code', + * { + * title: 'Code Review', + * argsSchema: z.object({ + * language: completable(z.string().describe('Programming language'), value => + * ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + * ) + * }) + * }, + * ({ language }) => ({ + * messages: [ + * { + * role: 'user' as const, + * content: { + * type: 'text' as const, + * text: `Review this ${language} code.` + * } + * } + * ] + * }) + * ); + * ``` + * * @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions */ export function completable(schema: T, complete: CompleteCallback): CompletableSchema { diff --git a/packages/server/src/server/mcp.examples.ts b/packages/server/src/server/mcp.examples.ts new file mode 100644 index 000000000..740c1bf18 --- /dev/null +++ b/packages/server/src/server/mcp.examples.ts @@ -0,0 +1,145 @@ +/** + * Type-checked examples for `mcp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { CallToolResult } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; + +import { McpServer } from './mcp.js'; +import { StdioServerTransport } from './stdio.js'; + +/** + * Example: Creating a new McpServer. + */ +function McpServer_basicUsage() { + //#region McpServer_basicUsage + const server = new McpServer({ + name: 'my-server', + version: '1.0.0' + }); + //#endregion McpServer_basicUsage + return server; +} + +/** + * Example: Registering a tool with inputSchema and outputSchema. + */ +function McpServer_registerTool_basic(server: McpServer) { + //#region McpServer_registerTool_basic + server.registerTool( + 'calculate-bmi', + { + title: 'BMI Calculator', + description: 'Calculate Body Mass Index', + inputSchema: z.object({ + weightKg: z.number(), + heightM: z.number() + }), + outputSchema: z.object({ bmi: z.number() }) + }, + async ({ weightKg, heightM }) => { + const output = { bmi: weightKg / (heightM * heightM) }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + //#endregion McpServer_registerTool_basic +} + +/** + * Example: Registering a static resource at a fixed URI. + */ +function McpServer_registerResource_static(server: McpServer) { + //#region McpServer_registerResource_static + server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) + ); + //#endregion McpServer_registerResource_static +} + +/** + * Example: Registering a prompt with an argument schema. + */ +function McpServer_registerPrompt_basic(server: McpServer) { + //#region McpServer_registerPrompt_basic + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ code: z.string() }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Please review this code:\n\n${code}` + } + } + ] + }) + ); + //#endregion McpServer_registerPrompt_basic +} + +/** + * Example: Connecting an McpServer to a stdio transport. + */ +async function McpServer_connect_stdio() { + //#region McpServer_connect_stdio + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(); + await server.connect(transport); + //#endregion McpServer_connect_stdio +} + +/** + * Example: Sending a log message to the client. + */ +async function McpServer_sendLoggingMessage_basic(server: McpServer) { + //#region McpServer_sendLoggingMessage_basic + await server.sendLoggingMessage({ + level: 'info', + data: 'Processing complete' + }); + //#endregion McpServer_sendLoggingMessage_basic +} + +/** + * Example: Logging from inside a tool handler via ctx.mcpReq.log(). + */ +function McpServer_registerTool_logging(server: McpServer) { + //#region McpServer_registerTool_logging + server.registerTool( + 'fetch-data', + { + description: 'Fetch data from an API', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }, ctx): Promise => { + await ctx.mcpReq.log('info', `Fetching ${url}`); + const res = await fetch(url); + await ctx.mcpReq.log('debug', `Response status: ${res.status}`); + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } + ); + //#endregion McpServer_registerTool_logging +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index e35871096..316074e2d 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -54,6 +54,14 @@ import { Server } from './server.js'; * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. * For advanced usage (like sending notifications or setting custom request handlers), use the underlying * {@linkcode Server} instance available via the {@linkcode McpServer.server | server} property. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_basicUsage" + * const server = new McpServer({ + * name: 'my-server', + * version: '1.0.0' + * }); + * ``` */ export class McpServer { /** @@ -93,6 +101,13 @@ export class McpServer { * Attaches to the given transport, starts it, and starts listening for messages. * * The `server` object assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_connect_stdio" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new StdioServerTransport(); + * await server.connect(transport); + * ``` */ async connect(transport: Transport): Promise { return await this.server.connect(transport); @@ -549,6 +564,21 @@ export class McpServer { /** * Registers a resource with a config object and callback. * For static resources, use a URI string. For dynamic resources, use a {@linkcode ResourceTemplate}. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_registerResource_static" + * server.registerResource( + * 'config', + * 'config://app', + * { + * title: 'Application Config', + * mimeType: 'text/plain' + * }, + * async uri => ({ + * contents: [{ uri: uri.href, text: 'App configuration here' }] + * }) + * ); + * ``` */ registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; registerResource( @@ -812,6 +842,29 @@ export class McpServer { /** * Registers a tool with a config object and callback. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_registerTool_basic" + * server.registerTool( + * 'calculate-bmi', + * { + * title: 'BMI Calculator', + * description: 'Calculate Body Mass Index', + * inputSchema: z.object({ + * weightKg: z.number(), + * heightM: z.number() + * }), + * outputSchema: z.object({ bmi: z.number() }) + * }, + * async ({ weightKg, heightM }) => { + * const output = { bmi: weightKg / (heightM * heightM) }; + * return { + * content: [{ type: 'text', text: JSON.stringify(output) }], + * structuredContent: output + * }; + * } + * ); + * ``` */ registerTool( name: string, @@ -846,6 +899,29 @@ export class McpServer { /** * Registers a prompt with a config object and callback. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_registerPrompt_basic" + * server.registerPrompt( + * 'review-code', + * { + * title: 'Code Review', + * description: 'Review code for best practices', + * argsSchema: z.object({ code: z.string() }) + * }, + * ({ code }) => ({ + * messages: [ + * { + * role: 'user' as const, + * content: { + * type: 'text' as const, + * text: `Please review this code:\n\n${code}` + * } + * } + * ] + * }) + * ); + * ``` */ registerPrompt( name: string, @@ -890,6 +966,14 @@ export class McpServer { * @see {@linkcode LoggingMessageNotification} * @param params * @param sessionId Optional for stateless transports and backward compatibility. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_sendLoggingMessage_basic" + * await server.sendLoggingMessage({ + * level: 'info', + * data: 'Processing complete' + * }); + * ``` */ async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { return this.server.sendLoggingMessage(params, sessionId); @@ -1008,7 +1092,7 @@ export type RegisteredTool = { execution?: ToolExecution; _meta?: Record; handler: AnyToolHandler; - /** @internal */ + /** @hidden */ executor: ToolExecutor; enabled: boolean; enable(): void; @@ -1147,7 +1231,7 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: AnySchema; - /** @internal */ + /** @hidden */ handler: PromptHandler; enabled: boolean; enable(): void; diff --git a/packages/server/src/server/middleware/hostHeaderValidation.examples.ts b/packages/server/src/server/middleware/hostHeaderValidation.examples.ts new file mode 100644 index 000000000..fd49d9751 --- /dev/null +++ b/packages/server/src/server/middleware/hostHeaderValidation.examples.ts @@ -0,0 +1,20 @@ +/** + * Type-checked examples for `hostHeaderValidation.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { validateHostHeader } from './hostHeaderValidation.js'; + +/** + * Example: Validating a host header against allowed hosts. + */ +function hostHeaderValidationResponse_basicUsage(req: Request) { + //#region hostHeaderValidationResponse_basicUsage + const result = validateHostHeader(req.headers.get('host'), ['localhost']); + //#endregion hostHeaderValidationResponse_basicUsage + return result; +} diff --git a/packages/server/src/server/middleware/hostHeaderValidation.ts b/packages/server/src/server/middleware/hostHeaderValidation.ts index 75466d655..a438bea72 100644 --- a/packages/server/src/server/middleware/hostHeaderValidation.ts +++ b/packages/server/src/server/middleware/hostHeaderValidation.ts @@ -44,8 +44,8 @@ export function localhostAllowedHostnames(): string[] { /** * Web-standard `Request` helper for DNS rebinding protection. * @example - * ```typescript - * const result = validateHostHeader(req.headers.get('host'), ['localhost']) + * ```ts source="./hostHeaderValidation.examples.ts#hostHeaderValidationResponse_basicUsage" + * const result = validateHostHeader(req.headers.get('host'), ['localhost']); * ``` */ export function hostHeaderValidationResponse(req: Request, allowedHostnames: string[]): Response | undefined { diff --git a/packages/server/src/server/stdio.examples.ts b/packages/server/src/server/stdio.examples.ts new file mode 100644 index 000000000..de4603eaa --- /dev/null +++ b/packages/server/src/server/stdio.examples.ts @@ -0,0 +1,22 @@ +/** + * Type-checked examples for `stdio.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { McpServer } from './mcp.js'; +import { StdioServerTransport } from './stdio.js'; + +/** + * Example: Basic stdio transport usage. + */ +async function StdioServerTransport_basicUsage() { + //#region StdioServerTransport_basicUsage + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(); + await server.connect(transport); + //#endregion StdioServerTransport_basicUsage +} diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index e022f69ab..562c6861c 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -8,6 +8,13 @@ import { process } from '@modelcontextprotocol/server/_shims'; * Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`. * * This transport is only available in Node.js environments. + * + * @example + * ```ts source="./stdio.examples.ts#StdioServerTransport_basicUsage" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new StdioServerTransport(); + * await server.connect(transport); + * ``` */ export class StdioServerTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); diff --git a/packages/server/src/server/streamableHttp.examples.ts b/packages/server/src/server/streamableHttp.examples.ts new file mode 100644 index 000000000..a805c1dce --- /dev/null +++ b/packages/server/src/server/streamableHttp.examples.ts @@ -0,0 +1,66 @@ +/** + * Type-checked examples for `streamableHttp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { McpServer } from './mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; + +/** + * Example: Stateful Streamable HTTP transport (Web Standard). + */ +async function WebStandardStreamableHTTPServerTransport_stateful() { + //#region WebStandardStreamableHTTPServerTransport_stateful + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID() + }); + + await server.connect(transport); + //#endregion WebStandardStreamableHTTPServerTransport_stateful +} + +/** + * Example: Stateless Streamable HTTP transport (Web Standard). + */ +async function WebStandardStreamableHTTPServerTransport_stateless() { + //#region WebStandardStreamableHTTPServerTransport_stateless + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + //#endregion WebStandardStreamableHTTPServerTransport_stateless + return transport; +} + +// Stubs for framework-specific examples +declare const app: { all(path: string, handler: (c: { req: { raw: Request } }) => Promise): void }; + +/** + * Example: Using with Hono.js. + */ +function WebStandardStreamableHTTPServerTransport_hono(transport: WebStandardStreamableHTTPServerTransport) { + //#region WebStandardStreamableHTTPServerTransport_hono + app.all('/mcp', async c => { + return transport.handleRequest(c.req.raw); + }); + //#endregion WebStandardStreamableHTTPServerTransport_hono +} + +/** + * Example: Using with Cloudflare Workers. + */ +function WebStandardStreamableHTTPServerTransport_workers(transport: WebStandardStreamableHTTPServerTransport) { + //#region WebStandardStreamableHTTPServerTransport_workers + const worker = { + async fetch(request: Request): Promise { + return transport.handleRequest(request); + } + }; + //#endregion WebStandardStreamableHTTPServerTransport_workers + return worker; +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index b5038d018..74e689892 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -176,32 +176,6 @@ export interface HandleRequestOptions { * * This transport works on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc. * - * Usage example: - * - * ```typescript - * // Stateful mode - server sets the session ID - * const statefulTransport = new WebStandardStreamableHTTPServerTransport({ - * sessionIdGenerator: () => crypto.randomUUID(), - * }); - * - * // Stateless mode - explicitly set session ID to undefined - * const statelessTransport = new WebStandardStreamableHTTPServerTransport({ - * sessionIdGenerator: undefined, - * }); - * - * // Hono.js usage - * app.all('/mcp', async (c) => { - * return transport.handleRequest(c.req.raw); - * }); - * - * // Cloudflare Workers usage - * export default { - * async fetch(request: Request): Promise { - * return transport.handleRequest(request); - * } - * }; - * ``` - * * In stateful mode: * - Session ID is generated and included in response headers * - Session ID is always included in initialization responses @@ -212,6 +186,40 @@ export interface HandleRequestOptions { * In stateless mode: * - No Session ID is included in any responses * - No session validation is performed + * + * @example Stateful setup + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateful" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * + * const transport = new WebStandardStreamableHTTPServerTransport({ + * sessionIdGenerator: () => crypto.randomUUID() + * }); + * + * await server.connect(transport); + * ``` + * + * @example Stateless setup + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateless" + * const transport = new WebStandardStreamableHTTPServerTransport({ + * sessionIdGenerator: undefined + * }); + * ``` + * + * @example Hono.js + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_hono" + * app.all('/mcp', async c => { + * return transport.handleRequest(c.req.raw); + * }); + * ``` + * + * @example Cloudflare Workers + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_workers" + * const worker = { + * async fetch(request: Request): Promise { + * return transport.handleRequest(request); + * } + * }; + * ``` */ export class WebStandardStreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode diff --git a/scripts/sync-snippets.ts b/scripts/sync-snippets.ts new file mode 100644 index 000000000..fb91eee1c --- /dev/null +++ b/scripts/sync-snippets.ts @@ -0,0 +1,595 @@ +/** + * Code Snippet Sync Script + * + * This script syncs code snippets into JSDoc comments and markdown files + * containing labeled code fences. + * + * ## Supported Source Files + * + * - **Full-file inclusion**: Any file type (e.g., `.json`, `.yaml`, `.sh`, `.ts`) + * - **Region extraction**: Only `.ts` files (using `//#region` markers) + * + * ## Code Fence Format + * + * Full-file inclusion (any file type): + * + * ``````typescript + * ```json source="./config.json" + * // entire file content is synced here + * ``` + * `````` + * + * Region extraction (.ts only): + * + * ``````typescript + * ```ts source="./path.examples.ts#regionName" + * // region content is synced here + * ``` + * `````` + * + * Optionally, a display filename can be shown before the source reference: + * + * ``````typescript + * ```ts my-app.ts source="./path.examples.ts#regionName" + * // code is synced here + * ``` + * `````` + * + * ## Region Format (in .examples.ts files) + * + * ``````typescript + * //#region regionName + * // code here + * //#endregion regionName + * `````` + * + * Run: pnpm sync:snippets + */ + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_ROOT = join(__dirname, '..'); +const PACKAGES_DIR = join(PROJECT_ROOT, 'packages'); +const DOCS_DIR = join(PROJECT_ROOT, 'docs'); + +/** Processing mode based on file type */ +type FileMode = 'jsdoc' | 'markdown'; + +/** + * Represents a labeled code fence found in a source file. + */ +interface LabeledCodeFence { + /** Optional display filename (e.g., "my-app.ts") */ + displayName?: string; + /** Relative path to the example file (e.g., "./app.examples.ts") */ + examplePath: string; + /** Region name (e.g., "App_basicUsage"), or undefined for whole file */ + regionName?: string; + /** Language from the code fence (e.g., "ts", "json", "yaml") */ + language: string; + /** Character index of the opening fence line start */ + openingFenceStart: number; + /** Character index after the opening fence line (after newline) */ + openingFenceEnd: number; + /** Character index of the closing fence line start */ + closingFenceStart: number; + /** The JSDoc line prefix extracted from context (e.g., " * ") */ + linePrefix: string; +} + +/** + * Cache for example file regions to avoid re-reading files. + * Key: `${absoluteExamplePath}#${regionName}` (empty regionName for whole file) + * Value: extracted code string + */ +type RegionCache = Map; + +/** + * Processing result for a source file. + */ +interface FileProcessingResult { + filePath: string; + modified: boolean; + snippetsProcessed: number; + errors: string[]; +} + +// JSDoc patterns - for code fences inside JSDoc comments with " * " prefix +// Matches: ``` [displayName] source="" or source="#" +// Example: " * ```ts my-app.ts source="./app.examples.ts#App_basicUsage"" +// Example: " * ```ts source="./app.examples.ts#App_basicUsage"" +// Example: " * ```ts source="./complete-example.ts"" (whole file) +const JSDOC_LABELED_FENCE_PATTERN = + /^(\s*\*\s*)```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/; +const JSDOC_CLOSING_FENCE_PATTERN = /^(\s*\*\s*)```\s*$/; + +// Markdown patterns - for plain code fences in markdown files (no prefix) +// Matches: ``` [displayName] source="" or source="#" +// Example: ```ts source="./patterns.ts#chunkedDataServer" +// Example: ```ts source="./complete-example.ts" (whole file) +const MARKDOWN_LABELED_FENCE_PATTERN = + /^```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/; +const MARKDOWN_CLOSING_FENCE_PATTERN = /^```\s*$/; + +/** + * Find all labeled code fences in a source file. + * @param content The file content + * @param filePath The file path (for error messages) + * @param mode The processing mode (jsdoc or markdown) + * @returns Array of labeled code fence references + */ +function findLabeledCodeFences( + content: string, + filePath: string, + mode: FileMode, +): LabeledCodeFence[] { + const results: LabeledCodeFence[] = []; + const lines = content.split('\n'); + let charIndex = 0; + + // Select patterns based on mode + const openPattern = + mode === 'jsdoc' + ? JSDOC_LABELED_FENCE_PATTERN + : MARKDOWN_LABELED_FENCE_PATTERN; + const closePattern = + mode === 'jsdoc' + ? JSDOC_CLOSING_FENCE_PATTERN + : MARKDOWN_CLOSING_FENCE_PATTERN; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const openMatch = line.match(openPattern); + + if (openMatch) { + let linePrefix: string; + let language: string; + let displayName: string | undefined; + let examplePath: string; + let regionName: string; + + if (mode === 'jsdoc') { + // JSDoc: group 1=prefix, 2=lang, 3=displayName, 4=path, 5=region + [, linePrefix, language, displayName, examplePath, regionName] = + openMatch; + } else { + // Markdown: group 1=lang, 2=displayName, 3=path, 4=region (no prefix) + [, language, displayName, examplePath, regionName] = openMatch; + linePrefix = ''; + } + + const openingFenceStart = charIndex; + const openingFenceEnd = charIndex + line.length + 1; // +1 for newline + + // Find closing fence + let closingFenceStart = -1; + let searchIndex = openingFenceEnd; + + for (let j = i + 1; j < lines.length; j++) { + const closeLine = lines[j]; + if (closePattern.test(closeLine)) { + closingFenceStart = searchIndex; + break; + } + searchIndex += closeLine.length + 1; + } + + if (closingFenceStart === -1) { + throw new Error( + `${filePath}: No closing fence for ${examplePath}#${regionName}`, + ); + } + + results.push({ + displayName, + examplePath, + regionName, + language, + openingFenceStart, + openingFenceEnd, + closingFenceStart, + linePrefix, + }); + } + + charIndex += line.length + 1; + } + + return results; +} + +/** + * Dedent content by removing a base indentation prefix from each line. + * @param content The content to dedent + * @param baseIndent The indentation to remove + * @returns The dedented content + */ +function dedent(content: string, baseIndent: string): string { + if (!baseIndent) return content; + + const lines = content.split('\n'); + const dedentedLines = lines.map((line) => { + // Preserve empty lines as-is + if (line.trim() === '') return ''; + // Remove the base indentation if present + if (line.startsWith(baseIndent)) { + return line.slice(baseIndent.length); + } + // Line has less indentation than base - keep as-is + return line; + }); + + // Trim trailing empty lines + while ( + dedentedLines.length > 0 && + dedentedLines[dedentedLines.length - 1] === '' + ) { + dedentedLines.pop(); + } + + return dedentedLines.join('\n'); +} + +/** + * Extract a region from an example file. + * @param exampleContent The content of the example file + * @param regionName The region name to extract + * @param examplePath The example file path (for error messages) + * @returns The dedented region content + */ +function extractRegion( + exampleContent: string, + regionName: string, + examplePath: string, +): string { + // Region extraction only supported for .ts files (uses //#region syntax) + if (!examplePath.endsWith('.ts')) { + throw new Error( + `Region extraction (#${regionName}) is only supported for .ts files. ` + + `Use full-file inclusion (without #regionName) for: ${examplePath}`, + ); + } + + const lineEnding = exampleContent.includes('\r\n') ? '\r\n' : '\n'; + const regionStart = `//#region ${regionName}${lineEnding}`; + const regionEnd = `//#endregion ${regionName}${lineEnding}`; + + const startIndex = exampleContent.indexOf(regionStart); + if (startIndex === -1) { + throw new Error(`Region "${regionName}" not found in ${examplePath}`); + } + + const endIndex = exampleContent.indexOf(regionEnd, startIndex); + if (endIndex === -1) { + throw new Error( + `Region end marker for "${regionName}" not found in ${examplePath}`, + ); + } + + // Get content after the region start line + const afterStart = exampleContent.indexOf('\n', startIndex); + if (afterStart === -1 || afterStart >= endIndex) { + return ''; // Empty region + } + + // Extract the raw content + const rawContent = exampleContent.slice(afterStart + 1, endIndex); + + // Determine base indentation from the //#region line + let lineStart = exampleContent.lastIndexOf('\n', startIndex); + lineStart = lineStart === -1 ? 0 : lineStart + 1; + const regionLine = exampleContent.slice(lineStart, startIndex); + + // The base indent is the whitespace before //#region + const baseIndent = regionLine; + + return dedent(rawContent, baseIndent); +} + +/** + * Get or load a region from the cache. + * @param sourceFilePath The source file requesting the region + * @param examplePath The relative path to the example file + * @param regionName The region name to extract, or undefined for whole file + * @param cache The region cache + * @returns The extracted code string + */ +function getOrLoadRegion( + sourceFilePath: string, + examplePath: string, + regionName: string | undefined, + cache: RegionCache, +): string { + // Resolve the example path relative to the source file + const sourceDir = dirname(sourceFilePath); + const absoluteExamplePath = resolve(sourceDir, examplePath); + + // File content is always cached with key ending in "#" (empty region) + const fileKey = `${absoluteExamplePath}#`; + let fileContent = cache.get(fileKey); + + if (fileContent === undefined) { + try { + fileContent = readFileSync(absoluteExamplePath, 'utf-8').trim(); + } catch { + throw new Error(`Example file not found: ${absoluteExamplePath}`); + } + cache.set(fileKey, fileContent); + } + + // If no region name, return whole file + if (!regionName) { + return fileContent; + } + + // Extract region from cached file content, cache the result + const regionKey = `${absoluteExamplePath}#${regionName}`; + let regionContent = cache.get(regionKey); + + if (regionContent === undefined) { + regionContent = extractRegion(fileContent, regionName, examplePath); + cache.set(regionKey, regionContent); + } + + return regionContent; +} + +/** + * Format code lines for insertion into a JSDoc comment. + * @param code The code to format + * @param linePrefix The JSDoc line prefix (e.g., " * ") + * @returns The formatted code with JSDoc prefixes + */ +function formatCodeLines(code: string, linePrefix: string): string { + const lines = code.split('\n'); + return lines + .map((line) => + line === '' ? linePrefix.trimEnd() : `${linePrefix}${line}`, + ) + .join('\n'); +} + +interface ProcessFileOptions { + check?: boolean; +} + +/** + * Process a single source file to sync snippets. + * @param filePath The source file path + * @param cache The region cache + * @param mode The processing mode (jsdoc or markdown) + * @returns The processing result + */ +function processFile( + filePath: string, + cache: RegionCache, + mode: FileMode, + options?: ProcessFileOptions, +): FileProcessingResult { + const result: FileProcessingResult = { + filePath, + modified: false, + snippetsProcessed: 0, + errors: [], + }; + + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch (err) { + result.errors.push(`Failed to read file: ${err}`); + return result; + } + + let fences: LabeledCodeFence[]; + try { + fences = findLabeledCodeFences(content, filePath, mode); + } catch (err) { + result.errors.push(err instanceof Error ? err.message : String(err)); + return result; + } + + if (fences.length === 0) { + return result; + } + + const originalContent = content; + + // Process fences in reverse order to preserve positions + for (let i = fences.length - 1; i >= 0; i--) { + const fence = fences[i]; + + try { + const code = getOrLoadRegion( + filePath, + fence.examplePath, + fence.regionName, + cache, + ); + + const formattedCode = formatCodeLines(code, fence.linePrefix); + + // Replace content between opening fence end and closing fence start + content = + content.slice(0, fence.openingFenceEnd) + + formattedCode + + '\n' + + content.slice(fence.closingFenceStart); + + result.snippetsProcessed++; + } catch (err) { + result.errors.push( + `${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if ( + result.snippetsProcessed > 0 && + result.errors.length === 0 && + content !== originalContent + ) { + if (!options?.check) { + writeFileSync(filePath, content); + } + result.modified = true; + } + + return result; +} + +/** + * Find all TypeScript source files in a directory, excluding examples, tests, and generated files. + * @param dir The directory to search + * @returns Array of absolute file paths + */ +function findSourceFiles(dir: string): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + const name = entry.name; + + // Only process .ts files + if (!name.endsWith('.ts')) continue; + + // Exclude example files, test files + if (name.endsWith('.examples.ts')) continue; + if (name.endsWith('.test.ts')) continue; + + // Get the relative path from the parent directory + const parentPath = entry.parentPath; + + // Exclude generated directory + if (parentPath.includes('/generated') || parentPath.includes('\\generated')) + continue; + + const fullPath = join(parentPath, name); + files.push(fullPath); + } + + return files; +} + +/** + * Find all markdown files in a directory. + * @param dir The directory to search + * @returns Array of absolute file paths + */ +function findMarkdownFiles(dir: string): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + // Only process .md files + if (!entry.name.endsWith('.md')) continue; + + const fullPath = join(entry.parentPath, entry.name); + files.push(fullPath); + } + + return files; +} + +/** + * Find all package src directories under the packages directory. + * @param packagesDir The packages directory + * @returns Array of absolute paths to src directories + */ +function findPackageSrcDirs(packagesDir: string): string[] { + const srcDirs: string[] = []; + const entries = readdirSync(packagesDir, { + withFileTypes: true, + recursive: true, + }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name !== 'src') continue; + + const fullPath = join(entry.parentPath, entry.name); + + // Only include src dirs that are direct children of a package + // (e.g., packages/core/src, packages/middleware/express/src) + // Skip nested src dirs like node_modules/*/src + if (fullPath.includes('node_modules')) continue; + + srcDirs.push(fullPath); + } + + return srcDirs; +} + +async function main() { + const checkMode = process.argv.includes('--check'); + console.log( + checkMode + ? 'Checking code snippets are in sync...\n' + : 'Syncing code snippets from example files...\n', + ); + + const cache: RegionCache = new Map(); + const results: FileProcessingResult[] = []; + + // Process TypeScript source files (JSDoc mode) across all packages + const packageSrcDirs = findPackageSrcDirs(PACKAGES_DIR); + for (const srcDir of packageSrcDirs) { + const sourceFiles = findSourceFiles(srcDir); + for (const filePath of sourceFiles) { + const result = processFile(filePath, cache, 'jsdoc', { check: checkMode }); + results.push(result); + } + } + + // Process markdown documentation files + const markdownFiles = findMarkdownFiles(DOCS_DIR); + for (const filePath of markdownFiles) { + const result = processFile(filePath, cache, 'markdown', { check: checkMode }); + results.push(result); + } + + // Report results + const modified = results.filter((r) => r.modified); + const errors = results.flatMap((r) => r.errors); + + if (modified.length > 0) { + if (checkMode) { + console.error(`${modified.length} file(s) out of sync:`); + } else { + console.log(`Modified ${modified.length} file(s):`); + } + for (const r of modified) { + console.log(` ${r.filePath} (${r.snippetsProcessed} snippet(s))`); + } + } else { + console.log('All snippets are up to date'); + } + + if (errors.length > 0) { + console.error('\nErrors:'); + for (const error of errors) { + console.error(` ${error}`); + } + process.exit(1); + } + + if (checkMode && modified.length > 0) { + console.error('\nRun "pnpm sync:snippets" to fix.'); + process.exit(1); + } + + console.log('\nSnippet sync complete!'); +} + +main().catch((error) => { + console.error('Snippet sync failed:', error); + process.exit(1); +}); diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 33fe9e267..365619844 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -28,7 +28,8 @@ export default { entryPointStrategy: 'packages', entryPoints, packageOptions: { - blockTags: [...OptionDefaults.blockTags, '@format'] + blockTags: [...OptionDefaults.blockTags, '@format'], + exclude: ['**/*.examples.ts'] }, projectDocuments: ['docs/documents.md'], navigation: { @@ -38,5 +39,6 @@ export default { headings: { readme: false }, + treatWarningsAsErrors: true, out: 'tmp/docs/', };