diff --git a/examples/client/README.md b/examples/client/README.md index 12a2b0d68b..7894f88e5b 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Tool list changed notifications | In-process example showing `notifications/tools/list_changed` end-to-end (no networking). | [`src/toolListChanged.ts`](src/toolListChanged.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/package.json b/examples/client/package.json index 57b329fd2d..af67fe8760 100644 --- a/examples/client/package.json +++ b/examples/client/package.json @@ -33,6 +33,8 @@ }, "dependencies": { "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", "ajv": "catalog:runtimeShared", "open": "^11.0.0", "zod": "catalog:runtimeShared" diff --git a/examples/client/src/toolListChanged.ts b/examples/client/src/toolListChanged.ts new file mode 100644 index 0000000000..1e3395f650 --- /dev/null +++ b/examples/client/src/toolListChanged.ts @@ -0,0 +1,115 @@ +/** + * Tool list changed notification example. + * + * Demonstrates how a client subscribes to `notifications/tools/list_changed` + * and automatically refreshes its tool list when the server registers a new + * tool after the connection is established. + * + * Uses InMemoryTransport so the example runs fully in-process — no server + * process or network required. + * + * Expected output: + * [server] started with 1 tool: get_weather + * [client] connected. initial tools: [ 'get_weather' ] + * [server] registering new tool: get_forecast + * [client] tool list changed — updated tools: [ 'get_weather', 'get_forecast' ] + * + * Closes #1132 + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod/v4'; + +async function main(): Promise { + // --- Server setup --------------------------------------------------- + const server = new McpServer({ name: 'weather-server', version: '1.0.0' }); + + server.registerTool( + 'get_weather', + { + description: 'Get current weather for a city', + inputSchema: z.object({ city: z.string() }) + }, + async ({ city }) => ({ content: [{ type: 'text', text: `Sunny in ${city}` }] }) + ); + + console.log('[server] started with 1 tool: get_weather'); + + // --- Client setup --------------------------------------------------- + // `listChanged.tools.onChanged` fires automatically whenever the server + // sends `notifications/tools/list_changed`. The SDK re-fetches the full + // tool list and passes it to `onChanged`. + // + // By default the client debounces list-changed notifications by 300 ms + // (so rapid back-to-back registrations coalesce into a single refresh). + // Here we set debounceMs: 0 so the callback fires immediately, which + // keeps the example output predictable. + let resolveChanged: () => void; + const changedOnce = new Promise(r => { + resolveChanged = r; + }); + + const client = new Client( + { name: 'weather-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (_error, tools) => { + if (_error) { + console.error('[client] error refreshing tools:', _error.message); + return; + } + const names = tools?.map(t => t.name) ?? []; + console.log('[client] tool list changed — updated tools:', names); + resolveChanged(); + } + } + } + } + ); + + // --- Connect via in-memory transport -------------------------------- + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const { tools: initialTools } = await client.listTools(); + console.log( + '[client] connected. initial tools:', + initialTools.map(t => t.name) + ); + + // --- Dynamic tool registration -------------------------------------- + // Registering a tool AFTER connect fires sendToolListChanged() inside + // McpServer, which pushes `notifications/tools/list_changed` to the + // client. The `onChanged` callback above runs automatically. + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log('[server] registering new tool: get_forecast'); + server.registerTool( + 'get_forecast', + { + description: 'Get a 5-day weather forecast for a city', + inputSchema: z.object({ city: z.string(), days: z.number().int().min(1).max(5).default(3) }) + }, + async ({ city, days }) => ({ + content: [{ type: 'text', text: `${days}-day forecast for ${city}: sunny throughout` }] + }) + ); + + // Wait for the onChanged callback to confirm the notification arrived + // before closing the connection. + await changedOnce; + + await client.close(); +} + +try { + await main(); +} catch (error) { + console.error('Error running tool list changed example:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/tsconfig.json b/examples/client/tsconfig.json index 5c1f7fc764..3ba64e0f2f 100644 --- a/examples/client/tsconfig.json +++ b/examples/client/tsconfig.json @@ -16,7 +16,10 @@ ], "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] + "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4fc799822..f685f21b54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,7 +251,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-plugin-n: specifier: catalog:devTools version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) @@ -292,6 +292,12 @@ importers: '@modelcontextprotocol/client': specifier: workspace:^ version: link:../../packages/client + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server ajv: specifier: catalog:runtimeShared version: 8.18.0 @@ -7161,15 +7167,14 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) @@ -7183,7 +7188,7 @@ snapshots: eslint: 9.39.4 eslint-compat-utils: 0.5.1(eslint@9.39.4) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7194,7 +7199,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7205,8 +7210,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack