From 3e6201b881798089d90ed02f71b31cc3410e152e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 16:20:11 +0000 Subject: [PATCH 1/2] docs: correctness and coverage pass for the 2026-07-28 revision Verify the guides against the current public surface and fill coverage gaps: - Era-scope the push-style API docs: ctx.mcpReq.elicitInput/requestSampling and server.server.listRoots() throw on 2026-07-28-era connections; point each section at the in-band input_required replacement. Scope the 'bidirectional' framing and deprecation windows to 2025-era connections. - New coverage: serving over HTTP with createMcpHandler/toNodeHandler, serveStdio options, subscription streams (client.listen, honoredFilter, closed reasons, watch loop) and server change notifications (handler.notify, era semantics), response caching (client cache options + server cacheHints), the native input_required/MRTR story incl. the requestState codec and manual multi-round-trip handling, OAuth resource-server setup, RFC 8707 resource indicators, issuer metadata validation, SdkHttpError and the ClientHttp* codes, listMaxPages, per-request logLevel on 2026-07-28. - Quickstarts: rewrite the client tutorial tool loop to handle multiple tool_use blocks per response (single user turn carrying all tool_results, is_error propagation, loop until no tool calls remain); add isError to the weather server's failure returns; drop unneeded 'as const' casts, dead bin entries, and the chmod build step; rename the example packages to @mcp-examples/* and add READMEs noting the manifests are monorepo-internal. - Fix stale paths/links (authExtensions.ts, vitest.setup.js, hostHeaderValidation.ts), index the migration guides from documents.md, note @modelcontextprotocol/server-legacy/sse in the FAQ, and add the registry pins row to behavior-surface-pins.md. All code fences are synced from typechecked example sources (pnpm sync:snippets clean; examples typecheck + lint pass; typedoc clean). --- .changeset/config.json | 2 - .changeset/pre.json | 2 - docs/behavior-surface-pins.md | 1 + docs/client-quickstart.md | 94 ++++--- docs/client.md | 216 ++++++++++++++- docs/faq.md | 6 +- docs/server-quickstart.md | 271 +++++++++---------- docs/server.md | 344 ++++++++++++++++++++++-- examples/client-quickstart/README.md | 5 + examples/client-quickstart/package.json | 5 +- examples/client-quickstart/src/index.ts | 87 +++--- examples/guides/clientGuide.examples.ts | 146 +++++++++- examples/guides/serverGuide.examples.ts | 275 ++++++++++++++++++- examples/server-quickstart/README.md | 5 + examples/server-quickstart/package.json | 5 +- examples/server-quickstart/src/index.ts | 259 +++++++++--------- 16 files changed, 1333 insertions(+), 390 deletions(-) create mode 100644 examples/client-quickstart/README.md create mode 100644 examples/server-quickstart/README.md diff --git a/.changeset/config.json b/.changeset/config.json index 6821c8c0ce..09102e78e8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,8 +9,6 @@ "updateInternalDependencies": "patch", "ignore": [ "@modelcontextprotocol/examples", - "@modelcontextprotocol/examples-client-quickstart", - "@modelcontextprotocol/examples-server-quickstart", "@mcp-examples/*" ] } diff --git a/.changeset/pre.json b/.changeset/pre.json index 6c0c4763db..6706064de1 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,8 +6,6 @@ "@modelcontextprotocol/tsconfig": "2.0.0", "@modelcontextprotocol/vitest-config": "2.0.0", "@modelcontextprotocol/examples": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-client-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-server-quickstart": "2.0.0-alpha.0", "@modelcontextprotocol/client": "2.0.0-alpha.0", "@modelcontextprotocol/core-internal": "2.0.0-alpha.0", "@modelcontextprotocol/express": "2.0.0-alpha.0", diff --git a/docs/behavior-surface-pins.md b/docs/behavior-surface-pins.md index 4631ea8e37..df4f171b08 100644 --- a/docs/behavior-surface-pins.md +++ b/docs/behavior-surface-pins.md @@ -29,6 +29,7 @@ CI pass — that reopens the silent-drift hole the pin exists to close. | Schema strict/strip/loose boundaries, key existence | `packages/core-internal/test/types/schemaBoundaryPins.test.ts` | | Published package set, export maps, ESM-only topology | `packages/core-internal/test/packageTopologyPins.test.ts` | | stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` | +| 2025-11-25 wire method-registry membership, schema identity | `packages/core-internal/test/types/registryPins.test.ts` | ## Writing a new pin diff --git a/docs/client-quickstart.md b/docs/client-quickstart.md index 4c287bd925..53bebe5df4 100644 --- a/docs/client-quickstart.md +++ b/docs/client-quickstart.md @@ -116,12 +116,11 @@ import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import readline from 'readline/promises'; -const ANTHROPIC_MODEL = 'claude-sonnet-4-5'; +const ANTHROPIC_MODEL = 'claude-sonnet-4-6'; class MCPClient { private mcp: Client; private _anthropic: Anthropic | null = null; - private transport: StdioClientTransport | null = null; private tools: Anthropic.Tool[] = []; constructor() { @@ -153,8 +152,8 @@ Next, we'll implement the method to connect to an MCP server: : process.execPath; // Initialize transport and connect to server - this.transport = new StdioClientTransport({ command, args: [serverScriptPath] }); - await this.mcp.connect(this.transport); + const transport = new StdioClientTransport({ command, args: [serverScriptPath] }); + await this.mcp.connect(transport); // List available tools const toolsResult = await this.mcp.listTools(); @@ -185,29 +184,40 @@ Now let's add the core functionality for processing queries and handling tool ca ]; // Initial Claude API call - const response = await this.anthropic.messages.create({ + let response = await this.anthropic.messages.create({ model: ANTHROPIC_MODEL, max_tokens: 1000, messages, tools: this.tools, }); - // Process response and handle tool calls + // Process responses, executing tool calls until Claude stops requesting them const finalText = []; - for (const content of response.content) { - if (content.type === 'text') { - finalText.push(content.text); - } else if (content.type === 'tool_use') { - // Execute tool call - const toolName = content.name; - const toolArgs = content.input as Record | undefined; + while (true) { + const toolUses: Anthropic.ToolUseBlock[] = []; + for (const content of response.content) { + if (content.type === 'text') { + finalText.push(content.text); + } else if (content.type === 'tool_use') { + toolUses.push(content); + } + } + + if (toolUses.length === 0) { + break; + } + + // Execute every requested tool call and collect the results + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const toolUse of toolUses) { + const toolArgs = toolUse.input as Record; const result = await this.mcp.callTool({ - name: toolName, + name: toolUse.name, arguments: toolArgs, }); - finalText.push(`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`); + finalText.push(`[Calling tool ${toolUse.name} with args ${JSON.stringify(toolArgs)}]`); // Extract text from tool result content blocks const toolResultText = result.content @@ -215,30 +225,33 @@ Now let's add the core functionality for processing queries and handling tool ca .map((block) => block.text) .join('\n'); - // Continue conversation with tool results - messages.push({ - role: 'assistant', - content: response.content, - }); - messages.push({ - role: 'user', - content: [{ - type: 'tool_result', - tool_use_id: content.id, - content: toolResultText, - }], - }); - - // Get next response from Claude - const followUp = await this.anthropic.messages.create({ - model: ANTHROPIC_MODEL, - max_tokens: 1000, - messages, + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: toolResultText, + // Tell Claude when the tool call failed + ...(result.isError ? { is_error: true } : {}), }); - - const firstBlock = followUp.content[0]; - finalText.push(firstBlock?.type === 'text' ? firstBlock.text : ''); } + + // Continue the conversation: the assistant turn, then ALL tool + // results together in a single user turn + messages.push({ + role: 'assistant', + content: response.content, + }); + messages.push({ + role: 'user', + content: toolResults, + }); + + // Get next response from Claude + response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + tools: this.tools, + }); } return finalText.join('\n'); @@ -364,13 +377,16 @@ When you submit a query: 5. Claude provides a natural language response 6. The response is displayed to you +> [!NOTE] +> By default, the client uses the legacy 2025-era `initialize` handshake, so it works with any 2025-era server, including the weather server from the server quickstart. To opt into the 2026-07-28 draft revision, see [Protocol version negotiation](./client.md#protocol-version-negotiation-2026-07-28-revision). + ## Troubleshooting ### Server Path Issues - Double-check the path to your server script is correct - Use the absolute path if the relative path isn't working -- For Windows users, make sure to use forward slashes (`/`) or escaped backslashes (`\\`) in the path +- On Windows, both backslashes (`\`) and forward slashes (`/`) work as path separators - Verify the server file has the correct extension (`.js` for Node.js or `.py` for Python) Example of correct path usage: @@ -411,7 +427,7 @@ node build/index.js C:/projects/mcp-server/build/index.js If you see: - `Error: Cannot find module`: Check your build folder and ensure TypeScript compilation succeeded -- `Connection refused`: Ensure the server is running and the path is correct +- `Error: spawn ... ENOENT` or an immediate exit: check the server script path and that `node` / `python3` can run it (the client spawns the server, so don't start it yourself) - `Tool execution failed`: Verify the tool's required environment variables are set - `ANTHROPIC_API_KEY is not set`: Check your environment variables (e.g., `export ANTHROPIC_API_KEY=...`) - `TypeError`: Ensure you're using the correct types for tool arguments diff --git a/docs/client.md b/docs/client.md index 16efc274f1..659b39abd0 100644 --- a/docs/client.md +++ b/docs/client.md @@ -15,6 +15,8 @@ The examples below use these imports. Adjust based on which features and transpo ```ts source="../examples/guides/clientGuide.examples.ts#imports" import type { AuthProvider, + CallToolResult, + InputRequiredResult, OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, @@ -24,16 +26,21 @@ import type { } from '@modelcontextprotocol/client'; import { applyMiddlewares, + checkResourceAllowed, Client, ClientCredentialsProvider, createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + isInputRequiredResult, IssuerMismatchError, + LOG_LEVEL_META_KEY, PrivateKeyJwtProvider, ProtocolError, + resourceUrlFromServerUrl, SdkError, SdkErrorCode, + SdkHttpError, SSEClientTransport, StreamableHTTPClientTransport, TRACEPARENT_META_KEY, @@ -119,6 +126,7 @@ client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide › Probe policy](./migration/support-2026-07-28.md#probe-policy) for the full failure semantics and probe-timeout behavior. +The version lists come from `ClientOptions.supportedProtocolVersions`: under `'auto'`, its 2026-era entries form the modern offer (default: the SDK's modern list), and a list with no 2025-era entry removes the legacy fallback; `connect()` rejects with `SdkError(EraNegotiationFailed)` instead of downgrading. The same modern subset bounds the overlap check of `connect({ prior })`. #### Skipping the probe: `connect({ prior })` @@ -140,6 +148,8 @@ await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted `DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof. +Unlike an `'auto'`/pinned connect, `connect({ prior })` never auto-opens a `subscriptions/listen` stream. Workers on this path are assumed request-only. A configured `listChanged` option registers its handlers but stays silent. Call [`client.listen(filter)`](#subscription-streams-2026-07-28) yourself if a prior-connected client should observe changes. + ### Disconnecting Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error. @@ -346,6 +356,32 @@ return client; For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). +Issuer validation also runs during discovery: the authorization server metadata's `issuer` must match the issuer identifier the well-known URL was built from (RFC 8414 §3.3), and a mismatch throws `IssuerMismatchError` +with `kind: 'metadata'` (the callback-leg RFC 9207 check above uses `kind: 'authorization_response'`). For authorization servers known to publish a mismatched `issuer`, both HTTP transports accept `skipIssuerMetadataValidation: true` (honoured when `authProvider` is an +`OAuthClientProvider`). This weakens mix-up protection, so leave it off unless you control the server. The migration guide's [Authorization-server mix-up defense](./migration/upgrade-to-v2.md#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33--action-required) section +describes the full model. + +#### Resource indicators (RFC 8707) + +The SDK binds tokens to your MCP server with the RFC 8707 `resource` parameter automatically. When protected resource metadata (RFC 9728) is discovered, the metadata's `resource` value is checked against the server URL (same origin, path prefix; see +`checkResourceAllowed()`) and attached to the authorization redirect and every token request. When the server publishes no resource metadata, no `resource` parameter is sent. + +Implement `validateResourceURL` on your provider to override the selection. Return a URL to force a specific `resource` value, or `undefined` to omit the parameter: + +```ts source="../examples/guides/clientGuide.examples.ts#auth_validateResourceURL" +class PinnedResourceProvider extends MyOAuthProvider { + async validateResourceURL(serverUrl: string | URL, resource?: string): Promise { + const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) + if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { + throw new Error(`Refusing resource ${resource} for server ${expected.href}`); + } + return expected; + } +} +``` + +`checkResourceAllowed` and `resourceUrlFromServerUrl` are exported from `@modelcontextprotocol/client` for custom implementations. + ### Cross-App Access (Enterprise Managed Authorization) {@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access @@ -417,6 +453,9 @@ const result = await client.callTool({ console.log(result.content); ``` +The aggregate walk is capped at `ClientOptions.listMaxPages` pages (default 64; `0` disables the cap). If a server's pagination never terminates, the call rejects with {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} code {@linkcode +@modelcontextprotocol/client!index.SdkErrorCode.ListPaginationExceeded | LIST_PAGINATION_EXCEEDED}. The same applies to `listPrompts()`, `listResources()`, and `listResourceTemplates()`. + Tool results may include a `structuredContent` field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing `content` which is for the LLM: ```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" @@ -456,7 +495,8 @@ console.log(result.content); ### `x-mcp-header` parameter mirroring (2026-07-28 draft) On a 2026-07-28 connection over Streamable HTTP, `callTool()` mirrors any argument whose `inputSchema` property carries an `x-mcp-header` annotation into an `Mcp-Param-{Name}` HTTP request header so intermediaries can route on it without parsing the body. The mirrored headers -are built from the client's internal `tools/list` cache; if you already hold the tool definition (e.g. from configuration), pass it via `CallToolRequestOptions.toolDefinition` so mirroring runs without a prior list. On a cache miss the call is sent without `Mcp-Param-*` headers +are built from the client's cached `tools/list` result (see [Response caching](#response-caching-2026-07-28-draft)); if you already hold the tool definition (e.g. from configuration), pass it via `CallToolRequestOptions.toolDefinition` so mirroring runs without a prior list. +On a cache miss the call is sent without `Mcp-Param-*` headers and, when a conforming server rejects it with `-32020` (`HeaderMismatch`), `callTool()` refreshes the definition cache once and retries. On a non-stdio modern connection `listTools()` (and the internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning that names the tool and the reason. Browser clients skip mirroring (dynamically named @@ -503,6 +543,10 @@ client.setNotificationHandler('notifications/resources/updated', async notificat await client.unsubscribeResource({ uri: 'config://app' }); ``` +> [!NOTE] +> `resources/subscribe` is a 2025-era method. On a 2026-07-28 connection, `subscribeResource()` throws a typed `SdkError` (`MethodNotSupportedByProtocolVersion`); request per-resource updates through the `resourceSubscriptions` field of a +> [subscription stream](#subscription-streams-2026-07-28) instead. The `notifications/resources/updated` handler is identical on both paths. + ## Prompts Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). @@ -542,6 +586,36 @@ const { completion } = await client.complete({ console.log(completion.values); // e.g. ['typescript'] ``` +## Response caching (2026-07-28 draft) + +On a 2026-07-28 connection, the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) carry `ttlMs` / `cacheScope` freshness hints (SEP-2549). The client honours them automatically: `listTools()`, +`listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` serve a still-fresh cached result without a round trip. `ttlMs` is capped at 24 hours (`MAX_CACHE_TTL_MS`); a missing or zero `ttlMs` means the result is never +served from cache, so against servers that don't send hints (including all 2025-era servers), nothing changes. + +Override the disposition per call with `cacheMode`: + +```ts source="../examples/guides/clientGuide.examples.ts#responseCache_basic" +const tools = await client.listTools(); // network, then cached for the server's ttlMs +const again = await client.listTools(); // served from cache while still fresh + +await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store +await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write +``` + +`'bypass'` leaves the cache byte-untouched, including the internal `tools/list` entry that [`x-mcp-header` parameter mirroring](#x-mcp-header-parameter-mirroring-2026-07-28-draft) and output-schema validation read. Cached entries are evicted automatically when the server +signals a change: a `list_changed` notification drops the matching list entries, and `notifications/resources/updated` drops the cached body for that URI (see [Notifications](#notifications)). + +Three {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | ClientOptions} fields tune the behavior: + +- **`responseCacheStore`**: the backing store; defaults to a per-client `InMemoryResponseCacheStore` (at most 512 `resources/read` entries by default). Supply your own `ResponseCacheStore` implementation (the interface is async-ready, so a + Redis-style store fits) to persist entries or share one store across clients. Entries are keyed by connected-server identity, so co-tenants never collide. +- **`cachePartition`**: opaque per-principal identifier (e.g. the auth subject) isolating `'private'`-scoped entries when one store serves several principals. `'public'`-scoped entries are shared within a server's namespace; `'private'` ones never cross partitions. +- **`defaultCacheTtlMs`**: TTL applied when a result arrives without `ttlMs` (any legacy-era response, for example). The default `0` means such results are never served from cache; list results are still stored (already stale) so the `tools/list`-derived index behind + mirroring and output validation keeps working, while `resources/read` bodies with a resolved TTL of `0` are not stored at all. Raise it to enable TTL caching against servers that don't send hints. + +> [!IMPORTANT] +> When one `responseCacheStore` is shared across users, always set `cachePartition` per principal. Without it, one user's `'private'`-scoped resource bodies can be served to another. + ## Notifications ### Automatic list-change tracking @@ -571,6 +645,8 @@ const client = new Client( ); ``` +`listChanged` is era-transparent: on a 2025-era connection it is fed by unsolicited notifications; on a 2026-07-28 connection the SDK [auto-opens a subscription stream](#subscription-streams-2026-07-28) for the configured types. + ### 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()}: @@ -590,8 +666,8 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = ``` > [!WARNING] -> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the -> [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. +> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). It remains fully functional on +> 2025-era connections during the deprecation window (at least twelve months); on the 2026-07-28 revision the log level travels per request instead (see below). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: @@ -599,8 +675,65 @@ To control the minimum severity of log messages the server sends, use {@linkcode await client.setLoggingLevel('warning'); ``` +`logging/setLevel` is not part of the 2026-07-28 revision, so on a connection that negotiated a modern era (see [Protocol version negotiation](#protocol-version-negotiation-2026-07-28-revision)) `setLoggingLevel()` rejects with `SdkError(MethodNotSupportedByProtocolVersion)`. On 2026-07-28 connections the level is declared **per request** instead: set the `io.modelcontextprotocol/logLevel` `_meta` key (exported as `LOG_LEVEL_META_KEY`) on each request you want logs for. When the key is absent, the server sends no `notifications/message` for that request; the client never attaches it automatically. + +```ts source="../examples/guides/clientGuide.examples.ts#logLevelMeta_modern" +const result = await client.callTool({ + name: 'fetch-data', + arguments: { url: 'https://example.com' }, + _meta: { [LOG_LEVEL_META_KEY]: 'debug' } +}); +``` + +Messages arrive through the same `notifications/message` handler shown above. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#ctxmcpreqlog-and-the-per-request-loglevel) for the server-side semantics. + > [!WARNING] -> `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. +> `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} resolve per notification type by last registration wins: `listChanged` installs its handler during `connect()`, so a manual handler registered +> after connecting silently disables `listChanged` for that type, and one registered before connecting is overwritten by it. + +### Subscription streams (2026-07-28) + +On a 2026-07-28 connection the server delivers change notifications only on a `subscriptions/listen` stream the client opens: nothing arrives unsolicited. The `listChanged` option handles this transparently: on a modern connection it auto-opens a stream whose filter is the +intersection of the configured sub-options and the server's advertised capabilities (the handle is exposed as {@linkcode @modelcontextprotocol/client!client/client.Client#autoOpenedSubscription | autoOpenedSubscription}). To open a stream explicitly, use {@linkcode +@modelcontextprotocol/client!client/client.Client#listen | listen()}: + +```ts source="../examples/guides/clientGuide.examples.ts#listen_basic" +client.setNotificationHandler('notifications/tools/list_changed', async () => { + const { tools } = await client.listTools(); + console.log('Tools changed:', tools.length); +}); +client.setNotificationHandler('notifications/resources/updated', async notification => { + console.log('Resource updated:', notification.params.uri); +}); + +const subscription = await client.listen({ + toolsListChanged: true, + resourceSubscriptions: ['config://app'] +}); +console.log('Server honored:', subscription.honoredFilter); + +// Later: tear the stream down +await subscription.close(); +``` + +`listen()` resolves once the server acknowledges the subscription. `honoredFilter` is the capability-gated subset the server agreed to deliver (e.g. `resourceSubscriptions` requires the server to advertise `resources: { subscribe: true }`). Notifications on the stream +dispatch to the same `setNotificationHandler` registrations as 2025-era unsolicited notifications. + +There is no automatic re-listen. `subscription.closed` resolves exactly once (it never rejects) with the reason: `'local'` (you called `close()`), `'graceful'` (the server ended the subscription deliberately, e.g. on shutdown), or `'remote'` (unexpected disconnect). A watch +loop re-listens on unexpected closes: + +```ts source="../examples/guides/clientGuide.examples.ts#listen_watchLoop" +while (watching) { + const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); + const reason = await sub.closed; + if (reason !== 'remote') break; // 'local' or 'graceful': done + await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen +} +``` + +On a 2025-era connection `listen()` throws a typed error steering to [`subscribeResource()`](#subscribing-to-resource-changes) and `listChanged`. See the +[2026-07-28 support guide › `subscriptions/listen`](./migration/support-2026-07-28.md#subscriptionslisten) for migration-level detail, and [`subscriptions/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/client.ts) for a +runnable example of both watch styles. ## Handling server-initiated requests @@ -613,12 +746,18 @@ const client = new Client( { capabilities: { sampling: {}, - elicitation: { form: {} } + elicitation: { form: {} }, + roots: { listChanged: true } } } ); ``` +On 2025-era connections these arrive as server→client JSON-RPC requests. On a 2026-07-28 connection there is no server→client request channel: the server answers `tools/call` / `prompts/get` / `resources/read` with an `input_required` result instead, and the client fulfils +the embedded requests automatically through the same handlers you register below, then retries the call with the collected responses and a byte-exact echo of the server's opaque `requestState`. `callTool()` and its siblings keep returning their plain result: the interactive +rounds happen inside the call, capped at `maxRounds` (default 10), after which the call rejects with a typed {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.InputRequiredRoundsExceeded | INPUT_REQUIRED_ROUNDS_EXCEEDED} error. Configure or disable this via +`ClientOptions.inputRequired` (`{ autoFulfill?: boolean; maxRounds?: number }`); see [Manual multi-round-trip handling](#manual-multi-round-trip-handling-2026-07-28) for the opt-out flow. Handlers are era-transparent: register once for both delivery paths. + ### Sampling > [!WARNING] @@ -687,6 +826,47 @@ client.setRequestHandler('roots/list', async () => { When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/client.Client#sendRootsListChanged | client.sendRootsListChanged()}. +### Manual multi-round-trip handling (2026-07-28) + +Hosts that surface input requests through their own UI loop can take over the rounds themselves. Set `inputRequired: { autoFulfill: false }`. An `input_required` response then surfaces as a typed error unless the call passes `allowInputRequired: true` to receive the raw +result. Retry with top-level `inputResponses` and a byte-exact `requestState` echo: + +```ts source="../examples/guides/clientGuide.examples.ts#inputRequired_manual" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { mode: 'auto' }, + inputRequired: { autoFulfill: false } + } +); +await client.connect(transport); + +const value = (await client.request( + { method: 'tools/call', params: { name: 'deploy', arguments: { env: 'prod' } } }, + { allowInputRequired: true } +)) as CallToolResult | InputRequiredResult; + +if (isInputRequiredResult(value)) { + // Collect responses for value.inputRequests from your UI, then retry: + await client.request( + { + method: 'tools/call', + params: { + name: 'deploy', + arguments: { env: 'prod' }, + inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, + requestState: value.requestState // echo byte-exact + } + }, + { allowInputRequired: true } + ); +} +``` + +The manual retry goes through `client.request()` rather than `callTool()`: `inputResponses` and `requestState` are not fields of the typed `CallToolRequest` params. On the explicit-schema `request()` path, wrap the result schema with {@linkcode +@modelcontextprotocol/client!index.withInputRequired | withInputRequired()} so both outcomes are typed and validated. For the full loop (including URL-mode elicitation), see [`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts). + ## Error handling ### Tool errors vs protocol errors @@ -721,7 +901,8 @@ try { {@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | -CAPABILITY_NOT_SUPPORTED}, and others. +CAPABILITY_NOT_SUPPORTED}, and others. The {@linkcode @modelcontextprotocol/client!index.SdkErrorCode | SdkErrorCode} enum is the complete vocabulary; the [error mapping table](./migration/upgrade-to-v2.md#sdkerrorcode-enum-complete) in the upgrade guide describes when each +code is raised. ### Connection lifecycle @@ -742,8 +923,8 @@ client.onclose = () => { ### Timeouts -All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | -SdkErrorCode.RequestTimeout}: +All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server (on a 2026-07-28 Streamable HTTP connection the per-request stream is aborted instead, which is the +spec's cancellation signal) and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: ```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_timeout" try { @@ -759,6 +940,23 @@ try { } ``` +### HTTP transport errors + +When an HTTP transport request fails with a non-OK status, the SDK throws {@linkcode @modelcontextprotocol/client!index.SdkHttpError | SdkHttpError}, an `SdkError` subclass with typed `data` (`{ status, statusText? }`) and `status`/`statusText` getters, so you can branch on the status without casting. The codes are the `ClientHttp*` members of `SdkErrorCode`: e.g. `CLIENT_HTTP_AUTHENTICATION` (a 401 persisting after re-authentication), `CLIENT_HTTP_FORBIDDEN` (a 403 `insufficient_scope` after the step-up +retry cap), `CLIENT_HTTP_FAILED_TO_OPEN_STREAM`. (Exception: an unexpected response content type throws a plain `SdkError` with code `CLIENT_HTTP_UNEXPECTED_CONTENT`.) + +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_http" +try { + await client.connect(transport); +} catch (error) { + if (error instanceof SdkHttpError) { + console.error(`HTTP ${error.status} (${error.statusText ?? ''}) [${error.code}]`); + } else { + throw error; + } +} +``` + ## 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` @@ -871,3 +1069,5 @@ For an end-to-end example of server-initiated SSE disconnection and automatic cl | SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | | Multiple clients | Independent client lifecycles to the same server | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | | URL elicitation | Handle sensitive data collection via browser | [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts) | +| Subscription streams | Auto-opened and manual `subscriptions/listen` streams (2026-07-28) | [`subscriptions/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/client.ts) | +| Multi-round-trip input | Auto-fulfilled and manual `input_required` flows (2026-07-28) | [`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts) | diff --git a/docs/faq.md b/docs/faq.md index 66f3d46c04..45603481ff 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -40,14 +40,14 @@ Once your project is using a single, compatible `zod` version, the `TS2589` erro ### How do I enable Web Crypto (`globalThis.crypto`) for client authentication in older Node.js versions? -The SDK’s OAuth client authentication helpers (for example, those in `packages/client/src/client/auth-extensions.ts` that use `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. This is especially important for **client credentials** and **JWT-based** +The SDK’s OAuth client authentication helpers (for example, those in `packages/client/src/client/authExtensions.ts` that use `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. This is especially important for **client credentials** and **JWT-based** authentication flows used by MCP clients. - **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default. - **Node.js v18.x**: `globalThis.crypto` may not be defined by default. In this repository we polyfill it for tests (see `packages/client/vitest.setup.js`), and you should do the same in your app if it is missing – or alternatively, run Node with `--experimental-global-webcrypto` as per your Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto ) -If you run clients on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.ts`: +If you run clients on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.js`: ```typescript import { webcrypto } from 'node:crypto'; @@ -76,7 +76,7 @@ Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTok ### Why did we remove `server` SSE transport? The SSE transport has been deprecated for a long time, and `v2` will not support it on the server side any more. Client side will keep supporting it in order to be able to connect to legacy SSE servers via the `v2` SDK, but serving SSE from `v2` will not be possible. Servers -wanting to switch to `v2` and using SSE should migrate to Streamable HTTP. +wanting to switch to `v2` and using SSE should migrate to Streamable HTTP. A frozen v1 copy of the server SSE transport remains available as `@modelcontextprotocol/server-legacy/sse` (deprecated). ## v1 (legacy) diff --git a/docs/server-quickstart.md b/docs/server-quickstart.md index 7b75629dfb..e9f057c015 100644 --- a/docs/server-quickstart.md +++ b/docs/server-quickstart.md @@ -88,13 +88,9 @@ Update your `package.json` to add `type: "module"` and a build script: ```json { "type": "module", - "bin": { - "weather": "./build/index.js" - }, "scripts": { - "build": "tsc && chmod 755 build/index.js" - }, - "files": ["build"] + "build": "tsc" + } } ``` @@ -129,17 +125,11 @@ Add these to the top of your `src/index.ts`: ```ts source="../examples/server-quickstart/src/index.ts#prelude" import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; const NWS_API_BASE = 'https://api.weather.gov'; const USER_AGENT = 'weather-app/1.0'; - -// Create server instance -const server = new McpServer({ - name: 'weather', - version: '1.0.0', -}); ``` ### Helper functions @@ -217,149 +207,156 @@ interface ForecastResponse { ### Registering tools -Each tool is registered with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | server.registerTool()}, which takes the tool name, a configuration object (with description and input schema), and a callback that implements the tool logic. Let's register our two weather tools: +Each tool is registered with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | server.registerTool()}, which takes the tool name, a configuration object (with description and input schema), and a callback that implements the tool logic. Create the server inside a `createServer()` factory and register both weather tools on it. The serving entry in the next step builds the instance it serves by calling this factory, so keep it cheap and side-effect-free: ```ts source="../examples/server-quickstart/src/index.ts#registerTools" -// Register weather tools -server.registerTool( - 'get-alerts', - { - title: 'Get Weather Alerts', - description: 'Get weather alerts for a state', - inputSchema: z.object({ - state: z.string().length(2) - .describe('Two-letter state code (e.g. CA, NY)'), - }), - }, - async ({ state }) => { - const stateCode = state.toUpperCase(); - const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; - const alertsData = await makeNWSRequest(alertsUrl); - - if (!alertsData) { - return { - content: [{ - type: 'text' as const, - text: 'Failed to retrieve alerts data', - }], - }; - } - - const features = alertsData.features || []; - - if (features.length === 0) { - return { - content: [{ - type: 'text' as const, - text: `No active alerts for ${stateCode}`, - }], - }; - } - - const formattedAlerts = features.map(formatAlert); - - return { - content: [{ - type: 'text' as const, - text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, - }], - }; - }, -); - -server.registerTool( - 'get-forecast', - { - title: 'Get Weather Forecast', - description: 'Get weather forecast for a location', - inputSchema: z.object({ - latitude: z.number().min(-90).max(90) - .describe('Latitude of the location'), - longitude: z.number().min(-180).max(180) - .describe('Longitude of the location'), - }), - }, - async ({ latitude, longitude }) => { - // Get grid point data - const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; - const pointsData = await makeNWSRequest(pointsUrl); - - if (!pointsData) { - return { - content: [{ - type: 'text' as const, - text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, - }], - }; - } - - const forecastUrl = pointsData.properties?.forecast; - if (!forecastUrl) { - return { - content: [{ - type: 'text' as const, - text: 'Failed to get forecast URL from grid point data', - }], - }; - } +// Create a server with both weather tools registered. The serving entry calls +// this factory to build the instance it serves, so keep it cheap and +// side-effect-free. +function createServer(): McpServer { + const server = new McpServer({ + name: 'weather', + version: '1.0.0', + }); + + server.registerTool( + 'get-alerts', + { + title: 'Get Weather Alerts', + description: 'Get weather alerts for a state', + inputSchema: z.object({ + state: z.string().length(2) + .describe('Two-letter state code (e.g. CA, NY)'), + }), + }, + async ({ state }) => { + const stateCode = state.toUpperCase(); + const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; + const alertsData = await makeNWSRequest(alertsUrl); + + if (!alertsData) { + return { + content: [{ + type: 'text', + text: 'Failed to retrieve alerts data', + }], + isError: true, + }; + } + + const features = alertsData.features || []; + + if (features.length === 0) { + return { + content: [{ + type: 'text', + text: `No active alerts for ${stateCode}`, + }], + }; + } + + const formattedAlerts = features.map(formatAlert); - // Get forecast data - const forecastData = await makeNWSRequest(forecastUrl); - if (!forecastData) { return { content: [{ - type: 'text' as const, - text: 'Failed to retrieve forecast data', + type: 'text', + text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, }], }; - } + }, + ); + + server.registerTool( + 'get-forecast', + { + title: 'Get Weather Forecast', + description: 'Get weather forecast for a location', + inputSchema: z.object({ + latitude: z.number().min(-90).max(90) + .describe('Latitude of the location'), + longitude: z.number().min(-180).max(180) + .describe('Longitude of the location'), + }), + }, + async ({ latitude, longitude }) => { + // Get grid point data + const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; + const pointsData = await makeNWSRequest(pointsUrl); + + if (!pointsData) { + return { + content: [{ + type: 'text', + text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, + }], + isError: true, + }; + } + + const forecastUrl = pointsData.properties?.forecast; + if (!forecastUrl) { + return { + content: [{ + type: 'text', + text: 'Failed to get forecast URL from grid point data', + }], + isError: true, + }; + } + + // Get forecast data + const forecastData = await makeNWSRequest(forecastUrl); + if (!forecastData) { + return { + content: [{ + type: 'text', + text: 'Failed to retrieve forecast data', + }], + isError: true, + }; + } + + const periods = forecastData.properties?.periods || []; + if (periods.length === 0) { + return { + content: [{ + type: 'text', + text: 'No forecast periods available', + }], + }; + } + + // Format forecast periods + const formattedForecast = periods.map((period: ForecastPeriod) => + [ + `${period.name || 'Unknown'}:`, + `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, + `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, + `${period.shortForecast || 'No forecast available'}`, + '---', + ].join('\n'), + ); - const periods = forecastData.properties?.periods || []; - if (periods.length === 0) { return { content: [{ - type: 'text' as const, - text: 'No forecast periods available', + type: 'text', + text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, }], }; - } + }, + ); - // Format forecast periods - const formattedForecast = periods.map((period: ForecastPeriod) => - [ - `${period.name || 'Unknown'}:`, - `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, - `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, - `${period.shortForecast || 'No forecast available'}`, - '---', - ].join('\n'), - ); - - return { - content: [{ - type: 'text' as const, - text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, - }], - }; - }, -); + return server; +} ``` ### Running the server -Finally, implement the main function to run the server: +Finally, serve the factory on stdio with {@linkcode @modelcontextprotocol/server!server/serveStdio.serveStdio | serveStdio}. The entry owns the transport and negotiates the protocol revision with each client, so the same factory serves current hosts (such as VS Code) and clients that speak the 2026-07-28 draft revision; see [Serving the 2026-07-28 draft revision on stdio](./server.md#serving-the-2026-07-28-draft-revision-on-stdio) in the server guide for the options: ```ts source="../examples/server-quickstart/src/index.ts#main" -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('Weather MCP Server running on stdio'); -} - -main().catch((error) => { - console.error('Fatal error in main():', error); - process.exit(1); -}); +void serveStdio(createServer); +console.error('Weather MCP Server running on stdio'); ``` > [!IMPORTANT] diff --git a/docs/server.md b/docs/server.md index 0f8e4d120a..cec0951ff8 100644 --- a/docs/server.md +++ b/docs/server.md @@ -18,12 +18,28 @@ The examples below use these imports. Adjust based on which features and transpo ```ts source="../examples/guides/serverGuide.examples.ts#imports" 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, TRACEPARENT_META_KEY } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { createServer } from 'node:http'; + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import type { CallToolResult, InputRequiredResult, OAuthMetadata, ResourceLink } from '@modelcontextprotocol/server'; +import { + acceptedContent, + completable, + createMcpHandler, + createRequestStateCodec, + inputRequired, + McpServer, + ResourceTemplate, + TRACEPARENT_META_KEY +} from '@modelcontextprotocol/server'; +import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; ``` @@ -52,6 +68,32 @@ await server.connect(transport); For a complete server with sessions and the browser-client CORS recipe, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). +#### Serving the 2026-07-28 draft revision over HTTP + +A hand-wired Streamable HTTP transport speaks the 2025-era protocol it was written for. To serve the 2026-07-28 draft revision, use `createMcpHandler`: it builds one instance from your factory per request and, by default, serves 2025-era traffic stateless from the same factory: + +```ts source="../examples/guides/serverGuide.examples.ts#createMcpHandler_basic" +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once; the same factory serves both eras + return server; +}); +``` + +`handler.fetch` is a web-standard `(Request) => Promise`: on Cloudflare Workers, Deno, or Bun, `export default handler` is all the mounting you need. For Express, Fastify, or plain `node:http`, wrap the handler once with `toNodeHandler` from +`@modelcontextprotocol/node`: + +```ts source="../examples/guides/serverGuide.examples.ts#createMcpHandler_node" +createServer(toNodeHandler(handler)).listen(3000); +// Express: app.all('/mcp', toNodeHandler(handler)); +// behind express.json(): const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body)); +``` + +**Options:** Pass `legacy: 'reject'` to refuse 2025-era requests with the unsupported-protocol-version error (the default, `'stateless'`, serves them per request with no sessions). `onerror` observes out-of-band errors without altering responses. The entry performs no +Origin/Host validation and no token verification itself. Mount [DNS rebinding protection](#dns-rebinding-protection) in front of it, and pass validated auth through `handler.fetch(request, { authInfo })` (or `req.auth` when using `toNodeHandler`). + +To keep an existing sessionful 2025 deployment serving legacy traffic, route with `isLegacyRequest` in front of a strict (`legacy: 'reject'`) handler. See the [2026-07-28 support guide](./migration/support-2026-07-28.md) for the migration patterns; a runnable dual-transport example lives at [`dual-era/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/dual-era/server.ts). + ### stdio For local, process-spawned integrations, use {@linkcode @modelcontextprotocol/server!server/stdio.StdioServerTransport | StdioServerTransport}: @@ -67,20 +109,20 @@ await server.connect(transport); A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` for long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: -```typescript -import { serveStdio } from '@modelcontextprotocol/server/stdio'; - +```ts source="../examples/guides/serverGuide.examples.ts#serveStdio_basic" serveStdio(() => { const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once — the same factory serves both eras + // register tools/resources/prompts once; the same factory serves both eras return server; }); ``` -Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to refuse -2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [2026-07-28 support guide](./migration/support-2026-07-28.md) for details). A runnable +Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [2026-07-28 support guide](./migration/support-2026-07-28.md) for details). A runnable example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.ts`. +**Options:** `legacy: 'reject'` refuses 2025-era openings with the unsupported-protocol-version error (default `'serve'`). `transport` brings your own `Transport` (for example a `StdioServerTransport` constructed over a socket), and the entry owns it either way. `onerror` +observes out-of-band errors. The returned handle's `close()` tears down the pinned instance and the transport. During era selection the entry may construct and discard a probe instance, so keep factories cheap and side-effect-free. + ## Server instructions Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to @@ -100,7 +142,8 @@ const server = new McpServer( Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values. +Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (any Standard Schema library that supports JSON Schema conversion: Zod v4 shown here; ArkType and Valibot also conform) to validate +arguments, and optionally an `outputSchema` for structured return values. > On the 2026-07-28 draft serving path, a tool whose `inputSchema` carries an `x-mcp-header` annotation has that argument mirrored into an `Mcp-Param-{Name}` HTTP request header by conforming clients. `createMcpHandler` validates those headers before dispatch and rejects a > `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32020` (`HeaderMismatch`). `registerTool` warns at registration time when an `x-mcp-header` declaration violates the @@ -299,6 +342,8 @@ server.registerResource( > **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within > the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. +To notify clients when a resource's content changes, see [Change notifications](#change-notifications). + ## Prompts Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use @@ -358,6 +403,8 @@ server.registerPrompt( ); ``` +For resource templates, pass a `complete` callback map to the `ResourceTemplate` constructor instead. + ## Extension capabilities A server advertises support for [MCP extensions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#capability-negotiation) through `capabilities.extensions` — a map from extension identifier to that extension's settings object. Declare entries with @@ -374,6 +421,44 @@ that extension's settings — `{}` means supported with no settings. For a runnable pair, see the [`extension-capabilities/` example](../examples/extension-capabilities/README.md); reading the map client-side is covered in the [client guide](./client.md#extension-capabilities). +## Cache hints (2026-07-28 draft) + +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) so clients and intermediaries know how long a response stays fresh and +whether it may be shared (SEP-2549). The SDK fills both fields automatically when serving that revision, defaulting to `ttlMs: 0` and `cacheScope: 'private'` (immediately stale, never shared). Responses to 2025-era requests are never affected. + +To advertise a real cache policy, set {@linkcode @modelcontextprotocol/server!server/server.ServerOptions | ServerOptions.cacheHints} per operation, and/or `cacheHint` on an individual resource registration: + +```ts source="../examples/guides/serverGuide.examples.ts#cacheHints_basic" +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { + cacheHints: { + // The tool list is the same for every caller and rarely changes: + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } + } + } +); + +server.registerResource( + 'config', + 'config://app', + { + mimeType: 'text/plain', + // Wins field-by-field over a cacheHints['resources/read'] entry; + // cacheScope stays at the 'private' default here. + cacheHint: { ttlMs: 300_000 } + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) +); +``` + +Resolution is per field, most specific author first: values set directly on the handler's result, then the resource's `cacheHint`, then the matching `cacheHints` entry, then the defaults. +Invalid hint values throw a `RangeError` at construction/registration time, and the `cacheHint` key is stripped from the resource's listed metadata (it configures the read result, not the listing). + +Use `cacheScope: 'public'` only for results that are identical for every caller: a `'public'` result may be served to other users by shared caches. Anything derived from the request's authorization context must stay `'private'` (the default). + ## Logging > [!WARNING] @@ -407,6 +492,9 @@ server.registerTool( ); ``` +On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the request's `io.modelcontextprotocol/logLevel` `_meta` key. When the client did not set one, the call is a silent no-op (the spec forbids sending `notifications/message` without the opt-in). On +2025-era connections the session level set via `logging/setLevel` applies as before. See [2026-07-28 support guide › per-request `logLevel`](./migration/support-2026-07-28.md#ctxmcpreqlog-and-the-per-request-loglevel). + ## Progress Progress notifications let a tool report incremental status updates during long-running operations (see [Progress](https://modelcontextprotocol.io/specification/latest/basic/utilities/progress) in the MCP specification). @@ -446,6 +534,61 @@ server.registerTool( `progress` must increase on each call. `total` and `message` are optional. If the client does not provide a `progressToken`, skip the notification. +## Change notifications + +Servers can signal that their tool, prompt, or resource lists changed, or that a specific resource's content changed, so clients can refresh. + +**List changes** are emitted automatically: registering, enabling, disabling, updating, or removing a tool, prompt, or resource sends the matching `notifications/*/list_changed` (`McpServer` advertises the corresponding `listChanged: true` capability on registration; +declare it up front only when using the low-level `Server`). You can also send them explicitly with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#sendToolListChanged | sendToolListChanged()}, `sendPromptListChanged()`, and `sendResourceListChanged()`. + +**Per-resource updates** (2025-era connections) require hand-wiring; `registerResource` has no subscribe option. Declare `resources: { subscribe: true }`, register the `resources/subscribe`/`resources/unsubscribe` handlers on the underlying low-level server, and push +{@linkcode @modelcontextprotocol/server!server/server.Server#sendResourceUpdated | sendResourceUpdated()} when the data changes: + +```ts source="../examples/guides/serverGuide.examples.ts#subscriptions_legacy" +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { capabilities: { resources: { subscribe: true, listChanged: true } } } +); + +const subscriptions = new Set(); +server.server.setRequestHandler('resources/subscribe', async request => { + subscriptions.add(request.params.uri); + return {}; +}); +server.server.setRequestHandler('resources/unsubscribe', async request => { + subscriptions.delete(request.params.uri); + return {}; +}); + +// When the underlying data changes: +async function onConfigChanged() { + if (subscriptions.has('config://app')) { + await server.server.sendResourceUpdated({ uri: 'config://app' }); + } +} +``` + +**On the 2026-07-28 revision** clients receive change notifications only on a `subscriptions/listen` stream they open, and the serving entries handle that method themselves (nothing to register). Over HTTP, publish through the handler's typed +{@linkcode @modelcontextprotocol/server!server/serverEventBus.ServerNotifier | notify} facade; each call reaches every open subscription that opted in: + +```ts source="../examples/guides/serverGuide.examples.ts#subscriptions_notify" +const handler = createMcpHandler(() => buildServer()); + +// When the underlying data changes: +handler.notify.resourceUpdated('config://app'); +handler.notify.toolsChanged(); +``` + +The default in-process {@linkcode @modelcontextprotocol/server!server/serverEventBus.InMemoryServerEventBus | InMemoryServerEventBus} covers single-process deployments; multi-process deployments supply their own +{@linkcode @modelcontextprotocol/server!server/serverEventBus.ServerEventBus | ServerEventBus} via the `bus` option. On stdio, `serveStdio` pins one instance per connection and routes its ordinary `send*ListChanged()` calls onto open subscriptions automatically. Per-resource updates need one change on a 2026 connection: the subscription bookkeeping lives at the entry (the client's listen filter), so the hand-wired `resources/subscribe` handlers above never run. Publish +`sendResourceUpdated()` unconditionally when the data changes and let the entry deliver it only to subscriptions that listed the URI. + +On the 2026-07-28 revision delivery is capability-gated per type: the entry honors `resourceSubscriptions` only when the server advertises `resources: { subscribe: true }`, and each list-changed type only with the matching `listChanged` capability (on 2025-era connections +the SDK gates sends on the presence of the corresponding capability). Clients subscribe to exact resource URIs. + +See [`subscriptions/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/server.ts) for a runnable dual-transport example, and the +[2026-07-28 support guide › `subscriptions/listen`](./migration/support-2026-07-28.md#subscriptionslisten) for migration-level detail. + ## Trace context propagation The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When @@ -478,14 +621,126 @@ To propagate context onward (for example on a server-initiated sampling request, ## Server-initiated requests -MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). +MCP is bidirectional: servers can request input _from_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). On 2025-era connections the server pushes a JSON-RPC request to the client (the sections below). On the 2026-07-28 revision there is no server→client request channel: the handler **returns** an `input_required` result carrying the embedded requests, +and the client retries the call with the responses. + +On a connection pinned to the 2026-07-28 draft revision (served via `serveStdio` or `createMcpHandler`), the push-style channels below throw an {@linkcode @modelcontextprotocol/server!index.SdkError | SdkError} with +code {@linkcode @modelcontextprotocol/server!index.SdkErrorCode.MethodNotSupportedByProtocolVersion | METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION} before anything reaches the wire (see the [2026-07-28 support guide](./migration/support-2026-07-28.md)). + +### Requesting input on 2026-07-28: `input_required` + +On the 2026-07-28 revision a `tools/call`, `prompts/get`, or `resources/read` handler requests client input by returning {@linkcode @modelcontextprotocol/server!index.inputRequired | inputRequired(...)}. The result names one or more +embedded requests, built with `inputRequired.elicit(...)` (form elicitation), `inputRequired.elicitUrl(...)` (URL elicitation), `inputRequired.createMessage(...)` (sampling), or `inputRequired.listRoots()`. Write the handler **write-once**: on every entry, first read what has already arrived via {@linkcode @modelcontextprotocol/server!index.acceptedContent | acceptedContent(ctx.mcpReq.inputResponses, key)}, and only ask for what is still missing: + +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_inputRequired" +server.registerTool( + 'deploy', + { + description: 'Deploy after user confirmation', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean' } }, + required: ['confirm'] + } + }) + } + }); + } + return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; + } +); +``` + +Every `input_required` result must carry at least one of `inputRequests` or `requestState`: the builder throws a `TypeError` otherwise, and the seam re-checks the rule for hand-built results. Each embedded request is checked against the capabilities the client declared on +the request's `_meta` envelope; a missing capability rejects the call with `-32021` before anything reaches the wire. The responses in `ctx.mcpReq.inputResponses` come from the client; treat them as untrusted input. + +For the full multi-step pattern (confirmation, then URL-mode sign-in), see [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts). + +#### Carrying state across rounds: `requestState` + +The 2026-07-28 serving entries are per-request: nothing survives between rounds on the server. To remember where a multi-step flow stands, return an opaque `requestState` string alongside (or instead of) `inputRequests`; the client echoes it back byte-for-byte on the retry +and the handler reads it back with the typed `ctx.mcpReq.requestState()` accessor. + +> [!IMPORTANT] +> `requestState` round-trips through the client and comes back as **attacker-controlled input**. State that influences authorization, resource access, or business logic must be integrity-protected; the SDK applies no protection of its own. Use +> {@linkcode @modelcontextprotocol/server!index.createRequestStateCodec | createRequestStateCodec}, an HMAC-SHA256 codec whose `verify` drops directly into the `ServerOptions.requestState` hook, which runs before the handler and answers tampered or expired state with a +> wire-level `-32602` (frozen message `"Invalid or expired requestState"`). The codec is signed, not encrypted. Do not put secrets in the payload. + +```ts source="../examples/guides/serverGuide.examples.ts#requestState_codec" +const stateCodec = createRequestStateCodec<{ step: string }>({ + key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share across instances in a fleet + ttlSeconds: 600 +}); + +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } } +); +``` + +Inside a handler, mint state on the way out and read it back on re-entry. The `requestState.verify` hook has already run by then, and the accessor returns its decoded payload (or the raw string when no hook is configured): + +```ts source="../examples/guides/serverGuide.examples.ts#requestState_mintDecode" +server.registerTool( + 'wipe-cache', + { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + const state = ctx.mcpReq.requestState<{ step: string }>(); + + if (state?.step !== 'confirmed') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Really wipe the cache?', + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + // Mint only what the response above already proved: the user confirmed. + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ + message: 'Which scope?', + requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + + const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); + return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + } +); +``` + +Mint state that records what earlier rounds already proved, never an outcome that has not happened yet. The codec makes the token tamper-proof, which means it is bearer proof of whatever you put in it: a token minted as `{ step: 'signed-in' }` before the user signs in grants that step to anyone who echoes it. + +See [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) for the worked end-to-end flow, and the +[2026-07-28 support guide › Replacing per-session state](./migration/support-2026-07-28.md#replacing-per-session-state-requeststate) for porting session-keyed code. ### Sampling > [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional on 2025-era connections during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to > calling LLM provider APIs directly from your server. +> [!NOTE] +> `ctx.mcpReq.requestSampling` is the 2025-era push channel and **throws a typed error on a 2026-07-28-era request**. On that revision, return `inputRequired({ inputRequests: { id: inputRequired.createMessage({ … }) } })` instead; see +> [Requesting input on 2026-07-28](#requesting-input-on-2026-07-28-input_required). + Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution. @@ -535,6 +790,10 @@ Elicitation lets a tool handler request direct input from the user — form fiel > [!IMPORTANT] > Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. +> [!NOTE] +> `ctx.mcpReq.elicitInput` is the 2025-era push channel and **throws a typed error on a 2026-07-28-era request**. Return `inputRequired.elicit(...)` (form) or `inputRequired.elicitUrl(...)` (URL) via `inputRequired({ inputRequests: { … } })` instead; see +> [Requesting input on 2026-07-28](#requesting-input-on-2026-07-28-input_required). The throw-style `UrlElicitationRequiredError` (`-32042`) also fails loudly toward 2026-era requests. + Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: ```ts source="../examples/guides/serverGuide.examples.ts#registerTool_elicitation" @@ -583,9 +842,13 @@ For runnable examples, see [`elicitation/server.ts`](https://github.com/modelcon ### Roots > [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional on 2025-era connections during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to > passing paths via tool parameters, resource URIs, or configuration. +> [!NOTE] +> `server.server.listRoots()` **throws a typed error on a 2026-07-28-era instance**. Return `inputRequired({ inputRequests: { roots: inputRequired.listRoots() } })` and read the response from `ctx.mcpReq.inputResponses` on re-entry; see +> [Requesting input on 2026-07-28](#requesting-input-on-2026-07-28-input_required). + Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): @@ -673,9 +936,53 @@ The app factories also validate the `Origin` header with the same arming rules: is no option that disables Origin validation for a localhost-class bind. Requests without an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework middleware (`originValidation`, `localhostOriginValidation`) can also be mounted explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. -If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) +If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/middleware/hostHeaderValidation.ts) middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. +### Authorization (OAuth resource server) + +HTTP servers can require OAuth bearer tokens (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). The SDK treats your server as an OAuth _resource server_: it verifies tokens issued by an authorization +server; it does not issue them. Token verification, the `WWW-Authenticate` challenge, and the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata document come from `@modelcontextprotocol/express`: + +```ts source="../examples/guides/serverGuide.examples.ts#auth_resourceServer" +const mcpServerUrl = new URL('https://api.example.com/mcp'); + +// Verify tokens however your deployment requires: JWT verification, +// RFC 7662 introspection, a call to your IdP. +const verifier: OAuthTokenVerifier = { + async verifyAccessToken(token) { + const payload = await verifyJwt(token); + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + } +}; + +// Public deployment: allow-list the public host (see DNS rebinding protection). +const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); + +// Serves /.well-known/oauth-protected-resource/mcp (RFC 9728) and mirrors the +// authorization server's metadata, so clients can discover your AS. +app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); + +// 401/403 responses carry `WWW-Authenticate: Bearer …` with `resource_metadata` +// pointing at the document above. That challenge is what starts the client +// SDK's OAuth flow. +const auth = requireBearerAuth({ + verifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); + +const node = toNodeHandler(createMcpHandler(buildServer)); +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); +``` + +`requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`; `toNodeHandler` forwards it so tool handlers read it as `ctx.http.authInfo` (and `createMcpHandler` factories as `ctx.authInfo`). A missing or invalid token gets `401 invalid_token`, as does a token whose `expiresAt` is unset or in the past. A valid token missing one of `requiredScopes` gets `403 insufficient_scope`; the challenge's `scope` field is what clients use for scope step-up (SEP-2350). + +Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) live in `@modelcontextprotocol/server-legacy/auth` as a frozen v1 copy; new code should use a dedicated IdP or OAuth library for the AS (see the [FAQ](./faq.md#where-are-the-server-auth-helpers)). + +For runnable examples, see [`bearer-auth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts) (minimal static verifier) and +[`oauth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/server.ts) (full discovery flow against a demo authorization server). + ## See also - [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable server examples @@ -693,3 +1000,6 @@ middleware source for reference. When mounting a handler bare on a fetch-native | Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/shared/src/inMemoryEventStore.ts) | | CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | | Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | +| Dual-era serving | One factory serving 2025 + 2026-07-28 over HTTP and stdio | [`dual-era/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/dual-era/server.ts) | +| Change notifications | Publish `subscriptions/listen` change events over HTTP and stdio | [`subscriptions/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/server.ts) | +| OAuth resource server | Bearer-token verification, `WWW-Authenticate` challenge, RFC 9728 metadata | [`bearer-auth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts), [`oauth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/server.ts) | diff --git a/examples/client-quickstart/README.md b/examples/client-quickstart/README.md new file mode 100644 index 0000000000..5e590eccf4 --- /dev/null +++ b/examples/client-quickstart/README.md @@ -0,0 +1,5 @@ +# client-quickstart + +Source for the [Client Quickstart](../../docs/client-quickstart.md) tutorial: an LLM-powered chatbot that connects to an MCP server over stdio and calls its tools. The tutorial walks through `src/index.ts` end to end. + +The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself, use the standalone manifests from the tutorial. diff --git a/examples/client-quickstart/package.json b/examples/client-quickstart/package.json index 646890492e..5b73c5c0d0 100644 --- a/examples/client-quickstart/package.json +++ b/examples/client-quickstart/package.json @@ -1,11 +1,8 @@ { - "name": "@modelcontextprotocol/examples-client-quickstart", + "name": "@mcp-examples/client-quickstart", "private": true, "version": "2.0.0-alpha.0", "type": "module", - "bin": { - "mcp-client-cli": "./build/index.js" - }, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" diff --git a/examples/client-quickstart/src/index.ts b/examples/client-quickstart/src/index.ts index 6764fe61ff..79fa48868a 100644 --- a/examples/client-quickstart/src/index.ts +++ b/examples/client-quickstart/src/index.ts @@ -4,12 +4,11 @@ import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import readline from 'readline/promises'; -const ANTHROPIC_MODEL = 'claude-sonnet-4-5'; +const ANTHROPIC_MODEL = 'claude-sonnet-4-6'; class MCPClient { private mcp: Client; private _anthropic: Anthropic | null = null; - private transport: StdioClientTransport | null = null; private tools: Anthropic.Tool[] = []; constructor() { @@ -37,8 +36,8 @@ class MCPClient { : process.execPath; // Initialize transport and connect to server - this.transport = new StdioClientTransport({ command, args: [serverScriptPath] }); - await this.mcp.connect(this.transport); + const transport = new StdioClientTransport({ command, args: [serverScriptPath] }); + await this.mcp.connect(transport); // List available tools const toolsResult = await this.mcp.listTools(); @@ -65,29 +64,40 @@ class MCPClient { ]; // Initial Claude API call - const response = await this.anthropic.messages.create({ + let response = await this.anthropic.messages.create({ model: ANTHROPIC_MODEL, max_tokens: 1000, messages, tools: this.tools, }); - // Process response and handle tool calls + // Process responses, executing tool calls until Claude stops requesting them const finalText = []; - for (const content of response.content) { - if (content.type === 'text') { - finalText.push(content.text); - } else if (content.type === 'tool_use') { - // Execute tool call - const toolName = content.name; - const toolArgs = content.input as Record | undefined; + while (true) { + const toolUses: Anthropic.ToolUseBlock[] = []; + for (const content of response.content) { + if (content.type === 'text') { + finalText.push(content.text); + } else if (content.type === 'tool_use') { + toolUses.push(content); + } + } + + if (toolUses.length === 0) { + break; + } + + // Execute every requested tool call and collect the results + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const toolUse of toolUses) { + const toolArgs = toolUse.input as Record; const result = await this.mcp.callTool({ - name: toolName, + name: toolUse.name, arguments: toolArgs, }); - finalText.push(`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`); + finalText.push(`[Calling tool ${toolUse.name} with args ${JSON.stringify(toolArgs)}]`); // Extract text from tool result content blocks const toolResultText = result.content @@ -95,30 +105,33 @@ class MCPClient { .map((block) => block.text) .join('\n'); - // Continue conversation with tool results - messages.push({ - role: 'assistant', - content: response.content, - }); - messages.push({ - role: 'user', - content: [{ - type: 'tool_result', - tool_use_id: content.id, - content: toolResultText, - }], + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: toolResultText, + // Tell Claude when the tool call failed + ...(result.isError ? { is_error: true } : {}), }); - - // Get next response from Claude - const followUp = await this.anthropic.messages.create({ - model: ANTHROPIC_MODEL, - max_tokens: 1000, - messages, - }); - - const firstBlock = followUp.content[0]; - finalText.push(firstBlock?.type === 'text' ? firstBlock.text : ''); } + + // Continue the conversation: the assistant turn, then ALL tool + // results together in a single user turn + messages.push({ + role: 'assistant', + content: response.content, + }); + messages.push({ + role: 'user', + content: toolResults, + }); + + // Get next response from Claude + response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + tools: this.tools, + }); } return finalText.join('\n'); diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index db6a2fd14f..bd065d6e9e 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -10,6 +10,8 @@ //#region imports import type { AuthProvider, + CallToolResult, + InputRequiredResult, OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, @@ -19,16 +21,21 @@ import type { } from '@modelcontextprotocol/client'; import { applyMiddlewares, + checkResourceAllowed, Client, ClientCredentialsProvider, createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + isInputRequiredResult, IssuerMismatchError, + LOG_LEVEL_META_KEY, PrivateKeyJwtProvider, ProtocolError, + resourceUrlFromServerUrl, SdkError, SdkErrorCode, + SdkHttpError, SSEClientTransport, StreamableHTTPClientTransport, TRACEPARENT_META_KEY, @@ -296,6 +303,20 @@ function auth_oauthClientProvider(onRedirect: (url: URL) => void) { authProvider: provider }); //#endregion auth_oauthClientProvider + + //#region auth_validateResourceURL + class PinnedResourceProvider extends MyOAuthProvider { + async validateResourceURL(serverUrl: string | URL, resource?: string): Promise { + const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) + if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { + throw new Error(`Refusing resource ${resource} for server ${expected.href}`); + } + return expected; + } + } + //#endregion auth_validateResourceURL + void PinnedResourceProvider; + return { provider, transport }; } @@ -465,6 +486,23 @@ async function complete_basic(client: Client) { //#endregion complete_basic } +// --------------------------------------------------------------------------- +// Response caching +// --------------------------------------------------------------------------- + +/** Example: Per-call cache disposition via cacheMode. */ +async function responseCache_basic(client: Client) { + //#region responseCache_basic + const tools = await client.listTools(); // network, then cached for the server's ttlMs + const again = await client.listTools(); // served from cache while still fresh + + await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store + await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write + //#endregion responseCache_basic + void tools; + void again; +} + // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- @@ -493,6 +531,18 @@ async function setLoggingLevel_basic(client: Client) { //#endregion setLoggingLevel_basic } +/** Example: Per-request log-level opt-in on a 2026-07-28 connection. */ +async function logLevelMeta_modern(client: Client) { + //#region logLevelMeta_modern + const result = await client.callTool({ + name: 'fetch-data', + arguments: { url: 'https://example.com' }, + _meta: { [LOG_LEVEL_META_KEY]: 'debug' } + }); + //#endregion logLevelMeta_modern + void result; +} + /** Example: Automatic list-change tracking via the listChanged option. */ async function listChanged_basic() { //#region listChanged_basic @@ -519,11 +569,45 @@ async function listChanged_basic() { return client; } +/** Example: Open a subscriptions/listen stream explicitly (2026-07-28). */ +async function listen_basic(client: Client) { + //#region listen_basic + client.setNotificationHandler('notifications/tools/list_changed', async () => { + const { tools } = await client.listTools(); + console.log('Tools changed:', tools.length); + }); + client.setNotificationHandler('notifications/resources/updated', async notification => { + console.log('Resource updated:', notification.params.uri); + }); + + const subscription = await client.listen({ + toolsListChanged: true, + resourceSubscriptions: ['config://app'] + }); + console.log('Server honored:', subscription.honoredFilter); + + // Later: tear the stream down + await subscription.close(); + //#endregion listen_basic +} + +/** Example: Watch loop that re-listens on unexpected closes. */ +async function listen_watchLoop(client: Client, watching: boolean) { + //#region listen_watchLoop + while (watching) { + const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); + const reason = await sub.closed; + if (reason !== 'remote') break; // 'local' or 'graceful': done + await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen + } + //#endregion listen_watchLoop +} + // --------------------------------------------------------------------------- // Handling server-initiated requests // --------------------------------------------------------------------------- -/** Example: Declare client capabilities for sampling and elicitation. */ +/** Example: Declare client capabilities for sampling, elicitation, and roots. */ function capabilities_declaration() { //#region capabilities_declaration const client = new Client( @@ -531,7 +615,8 @@ function capabilities_declaration() { { capabilities: { sampling: {}, - elicitation: { form: {} } + elicitation: { form: {} }, + roots: { listChanged: true } } } ); @@ -590,6 +675,42 @@ function roots_handler(client: Client) { //#endregion roots_handler } +/** Example: Manual multi-round-trip handling with autoFulfill disabled (2026-07-28). */ +async function inputRequired_manual(transport: StreamableHTTPClientTransport) { + //#region inputRequired_manual + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { mode: 'auto' }, + inputRequired: { autoFulfill: false } + } + ); + await client.connect(transport); + + const value = (await client.request( + { method: 'tools/call', params: { name: 'deploy', arguments: { env: 'prod' } } }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (isInputRequiredResult(value)) { + // Collect responses for value.inputRequests from your UI, then retry: + await client.request( + { + method: 'tools/call', + params: { + name: 'deploy', + arguments: { env: 'prod' }, + inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, + requestState: value.requestState // echo byte-exact + } + }, + { allowInputRequired: true } + ); + } + //#endregion inputRequired_manual +} + // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- @@ -655,6 +776,21 @@ async function errorHandling_timeout(client: Client) { //#endregion errorHandling_timeout } +/** Example: Typed HTTP transport errors. */ +async function errorHandling_http(client: Client, transport: StreamableHTTPClientTransport) { + //#region errorHandling_http + try { + await client.connect(transport); + } catch (error) { + if (error instanceof SdkHttpError) { + console.error(`HTTP ${error.status} (${error.statusText ?? ''}) [${error.code}]`); + } else { + throw error; + } + } + //#endregion errorHandling_http +} + // --------------------------------------------------------------------------- // Advanced patterns // --------------------------------------------------------------------------- @@ -765,16 +901,22 @@ void readResource_basic; void subscribeResource_basic; void getPrompt_basic; void complete_basic; +void responseCache_basic; void notificationHandler_basic; void setLoggingLevel_basic; +void logLevelMeta_modern; void listChanged_basic; +void listen_basic; +void listen_watchLoop; void capabilities_declaration; void sampling_handler; void elicitation_handler; void roots_handler; +void inputRequired_manual; void errorHandling_toolErrors; void errorHandling_lifecycle; void errorHandling_timeout; +void errorHandling_http; void middleware_basic; void traceContext_perRequest; void traceContext_middleware; diff --git a/examples/guides/serverGuide.examples.ts b/examples/guides/serverGuide.examples.ts index b8f8ec8f04..a0ffb9cf93 100644 --- a/examples/guides/serverGuide.examples.ts +++ b/examples/guides/serverGuide.examples.ts @@ -9,12 +9,28 @@ //#region imports 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, TRACEPARENT_META_KEY } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { createServer } from 'node:http'; + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import type { CallToolResult, InputRequiredResult, OAuthMetadata, ResourceLink } from '@modelcontextprotocol/server'; +import { + acceptedContent, + completable, + createMcpHandler, + createRequestStateCodec, + inputRequired, + McpServer, + ResourceTemplate, + TRACEPARENT_META_KEY +} from '@modelcontextprotocol/server'; +import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; //#endregion imports @@ -289,6 +305,40 @@ function extensionCapabilities_register(server: McpServer) { //#endregion extensionCapabilities_register } +// --------------------------------------------------------------------------- +// Cache hints +// --------------------------------------------------------------------------- + +/** Example: cache hints via ServerOptions.cacheHints and a per-resource cacheHint. */ +function cacheHints_basic() { + //#region cacheHints_basic + const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { + cacheHints: { + // The tool list is the same for every caller and rarely changes: + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } + } + } + ); + + server.registerResource( + 'config', + 'config://app', + { + mimeType: 'text/plain', + // Wins field-by-field over a cacheHints['resources/read'] entry; + // cacheScope stays at the 'private' default here. + cacheHint: { ttlMs: 300_000 } + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) + ); + //#endregion cacheHints_basic + return server; +} + // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- @@ -379,6 +429,50 @@ function registerTool_traceContext(server: McpServer) { //#endregion registerTool_traceContext } +// --------------------------------------------------------------------------- +// Change notifications +// --------------------------------------------------------------------------- + +/** Example: hand-wired resources/subscribe handlers + sendResourceUpdated (2025-era). */ +function subscriptions_legacy() { + //#region subscriptions_legacy + const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { capabilities: { resources: { subscribe: true, listChanged: true } } } + ); + + const subscriptions = new Set(); + server.server.setRequestHandler('resources/subscribe', async request => { + subscriptions.add(request.params.uri); + return {}; + }); + server.server.setRequestHandler('resources/unsubscribe', async request => { + subscriptions.delete(request.params.uri); + return {}; + }); + + // When the underlying data changes: + async function onConfigChanged() { + if (subscriptions.has('config://app')) { + await server.server.sendResourceUpdated({ uri: 'config://app' }); + } + } + //#endregion subscriptions_legacy + return onConfigChanged; +} + +/** Example: publishing change events through the createMcpHandler notify facade (2026-07-28). */ +function subscriptions_notify(buildServer: () => McpServer) { + //#region subscriptions_notify + const handler = createMcpHandler(() => buildServer()); + + // When the underlying data changes: + handler.notify.resourceUpdated('config://app'); + handler.notify.toolsChanged(); + //#endregion subscriptions_notify + return handler; +} + // --------------------------------------------------------------------------- // Server-initiated requests // --------------------------------------------------------------------------- @@ -479,6 +573,90 @@ function registerTool_roots(server: McpServer) { //#endregion registerTool_roots } +/** Example: write-once tool requesting input via an input_required return (2026-07-28). */ +function registerTool_inputRequired(server: McpServer) { + //#region registerTool_inputRequired + server.registerTool( + 'deploy', + { + description: 'Deploy after user confirmation', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean' } }, + required: ['confirm'] + } + }) + } + }); + } + return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; + } + ); + //#endregion registerTool_inputRequired +} + +/** Example: HMAC-protected requestState via createRequestStateCodec + the verify hook. */ +function requestState_codec() { + //#region requestState_codec + const stateCodec = createRequestStateCodec<{ step: string }>({ + key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share across instances in a fleet + ttlSeconds: 600 + }); + + const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } } + ); + //#endregion requestState_codec + + //#region requestState_mintDecode + server.registerTool( + 'wipe-cache', + { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + const state = ctx.mcpReq.requestState<{ step: string }>(); + + if (state?.step !== 'confirmed') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Really wipe the cache?', + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + // Mint only what the response above already proved: the user confirmed. + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ + message: 'Which scope?', + requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + + const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); + return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + } + ); + //#endregion requestState_mintDecode + return server; +} + // --------------------------------------------------------------------------- // Transports // --------------------------------------------------------------------------- @@ -532,6 +710,38 @@ async function stdio_basic() { //#endregion stdio_basic } +/** Example: serveStdio serving both protocol eras on stdio from one factory. */ +function serveStdio_basic() { + //#region serveStdio_basic + serveStdio(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once; the same factory serves both eras + return server; + }); + //#endregion serveStdio_basic +} + +/** Example: createMcpHandler serving both protocol eras over HTTP from one factory. */ +function createMcpHandler_basic() { + //#region createMcpHandler_basic + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once; the same factory serves both eras + return server; + }); + //#endregion createMcpHandler_basic + return handler; +} + +/** Example: mounting an McpHttpHandler on node:http via toNodeHandler. */ +function createMcpHandler_node(handler: ReturnType) { + //#region createMcpHandler_node + createServer(toNodeHandler(handler)).listen(3000); + // Express: app.all('/mcp', toNodeHandler(handler)); + // behind express.json(): const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body)); + //#endregion createMcpHandler_node +} + // --------------------------------------------------------------------------- // Shutdown // --------------------------------------------------------------------------- @@ -595,6 +805,50 @@ function dnsRebinding_allowedHosts() { return app; } +// --------------------------------------------------------------------------- +// Authorization (OAuth resource server) +// --------------------------------------------------------------------------- + +/** Example: protecting an HTTP server as an OAuth resource server. */ +function auth_resourceServer( + verifyJwt: (token: string) => Promise<{ sub: string; scopes: string[]; exp: number }>, + oauthMetadata: OAuthMetadata, + buildServer: () => McpServer +) { + //#region auth_resourceServer + const mcpServerUrl = new URL('https://api.example.com/mcp'); + + // Verify tokens however your deployment requires: JWT verification, + // RFC 7662 introspection, a call to your IdP. + const verifier: OAuthTokenVerifier = { + async verifyAccessToken(token) { + const payload = await verifyJwt(token); + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + } + }; + + // Public deployment: allow-list the public host (see DNS rebinding protection). + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); + + // Serves /.well-known/oauth-protected-resource/mcp (RFC 9728) and mirrors the + // authorization server's metadata, so clients can discover your AS. + app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); + + // 401/403 responses carry `WWW-Authenticate: Bearer …` with `resource_metadata` + // pointing at the document above. That challenge is what starts the client + // SDK's OAuth flow. + const auth = requireBearerAuth({ + verifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + }); + + const node = toNodeHandler(createMcpHandler(buildServer)); + app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); + //#endregion auth_resourceServer + return app; +} + // Suppress unused-function warnings (functions exist solely for type-checking) void instructions_basic; void registerTool_basic; @@ -608,16 +862,25 @@ void registerTool_traceContext; void registerTool_sampling; void registerTool_elicitation; void registerTool_roots; +void registerTool_inputRequired; +void requestState_codec; void registerResource_static; void registerResource_template; void registerPrompt_basic; void registerPrompt_completion; void extensionCapabilities_register; +void cacheHints_basic; +void subscriptions_legacy; +void subscriptions_notify; void streamableHttp_stateful; void streamableHttp_stateless; void streamableHttp_jsonResponse; void stdio_basic; +void serveStdio_basic; +void createMcpHandler_basic; +void createMcpHandler_node; void shutdown_statefulHttp; void shutdown_stdio; void dnsRebinding_basic; void dnsRebinding_allowedHosts; +void auth_resourceServer; diff --git a/examples/server-quickstart/README.md b/examples/server-quickstart/README.md new file mode 100644 index 0000000000..9e19c1e77f --- /dev/null +++ b/examples/server-quickstart/README.md @@ -0,0 +1,5 @@ +# server-quickstart + +Source for the [Server Quickstart](../../docs/server-quickstart.md) tutorial: a stdio weather server exposing `get-alerts` and `get-forecast` tools. The tutorial walks through `src/index.ts` end to end. + +The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the server yourself, use the standalone manifests from the tutorial. diff --git a/examples/server-quickstart/package.json b/examples/server-quickstart/package.json index e06a9832f9..e3b7960fb2 100644 --- a/examples/server-quickstart/package.json +++ b/examples/server-quickstart/package.json @@ -1,11 +1,8 @@ { - "name": "@modelcontextprotocol/examples-server-quickstart", + "name": "@mcp-examples/server-quickstart", "private": true, "version": "2.0.0-alpha.0", "type": "module", - "bin": { - "weather": "./build/index.js" - }, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" diff --git a/examples/server-quickstart/src/index.ts b/examples/server-quickstart/src/index.ts index 22d459173c..1559a434d5 100644 --- a/examples/server-quickstart/src/index.ts +++ b/examples/server-quickstart/src/index.ts @@ -1,16 +1,10 @@ //#region prelude import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; const NWS_API_BASE = 'https://api.weather.gov'; const USER_AGENT = 'weather-app/1.0'; - -// Create server instance -const server = new McpServer({ - name: 'weather', - version: '1.0.0', -}); //#endregion prelude //#region helpers @@ -83,140 +77,147 @@ interface ForecastResponse { //#endregion helpers //#region registerTools -// Register weather tools -server.registerTool( - 'get-alerts', - { - title: 'Get Weather Alerts', - description: 'Get weather alerts for a state', - inputSchema: z.object({ - state: z.string().length(2) - .describe('Two-letter state code (e.g. CA, NY)'), - }), - }, - async ({ state }) => { - const stateCode = state.toUpperCase(); - const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; - const alertsData = await makeNWSRequest(alertsUrl); - - if (!alertsData) { - return { - content: [{ - type: 'text' as const, - text: 'Failed to retrieve alerts data', - }], - }; - } - - const features = alertsData.features || []; - - if (features.length === 0) { - return { - content: [{ - type: 'text' as const, - text: `No active alerts for ${stateCode}`, - }], - }; - } - - const formattedAlerts = features.map(formatAlert); - - return { - content: [{ - type: 'text' as const, - text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, - }], - }; - }, -); - -server.registerTool( - 'get-forecast', - { - title: 'Get Weather Forecast', - description: 'Get weather forecast for a location', - inputSchema: z.object({ - latitude: z.number().min(-90).max(90) - .describe('Latitude of the location'), - longitude: z.number().min(-180).max(180) - .describe('Longitude of the location'), - }), - }, - async ({ latitude, longitude }) => { - // Get grid point data - const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; - const pointsData = await makeNWSRequest(pointsUrl); - - if (!pointsData) { - return { - content: [{ - type: 'text' as const, - text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, - }], - }; - } +// Create a server with both weather tools registered. The serving entry calls +// this factory to build the instance it serves, so keep it cheap and +// side-effect-free. +function createServer(): McpServer { + const server = new McpServer({ + name: 'weather', + version: '1.0.0', + }); + + server.registerTool( + 'get-alerts', + { + title: 'Get Weather Alerts', + description: 'Get weather alerts for a state', + inputSchema: z.object({ + state: z.string().length(2) + .describe('Two-letter state code (e.g. CA, NY)'), + }), + }, + async ({ state }) => { + const stateCode = state.toUpperCase(); + const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; + const alertsData = await makeNWSRequest(alertsUrl); + + if (!alertsData) { + return { + content: [{ + type: 'text', + text: 'Failed to retrieve alerts data', + }], + isError: true, + }; + } + + const features = alertsData.features || []; + + if (features.length === 0) { + return { + content: [{ + type: 'text', + text: `No active alerts for ${stateCode}`, + }], + }; + } + + const formattedAlerts = features.map(formatAlert); - const forecastUrl = pointsData.properties?.forecast; - if (!forecastUrl) { return { content: [{ - type: 'text' as const, - text: 'Failed to get forecast URL from grid point data', + type: 'text', + text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, }], }; - } + }, + ); + + server.registerTool( + 'get-forecast', + { + title: 'Get Weather Forecast', + description: 'Get weather forecast for a location', + inputSchema: z.object({ + latitude: z.number().min(-90).max(90) + .describe('Latitude of the location'), + longitude: z.number().min(-180).max(180) + .describe('Longitude of the location'), + }), + }, + async ({ latitude, longitude }) => { + // Get grid point data + const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; + const pointsData = await makeNWSRequest(pointsUrl); + + if (!pointsData) { + return { + content: [{ + type: 'text', + text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, + }], + isError: true, + }; + } + + const forecastUrl = pointsData.properties?.forecast; + if (!forecastUrl) { + return { + content: [{ + type: 'text', + text: 'Failed to get forecast URL from grid point data', + }], + isError: true, + }; + } + + // Get forecast data + const forecastData = await makeNWSRequest(forecastUrl); + if (!forecastData) { + return { + content: [{ + type: 'text', + text: 'Failed to retrieve forecast data', + }], + isError: true, + }; + } + + const periods = forecastData.properties?.periods || []; + if (periods.length === 0) { + return { + content: [{ + type: 'text', + text: 'No forecast periods available', + }], + }; + } + + // Format forecast periods + const formattedForecast = periods.map((period: ForecastPeriod) => + [ + `${period.name || 'Unknown'}:`, + `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, + `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, + `${period.shortForecast || 'No forecast available'}`, + '---', + ].join('\n'), + ); - // Get forecast data - const forecastData = await makeNWSRequest(forecastUrl); - if (!forecastData) { return { content: [{ - type: 'text' as const, - text: 'Failed to retrieve forecast data', + type: 'text', + text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, }], }; - } + }, + ); - const periods = forecastData.properties?.periods || []; - if (periods.length === 0) { - return { - content: [{ - type: 'text' as const, - text: 'No forecast periods available', - }], - }; - } - - // Format forecast periods - const formattedForecast = periods.map((period: ForecastPeriod) => - [ - `${period.name || 'Unknown'}:`, - `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, - `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, - `${period.shortForecast || 'No forecast available'}`, - '---', - ].join('\n'), - ); - - return { - content: [{ - type: 'text' as const, - text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, - }], - }; - }, -); + return server; +} //#endregion registerTools //#region main -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('Weather MCP Server running on stdio'); -} - -main().catch((error) => { - console.error('Fatal error in main():', error); - process.exit(1); -}); +void serveStdio(createServer); +console.error('Weather MCP Server running on stdio'); //#endregion main From 7cd032cb0326bbb6f0ec5a08530642b926097daf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 16:20:11 +0000 Subject: [PATCH 2/2] docs: note the legacy shim for input_required on 2025-era connections Documents that handlers returning input_required stay write-once on 2025-era connections, where the SDK's default-on legacy shim fulfils the embedded requests by issuing real elicitation/sampling/roots requests over the session, and links the shim section of the 2026-07-28 support guide for the configuration knobs and limits. Merge together with (or after) the legacy-shim change itself: the described behavior and the linked support-guide section land with it. Without that change, an input_required return on a 2025-era connection fails the request. --- docs/server.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/server.md b/docs/server.md index cec0951ff8..918b3c297e 100644 --- a/docs/server.md +++ b/docs/server.md @@ -663,6 +663,8 @@ server.registerTool( Every `input_required` result must carry at least one of `inputRequests` or `requestState`: the builder throws a `TypeError` otherwise, and the seam re-checks the rule for hand-built results. Each embedded request is checked against the capabilities the client declared on the request's `_meta` envelope; a missing capability rejects the call with `-32021` before anything reaches the wire. The responses in `ctx.mcpReq.inputResponses` come from the client; treat them as untrusted input. +On 2025-era connections you don't need to branch: the SDK's legacy shim (on by default) fulfils `input_required` returns by issuing real elicitation/sampling/roots requests over the session, so handlers stay write-once. Knobs and limits are described in [the legacy shim section of the 2026-07-28 support guide](./migration/support-2026-07-28.md#legacy-shim-for-input_required). + For the full multi-step pattern (confirmation, then URL-mode sign-in), see [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts). #### Carrying state across rounds: `requestState`