docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
54 changed files with 11884 additions and 4971 deletions
|
|
@ -66,12 +66,12 @@ The one exception to the generic commands is the reference pair: [`cli-client/`]
|
|||
|
||||
## Excluded
|
||||
|
||||
| Directory | What it is | Why not in CI |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
|
||||
| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. |
|
||||
| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. |
|
||||
| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. |
|
||||
| Directory | What it is | Why not in CI |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
|
||||
| [`guides/`](./guides/README.md) | Per-page snippet companions synced into the `docs/` guide pages | Not a client/server story pair; typechecked and executed by `pnpm docs:examples`. |
|
||||
| `server-quickstart/`, `client-quickstart/` | Standalone starter projects (the original quickstart sources) | External network / API key; typecheck-only. |
|
||||
| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. |
|
||||
|
||||
## Multi-node deployment patterns
|
||||
|
||||
|
|
@ -173,5 +173,5 @@ For scenarios where local in-memory state must be maintained on specific nodes,
|
|||
|
||||
## Backwards compatibility (Streamable HTTP ↔ legacy SSE)
|
||||
|
||||
A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try
|
||||
`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern.
|
||||
A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows [Fall back to SSE for servers that predate Streamable HTTP](../docs/clients/connect.md#fall-back-to-sse-for-servers-that-predate-streamable-http) in the client docs — try
|
||||
`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the `connect_sseFallback` snippet in `guides/clients/connect.examples.ts` is the complete pattern.
|
||||
|
|
|
|||
|
|
@ -1,5 +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.
|
||||
An LLM-powered chatbot that connects to an MCP server over stdio and calls its tools (`src/index.ts`). It was the source for the retired client-quickstart tutorial; the current getting-started tutorial is [Build your first client](../../docs/get-started/first-client.md).
|
||||
|
||||
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.
|
||||
The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself outside the monorepo, copy `src/index.ts` into a standalone project that depends on the published packages.
|
||||
|
|
|
|||
|
|
@ -42,5 +42,25 @@ export default [
|
|||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// Guide companions are documentation snippets: filenames mirror their page slugs,
|
||||
// every //#region must stay self-contained when rendered on its page, and snippet
|
||||
// style follows the docs register rather than the example-app style rules.
|
||||
// The import restrictions above still apply.
|
||||
files: ['guides/**/*.ts'],
|
||||
rules: {
|
||||
'unicorn/filename-case': 'off',
|
||||
'unicorn/switch-case-braces': 'off',
|
||||
'unicorn/numeric-separators-style': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
'unicorn/no-await-expression-member': 'off',
|
||||
'unicorn/prefer-response-static-json': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'off',
|
||||
'simple-import-sort/imports': 'off',
|
||||
'import/no-duplicates': 'off',
|
||||
'unicorn/require-array-join-separator': 'off',
|
||||
'unicorn/explicit-length-check': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# guides
|
||||
|
||||
Snippet collections synced into `docs/server.md` and `docs/client.md` via `pnpm sync:snippets`. Typecheck-only — these are not runnable programs.
|
||||
Per-page snippet companions for the `docs/` guide pages: each `<dir>/<page>.examples.ts` is the type-checked source for the code fences in `docs/<dir>/<page>.md`, synced via `pnpm sync:snippets`. Companions that quote output are also run as real programs by `pnpm docs:examples`; the rest opt out with a `// docs: typecheck-only` first line.
|
||||
|
|
|
|||
100
examples/guides/advanced/custom-methods.examples.ts
Normal file
100
examples/guides/advanced/custom-methods.examples.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/custom-methods.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects an in-memory client and produces the output the page quotes
|
||||
* verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/custom-methods.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region setRequestHandler_custom
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
|
||||
const SearchResult = z.object({ items: z.array(z.string()) });
|
||||
|
||||
const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' });
|
||||
|
||||
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => {
|
||||
return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) };
|
||||
});
|
||||
//#endregion setRequestHandler_custom
|
||||
|
||||
// "Declare an extension capability" — must happen before the server connects.
|
||||
//#region registerCapabilities_extensions
|
||||
mcp.server.registerCapabilities({
|
||||
extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } }
|
||||
});
|
||||
//#endregion registerCapabilities_extensions
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory client drives the calls whose
|
||||
// output advanced/custom-methods.md quotes verbatim. Any MCP client behaves
|
||||
// the same. Imported dynamically so the page's lead region stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const client = new Client({ name: 'custom-methods-docs-harness', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcp.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "Call it from the client" — the result the page quotes.
|
||||
//#region request_custom
|
||||
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
|
||||
console.log(result);
|
||||
//#endregion request_custom
|
||||
|
||||
// Proof for the page's ::: tip — params that fail `SearchParams` are rejected
|
||||
// before the handler runs. Throws (non-zero exit) if the claim is false.
|
||||
let rejection: Error | undefined;
|
||||
try {
|
||||
await client.request({ method: 'acme/search', params: { query: 42 } }, SearchResult);
|
||||
} catch (error) {
|
||||
rejection = error as Error;
|
||||
}
|
||||
if (!rejection) {
|
||||
throw new Error('custom-methods.md tip claim failed: invalid params were accepted');
|
||||
}
|
||||
console.log(rejection.message);
|
||||
|
||||
// "Read the negotiated extensions on the client" — the map the page quotes.
|
||||
//#region getServerCapabilities_extensions
|
||||
const extensions = client.getServerCapabilities()?.extensions ?? {};
|
||||
console.log(extensions);
|
||||
//#endregion getServerCapabilities_extensions
|
||||
|
||||
// "Send a custom notification from the handler" — registering the method again
|
||||
// replaces its handler with one that reports progress.
|
||||
//#region setRequestHandler_notify
|
||||
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => {
|
||||
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
|
||||
const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`);
|
||||
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
|
||||
return { items };
|
||||
});
|
||||
//#endregion setRequestHandler_notify
|
||||
|
||||
// "Receive it on the client" — the progress params the page quotes.
|
||||
//#region setNotificationHandler_custom
|
||||
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
|
||||
|
||||
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
|
||||
console.log(params);
|
||||
});
|
||||
|
||||
await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult);
|
||||
//#endregion setNotificationHandler_custom
|
||||
|
||||
// Let the notification microtasks drain before tearing the pair down.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
await client.close();
|
||||
await mcp.close();
|
||||
210
examples/guides/advanced/custom-transports.examples.ts
Normal file
210
examples/guides/advanced/custom-transports.examples.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/custom-transports.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects a client and a server over the loopback transport and
|
||||
* produces the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/custom-transports.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region transport_loopback
|
||||
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
|
||||
|
||||
export class LoopbackTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
private peer?: LoopbackTransport;
|
||||
|
||||
/** Cross-wire two ends: whatever one end sends, the other receives. */
|
||||
static link(a: LoopbackTransport, b: LoopbackTransport): void {
|
||||
a.peer = b;
|
||||
b.peer = a;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Open your channel here. The loopback has nothing to open.
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
const peer = this.peer;
|
||||
if (!peer) throw new Error('Loopback peer is gone');
|
||||
queueMicrotask(() => peer.onmessage?.(message));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.peer = undefined;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
//#endregion transport_loopback
|
||||
|
||||
// "Connect it like a built-in transport" — produces the output the page quotes.
|
||||
//#region connect_loopback
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
server.registerTool('ping', { description: 'Reply with pong' }, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }]
|
||||
}));
|
||||
|
||||
const client = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
const serverEnd = new LoopbackTransport();
|
||||
const clientEnd = new LoopbackTransport();
|
||||
LoopbackTransport.link(serverEnd, clientEnd);
|
||||
|
||||
await server.connect(serverEnd);
|
||||
await client.connect(clientEnd);
|
||||
|
||||
const result = await client.callTool({ name: 'ping' });
|
||||
console.log(result.content);
|
||||
//#endregion connect_loopback
|
||||
|
||||
// "Frame messages over a byte stream" — typechecked, not run (it needs a real
|
||||
// socket). The runnable proof on this page is the loopback transport above.
|
||||
//#region transport_socket
|
||||
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export class SocketTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
private readonly readBuffer = new ReadBuffer();
|
||||
|
||||
constructor(private readonly socket: Socket) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.socket.on('data', chunk => {
|
||||
try {
|
||||
this.readBuffer.append(chunk);
|
||||
let message = this.readBuffer.readMessage();
|
||||
while (message !== null) {
|
||||
this.onmessage?.(message);
|
||||
message = this.readBuffer.readMessage();
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
});
|
||||
this.socket.on('error', error => this.onerror?.(error));
|
||||
this.socket.on('close', () => this.onclose?.());
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.socket.write(serializeMessage(message));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.socket.end();
|
||||
}
|
||||
}
|
||||
//#endregion transport_socket
|
||||
|
||||
// "Report a session ID and the negotiated version".
|
||||
//#region transport_session
|
||||
export class SessionLoopbackTransport extends LoopbackTransport {
|
||||
sessionId?: string;
|
||||
|
||||
protocolVersion?: string;
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this.protocolVersion = version;
|
||||
}
|
||||
}
|
||||
//#endregion transport_session
|
||||
|
||||
// "Opt into per-request cancellation" — typechecked, not run.
|
||||
import type { TransportSendOptions } from '@modelcontextprotocol/server';
|
||||
import { parseJSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
|
||||
export class HttpPostTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
constructor(private readonly endpoint: URL) {}
|
||||
|
||||
//#region transport_perRequest
|
||||
readonly hasPerRequestStream = true;
|
||||
|
||||
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(message),
|
||||
signal: options?.requestSignal
|
||||
});
|
||||
this.onmessage?.(parseJSONRPCMessage(await response.json()));
|
||||
}
|
||||
//#endregion transport_perRequest
|
||||
|
||||
async start(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). Drives the remaining claims the page makes
|
||||
// and throws (non-zero exit) if any of them is false.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
// "Report a session ID and the negotiated version" — the version the page quotes.
|
||||
const sessionServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
const sessionClient = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
const sessionServerEnd = new SessionLoopbackTransport();
|
||||
const sessionClientEnd = new SessionLoopbackTransport();
|
||||
LoopbackTransport.link(sessionServerEnd, sessionClientEnd);
|
||||
|
||||
await sessionServer.connect(sessionServerEnd);
|
||||
await sessionClient.connect(sessionClientEnd);
|
||||
|
||||
console.log(sessionClientEnd.protocolVersion);
|
||||
if (sessionServerEnd.protocolVersion !== sessionClientEnd.protocolVersion) {
|
||||
throw new Error(
|
||||
`custom-transports.md claim failed: server end negotiated ${sessionServerEnd.protocolVersion}, client end ${sessionClientEnd.protocolVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
await sessionClient.close();
|
||||
await sessionServer.close();
|
||||
|
||||
// "Test it against the in-memory pair" — the reference transport returns the
|
||||
// same result the loopback produced.
|
||||
const referenceServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
referenceServer.registerTool('ping', { description: 'Reply with pong' }, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }]
|
||||
}));
|
||||
const referenceClient = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
//#region inMemory_pair
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair();
|
||||
//#endregion inMemory_pair
|
||||
|
||||
await referenceServer.connect(inMemoryServerEnd);
|
||||
await referenceClient.connect(inMemoryClientEnd);
|
||||
|
||||
const referenceResult = await referenceClient.callTool({ name: 'ping' });
|
||||
if (JSON.stringify(referenceResult.content) !== JSON.stringify(result.content)) {
|
||||
throw new Error(`custom-transports.md claim failed: in-memory result ${JSON.stringify(referenceResult.content)}`);
|
||||
}
|
||||
|
||||
await referenceClient.close();
|
||||
await referenceServer.close();
|
||||
135
examples/guides/advanced/gateway.examples.ts
Normal file
135
examples/guides/advanced/gateway.examples.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/advanced/gateway.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences
|
||||
* (`pnpm sync:snippets --check` reports drift).
|
||||
*
|
||||
* The page's program runs for real: the harness below builds a
|
||||
* `createMcpHandler` server and routes `globalThis.fetch` for
|
||||
* `http://localhost:3000/mcp` into `handler.fetch`, so the HTTP regions
|
||||
* execute in-process without binding a port. The output the page quotes
|
||||
* verbatim is whatever this file prints. The server's `request_count` tool
|
||||
* returns how many MCP requests reached the process (`createMcpHandler`
|
||||
* builds one server instance per request), which is what proves that
|
||||
* `connect({ prior })` sent nothing.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/gateway.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). Every `new McpServer(...)` is one inbound
|
||||
// MCP request, so the module-level counter is the number of requests the
|
||||
// process has answered — `request_count` exposes it to the page's clients.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let requests = 0;
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
requests++;
|
||||
const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
||||
server.registerTool('echo', { description: 'Echo the input back', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
server.registerTool(
|
||||
'request_count',
|
||||
{ description: 'Number of MCP requests this server process has answered', outputSchema: z.object({ requests: z.number() }) },
|
||||
async () => ({ content: [{ type: 'text', text: String(requests) }], structuredContent: { requests } })
|
||||
);
|
||||
return server;
|
||||
});
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request);
|
||||
return realFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
// ## Connect with a prior discover result
|
||||
|
||||
//#region connect_prior
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const url = new URL('http://localhost:3000/mcp');
|
||||
|
||||
// Probe once …
|
||||
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await bootstrap.connect(new StreamableHTTPClientTransport(url));
|
||||
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
||||
|
||||
// … then every other client connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
//#endregion connect_prior
|
||||
|
||||
// ## Probe once at bootstrap
|
||||
|
||||
//#region bootstrap_probe
|
||||
console.log(bootstrap.getDiscoverResult());
|
||||
//#endregion bootstrap_probe
|
||||
|
||||
// ## Persist the advertisement
|
||||
|
||||
//#region persist_advertisement
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
|
||||
const prior = JSON.parse(persisted) as DiscoverResult;
|
||||
//#endregion persist_advertisement
|
||||
|
||||
// ## Fan out to workers
|
||||
|
||||
//#region fan_out
|
||||
const fleet = await Promise.all(
|
||||
['worker-a', 'worker-b', 'worker-c'].map(async name => {
|
||||
const replica = new Client({ name, version: '1.0.0' });
|
||||
await replica.connect(new StreamableHTTPClientTransport(url), { prior });
|
||||
return replica;
|
||||
})
|
||||
);
|
||||
|
||||
const proof = await worker.callTool({ name: 'request_count' });
|
||||
console.log(proof.structuredContent);
|
||||
//#endregion fan_out
|
||||
|
||||
// ## Open a listen stream when a worker needs notifications
|
||||
|
||||
//#region listen_worker
|
||||
const subscription = await worker.listen({ toolsListChanged: true });
|
||||
console.log(subscription.honoredFilter);
|
||||
//#endregion listen_worker
|
||||
|
||||
await subscription.close();
|
||||
|
||||
// ## Handle a stale or incompatible advertisement
|
||||
|
||||
//#region prior_stale
|
||||
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
|
||||
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
|
||||
|
||||
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
|
||||
} catch (error) {
|
||||
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
|
||||
console.log(error.code);
|
||||
|
||||
// Fall back to a fresh probe, then re-persist getDiscoverResult().
|
||||
await late.connect(new StreamableHTTPClientTransport(url));
|
||||
console.log('re-probed:', late.getNegotiatedProtocolVersion());
|
||||
}
|
||||
//#endregion prior_stale
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness teardown.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const client of [bootstrap, worker, late, ...fleet]) await client.close();
|
||||
await handler.close();
|
||||
globalThis.fetch = realFetch;
|
||||
179
examples/guides/advanced/low-level-server.examples.ts
Normal file
179
examples/guides/advanced/low-level-server.examples.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/low-level-server.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness between the
|
||||
* regions connects in-memory clients and produces every output the page quotes
|
||||
* verbatim, exiting non-zero on drift.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/low-level-server.examples.ts # from examples/
|
||||
*
|
||||
* `lowLevel_serve` lives in a never-invoked wrapper: `serveStdio` would bind
|
||||
* this process's real stdin/stdout, so that one region is typecheck-only.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console, import/no-duplicates */
|
||||
// Harness imports. The page's lead block (the first region) carries its own
|
||||
// `Server` import so the rendered fence stands alone.
|
||||
import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Build the server and list your tools by hand"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_listTools
|
||||
import { Server } from '@modelcontextprotocol/server';
|
||||
|
||||
const catalog = [
|
||||
{ name: 'Espresso cup', price: 12 },
|
||||
{ name: 'Travel mug', price: 24 },
|
||||
{ name: 'Mug rack', price: 36 }
|
||||
];
|
||||
|
||||
const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search the product catalog',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string', description: 'Substring to match against product names' } },
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
//#endregion lowLevel_listTools
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Handle tools/call yourself"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_callTool
|
||||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name !== 'search') {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
||||
}
|
||||
const { query } = request.params.arguments as { query: string };
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
|
||||
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
|
||||
});
|
||||
//#endregion lowLevel_callTool
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory client drives the calls whose
|
||||
// output low-level-server.md quotes verbatim. Imported dynamically so the
|
||||
// page's lead region stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, InMemoryTransport, ProtocolError } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const client = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// The handler answers a valid call exactly like the McpServer version.
|
||||
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
|
||||
console.log(result.content);
|
||||
|
||||
// Nothing validated `query`, so a wrongly-typed argument reaches the handler
|
||||
// and crashes it: the client sees a JSON-RPC error, not a tool result.
|
||||
const crashed = await client.callTool({ name: 'search', arguments: { query: 42 } }).catch((error: unknown) => error);
|
||||
if (!(crashed instanceof ProtocolError)) {
|
||||
throw new Error(`low-level-server.md expected the unvalidated call to reject: ${JSON.stringify(crashed)}`);
|
||||
}
|
||||
console.log(`${crashed.name} ${crashed.code}: ${crashed.message}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Validate arguments yourself"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_validate
|
||||
const SearchArguments = fromJsonSchema<{ query: string }>({
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query']
|
||||
});
|
||||
|
||||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name !== 'search') {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
||||
}
|
||||
const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {});
|
||||
if (parsed.issues) {
|
||||
return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true };
|
||||
}
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase()));
|
||||
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
|
||||
});
|
||||
//#endregion lowLevel_validate
|
||||
|
||||
// The same wrongly-typed call now comes back as an ordinary isError result.
|
||||
const rejected = await client.callTool({ name: 'search', arguments: { query: 42 } });
|
||||
console.log(rejected);
|
||||
if (rejected.isError !== true) {
|
||||
throw new Error(`low-level-server.md expected the validated call to return isError: ${JSON.stringify(rejected)}`);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Serve it with the same entry points" — typecheck-only. `serveStdio` would
|
||||
// take over this process's stdin/stdout, so the harness never calls this.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function lowLevel_serve() {
|
||||
//#region lowLevel_serve
|
||||
serveStdio(() => server);
|
||||
createMcpHandler(() => server);
|
||||
//#endregion lowLevel_serve
|
||||
}
|
||||
void lowLevel_serve;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Reach the low level from McpServer"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region lowLevel_escapeHatch
|
||||
const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
|
||||
|
||||
mcp.registerTool(
|
||||
'search',
|
||||
{ description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) },
|
||||
async ({ query }) => {
|
||||
const names = catalog.filter(product => product.name.includes(query)).map(product => product.name);
|
||||
return { content: [{ type: 'text', text: names.join('\n') }] };
|
||||
}
|
||||
);
|
||||
|
||||
const subscriptions = new Set<string>();
|
||||
mcp.server.setRequestHandler('resources/subscribe', async request => {
|
||||
subscriptions.add(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
//#endregion lowLevel_escapeHatch
|
||||
|
||||
// Harness: prove the page's claim for the section above — `registerTool` still
|
||||
// owns `tools/list`, and the hand-registered handler answers
|
||||
// `resources/subscribe` on the same connection.
|
||||
const mcpClient = new Client({ name: 'low-level-docs-harness', version: '1.0.0' });
|
||||
const [mcpClientTransport, mcpServerTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcp.connect(mcpServerTransport);
|
||||
await mcpClient.connect(mcpClientTransport);
|
||||
|
||||
const { tools } = await mcpClient.listTools();
|
||||
await mcpClient.subscribeResource({ uri: 'demo://config' });
|
||||
if (tools.length !== 1 || tools[0]?.name !== 'search' || !subscriptions.has('demo://config')) {
|
||||
throw new Error(`low-level-server.md escape-hatch claim failed: ${JSON.stringify({ tools, subscriptions: [...subscriptions] })}`);
|
||||
}
|
||||
|
||||
await mcpClient.close();
|
||||
await mcp.close();
|
||||
129
examples/guides/advanced/schema-libraries.examples.ts
Normal file
129
examples/guides/advanced/schema-libraries.examples.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/schema-libraries.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects an in-memory client and produces the output the page quotes
|
||||
* verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/schema-libraries.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region registerTool_arktype
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { type } from 'arktype';
|
||||
|
||||
const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'greet',
|
||||
{
|
||||
description: 'Greet someone by name',
|
||||
inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' })
|
||||
},
|
||||
async ({ name, times }) => ({
|
||||
content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }]
|
||||
})
|
||||
);
|
||||
//#endregion registerTool_arktype
|
||||
|
||||
//#region registerTool_valibot
|
||||
import { toStandardJsonSchema } from '@valibot/to-json-schema';
|
||||
import * as v from 'valibot';
|
||||
|
||||
server.registerTool(
|
||||
'shout',
|
||||
{ description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) },
|
||||
async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] })
|
||||
);
|
||||
//#endregion registerTool_valibot
|
||||
|
||||
//#region registerTool_fromJsonSchema
|
||||
import { fromJsonSchema } from '@modelcontextprotocol/server';
|
||||
|
||||
server.registerTool(
|
||||
'farewell',
|
||||
{
|
||||
description: 'Say goodbye',
|
||||
inputSchema: fromJsonSchema<{ name: string }>({
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name']
|
||||
})
|
||||
},
|
||||
async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] })
|
||||
);
|
||||
//#endregion registerTool_fromJsonSchema
|
||||
|
||||
//#region registerTool_outputSchema
|
||||
server.registerTool(
|
||||
'measure',
|
||||
{
|
||||
description: 'Measure the length of a name',
|
||||
inputSchema: type({ name: 'string' }),
|
||||
outputSchema: type({ name: 'string', length: 'number' })
|
||||
},
|
||||
async ({ name }) => {
|
||||
const output = { name, length: name.length };
|
||||
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
|
||||
}
|
||||
);
|
||||
//#endregion registerTool_outputSchema
|
||||
|
||||
//#region jsonSchemaValidator_ajv
|
||||
import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
|
||||
|
||||
const ajv = new Ajv({ strict: true, allErrors: true });
|
||||
addFormats(ajv);
|
||||
const validator = new AjvJsonSchemaValidator(ajv);
|
||||
|
||||
const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator });
|
||||
//#endregion jsonSchemaValidator_ajv
|
||||
|
||||
//#region jsonSchemaValidator_cfWorker
|
||||
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
|
||||
|
||||
const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
|
||||
//#endregion jsonSchemaValidator_cfWorker
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory client drives the calls whose
|
||||
// output advanced/schema-libraries.md quotes verbatim. Any MCP client behaves
|
||||
// the same. Imported dynamically so the page's lead region stays
|
||||
// self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const client = new Client({ name: 'schema-libraries-docs-harness', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "Register a tool with an ArkType schema" — the rejection the page quotes.
|
||||
const rejected = await client.callTool({ name: 'greet', arguments: { name: 'Ada', times: 99 } });
|
||||
console.log(rejected);
|
||||
|
||||
// "Register a tool with a Valibot schema" — proves the page's prose claim that
|
||||
// the call validates and dispatches like any other tool.
|
||||
const shouted = await client.callTool({ name: 'shout', arguments: { name: 'Ada' } });
|
||||
if (JSON.stringify(shouted.content) !== JSON.stringify([{ type: 'text', text: 'HELLO, ADA' }])) {
|
||||
throw new Error(`schema-libraries.md valibot claim failed: ${JSON.stringify(shouted)}`);
|
||||
}
|
||||
|
||||
// "Start from JSON Schema you already have" — the advertised schema the page quotes.
|
||||
const { tools } = await client.listTools();
|
||||
const farewell = tools.find(tool => tool.name === 'farewell');
|
||||
console.log(farewell?.inputSchema);
|
||||
|
||||
// "Validate structured output with any library" — the structured result the page quotes.
|
||||
const measured = await client.callTool({ name: 'measure', arguments: { name: 'Ada' } });
|
||||
console.log(measured);
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
await strict.close();
|
||||
await edge.close();
|
||||
104
examples/guides/advanced/wire-schemas.examples.ts
Normal file
104
examples/guides/advanced/wire-schemas.examples.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Companion example for `docs/advanced/wire-schemas.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: it is the gateway path
|
||||
* the page walks through — raw JSON in, validated with the schemas from
|
||||
* `@modelcontextprotocol/core`, no `Client` or `Server` anywhere — and it
|
||||
* prints the exact output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/advanced/wire-schemas.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, no-console */
|
||||
|
||||
// "Validate a wire payload" — the happy path the page quotes.
|
||||
//#region wireSchemas_validateResult
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
// The body an upstream server returned for a tools/call you forwarded.
|
||||
const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}');
|
||||
|
||||
const parsed = CallToolResultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`);
|
||||
}
|
||||
console.log(parsed.data.content);
|
||||
//#endregion wireSchemas_validateResult
|
||||
|
||||
// "Validate a wire payload" — the rejection the page quotes.
|
||||
//#region wireSchemas_validateResult_invalid
|
||||
const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' });
|
||||
console.log(malformed.error?.issues);
|
||||
//#endregion wireSchemas_validateResult_invalid
|
||||
|
||||
// "Pick the schema for the message you hold" — the undecoded envelope.
|
||||
//#region wireSchemas_envelope
|
||||
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}';
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(frame));
|
||||
//#endregion wireSchemas_envelope
|
||||
|
||||
// "Route raw JSON-RPC in a proxy" — branch on method, then the per-method schema.
|
||||
//#region wireSchemas_route
|
||||
import { CallToolRequestSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
if ('method' in message) {
|
||||
switch (message.method) {
|
||||
case 'tools/call': {
|
||||
const call = CallToolRequestSchema.parse(message);
|
||||
console.log(`forward tools/call for ${call.params.name} upstream`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`forward ${message.method} unchanged`);
|
||||
}
|
||||
}
|
||||
//#endregion wireSchemas_route
|
||||
|
||||
// "Validate OAuth and discovery metadata" — the second export group.
|
||||
//#region wireSchemas_oauthMetadata
|
||||
import { OAuthMetadataSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
// In production this body comes from GET <issuer>/.well-known/oauth-authorization-server.
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
})
|
||||
);
|
||||
|
||||
const metadata = OAuthMetadataSchema.parse(await response.json());
|
||||
console.log(metadata.token_endpoint);
|
||||
//#endregion wireSchemas_oauthMetadata
|
||||
|
||||
// "Get the TypeScript types, guards and errors from the SDK packages" — core is
|
||||
// Zod values only; the names live in /client and /server.
|
||||
//#region wireSchemas_types
|
||||
import type { CallToolResult } from '@modelcontextprotocol/client';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// The SDK's spec type and the schema's own inferred output describe the same value.
|
||||
const relayed: CallToolResult = parsed.data;
|
||||
type CallToolResultFromCore = z.infer<typeof CallToolResultSchema>;
|
||||
//#endregion wireSchemas_types
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-checks (not shown on the page). Throw — non-zero exit — if any claim
|
||||
// the page makes stops being true.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (malformed.success) {
|
||||
throw new Error('wire-schemas.md claim failed: a non-array `content` must not parse');
|
||||
}
|
||||
if (!('method' in message) || message.method !== 'tools/call') {
|
||||
throw new Error('wire-schemas.md claim failed: the envelope did not narrow to a tools/call request');
|
||||
}
|
||||
if (relayed.content[0]?.type !== 'text') {
|
||||
throw new Error('wire-schemas.md claim failed: parsed result is not assignable to CallToolResult');
|
||||
}
|
||||
|
|
@ -1,923 +0,0 @@
|
|||
/**
|
||||
* Type-checked examples for docs/client.md.
|
||||
*
|
||||
* Regions are synced into markdown code fences via `pnpm sync:snippets`.
|
||||
* Each function wraps a single region. The function name matches the region name.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
//#region imports
|
||||
import type {
|
||||
AuthProvider,
|
||||
CallToolResult,
|
||||
InputRequiredResult,
|
||||
OAuthClientInformationContext,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthClientProvider,
|
||||
OAuthDiscoveryState,
|
||||
OAuthTokens
|
||||
} 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,
|
||||
TRACESTATE_META_KEY,
|
||||
UnauthorizedError
|
||||
} from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
//#endregion imports
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connecting to a server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Streamable HTTP transport. */
|
||||
async function connect_streamableHttp() {
|
||||
//#region connect_streamableHttp
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
|
||||
|
||||
await client.connect(transport);
|
||||
//#endregion connect_streamableHttp
|
||||
}
|
||||
|
||||
/** Example: stdio transport for local process-spawned servers. */
|
||||
async function connect_stdio() {
|
||||
//#region connect_stdio
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
//#endregion connect_stdio
|
||||
}
|
||||
|
||||
/** Example: Try Streamable HTTP, fall back to legacy SSE. */
|
||||
async function connect_sseFallback(url: string) {
|
||||
//#region connect_sseFallback
|
||||
const baseUrl = new URL(url);
|
||||
|
||||
try {
|
||||
// Try modern Streamable HTTP transport first
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
return { client, transport };
|
||||
} catch {
|
||||
// Fall back to legacy SSE transport
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
const transport = new SSEClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
return { client, transport };
|
||||
}
|
||||
//#endregion connect_sseFallback
|
||||
}
|
||||
|
||||
/** Example: Opt into 2026-07-28 protocol version negotiation. */
|
||||
async function Client_versionNegotiation(transport: StreamableHTTPClientTransport) {
|
||||
//#region Client_versionNegotiation
|
||||
// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake
|
||||
// against a 2025-only server.
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await client.connect(transport);
|
||||
|
||||
client.getProtocolEra(); // 'modern' or 'legacy'
|
||||
client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25'
|
||||
//#endregion Client_versionNegotiation
|
||||
}
|
||||
|
||||
/** Example: zero-round-trip connect from a persisted DiscoverResult. */
|
||||
async function Client_connect_prior(url: URL) {
|
||||
//#region Client_connect_prior
|
||||
// Probe once (here via the 'auto'-mode connect), persist the result …
|
||||
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await bootstrap.connect(new StreamableHTTPClientTransport(url));
|
||||
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
||||
|
||||
// … then every worker connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
//#endregion Client_connect_prior
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnecting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Graceful disconnect for Streamable HTTP. */
|
||||
async function disconnect_streamableHttp(client: Client, transport: StreamableHTTPClientTransport) {
|
||||
//#region disconnect_streamableHttp
|
||||
await transport.terminateSession(); // notify the server (recommended)
|
||||
await client.close();
|
||||
//#endregion disconnect_streamableHttp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Access server instructions after connecting. */
|
||||
async function serverInstructions_basic(client: Client) {
|
||||
//#region serverInstructions_basic
|
||||
const instructions = client.getInstructions();
|
||||
|
||||
const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n');
|
||||
|
||||
console.log(systemPrompt);
|
||||
//#endregion serverInstructions_basic
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension capabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Read the negotiated extension capabilities after connecting. */
|
||||
function extensionCapabilities_read(client: Client) {
|
||||
//#region extensionCapabilities_read
|
||||
const extensions = client.getServerCapabilities()?.extensions ?? {};
|
||||
|
||||
if ('com.example/feature-flags' in extensions) {
|
||||
// Advertised on this connection; the entry's value is its settings object.
|
||||
}
|
||||
//#endregion extensionCapabilities_read
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Minimal AuthProvider for bearer auth with externally-managed tokens. */
|
||||
async function auth_tokenProvider(getStoredToken: () => Promise<string>) {
|
||||
//#region auth_tokenProvider
|
||||
const authProvider: AuthProvider = { token: async () => getStoredToken() };
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
|
||||
//#endregion auth_tokenProvider
|
||||
return transport;
|
||||
}
|
||||
|
||||
/** Example: Client credentials auth for service-to-service communication. */
|
||||
async function auth_clientCredentials() {
|
||||
//#region auth_clientCredentials
|
||||
const authProvider = new ClientCredentialsProvider({
|
||||
clientId: 'my-service',
|
||||
clientSecret: 'my-secret'
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
|
||||
|
||||
await client.connect(transport);
|
||||
//#endregion auth_clientCredentials
|
||||
}
|
||||
|
||||
/** Example: Private key JWT auth. */
|
||||
async function auth_privateKeyJwt(pemEncodedKey: string) {
|
||||
//#region auth_privateKeyJwt
|
||||
const authProvider = new PrivateKeyJwtProvider({
|
||||
clientId: 'my-service',
|
||||
privateKey: pemEncodedKey,
|
||||
algorithm: 'RS256'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
|
||||
//#endregion auth_privateKeyJwt
|
||||
return transport;
|
||||
}
|
||||
|
||||
/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */
|
||||
async function auth_crossAppAccess(getIdToken: () => Promise<string>) {
|
||||
//#region auth_crossAppAccess
|
||||
const authProvider = new CrossAppAccessProvider({
|
||||
assertion: async ctx => {
|
||||
// ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
|
||||
const result = await discoverAndRequestJwtAuthGrant({
|
||||
idpUrl: 'https://idp.example.com',
|
||||
audience: ctx.authorizationServerUrl,
|
||||
resource: ctx.resourceUrl,
|
||||
idToken: await getIdToken(),
|
||||
clientId: 'my-idp-client',
|
||||
clientSecret: 'my-idp-secret',
|
||||
scope: ctx.scope,
|
||||
fetchFn: ctx.fetchFn
|
||||
});
|
||||
return result.jwtAuthGrant;
|
||||
},
|
||||
clientId: 'my-mcp-client',
|
||||
clientSecret: 'my-mcp-secret'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });
|
||||
//#endregion auth_crossAppAccess
|
||||
return transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Minimal `OAuthClientProvider` for the authorization-code flow.
|
||||
* Client credentials are stored per authorization-server `issuer` (SEP-2352).
|
||||
*/
|
||||
function auth_oauthClientProvider(onRedirect: (url: URL) => void) {
|
||||
//#region auth_oauthClientProvider
|
||||
class MyOAuthProvider implements OAuthClientProvider {
|
||||
// Key DCR-obtained credentials by issuer so a client_id registered with one
|
||||
// authorization server is never returned for another (SEP-2352).
|
||||
private creds = new Map<string, OAuthClientInformationMixed>();
|
||||
private storedTokens?: OAuthTokens;
|
||||
private verifier?: string;
|
||||
private discovery?: OAuthDiscoveryState;
|
||||
lastState?: string;
|
||||
|
||||
readonly redirectUrl = 'http://localhost:8090/callback';
|
||||
readonly clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'My MCP Client',
|
||||
redirect_uris: ['http://localhost:8090/callback'],
|
||||
// Loopback redirect → the SDK would default this to 'native'; set
|
||||
// explicitly when the heuristic is wrong for your deployment (SEP-837).
|
||||
application_type: 'native'
|
||||
};
|
||||
|
||||
clientInformation(ctx?: OAuthClientInformationContext) {
|
||||
return ctx ? this.creds.get(ctx.issuer) : undefined;
|
||||
}
|
||||
saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
|
||||
if (ctx) this.creds.set(ctx.issuer, info);
|
||||
}
|
||||
tokens() {
|
||||
return this.storedTokens;
|
||||
}
|
||||
saveTokens(tokens: OAuthTokens) {
|
||||
// In production, persist to OS keychain / secure storage — never plain files.
|
||||
this.storedTokens = tokens;
|
||||
}
|
||||
// CSRF binding for the redirect — the SDK puts this on the authorize URL;
|
||||
// your callback handler compares it before calling `finishAuth`.
|
||||
state() {
|
||||
this.lastState = crypto.randomUUID();
|
||||
return this.lastState;
|
||||
}
|
||||
// Callback-leg AS-binding (SEP-2352): record what discovery resolved before
|
||||
// the redirect so the SDK can verify the code is exchanged at the same AS.
|
||||
saveDiscoveryState(state: OAuthDiscoveryState) {
|
||||
this.discovery = state;
|
||||
}
|
||||
discoveryState() {
|
||||
return this.discovery;
|
||||
}
|
||||
redirectToAuthorization(url: URL) {
|
||||
onRedirect(url);
|
||||
}
|
||||
saveCodeVerifier(v: string) {
|
||||
this.verifier = v;
|
||||
}
|
||||
codeVerifier() {
|
||||
if (!this.verifier) throw new Error('no code verifier');
|
||||
return this.verifier;
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new MyOAuthProvider();
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Example: Handling the OAuth callback — extract `iss` for RFC 9207 validation. */
|
||||
async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastState?: string }, waitForCallback: () => Promise<string>) {
|
||||
//#region auth_finishAuth
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
} catch (error) {
|
||||
// With version negotiation, the connect-time 401 may surface wrapped as
|
||||
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
|
||||
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
|
||||
if (!(root instanceof UnauthorizedError)) throw error;
|
||||
// The transport called redirectToAuthorization(); fall through to the browser callback.
|
||||
}
|
||||
|
||||
const callbackUrl = await waitForCallback();
|
||||
const params = new URL(callbackUrl).searchParams;
|
||||
|
||||
// The SDK does not validate `state` — compare it to the value your provider generated.
|
||||
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');
|
||||
|
||||
try {
|
||||
// Preferred: hand over the whole query — the SDK extracts `code` and
|
||||
// `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived
|
||||
// `error`/`error_description` text on mismatch.
|
||||
await transport.finishAuth(params);
|
||||
} catch (error) {
|
||||
if (error instanceof IssuerMismatchError) {
|
||||
// Mix-up attack: do NOT render params.get('error_description') to the user.
|
||||
throw new Error('Authorization failed: issuer mismatch');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Reconnect on a FRESH transport — a started transport cannot be restarted;
|
||||
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
|
||||
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
|
||||
return client;
|
||||
//#endregion auth_finishAuth
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Using server features
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: List and call tools. */
|
||||
async function callTool_basic(client: Client) {
|
||||
//#region callTool_basic
|
||||
const { tools } = await client.listTools();
|
||||
console.log(
|
||||
'Available tools:',
|
||||
tools.map(t => t.name)
|
||||
);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'calculate-bmi',
|
||||
arguments: { weightKg: 70, heightM: 1.75 }
|
||||
});
|
||||
console.log(result.content);
|
||||
//#endregion callTool_basic
|
||||
}
|
||||
|
||||
/** Example: Structured tool output. */
|
||||
async function callTool_structuredOutput(client: Client) {
|
||||
//#region callTool_structuredOutput
|
||||
const result = await client.callTool({
|
||||
name: 'calculate-bmi',
|
||||
arguments: { weightKg: 70, heightM: 1.75 }
|
||||
});
|
||||
|
||||
// Machine-readable output for the client application. SEP-2106: structuredContent is
|
||||
// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use.
|
||||
if (result.structuredContent !== undefined) {
|
||||
const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 }
|
||||
if (typeof sc === 'object' && sc !== null && 'bmi' in sc) {
|
||||
console.log(sc.bmi);
|
||||
}
|
||||
}
|
||||
//#endregion callTool_structuredOutput
|
||||
}
|
||||
|
||||
/** Example: Track progress of a long-running tool call. */
|
||||
async function callTool_progress(client: Client) {
|
||||
//#region callTool_progress
|
||||
const result = await client.callTool(
|
||||
{ name: 'long-operation', arguments: {} },
|
||||
{
|
||||
onprogress: ({ progress, total }: { progress: number; total?: number }) => {
|
||||
console.log(`Progress: ${progress}/${total ?? '?'}`);
|
||||
},
|
||||
resetTimeoutOnProgress: true,
|
||||
maxTotalTimeout: 600_000
|
||||
}
|
||||
);
|
||||
console.log(result.content);
|
||||
//#endregion callTool_progress
|
||||
}
|
||||
|
||||
/** Example: List and read resources. */
|
||||
async function readResource_basic(client: Client) {
|
||||
//#region readResource_basic
|
||||
const { resources } = await client.listResources();
|
||||
console.log(
|
||||
'Available resources:',
|
||||
resources.map(r => r.name)
|
||||
);
|
||||
|
||||
const { contents } = await client.readResource({ uri: 'config://app' });
|
||||
for (const item of contents) {
|
||||
console.log(item);
|
||||
}
|
||||
//#endregion readResource_basic
|
||||
}
|
||||
|
||||
/** Example: Subscribe to resource changes. */
|
||||
async function subscribeResource_basic(client: Client) {
|
||||
//#region subscribeResource_basic
|
||||
await client.subscribeResource({ uri: 'config://app' });
|
||||
|
||||
client.setNotificationHandler('notifications/resources/updated', async notification => {
|
||||
if (notification.params.uri === 'config://app') {
|
||||
const { contents } = await client.readResource({ uri: 'config://app' });
|
||||
console.log('Config updated:', contents);
|
||||
}
|
||||
});
|
||||
|
||||
// Later: stop receiving updates
|
||||
await client.unsubscribeResource({ uri: 'config://app' });
|
||||
//#endregion subscribeResource_basic
|
||||
}
|
||||
|
||||
/** Example: List and get prompts. */
|
||||
async function getPrompt_basic(client: Client) {
|
||||
//#region getPrompt_basic
|
||||
const { prompts } = await client.listPrompts();
|
||||
console.log(
|
||||
'Available prompts:',
|
||||
prompts.map(p => p.name)
|
||||
);
|
||||
|
||||
const { messages } = await client.getPrompt({
|
||||
name: 'review-code',
|
||||
arguments: { code: 'console.log("hello")' }
|
||||
});
|
||||
console.log(messages);
|
||||
//#endregion getPrompt_basic
|
||||
}
|
||||
|
||||
/** Example: Request argument completions. */
|
||||
async function complete_basic(client: Client) {
|
||||
//#region complete_basic
|
||||
const { completion } = await client.complete({
|
||||
ref: {
|
||||
type: 'ref/prompt',
|
||||
name: 'review-code'
|
||||
},
|
||||
argument: {
|
||||
name: 'language',
|
||||
value: 'type'
|
||||
}
|
||||
});
|
||||
console.log(completion.values); // e.g. ['typescript']
|
||||
//#endregion complete_basic
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Handle log messages and list-change notifications. */
|
||||
function notificationHandler_basic(client: Client) {
|
||||
//#region notificationHandler_basic
|
||||
// Server log messages (sent by the server during request processing)
|
||||
client.setNotificationHandler('notifications/message', notification => {
|
||||
const { level, data } = notification.params;
|
||||
console.log(`[${level}]`, data);
|
||||
});
|
||||
|
||||
// Server's resource list changed — re-fetch the list
|
||||
client.setNotificationHandler('notifications/resources/list_changed', async () => {
|
||||
const { resources } = await client.listResources();
|
||||
console.log('Resources changed:', resources.length);
|
||||
});
|
||||
//#endregion notificationHandler_basic
|
||||
}
|
||||
|
||||
/** Example: Control server log level. */
|
||||
async function setLoggingLevel_basic(client: Client) {
|
||||
//#region setLoggingLevel_basic
|
||||
await client.setLoggingLevel('warning');
|
||||
//#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
|
||||
const client = new Client(
|
||||
{ name: 'my-client', version: '1.0.0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: {
|
||||
onChanged: (error, tools) => {
|
||||
if (error) {
|
||||
console.error('Failed to refresh tools:', error);
|
||||
return;
|
||||
}
|
||||
console.log('Tools updated:', tools);
|
||||
}
|
||||
},
|
||||
prompts: {
|
||||
onChanged: (error, prompts) => console.log('Prompts updated:', prompts)
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
//#endregion listChanged_basic
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 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, elicitation, and roots. */
|
||||
function capabilities_declaration() {
|
||||
//#region capabilities_declaration
|
||||
const client = new Client(
|
||||
{ name: 'my-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
elicitation: { form: {} },
|
||||
roots: { listChanged: true }
|
||||
}
|
||||
}
|
||||
);
|
||||
//#endregion capabilities_declaration
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Example: Handle a sampling request from the server. */
|
||||
function sampling_handler(client: Client) {
|
||||
//#region sampling_handler
|
||||
client.setRequestHandler('sampling/createMessage', async request => {
|
||||
const lastMessage = request.params.messages.at(-1);
|
||||
console.log('Sampling request:', lastMessage);
|
||||
|
||||
// In production, send messages to your LLM here
|
||||
return {
|
||||
model: 'my-model',
|
||||
role: 'assistant' as const,
|
||||
content: {
|
||||
type: 'text' as const,
|
||||
text: 'Response from the model'
|
||||
}
|
||||
};
|
||||
});
|
||||
//#endregion sampling_handler
|
||||
}
|
||||
|
||||
/** Example: Handle an elicitation request from the server. */
|
||||
function elicitation_handler(client: Client) {
|
||||
//#region elicitation_handler
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
console.log('Server asks:', request.params.message);
|
||||
|
||||
if (request.params.mode === 'form') {
|
||||
// Present the schema-driven form to the user
|
||||
console.log('Schema:', request.params.requestedSchema);
|
||||
return { action: 'accept', content: { confirm: true } };
|
||||
}
|
||||
|
||||
return { action: 'decline' };
|
||||
});
|
||||
//#endregion elicitation_handler
|
||||
}
|
||||
|
||||
/** Example: Expose filesystem roots to the server. */
|
||||
function roots_handler(client: Client) {
|
||||
//#region roots_handler
|
||||
client.setRequestHandler('roots/list', async () => {
|
||||
return {
|
||||
roots: [
|
||||
{ uri: 'file:///home/user/projects/my-app', name: 'My App' },
|
||||
{ uri: 'file:///home/user/data', name: 'Data' }
|
||||
]
|
||||
};
|
||||
});
|
||||
//#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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Tool errors vs protocol errors. */
|
||||
async function errorHandling_toolErrors(client: Client) {
|
||||
//#region errorHandling_toolErrors
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: 'fetch-data',
|
||||
arguments: { url: 'https://example.com' }
|
||||
});
|
||||
|
||||
// Tool-level error: the tool ran but reported a problem
|
||||
if (result.isError) {
|
||||
console.error('Tool error:', result.content);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Success:', result.content);
|
||||
} catch (error) {
|
||||
// Protocol-level error: the request itself failed
|
||||
if (error instanceof ProtocolError) {
|
||||
console.error(`Protocol error ${error.code}: ${error.message}`);
|
||||
} else if (error instanceof SdkError) {
|
||||
console.error(`SDK error [${error.code}]: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
//#endregion errorHandling_toolErrors
|
||||
}
|
||||
|
||||
/** Example: Connection lifecycle callbacks. */
|
||||
function errorHandling_lifecycle(client: Client) {
|
||||
//#region errorHandling_lifecycle
|
||||
// Out-of-band errors (SSE disconnects, parse errors)
|
||||
client.onerror = error => {
|
||||
console.error('Transport error:', error.message);
|
||||
};
|
||||
|
||||
// Connection closed (pending requests are rejected with CONNECTION_CLOSED)
|
||||
client.onclose = () => {
|
||||
console.log('Connection closed');
|
||||
};
|
||||
//#endregion errorHandling_lifecycle
|
||||
}
|
||||
|
||||
/** Example: Custom timeouts. */
|
||||
async function errorHandling_timeout(client: Client) {
|
||||
//#region errorHandling_timeout
|
||||
try {
|
||||
const result = await client.callTool(
|
||||
{ name: 'slow-operation', arguments: {} },
|
||||
{ timeout: 120_000 } // 2 minutes instead of the default 60 seconds
|
||||
);
|
||||
console.log(result.content);
|
||||
} catch (error) {
|
||||
if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
|
||||
console.error('Request timed out');
|
||||
}
|
||||
}
|
||||
//#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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: Client middleware that adds a custom header. */
|
||||
async function middleware_basic() {
|
||||
//#region middleware_basic
|
||||
const authMiddleware = createMiddleware(async (next, input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('X-Custom-Header', 'my-value');
|
||||
return next(input, { ...init, headers });
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
|
||||
fetch: applyMiddlewares(authMiddleware)(fetch)
|
||||
});
|
||||
//#endregion middleware_basic
|
||||
return transport;
|
||||
}
|
||||
|
||||
/** Example: Attach W3C Trace Context to a single request via `_meta`. */
|
||||
async function traceContext_perRequest(client: Client) {
|
||||
//#region traceContext_perRequest
|
||||
// Values would normally come from your tracer's active span context.
|
||||
const result = await client.callTool({
|
||||
name: 'calculate-bmi',
|
||||
arguments: { weightKg: 70, heightM: 1.75 },
|
||||
_meta: {
|
||||
[TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
|
||||
[TRACESTATE_META_KEY]: 'vendor1=opaqueValue1'
|
||||
}
|
||||
});
|
||||
console.log(result.content);
|
||||
//#endregion traceContext_perRequest
|
||||
}
|
||||
|
||||
/** Example: Client middleware that injects trace context into every outgoing request. */
|
||||
async function traceContext_middleware() {
|
||||
//#region traceContext_middleware
|
||||
const traceContextMiddleware = createMiddleware(async (next, input, init) => {
|
||||
if (typeof init?.body !== 'string') {
|
||||
return next(input, init);
|
||||
}
|
||||
const message = JSON.parse(init.body) as {
|
||||
method?: string;
|
||||
params?: { _meta?: Record<string, unknown>; [key: string]: unknown };
|
||||
};
|
||||
// Only requests and notifications carry params._meta; skip responses.
|
||||
if (message.method === undefined) {
|
||||
return next(input, init);
|
||||
}
|
||||
message.params = {
|
||||
...message.params,
|
||||
_meta: {
|
||||
...message.params?._meta,
|
||||
// Replace with values from your tracer's active span context.
|
||||
[TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
|
||||
}
|
||||
};
|
||||
return next(input, { ...init, body: JSON.stringify(message) });
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
|
||||
fetch: applyMiddlewares(traceContextMiddleware)(fetch)
|
||||
});
|
||||
//#endregion traceContext_middleware
|
||||
return transport;
|
||||
}
|
||||
|
||||
/** Example: Track resumption tokens for SSE reconnection. */
|
||||
async function resumptionToken_basic(client: Client) {
|
||||
//#region resumptionToken_basic
|
||||
let lastToken: string | undefined;
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'long-running-operation', arguments: {} }
|
||||
},
|
||||
{
|
||||
resumptionToken: lastToken,
|
||||
onresumptiontoken: (token: string) => {
|
||||
lastToken = token;
|
||||
// Persist token to survive restarts
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(result);
|
||||
//#endregion resumptionToken_basic
|
||||
}
|
||||
|
||||
// Suppress unused-function warnings (functions exist solely for type-checking)
|
||||
void connect_streamableHttp;
|
||||
void connect_stdio;
|
||||
void connect_sseFallback;
|
||||
void Client_versionNegotiation;
|
||||
void disconnect_streamableHttp;
|
||||
void serverInstructions_basic;
|
||||
void extensionCapabilities_read;
|
||||
void auth_tokenProvider;
|
||||
void auth_clientCredentials;
|
||||
void auth_privateKeyJwt;
|
||||
void auth_crossAppAccess;
|
||||
void callTool_basic;
|
||||
void callTool_structuredOutput;
|
||||
void callTool_progress;
|
||||
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;
|
||||
void resumptionToken_basic;
|
||||
145
examples/guides/clients/caching.examples.ts
Normal file
145
examples/guides/clients/caching.examples.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/clients/caching.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift).
|
||||
*
|
||||
* The page's main program runs for real: a hint-sending `McpServer` is served
|
||||
* by `createMcpHandler` and the client's transport `fetch` is routed into
|
||||
* `handler.fetch`, so a real 2026-07-28 Streamable HTTP exchange runs
|
||||
* in-process without binding a port. The harness counts the JSON-RPC requests
|
||||
* that actually reach the server; the counts the page quotes verbatim are
|
||||
* whatever this file prints. It throws (non-zero exit) if a cache-served call
|
||||
* reaches the server.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/caching.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import type { ResponseCacheStore } from '@modelcontextprotocol/client';
|
||||
import { Client, InMemoryResponseCacheStore, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ## Have the server send the hint
|
||||
// The factory body carries the page's server-side region. `createMcpHandler`
|
||||
// (the harness) serves it; the tool and resource give the cache something to
|
||||
// hold.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
//#region cacheHints_server
|
||||
const server = new McpServer(
|
||||
{ name: 'catalog', version: '1.0.0' },
|
||||
{
|
||||
cacheHints: {
|
||||
'tools/list': { ttlMs: 60_000, cacheScope: 'public' },
|
||||
'resources/read': { ttlMs: 5_000, cacheScope: 'private' }
|
||||
}
|
||||
}
|
||||
);
|
||||
//#endregion cacheHints_server
|
||||
|
||||
server.registerTool('search', { description: 'Search the product catalog' }, async () => ({
|
||||
content: [{ type: 'text', text: 'Espresso cup\nTravel mug\nMug rack' }]
|
||||
}));
|
||||
|
||||
server.registerResource('app-config', 'config://app', { mimeType: 'application/json' }, async uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"theme":"dark"}' }]
|
||||
}));
|
||||
|
||||
return server;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). The transport's `fetch` is routed into
|
||||
// `handler.fetch` — [Test a server](docs/testing.md) wiring — and counts
|
||||
// every JSON-RPC request that reaches the server.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const reached = new Map<string, number>();
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://caching.example/mcp'), {
|
||||
fetch: (url, init) => {
|
||||
if (typeof init?.body === 'string') {
|
||||
const message = JSON.parse(init.body) as { method?: string };
|
||||
if (typeof message.method === 'string') reached.set(message.method, (reached.get(message.method) ?? 0) + 1);
|
||||
}
|
||||
return handler.fetch(new Request(url, init));
|
||||
}
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'caching-docs-harness', version: '1.0.0' },
|
||||
// Cache hints ride the 2026-07-28 revision — see docs/protocol-versions.md.
|
||||
{ versionNegotiation: { mode: 'auto' } }
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// ## Let the cache work — the calls whose request counts the page quotes.
|
||||
|
||||
//#region responseCache_use
|
||||
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_use
|
||||
|
||||
console.log('tools/list requests that reached the server:', reached.get('tools/list'));
|
||||
console.log('resources/read requests that reached the server:', reached.get('resources/read'));
|
||||
|
||||
// Self-verification: the page claims the second listTools() made no round trip
|
||||
// (two tools/list requests total: the first call and the 'refresh') and that
|
||||
// the cached result carries the same tools.
|
||||
if (reached.get('tools/list') !== 2 || reached.get('resources/read') !== 1) {
|
||||
throw new Error(`caching.md claim failed: tools/list=${reached.get('tools/list')}, resources/read=${reached.get('resources/read')}`);
|
||||
}
|
||||
if (again.tools.map(tool => tool.name).join() !== tools.tools.map(tool => tool.name).join()) {
|
||||
throw new Error('caching.md claim failed: cached listTools() differs from the first result');
|
||||
}
|
||||
|
||||
await client.close();
|
||||
await handler.close();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The remaining regions configure a Client; they typecheck but never connect
|
||||
// (each would need its own server), so they live in wrapper functions that are
|
||||
// never called.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ## Bring your own store
|
||||
|
||||
function responseCacheStore_shared() {
|
||||
//#region responseCacheStore_shared
|
||||
const store = new InMemoryResponseCacheStore({ maxEntries: 2048 });
|
||||
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' }, { responseCacheStore: store });
|
||||
//#endregion responseCacheStore_shared
|
||||
return client;
|
||||
}
|
||||
|
||||
// ## Partition the store per user
|
||||
|
||||
function cachePartition_perUser(sharedStore: ResponseCacheStore, userId: string) {
|
||||
//#region cachePartition_perUser
|
||||
const client = new Client({ name: 'gateway', version: '1.0.0' }, { responseCacheStore: sharedStore, cachePartition: userId });
|
||||
//#endregion cachePartition_perUser
|
||||
return client;
|
||||
}
|
||||
|
||||
// ## Cache against servers that send no hints
|
||||
|
||||
function defaultCacheTtlMs_optIn() {
|
||||
//#region defaultCacheTtlMs_optIn
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' }, { defaultCacheTtlMs: 60_000 });
|
||||
//#endregion defaultCacheTtlMs_optIn
|
||||
return client;
|
||||
}
|
||||
|
||||
void responseCacheStore_shared;
|
||||
void cachePartition_perUser;
|
||||
void defaultCacheTtlMs_optIn;
|
||||
205
examples/guides/clients/calling.examples.ts
Normal file
205
examples/guides/clients/calling.examples.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Companion example for `docs/clients/calling.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness around the
|
||||
* regions registers the in-memory `orders` server the page's client talks to
|
||||
* and produces the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/calling.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
|
||||
import { completable, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). The page documents the CLIENT verbs; this
|
||||
// `orders` server only exists so every block has something real to call and
|
||||
// every quoted output is real. Any MCP server behaves the same.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const orders = [
|
||||
{ id: 'A-1041', customer: 'Ada', items: 3, total: 61.5, currency: 'EUR', status: 'shipped' },
|
||||
{ id: 'A-1042', customer: 'Lin', items: 1, total: 18, currency: 'EUR', status: 'open' }
|
||||
];
|
||||
|
||||
const server = new McpServer({ name: 'orders', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'lookup-order',
|
||||
{
|
||||
description: 'Look up one order by its id',
|
||||
inputSchema: z.object({ id: z.string().describe('Order id, e.g. A-1041') })
|
||||
},
|
||||
async ({ id }) => {
|
||||
const order = orders.find(candidate => candidate.id === id);
|
||||
if (!order) throw new Error(`No order ${id}`);
|
||||
return { content: [{ type: 'text', text: `${order.id}: ${order.items} items, ${order.status}` }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'order-total',
|
||||
{
|
||||
description: 'Total of one order',
|
||||
inputSchema: z.object({ id: z.string() }),
|
||||
outputSchema: z.object({ id: z.string(), total: z.number(), currency: z.string() })
|
||||
},
|
||||
async ({ id }) => {
|
||||
const order = orders.find(candidate => candidate.id === id);
|
||||
if (!order) throw new Error(`No order ${id}`);
|
||||
const output = { id: order.id, total: order.total, currency: order.currency };
|
||||
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'export-orders',
|
||||
{
|
||||
description: 'Export every order to the given format',
|
||||
inputSchema: z.object({ format: z.string() })
|
||||
},
|
||||
async ({ format }, ctx) => {
|
||||
const progressToken = ctx.mcpReq._meta?.progressToken;
|
||||
for (const [index, order] of orders.entries()) {
|
||||
if (progressToken !== undefined) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken, progress: index + 1, total: orders.length, message: `exported ${order.id}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `${orders.length} orders exported as ${format}` }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'recent-orders',
|
||||
'orders://recent',
|
||||
{ description: 'Ids of the most recent orders', mimeType: 'application/json' },
|
||||
async uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(orders.map(order => order.id)) }]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'summarize-order',
|
||||
{
|
||||
description: 'Write a status update for one order',
|
||||
argsSchema: z.object({
|
||||
id: z.string().describe('Order id'),
|
||||
tone: completable(z.string().describe('Writing tone'), value =>
|
||||
['formal', 'friendly', 'terse'].filter(tone => tone.startsWith(value))
|
||||
)
|
||||
})
|
||||
},
|
||||
({ id, tone }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: { type: 'text' as const, text: `Write a ${tone} status update for order ${id}.` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'orders-cli', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "List the tools and call one" — the names and the content the page quotes.
|
||||
//#region listTools_callTool
|
||||
const { tools } = await client.listTools();
|
||||
console.log(tools.map(tool => tool.name));
|
||||
|
||||
const result = await client.callTool({ name: 'lookup-order', arguments: { id: 'A-1041' } });
|
||||
console.log(result.content);
|
||||
//#endregion listTools_callTool
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Let the SDK walk the pages". `McpServer` answers `tools/list` in one page,
|
||||
// so the harness swaps in a handler that serves the SAME three definitions two
|
||||
// per page — the aggregate walk and the per-page call below are both real.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.server.setRequestHandler('tools/list', async request =>
|
||||
request.params?.cursor === undefined ? { tools: tools.slice(0, 2), nextCursor: 'page-2' } : { tools: tools.slice(2) }
|
||||
);
|
||||
|
||||
// Proof for the page's aggregate claim: against the now-paginating server,
|
||||
// the no-cursor call still returns every tool, with `nextCursor` cleared.
|
||||
// Throws (non-zero exit) if the claim is false.
|
||||
const walked = await client.listTools();
|
||||
if (walked.tools.length !== tools.length || walked.nextCursor !== undefined) {
|
||||
throw new Error(`calling.md claim failed: aggregated walk returned ${JSON.stringify(walked)}`);
|
||||
}
|
||||
|
||||
// The cursor an earlier page of this server handed back in `nextCursor`.
|
||||
const heldCursor = 'page-2';
|
||||
|
||||
// "Let the SDK walk the pages" — one raw page, the output the page quotes.
|
||||
//#region listTools_onePage
|
||||
const page = await client.listTools({ cursor: heldCursor });
|
||||
console.log(
|
||||
page.tools.map(tool => tool.name),
|
||||
page.nextCursor
|
||||
);
|
||||
//#endregion listTools_onePage
|
||||
|
||||
// "Read structured output" — the narrowed `structuredContent` the page quotes.
|
||||
//#region callTool_structured
|
||||
const details = await client.callTool({ name: 'order-total', arguments: { id: 'A-1041' } });
|
||||
|
||||
const total: unknown = details.structuredContent;
|
||||
if (typeof total === 'object' && total !== null && 'currency' in total) {
|
||||
console.log(total);
|
||||
}
|
||||
//#endregion callTool_structured
|
||||
|
||||
// "Read a resource" — the uri list and the contents the page quotes.
|
||||
//#region readResource_basic
|
||||
const { resources } = await client.listResources();
|
||||
console.log(resources.map(resource => resource.uri));
|
||||
|
||||
const { contents } = await client.readResource({ uri: 'orders://recent' });
|
||||
console.log(contents[0]);
|
||||
//#endregion readResource_basic
|
||||
|
||||
// "Get a prompt" — the prompt names and the filled-in messages the page quotes.
|
||||
//#region getPrompt_basic
|
||||
const { prompts } = await client.listPrompts();
|
||||
console.log(prompts.map(prompt => prompt.name));
|
||||
|
||||
const prompt = await client.getPrompt({ name: 'summarize-order', arguments: { id: 'A-1041', tone: 'terse' } });
|
||||
console.log(prompt.messages);
|
||||
//#endregion getPrompt_basic
|
||||
|
||||
// "Autocomplete an argument" — the suggestions the page quotes.
|
||||
//#region complete_tone
|
||||
const { completion } = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'summarize-order' },
|
||||
argument: { name: 'tone', value: 'f' }
|
||||
});
|
||||
console.log(completion.values);
|
||||
//#endregion complete_tone
|
||||
|
||||
// "Track progress on a long call" — the progress updates and the final result.
|
||||
//#region callTool_progress
|
||||
const exported = await client.callTool(
|
||||
{ name: 'export-orders', arguments: { format: 'csv' } },
|
||||
{
|
||||
onprogress: update => console.log(update),
|
||||
resetTimeoutOnProgress: true,
|
||||
maxTotalTimeout: 600_000
|
||||
}
|
||||
);
|
||||
console.log(exported.content);
|
||||
//#endregion callTool_progress
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
116
examples/guides/clients/connect.examples.ts
Normal file
116
examples/guides/clients/connect.examples.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Runnable, type-checked companion for `docs/clients/connect.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's code fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift).
|
||||
*
|
||||
* The page's main program — connect over Streamable HTTP, read what the server
|
||||
* told you, close — runs for real: the harness below builds a `createMcpHandler`
|
||||
* server and routes `globalThis.fetch` for `http://localhost:3000/mcp` into
|
||||
* `handler.fetch`, so the HTTP regions execute in-process without binding a
|
||||
* port. The output the page quotes verbatim is whatever this file prints.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/connect.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). A `createMcpHandler` server answers the
|
||||
// page's `http://localhost:3000/mcp` URL in-process: `globalThis.fetch` for
|
||||
// that host is routed into `handler.fetch`, so the page's HTTP regions run
|
||||
// verbatim against a real Streamable HTTP server without binding a port.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer(
|
||||
{ name: 'travel', version: '2.1.0' },
|
||||
{ instructions: 'Call list-trips before book-trip. Dates are ISO 8601.' }
|
||||
);
|
||||
server.registerTool(
|
||||
'list-trips',
|
||||
{ description: 'List the trips on file', inputSchema: z.object({ year: z.number().int() }) },
|
||||
async ({ year }) => ({ content: [{ type: 'text', text: `No trips in ${year}` }] })
|
||||
);
|
||||
return server;
|
||||
});
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request);
|
||||
return realFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
// ## Create a client and connect over HTTP
|
||||
|
||||
//#region connect_streamableHttp
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
|
||||
|
||||
await client.connect(transport);
|
||||
//#endregion connect_streamableHttp
|
||||
|
||||
// ## Read what the server told you at connect time
|
||||
|
||||
//#region connect_introspect
|
||||
console.log(client.getServerVersion());
|
||||
console.log(client.getServerCapabilities());
|
||||
console.log(client.getInstructions());
|
||||
//#endregion connect_introspect
|
||||
|
||||
// ## Disconnect cleanly
|
||||
|
||||
//#region connect_close
|
||||
await transport.terminateSession();
|
||||
await client.close();
|
||||
//#endregion connect_close
|
||||
|
||||
await handler.close();
|
||||
globalThis.fetch = realFetch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The remaining transports cannot run inside this self-terminating harness —
|
||||
// stdio spawns a process, the SSE fallback needs a legacy server — so their
|
||||
// regions live in wrapper functions that typecheck but are never called.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ## Connect to a local process over stdio
|
||||
|
||||
async function connect_stdio() {
|
||||
//#region connect_stdio
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
|
||||
|
||||
await client.connect(transport);
|
||||
//#endregion connect_stdio
|
||||
}
|
||||
|
||||
// ## Fall back to SSE for legacy servers
|
||||
|
||||
async function connect_sseFallback(url: string) {
|
||||
//#region connect_sseFallback
|
||||
try {
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
|
||||
return client;
|
||||
} catch {
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
await client.connect(new SSEClientTransport(new URL(url)));
|
||||
return client;
|
||||
}
|
||||
//#endregion connect_sseFallback
|
||||
}
|
||||
|
||||
void connect_stdio;
|
||||
void connect_sseFallback;
|
||||
89
examples/guides/clients/machine-auth.examples.ts
Normal file
89
examples/guides/clients/machine-auth.examples.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// docs: typecheck-only
|
||||
/**
|
||||
* Type-checked companion for `docs/clients/machine-auth.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's `ts` fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). Every flow
|
||||
* on the page needs a live authorization server, so there is nothing meaningful
|
||||
* to run — the regions only typecheck.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { AuthProvider } from '@modelcontextprotocol/client';
|
||||
import { CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, PrivateKeyJwtProvider } from '@modelcontextprotocol/client';
|
||||
|
||||
// "Authenticate with client credentials" — the page's lead block. The import line
|
||||
// is part of the region so the first fence on the page names where the providers
|
||||
// come from.
|
||||
//#region clientCredentials_connect
|
||||
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const authProvider = new ClientCredentialsProvider({
|
||||
clientId: 'reporting-job',
|
||||
clientSecret: 'reporting-job-secret'
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'reporting-job', version: '1.0.0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
|
||||
await client.connect(transport);
|
||||
//#endregion clientCredentials_connect
|
||||
|
||||
// "Bring your own bearer token" — the minimal AuthProvider shape: token() only.
|
||||
function bearerToken_provider(getStoredToken: () => Promise<string>) {
|
||||
//#region bearerToken_provider
|
||||
const authProvider: AuthProvider = { token: async () => getStoredToken() };
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
//#endregion bearerToken_provider
|
||||
return transport;
|
||||
}
|
||||
|
||||
// "Sign with a private key instead of a secret" — private_key_jwt token-endpoint auth.
|
||||
function privateKeyJwt_provider(pemEncodedKey: string) {
|
||||
//#region privateKeyJwt_provider
|
||||
const authProvider = new PrivateKeyJwtProvider({
|
||||
clientId: 'reporting-job',
|
||||
privateKey: pemEncodedKey,
|
||||
algorithm: 'RS256'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
//#endregion privateKeyJwt_provider
|
||||
return transport;
|
||||
}
|
||||
|
||||
// "Act for an enterprise user with cross-app access" — SEP-990 / Enterprise
|
||||
// Managed Authorization. The assertion callback turns the IdP ID Token into a
|
||||
// JWT Authorization Grant; the provider exchanges that for the access token.
|
||||
function crossAppAccess_provider(getIdToken: () => Promise<string>) {
|
||||
//#region crossAppAccess_provider
|
||||
const authProvider = new CrossAppAccessProvider({
|
||||
assertion: async ctx => {
|
||||
const grant = await discoverAndRequestJwtAuthGrant({
|
||||
idpUrl: 'https://idp.example.com',
|
||||
audience: ctx.authorizationServerUrl,
|
||||
resource: ctx.resourceUrl,
|
||||
idToken: await getIdToken(),
|
||||
clientId: 'idp-exchange-client',
|
||||
clientSecret: 'idp-exchange-secret',
|
||||
scope: ctx.scope,
|
||||
fetchFn: ctx.fetchFn
|
||||
});
|
||||
return grant.jwtAuthGrant;
|
||||
},
|
||||
clientId: 'reporting-job',
|
||||
clientSecret: 'reporting-job-secret'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
//#endregion crossAppAccess_provider
|
||||
return transport;
|
||||
}
|
||||
|
||||
void bearerToken_provider;
|
||||
void privateKeyJwt_provider;
|
||||
void crossAppAccess_provider;
|
||||
136
examples/guides/clients/middleware.examples.ts
Normal file
136
examples/guides/clients/middleware.examples.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Companion example for `docs/clients/middleware.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness at the
|
||||
* bottom routes every middleware stack into an in-process `createMcpHandler`
|
||||
* — no port, no socket — and produces the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/middleware.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
|
||||
import { Client, withLogging, withOAuth } from '@modelcontextprotocol/client';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Write a middleware"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region middleware_create
|
||||
import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const tagRequests = createMiddleware(async (next, input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('X-Request-Source', 'reports-cli');
|
||||
return next(input, { ...init, headers });
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
|
||||
fetch: applyMiddlewares(tagRequests)(fetch)
|
||||
});
|
||||
//#endregion middleware_create
|
||||
void transport;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Compose several middlewares" — the stub base fetch makes the order
|
||||
// observable without any network.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region middleware_order
|
||||
const stamp = (name: string) =>
|
||||
createMiddleware(async (next, input, init) => {
|
||||
console.log(`-> ${name}`);
|
||||
const response = await next(input, init);
|
||||
console.log(`<- ${name}`);
|
||||
return response;
|
||||
});
|
||||
|
||||
const base = async () => new Response('ok');
|
||||
await applyMiddlewares(stamp('retry'), stamp('auth'), stamp('trace'))(base)('http://localhost:3000/mcp');
|
||||
//#endregion middleware_order
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Use the built-in logging middleware"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region middleware_logging
|
||||
const loggedFetch = applyMiddlewares(tagRequests, withLogging())(fetch);
|
||||
//#endregion middleware_logging
|
||||
void loggedFetch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Combine middleware with an auth provider" — never invoked: a working
|
||||
// `OAuthClientProvider` is the reader's, not this program's (docs/clients/oauth.md).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: OAuth expressed as one layer of a middleware stack. */
|
||||
function authenticatedTransport(provider: OAuthClientProvider) {
|
||||
//#region middleware_withOAuth
|
||||
const serverUrl = new URL('http://localhost:3000/mcp');
|
||||
const authed = new StreamableHTTPClientTransport(serverUrl, {
|
||||
fetch: applyMiddlewares(withOAuth(provider, serverUrl), withLogging({ statusLevel: 400 }))(fetch)
|
||||
});
|
||||
//#endregion middleware_withOAuth
|
||||
return authed;
|
||||
}
|
||||
void authenticatedTransport;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Inspect the response"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region middleware_inspect
|
||||
const observeStatus = createMiddleware(async (next, input, init) => {
|
||||
const response = await next(input, init);
|
||||
if (typeof init?.body === 'string') {
|
||||
const { method } = JSON.parse(init.body) as { method?: string };
|
||||
console.log(`${method ?? 'response'} -> HTTP ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
//#endregion middleware_inspect
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-process `createMcpHandler` stands in
|
||||
// for the network: `serverFetch` has the same shape as global `fetch`, so the
|
||||
// exact middleware values defined in the regions above compose onto it
|
||||
// unchanged. Each block connects a real Client over Streamable HTTP and calls
|
||||
// one tool; the console output is what the page quotes verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: 'reports', version: '1.0.0' });
|
||||
server.registerTool('ping', { description: 'Reply with pong', inputSchema: z.object({ tag: z.string() }) }, async ({ tag }) => ({
|
||||
content: [{ type: 'text', text: `pong ${tag}` }]
|
||||
}));
|
||||
return server;
|
||||
});
|
||||
type AnyFetch = (url: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
const serverFetch: AnyFetch = (url, init) => handler.fetch(new Request(url, init));
|
||||
|
||||
async function drive(fetchImpl: AnyFetch): Promise<void> {
|
||||
const client = new Client({ name: 'middleware-docs-harness', version: '1.0.0' });
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { fetch: fetchImpl }));
|
||||
await client.callTool({ name: 'ping', arguments: { tag: 'docs' } });
|
||||
await client.close();
|
||||
}
|
||||
|
||||
// "Use the built-in logging middleware" — the lines the page quotes. The default
|
||||
// logger derives each duration from `performance.now()`; pin it while this stack
|
||||
// runs so the quoted output is reproducible byte for byte.
|
||||
console.log('--- withLogging');
|
||||
const realNow = performance.now.bind(performance);
|
||||
performance.now = () => 0;
|
||||
await drive(applyMiddlewares(tagRequests, withLogging())(serverFetch));
|
||||
performance.now = realNow;
|
||||
|
||||
// "Inspect the response" — the method -> status lines the page quotes.
|
||||
console.log('--- inspect');
|
||||
await drive(applyMiddlewares(observeStatus)(serverFetch));
|
||||
|
||||
await handler.close();
|
||||
181
examples/guides/clients/oauth.examples.ts
Normal file
181
examples/guides/clients/oauth.examples.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// docs: typecheck-only
|
||||
/**
|
||||
* Type-checked companion for `docs/clients/oauth.md`.
|
||||
*
|
||||
* Each `//#region` block is synced byte-for-byte into that page's `ts` fences by
|
||||
* `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The page is
|
||||
* the user-facing authorization-code flow — finishing it needs a browser and an
|
||||
* authorization server, so nothing here runs in CI; the file only typechecks.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type {
|
||||
OAuthClientInformationContext,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthClientProvider,
|
||||
OAuthDiscoveryState,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/client';
|
||||
import {
|
||||
checkResourceAllowed,
|
||||
Client,
|
||||
IssuerMismatchError,
|
||||
resourceUrlFromServerUrl,
|
||||
StreamableHTTPClientTransport,
|
||||
UnauthorizedError
|
||||
} from '@modelcontextprotocol/client';
|
||||
|
||||
// Stand-ins for the host application: how it opens a browser, and how its
|
||||
// callback endpoint hands the redirect URL back to this code.
|
||||
declare function onRedirect(url: URL): void;
|
||||
declare function waitForCallback(): Promise<string>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Implement OAuthClientProvider" — the page shows this class AFTER the
|
||||
// transport that uses it; it lives first here so every region below can see it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region MyOAuthProvider_class
|
||||
class MyOAuthProvider implements OAuthClientProvider {
|
||||
// Key DCR-obtained credentials by issuer so a client_id registered with one
|
||||
// authorization server is never returned for another (SEP-2352).
|
||||
private creds = new Map<string, OAuthClientInformationMixed>();
|
||||
private storedTokens?: OAuthTokens;
|
||||
private verifier?: string;
|
||||
private discovery?: OAuthDiscoveryState;
|
||||
lastState?: string;
|
||||
|
||||
readonly redirectUrl = 'http://localhost:8090/callback';
|
||||
readonly clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'My MCP Client',
|
||||
redirect_uris: ['http://localhost:8090/callback'],
|
||||
// Loopback redirect → the SDK would default this to 'native'; set
|
||||
// explicitly when the heuristic is wrong for your deployment (SEP-837).
|
||||
application_type: 'native'
|
||||
};
|
||||
|
||||
clientInformation(ctx?: OAuthClientInformationContext) {
|
||||
return ctx ? this.creds.get(ctx.issuer) : undefined;
|
||||
}
|
||||
saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
|
||||
if (ctx) this.creds.set(ctx.issuer, info);
|
||||
}
|
||||
tokens() {
|
||||
return this.storedTokens;
|
||||
}
|
||||
saveTokens(tokens: OAuthTokens) {
|
||||
// In production, persist to OS keychain / secure storage — never plain files.
|
||||
this.storedTokens = tokens;
|
||||
}
|
||||
// CSRF binding for the redirect — the SDK puts this on the authorize URL;
|
||||
// your callback handler compares it before calling `finishAuth`.
|
||||
state() {
|
||||
this.lastState = crypto.randomUUID();
|
||||
return this.lastState;
|
||||
}
|
||||
// Callback-leg AS-binding (SEP-2352): record what discovery resolved before
|
||||
// the redirect so the SDK can verify the code is exchanged at the same AS.
|
||||
saveDiscoveryState(state: OAuthDiscoveryState) {
|
||||
this.discovery = state;
|
||||
}
|
||||
discoveryState() {
|
||||
return this.discovery;
|
||||
}
|
||||
redirectToAuthorization(url: URL) {
|
||||
onRedirect(url);
|
||||
}
|
||||
saveCodeVerifier(v: string) {
|
||||
this.verifier = v;
|
||||
}
|
||||
codeVerifier() {
|
||||
if (!this.verifier) throw new Error('no code verifier');
|
||||
return this.verifier;
|
||||
}
|
||||
}
|
||||
//#endregion MyOAuthProvider_class
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Hand the transport an OAuth provider"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: connect with an `authProvider`; an auth-gated server throws `UnauthorizedError`. */
|
||||
async function authProvider_connect() {
|
||||
//#region authProvider_connect
|
||||
const provider = new MyOAuthProvider();
|
||||
const client = new Client({ name: 'my-app', version: '1.0.0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), {
|
||||
authProvider: provider
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnauthorizedError)) throw error;
|
||||
// The transport already called provider.redirectToAuthorization(url):
|
||||
// the end user is in the browser, at the authorization server.
|
||||
}
|
||||
//#endregion authProvider_connect
|
||||
return { client, provider, transport };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Finish the flow from the callback"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: state check, code exchange, reconnect on a fresh transport. */
|
||||
async function finishAuth_callback() {
|
||||
const { client, provider, transport } = await authProvider_connect();
|
||||
const url = new URL('https://api.example.com/mcp');
|
||||
//#region finishAuth_callback
|
||||
const callbackUrl = await waitForCallback(); // however your app receives the redirect
|
||||
const params = new URL(callbackUrl).searchParams;
|
||||
|
||||
// The SDK does not validate `state` — compare it to the value your provider generated.
|
||||
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');
|
||||
|
||||
await transport.finishAuth(params);
|
||||
|
||||
// Reconnect on a FRESH transport — a started transport cannot be restarted.
|
||||
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
|
||||
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
|
||||
//#endregion finishAuth_callback
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Handle issuer mismatch"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Example: the RFC 9207 mix-up defense around `finishAuth`. */
|
||||
async function finishAuth_issuerMismatch(transport: StreamableHTTPClientTransport, params: URLSearchParams) {
|
||||
//#region finishAuth_issuerMismatch
|
||||
try {
|
||||
await transport.finishAuth(params);
|
||||
} catch (error) {
|
||||
if (error instanceof IssuerMismatchError) {
|
||||
// Mix-up attack: never render params.get('error_description') to the user.
|
||||
throw new Error('Authorization failed: issuer mismatch');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
//#endregion finishAuth_issuerMismatch
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Pin the resource indicator"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region validateResourceURL_pin
|
||||
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 validateResourceURL_pin
|
||||
67
examples/guides/clients/roots.examples.ts
Normal file
67
examples/guides/clients/roots.examples.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Companion example for `docs/clients/roots.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects an in-memory server that requests `roots/list` and produces
|
||||
* the output the page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/roots.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region roots_capability
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
|
||||
const client = new Client({ name: 'workspace-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } });
|
||||
//#endregion roots_capability
|
||||
|
||||
//#region roots_listHandler
|
||||
const roots = [
|
||||
{ uri: 'file:///home/user/projects/my-app', name: 'My App' },
|
||||
{ uri: 'file:///home/user/data', name: 'Data' }
|
||||
];
|
||||
|
||||
client.setRequestHandler('roots/list', async () => {
|
||||
return { roots };
|
||||
});
|
||||
//#endregion roots_listHandler
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). An in-memory low-level Server plays the
|
||||
// counterpart: it requests `roots/list`, and requests it again when the client
|
||||
// sends `notifications/roots/list_changed`. Any MCP server behaves the same.
|
||||
// Imported dynamically so the page's lead region stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { Server } = await import('@modelcontextprotocol/server');
|
||||
const { InMemoryTransport } = await import('@modelcontextprotocol/client');
|
||||
|
||||
const server = new Server({ name: 'roots-docs-harness', version: '1.0.0' });
|
||||
|
||||
const relisted = new Promise<void>(resolve => {
|
||||
server.setNotificationHandler('notifications/roots/list_changed', async () => {
|
||||
console.log((await server.listRoots()).roots);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "Answer roots/list" — the list the page quotes.
|
||||
console.log((await server.listRoots()).roots);
|
||||
|
||||
// "Tell the server when the roots change" — the notification triggers the
|
||||
// harness's re-list above, whose output the page quotes.
|
||||
//#region roots_listChanged
|
||||
roots.push({ uri: 'file:///home/user/projects/another-app', name: 'Another app' });
|
||||
await client.sendRootsListChanged();
|
||||
//#endregion roots_listChanged
|
||||
|
||||
await relisted;
|
||||
await client.close();
|
||||
await server.close();
|
||||
176
examples/guides/clients/server-requests.examples.ts
Normal file
176
examples/guides/clients/server-requests.examples.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Companion example for `docs/clients/server-requests.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
|
||||
* regions connects this client over an in-memory transport pair to a server
|
||||
* whose tools elicit input and request sampling, and produces the output the
|
||||
* page quotes verbatim.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/server-requests.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
//#region Client_capabilities
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'my-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
elicitation: { form: {}, url: {} }
|
||||
}
|
||||
}
|
||||
);
|
||||
//#endregion Client_capabilities
|
||||
|
||||
// "Handle an elicitation request" — one handler, both modes.
|
||||
//#region setRequestHandler_elicitation
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
if (request.params.mode === 'url') {
|
||||
// Open request.params.url in the user's browser; answer when they finish.
|
||||
return { action: 'accept' };
|
||||
}
|
||||
// Render request.params.requestedSchema as a form; return what the user entered.
|
||||
return { action: 'accept', content: { city: 'Lisbon' } };
|
||||
});
|
||||
//#endregion setRequestHandler_elicitation
|
||||
|
||||
// "Handle a sampling request" — a canned model stands in for a real provider.
|
||||
//#region setRequestHandler_sampling
|
||||
client.setRequestHandler('sampling/createMessage', async request => {
|
||||
const lastMessage = request.params.messages.at(-1);
|
||||
console.log('Sampling request:', lastMessage?.content);
|
||||
|
||||
// In production, run the messages through your model here.
|
||||
return {
|
||||
model: 'host-model',
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: 'One travel mug to Lisbon.' }
|
||||
};
|
||||
});
|
||||
//#endregion setRequestHandler_sampling
|
||||
|
||||
// "Cap or disable automatic fulfilment" — the same constructor with the
|
||||
// `inputRequired` option added. Wrapped so the file keeps a single live client.
|
||||
function Client_inputRequired() {
|
||||
//#region Client_inputRequired
|
||||
const client = new Client(
|
||||
{ name: 'my-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: { sampling: {}, elicitation: { form: {}, url: {} } },
|
||||
inputRequired: { maxRounds: 3 }
|
||||
}
|
||||
);
|
||||
//#endregion Client_inputRequired
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness (not shown on the page). A server whose tools elicit input and
|
||||
// request sampling drives the handlers above over an in-memory transport pair.
|
||||
// Imported dynamically so the page's lead region stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { getSupportedElicitationModes, InMemoryTransport } = await import('@modelcontextprotocol/client');
|
||||
const { McpServer } = await import('@modelcontextprotocol/server');
|
||||
const z = await import('zod/v4');
|
||||
|
||||
const server = new McpServer({ name: 'orders', version: '1.0.0' });
|
||||
|
||||
// Form-mode elicitation: docs/servers/elicitation.md owns this side.
|
||||
server.registerTool(
|
||||
'place-order',
|
||||
{
|
||||
description: 'Place an order after collecting a shipping city',
|
||||
inputSchema: z.object({ item: z.string() })
|
||||
},
|
||||
async ({ item }, ctx) => {
|
||||
const answer = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: `Where should we ship the ${item}?`,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', title: 'City' } },
|
||||
required: ['city']
|
||||
}
|
||||
});
|
||||
if (answer.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: `Order ${answer.action}.` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Order placed: ${item} ships to ${answer.content?.city}.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// URL-mode elicitation: exercises the handler's `url` branch.
|
||||
server.registerTool(
|
||||
'link-card',
|
||||
{
|
||||
description: 'Link a payment card through a hosted flow',
|
||||
inputSchema: z.object({ provider: z.string() })
|
||||
},
|
||||
async ({ provider }, ctx) => {
|
||||
const answer = await ctx.mcpReq.elicitInput({
|
||||
mode: 'url',
|
||||
message: `Sign in to ${provider} to link your card`,
|
||||
url: `https://pay.example.com/link/${encodeURIComponent(provider)}`,
|
||||
elicitationId: crypto.randomUUID()
|
||||
});
|
||||
return { content: [{ type: 'text', text: `Card link: ${answer.action}.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// Sampling: docs/servers/sampling.md owns this side.
|
||||
server.registerTool(
|
||||
'summarize-order',
|
||||
{
|
||||
description: 'Summarize the latest order with the host model',
|
||||
inputSchema: z.object({ order: z.string() })
|
||||
},
|
||||
async ({ order }, ctx) => {
|
||||
const response = await ctx.mcpReq.requestSampling({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize this order: ${order}` } }],
|
||||
maxTokens: 200
|
||||
});
|
||||
const block = Array.isArray(response.content) ? response.content[0] : response.content;
|
||||
const summary = block?.type === 'text' ? block.text : '(non-text)';
|
||||
return { content: [{ type: 'text', text: `${response.model}: ${summary}` }] };
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// "Handle an elicitation request" — the form round trip the page quotes.
|
||||
const placed = await client.callTool({ name: 'place-order', arguments: { item: 'Travel mug' } });
|
||||
console.log(placed.content);
|
||||
|
||||
// "Handle a sampling request" — the handler logs the prompt it received, then
|
||||
// the tool result carries the handler's completion. Both lines are quoted.
|
||||
const summarized = await client.callTool({ name: 'summarize-order', arguments: { order: '1 Travel mug to Lisbon' } });
|
||||
console.log(summarized.content);
|
||||
|
||||
// Proof for the elicitation section's `url` branch — not quoted on the page,
|
||||
// but the run fails (non-zero exit) if the branch stops working.
|
||||
const linked = await client.callTool({ name: 'link-card', arguments: { provider: 'examplepay' } });
|
||||
const linkedText = linked.content?.[0];
|
||||
if (linkedText?.type !== 'text' || linkedText.text !== 'Card link: accept.') {
|
||||
throw new Error(`server-requests.md url-branch claim failed: ${JSON.stringify(linked.content)}`);
|
||||
}
|
||||
|
||||
// Proof for the page's ::: tip — an empty `elicitation: {}` capability means
|
||||
// form mode only; `url` has to be declared explicitly.
|
||||
const modes = getSupportedElicitationModes({});
|
||||
if (modes.supportsFormMode !== true || modes.supportsUrlMode !== false) {
|
||||
throw new Error(`server-requests.md tip claim failed: ${JSON.stringify(modes)}`);
|
||||
}
|
||||
|
||||
// Keep the round-cap constructor type-checked (its region is page-only).
|
||||
void Client_inputRequired;
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
199
examples/guides/clients/subscriptions.examples.ts
Normal file
199
examples/guides/clients/subscriptions.examples.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Companion example for `docs/clients/subscriptions.md`.
|
||||
*
|
||||
* Every `ts` fence on that page is synced from a `//#region` in this file
|
||||
* (`pnpm sync:snippets --check`). The file also runs: the harness drives a
|
||||
* `createMcpHandler` entry in process — no port, no socket — over a
|
||||
* 2026-07-28 connection, publishes the changes, and produces the output the
|
||||
* page quotes verbatim. It exits non-zero if a quoted line never appears.
|
||||
*
|
||||
* pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
* npx tsx guides/clients/subscriptions.examples.ts # from examples/
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness server (not shown on the page). A per-request factory whose tool
|
||||
// set and `config://app` resource the harness mutates between publishes.
|
||||
// `subscriptions/listen` is served by the entry, so the page's client talks
|
||||
// to `handler.fetch` directly — the URL is never dialed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let settings = { theme: 'light' };
|
||||
let archiveEnabled = false;
|
||||
|
||||
function buildNotesServer(): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: 'notes', version: '1.0.0' },
|
||||
{ capabilities: { tools: { listChanged: true }, resources: { subscribe: true } } }
|
||||
);
|
||||
server.registerTool(
|
||||
'search-notes',
|
||||
{ description: 'Search notes', inputSchema: z.object({ query: z.string() }) },
|
||||
async ({ query }) => ({
|
||||
content: [{ type: 'text', text: `no notes match ${query}` }]
|
||||
})
|
||||
);
|
||||
if (archiveEnabled) {
|
||||
server.registerTool('archive-note', { description: 'Archive a note' }, async () => ({
|
||||
content: [{ type: 'text', text: 'archived' }]
|
||||
}));
|
||||
}
|
||||
server.registerResource('config', 'config://app', { mimeType: 'application/json' }, async uri => ({
|
||||
contents: [{ uri: uri.href, text: JSON.stringify(settings) }]
|
||||
}));
|
||||
return server;
|
||||
}
|
||||
|
||||
// Each leg gets its own handler instance (and therefore its own event bus),
|
||||
// so a publish meant for one leg cannot reach the other.
|
||||
const handler = createMcpHandler(buildNotesServer);
|
||||
const autoHandler = createMcpHandler(buildNotesServer);
|
||||
|
||||
/** Connect `client` to `target` over the 2026-07-28 revision, in process. */
|
||||
async function connect(client: Client, target: typeof handler): Promise<void> {
|
||||
client.setVersionNegotiation({ mode: 'auto' });
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), {
|
||||
fetch: (url, init) => target.fetch(new Request(url, init))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Every `console.log` line is also recorded so the harness can wait for the
|
||||
// exact output the page quotes (and fail loudly if it never arrives).
|
||||
const logged: string[] = [];
|
||||
const realLog = console.log.bind(console);
|
||||
console.log = (...args: unknown[]) => {
|
||||
logged.push(args.map(String).join(' '));
|
||||
realLog(...args);
|
||||
};
|
||||
/** Wait until a logged line starts with `prefix`, or throw after 5 s. */
|
||||
async function loggedLine(prefix: string): Promise<void> {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!logged.some(line => line.startsWith(prefix))) {
|
||||
if (Date.now() > deadline) throw new Error(`timed out waiting for output line "${prefix}"`);
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
const client = new Client({ name: 'notes-watcher', version: '1.0.0' });
|
||||
await connect(client, handler);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Open a subscription stream" — the honored-filter line the page quotes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region listen_open
|
||||
client.setNotificationHandler('notifications/tools/list_changed', async () => {
|
||||
const { tools } = await client.listTools();
|
||||
console.log('Tools changed:', tools.length);
|
||||
});
|
||||
|
||||
const subscription = await client.listen({
|
||||
toolsListChanged: true,
|
||||
resourceSubscriptions: ['config://app']
|
||||
});
|
||||
console.log('Server honored:', subscription.honoredFilter);
|
||||
//#endregion listen_open
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Handle the notifications" — the second handler, then one publish of each
|
||||
// kind. Both notification lines the page quotes come from these handlers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region listen_updated
|
||||
client.setNotificationHandler('notifications/resources/updated', async notification => {
|
||||
const { contents } = await client.readResource({ uri: notification.params.uri });
|
||||
console.log('Updated', notification.params.uri, contents);
|
||||
});
|
||||
//#endregion listen_updated
|
||||
|
||||
archiveEnabled = true;
|
||||
handler.notify.toolsChanged();
|
||||
await loggedLine('Tools changed:');
|
||||
|
||||
settings = { theme: 'dark' };
|
||||
handler.notify.resourceUpdated('config://app');
|
||||
await loggedLine('Updated config://app');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Close the stream and react to closure" — the close reason the page quotes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region listen_close
|
||||
await subscription.close();
|
||||
console.log('Closed:', await subscription.closed);
|
||||
//#endregion listen_close
|
||||
|
||||
await client.close();
|
||||
await handler.close();
|
||||
|
||||
/** Example: re-listen only on an unexpected disconnect. Never invoked — the page's watch-loop block. */
|
||||
async function watchConfig(watching: boolean): Promise<void> {
|
||||
//#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
|
||||
}
|
||||
void watchConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Let the SDK open the stream for you" — a second client whose stream the
|
||||
// SDK opens from the `listChanged` option. The harness publishes one more
|
||||
// tool change to produce the `onChanged` line the page quotes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
//#region listChanged_auto
|
||||
const watcher = new Client(
|
||||
{ name: 'notes-watcher', version: '1.0.0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: {
|
||||
onChanged: (error, tools) => {
|
||||
if (error) {
|
||||
console.error('Refresh failed:', error);
|
||||
return;
|
||||
}
|
||||
console.log('Tools refreshed:', tools?.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
//#endregion listChanged_auto
|
||||
|
||||
await connect(watcher, autoHandler);
|
||||
if (watcher.autoOpenedSubscription === undefined) {
|
||||
throw new Error('listChanged should auto-open a subscription stream on a 2026-07-28 connection');
|
||||
}
|
||||
console.log('Auto-opened:', watcher.autoOpenedSubscription.honoredFilter);
|
||||
|
||||
archiveEnabled = false;
|
||||
autoHandler.notify.toolsChanged();
|
||||
await loggedLine('Tools refreshed:');
|
||||
|
||||
await watcher.autoOpenedSubscription.close();
|
||||
await watcher.close();
|
||||
await autoHandler.close();
|
||||
|
||||
/** Example: the 2025-era per-resource path. Never invoked — `resources/subscribe` is not part of 2026-07-28. */
|
||||
async function legacySubscribe(client: Client): Promise<void> {
|
||||
//#region subscribeResource_legacy
|
||||
await client.subscribeResource({ uri: 'config://app' });
|
||||
|
||||
// The same notifications/resources/updated handler fires.
|
||||
|
||||
await client.unsubscribeResource({ uri: 'config://app' });
|
||||
//#endregion subscribeResource_legacy
|
||||
}
|
||||
void legacySubscribe;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue