docs: correctness and coverage pass for the 2026-07-28 revision (#2389)

This commit is contained in:
Felix Weinberger 2026-06-29 18:44:25 +01:00 • committed by GitHub
commit 199a28e9e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1335 additions and 390 deletions

View file

@ -9,8 +9,6 @@
"updateInternalDependencies": "patch",
"ignore": [
"@modelcontextprotocol/examples",
"@modelcontextprotocol/examples-client-quickstart",
"@modelcontextprotocol/examples-server-quickstart",
"@mcp-examples/*"
]
}

View file

@ -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",

View file

@ -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

View file

@ -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<string, unknown> | 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<string, unknown>;
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,
toolResults.push({
type: 'tool_result',
tool_use_id: toolUse.id,
content: toolResultText,
// Tell Claude when the tool call failed
...(result.isError ? { is_error: true } : {}),
});
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,
});
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

View file

@ -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<URL | undefined> {
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) |

View file

@ -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)

View file

@ -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<AlertsResponse>(alertsUrl);
// 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<AlertsResponse>(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);
if (!alertsData) {
return {
content: [{
type: 'text' as const,
text: 'Failed to retrieve alerts data',
type: 'text',
text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`,
}],
};
}
},
);
const features = alertsData.features || [];
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<PointsResponse>(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<ForecastResponse>(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'),
);
if (features.length === 0) {
return {
content: [{
type: 'text' as const,
text: `No active alerts for ${stateCode}`,
type: 'text',
text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`,
}],
};
}
},
);
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<PointsResponse>(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',
}],
};
}
// Get forecast data
const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
if (!forecastData) {
return {
content: [{
type: 'text' as const,
text: 'Failed to retrieve forecast data',
}],
};
}
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;
}
```
### 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]

View file

@ -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 { createServer } from 'node:http';
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 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<Response>`: 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<string>();
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,128 @@ 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<CallToolResult | InputRequiredResult> => {
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.
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`
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<State>()` 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<CallToolResult | InputRequiredResult> => {
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 +792,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 +844,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 +938,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 +1002,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) |

View file

@ -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.

View file

@ -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"

View file

@ -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<string, unknown> | 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<string, unknown>;
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,
toolResults.push({
type: 'tool_result',
tool_use_id: toolUse.id,
content: toolResultText,
// Tell Claude when the tool call failed
...(result.isError ? { is_error: true } : {}),
});
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,
});
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');

View file

@ -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<URL | undefined> {
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;

View file

@ -9,12 +9,28 @@
//#region imports
import { randomUUID } from 'node:crypto';
import { createServer } from 'node:http';
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 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<string>();
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<CallToolResult | InputRequiredResult> => {
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<CallToolResult | InputRequiredResult> => {
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<typeof createMcpHandler>) {
//#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;

View file

@ -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.

View file

@ -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"

View file

@ -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<AlertsResponse>(alertsUrl);
// 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<AlertsResponse>(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);
if (!alertsData) {
return {
content: [{
type: 'text' as const,
text: 'Failed to retrieve alerts data',
type: 'text',
text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`,
}],
};
}
},
);
const features = alertsData.features || [];
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<PointsResponse>(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<ForecastResponse>(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'),
);
if (features.length === 0) {
return {
content: [{
type: 'text' as const,
text: `No active alerts for ${stateCode}`,
type: 'text',
text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`,
}],
};
}
},
);
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<PointsResponse>(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',
}],
};
}
// Get forecast data
const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
if (!forecastData) {
return {
content: [{
type: 'text' as const,
text: 'Failed to retrieve forecast data',
}],
};
}
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