diff --git a/.changeset/config.json b/.changeset/config.json index eb43bdc7f..766607786 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,6 +9,7 @@ "updateInternalDependencies": "patch", "ignore": [ "@modelcontextprotocol/examples-client", + "@modelcontextprotocol/examples-client-multi-server", "@modelcontextprotocol/examples-client-quickstart", "@modelcontextprotocol/examples-server", "@modelcontextprotocol/examples-server-quickstart", diff --git a/.prettierignore b/.prettierignore index 1aecab1f5..f6b406996 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,6 +12,7 @@ pnpm-lock.yaml # Ignore generated files src/spec.types.ts -# Quickstart examples uses 2-space indent to match ecosystem conventions +# Standalone examples use 2-space indent to match ecosystem conventions +examples/client-multi-server/ examples/client-quickstart/ examples/server-quickstart/ diff --git a/examples/client-multi-server/.gitignore b/examples/client-multi-server/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/examples/client-multi-server/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/examples/client-multi-server/README.md b/examples/client-multi-server/README.md new file mode 100644 index 000000000..9a0d794d6 --- /dev/null +++ b/examples/client-multi-server/README.md @@ -0,0 +1,82 @@ +# Multi-Server MCP Client Example + +A CLI chatbot that connects to multiple MCP servers simultaneously, aggregates their tools, and routes tool calls to the correct server. This is the TypeScript equivalent of the [Python SDK's simple-chatbot example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-chatbot). + +## Prerequisites + +- Node.js 20+ +- An [Anthropic API key](https://console.anthropic.com/) + +## Quick Start + +```bash +# Install dependencies (from repo root) +pnpm install + +# Set your API key +export ANTHROPIC_API_KEY=your-api-key-here + +# Run with the default servers.json config +cd examples/client-multi-server +npx tsx src/index.ts +``` + +## Configuration + +Servers are configured via a JSON file (default: `servers.json` in the working directory). Pass a custom path as the first argument: + +```bash +npx tsx src/index.ts /path/to/my-servers.json +``` + +The config file uses the same format as Claude Desktop and other MCP clients: + +```json +{ + "mcpServers": { + "everything": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everything"] + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + } + } +} +``` + +Each entry under `mcpServers` defines a server to connect to via stdio: + +- `command`: the executable to run +- `args`: command-line arguments (optional) +- `env`: additional environment variables (optional, merged with the current environment) + +## How It Works + +1. Reads the server config and connects to each MCP server in sequence +2. Discovers tools from every connected server and builds a unified tool list +3. Maintains a mapping from each tool name to its originating server +4. Sends the full tool list to Claude with each request +5. When Claude calls a tool, routes the call to the correct server +6. Supports multi-step tool use (agentic loop) where Claude can chain multiple tool calls + +## Usage + +``` +$ npx tsx src/index.ts +Connecting to server: everything... + Connected to everything with tools: echo, add, ... + +Total tools available: 12 + +Multi-Server MCP Client Started! +Type your queries or "quit" to exit. + +Query: What tools do you have access to? + +I have access to 12 tools from the "everything" server... + +Query: quit +Disconnecting from everything... +``` diff --git a/examples/client-multi-server/package.json b/examples/client-multi-server/package.json new file mode 100644 index 000000000..6b044cb6d --- /dev/null +++ b/examples/client-multi-server/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modelcontextprotocol/examples-client-multi-server", + "private": true, + "version": "2.0.0-alpha.0", + "type": "module", + "bin": { + "mcp-multi-server-client": "./build/index.js" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.74.0", + "@modelcontextprotocol/client": "workspace:^" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "catalog:devTools" + } +} diff --git a/examples/client-multi-server/servers.json b/examples/client-multi-server/servers.json new file mode 100644 index 000000000..b538bf665 --- /dev/null +++ b/examples/client-multi-server/servers.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "everything": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-everything"] + } + } +} diff --git a/examples/client-multi-server/src/index.ts b/examples/client-multi-server/src/index.ts new file mode 100644 index 000000000..64dc39518 --- /dev/null +++ b/examples/client-multi-server/src/index.ts @@ -0,0 +1,218 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import readline from 'readline/promises'; + +import Anthropic from '@anthropic-ai/sdk'; +import { Client, StdioClientTransport } from '@modelcontextprotocol/client'; + +const ANTHROPIC_MODEL = 'claude-sonnet-4-5'; + +interface ServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +interface ServersConfig { + mcpServers: Record; +} + +class MultiServerClient { + private servers: Map = new Map(); + private toolToServer: Map = new Map(); + private _anthropic: Anthropic | null = null; + private tools: Anthropic.Tool[] = []; + + private get anthropic(): Anthropic { + return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + } + + async connectToServers(configPath: string) { + const raw = readFileSync(resolve(configPath), 'utf-8'); + const config: ServersConfig = JSON.parse(raw); + + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + console.log(`Connecting to server: ${name}...`); + try { + const transport = new StdioClientTransport({ + command: serverConfig.command, + args: serverConfig.args, + env: serverConfig.env + ? { ...process.env as Record, ...serverConfig.env } + : undefined, + }); + const client = new Client({ name: `multi-server-client-${name}`, version: '1.0.0' }); + await client.connect(transport); + this.servers.set(name, client); + + // Discover tools from this server + const toolsResult = await client.listTools(); + for (const tool of toolsResult.tools) { + const prefixedName = `${name}__${tool.name}`; + if (this.toolToServer.has(prefixedName)) { + console.warn( + ` Warning: tool "${tool.name}" from server "${name}" collides with an existing tool.` + ); + } + this.toolToServer.set(prefixedName, { serverName: name, originalName: tool.name }); + this.tools.push({ + name: prefixedName, + description: `[${name}] ${tool.description ?? ''}`, + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + }); + } + console.log( + ` Connected to ${name} with tools: ${toolsResult.tools.map((t) => t.name).join(', ')}` + ); + } catch (e) { + console.error(` Failed to connect to ${name}:`, e); + throw e; + } + } + + console.log(`\nTotal tools available: ${this.tools.length}`); + } + + async processQuery(query: string) { + const messages: Anthropic.MessageParam[] = [{ role: 'user', content: query }]; + + // Agentic loop: keep processing until the model stops issuing tool calls + let response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + tools: this.tools, + }); + + const finalText: string[] = []; + + while (response.stop_reason === 'tool_use') { + const assistantContent = response.content; + messages.push({ role: 'assistant', content: assistantContent }); + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + + for (const block of assistantContent) { + if (block.type === 'text') { + finalText.push(block.text); + } else if (block.type === 'tool_use') { + const mapping = this.toolToServer.get(block.name); + if (!mapping) { + toolResults.push({ + type: 'tool_result', + tool_use_id: block.id, + content: `Error: unknown tool "${block.name}"`, + is_error: true, + }); + continue; + } + + const { serverName, originalName } = mapping; + const client = this.servers.get(serverName)!; + console.log(` [Calling ${originalName} on server "${serverName}"]`); + + try { + const result = await client.callTool({ + name: originalName, + arguments: block.input as Record | undefined, + }); + + const resultText = result.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); + + toolResults.push({ + type: 'tool_result', + tool_use_id: block.id, + content: resultText, + }); + } catch (e) { + toolResults.push({ + type: 'tool_result', + tool_use_id: block.id, + content: `Error executing tool: ${e}`, + is_error: true, + }); + } + } + } + + messages.push({ role: 'user', content: toolResults }); + + response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + tools: this.tools, + }); + } + + // Collect any remaining text from the final response + for (const block of response.content) { + if (block.type === 'text') { + finalText.push(block.text); + } + } + + return finalText.join('\n'); + } + + async chatLoop() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log('\nMulti-Server MCP Client Started!'); + console.log('Type your queries or "quit" to exit.\n'); + + while (true) { + const message = await rl.question('Query: '); + if (message.toLowerCase() === 'quit') { + break; + } + const response = await this.processQuery(message); + console.log('\n' + response + '\n'); + } + } finally { + rl.close(); + } + } + + async cleanup() { + for (const [name, client] of this.servers) { + console.log(`Disconnecting from ${name}...`); + await client.close(); + } + } +} + +async function main() { + const configPath = process.argv[2] ?? 'servers.json'; + + const mcpClient = new MultiServerClient(); + try { + await mcpClient.connectToServers(configPath); + + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + console.log( + '\nNo ANTHROPIC_API_KEY found. To chat with these tools via Claude, set your API key:' + + '\n export ANTHROPIC_API_KEY=your-api-key-here' + ); + return; + } + + await mcpClient.chatLoop(); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await mcpClient.cleanup(); + process.exit(0); + } +} + +main(); diff --git a/examples/client-multi-server/tsconfig.json b/examples/client-multi-server/tsconfig.json new file mode 100644 index 000000000..9c229e5fe --- /dev/null +++ b/examples/client-multi-server/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" + ] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899586750..cf8b97371 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,22 @@ importers: specifier: catalog:devTools version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + examples/client-multi-server: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.74.0 + version: 0.74.0(zod@4.3.5) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.4 + typescript: + specifier: catalog:devTools + version: 5.9.3 + examples/client-quickstart: dependencies: '@anthropic-ai/sdk':