docs: correctness and coverage pass for the 2026-07-28 revision (#2389)
This commit is contained in:
parent
36055d5bbd
commit
199a28e9e2
16 changed files with 1335 additions and 390 deletions
|
|
@ -9,8 +9,6 @@
|
|||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@modelcontextprotocol/examples",
|
||||
"@modelcontextprotocol/examples-client-quickstart",
|
||||
"@modelcontextprotocol/examples-server-quickstart",
|
||||
"@mcp-examples/*"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
216
docs/client.md
216
docs/client.md
|
|
@ -15,6 +15,8 @@ The examples below use these imports. Adjust based on which features and transpo
|
|||
```ts source="../examples/guides/clientGuide.examples.ts#imports"
|
||||
import type {
|
||||
AuthProvider,
|
||||
CallToolResult,
|
||||
InputRequiredResult,
|
||||
OAuthClientInformationContext,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
|
|
@ -24,16 +26,21 @@ import type {
|
|||
} from '@modelcontextprotocol/client';
|
||||
import {
|
||||
applyMiddlewares,
|
||||
checkResourceAllowed,
|
||||
Client,
|
||||
ClientCredentialsProvider,
|
||||
createMiddleware,
|
||||
CrossAppAccessProvider,
|
||||
discoverAndRequestJwtAuthGrant,
|
||||
isInputRequiredResult,
|
||||
IssuerMismatchError,
|
||||
LOG_LEVEL_META_KEY,
|
||||
PrivateKeyJwtProvider,
|
||||
ProtocolError,
|
||||
resourceUrlFromServerUrl,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
SSEClientTransport,
|
||||
StreamableHTTPClientTransport,
|
||||
TRACEPARENT_META_KEY,
|
||||
|
|
@ -119,6 +126,7 @@ client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25'
|
|||
|
||||
Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an
|
||||
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide › Probe policy](./migration/support-2026-07-28.md#probe-policy) for the full failure semantics and probe-timeout behavior.
|
||||
The version lists come from `ClientOptions.supportedProtocolVersions`: under `'auto'`, its 2026-era entries form the modern offer (default: the SDK's modern list), and a list with no 2025-era entry removes the legacy fallback; `connect()` rejects with `SdkError(EraNegotiationFailed)` instead of downgrading. The same modern subset bounds the overlap check of `connect({ prior })`.
|
||||
|
||||
#### Skipping the probe: `connect({ prior })`
|
||||
|
||||
|
|
@ -140,6 +148,8 @@ await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse
|
|||
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
|
||||
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.
|
||||
|
||||
Unlike an `'auto'`/pinned connect, `connect({ prior })` never auto-opens a `subscriptions/listen` stream. Workers on this path are assumed request-only. A configured `listChanged` option registers its handlers but stays silent. Call [`client.listen(filter)`](#subscription-streams-2026-07-28) yourself if a prior-connected client should observe changes.
|
||||
|
||||
### Disconnecting
|
||||
|
||||
Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
|
||||
|
|
@ -346,6 +356,32 @@ return client;
|
|||
For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and
|
||||
[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts).
|
||||
|
||||
Issuer validation also runs during discovery: the authorization server metadata's `issuer` must match the issuer identifier the well-known URL was built from (RFC 8414 §3.3), and a mismatch throws `IssuerMismatchError`
|
||||
with `kind: 'metadata'` (the callback-leg RFC 9207 check above uses `kind: 'authorization_response'`). For authorization servers known to publish a mismatched `issuer`, both HTTP transports accept `skipIssuerMetadataValidation: true` (honoured when `authProvider` is an
|
||||
`OAuthClientProvider`). This weakens mix-up protection, so leave it off unless you control the server. The migration guide's [Authorization-server mix-up defense](./migration/upgrade-to-v2.md#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33--action-required) section
|
||||
describes the full model.
|
||||
|
||||
#### Resource indicators (RFC 8707)
|
||||
|
||||
The SDK binds tokens to your MCP server with the RFC 8707 `resource` parameter automatically. When protected resource metadata (RFC 9728) is discovered, the metadata's `resource` value is checked against the server URL (same origin, path prefix; see
|
||||
`checkResourceAllowed()`) and attached to the authorization redirect and every token request. When the server publishes no resource metadata, no `resource` parameter is sent.
|
||||
|
||||
Implement `validateResourceURL` on your provider to override the selection. Return a URL to force a specific `resource` value, or `undefined` to omit the parameter:
|
||||
|
||||
```ts source="../examples/guides/clientGuide.examples.ts#auth_validateResourceURL"
|
||||
class PinnedResourceProvider extends MyOAuthProvider {
|
||||
async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<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) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
344
docs/server.md
344
docs/server.md
|
|
@ -18,12 +18,28 @@ The examples below use these imports. Adjust based on which features and transpo
|
|||
|
||||
```ts source="../examples/guides/serverGuide.examples.ts#imports"
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { 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) |
|
||||
|
|
|
|||
5
examples/client-quickstart/README.md
Normal file
5
examples/client-quickstart/README.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
5
examples/server-quickstart/README.md
Normal file
5
examples/server-quickstart/README.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue