diff --git a/README.md b/README.md index 92f56786f..d39e66ca1 100644 --- a/README.md +++ b/README.md @@ -784,6 +784,39 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Code Mode Wrapper (experimental) + +You can also run a lightweight “code-mode” wrapper that proxies multiple MCP servers through a single stdio endpoint and only loads tool definitions on demand. Create a config file (for example `code-config.mcp-servers.json`) inside your local checkout of this SDK: + +```json +{ + "downstreams": [ + { + "id": "playwright", + "description": "Browser automation via Playwright", + "command": "node", + "args": ["/Users/you/Desktop/playwright-mcp/cli.js", "--headless", "--browser=chromium"] + } + ] +} +``` + +Then launch the wrapper: + +```bash +pnpm code-mode -- --config ./code-config.mcp-servers.json +``` + +Point your MCP client (Cursor, VS Code, etc.) at the wrapper command instead of individual servers. The wrapper publishes four meta-tools: + +1. `list_mcp_servers` — enumerate the configured downstream servers (IDs + descriptions). +2. `list_tool_names` — requires a `serverId` and returns just the tool names/descriptions for that server. +3. `get_tool_implementation` — loads the full schema and a generated TypeScript stub for a specific tool. +4. `call_tool` — proxies the downstream tool call unchanged. + +This mirrors the progressive disclosure workflow described in Anthropic’s [Code execution with MCP: Building more efficient agents](https://www.anthropic.com/engineering/code-execution-with-mcp): models explore servers first, then drill into tools only when needed. You can keep +many MCP servers configured without loading all of their schemas into the prompt, and the LLM pays the context cost only for the server/tool it decides to use. + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. diff --git a/package.json b/package.json index 01d0f0474..6f41867d7 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,8 @@ "test:watch": "vitest", "start": "npm run server", "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "client": "tsx scripts/cli.ts client", + "code-mode": "tsx scripts/code-mode.ts" }, "dependencies": { "ajv": "^8.17.1", diff --git a/scripts/code-mode.ts b/scripts/code-mode.ts new file mode 100644 index 000000000..fef7fbf0a --- /dev/null +++ b/scripts/code-mode.ts @@ -0,0 +1,119 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { CodeModeWrapper } from '../src/code-mode/index.js'; +import type { DownstreamConfig } from '../src/code-mode/downstream.js'; +import { StdioServerTransport } from '../src/server/stdio.js'; +import type { Implementation } from '../src/types.js'; + +type CodeModeConfig = { + server?: Implementation; + downstreams: DownstreamConfig[]; +}; + +function parseArgs(argv: string[]): string | undefined { + for (let i = 0; i < argv.length; i += 1) { + const current = argv[i]; + if (current === '--config' || current === '-c') { + return argv[i + 1]; + } + + if (current?.startsWith('--config=')) { + return current.split('=')[1]; + } + } + + return undefined; +} + +function assertDownstreamConfig(value: unknown): asserts value is DownstreamConfig[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error('Config must include a non-empty "downstreams" array.'); + } + + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + throw new Error('Invalid downstream entry.'); + } + + const { id, command, args, env, cwd } = entry as DownstreamConfig; + if (!id || typeof id !== 'string') { + throw new Error('Each downstream requires a string "id".'); + } + + if (!command || typeof command !== 'string') { + throw new Error(`Downstream "${id}" is missing a "command".`); + } + + if (args && !Array.isArray(args)) { + throw new Error(`Downstream "${id}" has invalid "args"; expected an array.`); + } + + if (env && typeof env !== 'object') { + throw new Error(`Downstream "${id}" has invalid "env"; expected an object.`); + } + + if (cwd && typeof cwd !== 'string') { + throw new Error(`Downstream "${id}" has invalid "cwd"; expected a string.`); + } + } +} + +async function readConfig(configPath: string): Promise { + const resolved = path.resolve(process.cwd(), configPath); + const raw = await readFile(resolved, 'utf8'); + const parsed = JSON.parse(raw); + + assertDownstreamConfig(parsed.downstreams); + + return { + server: parsed.server, + downstreams: parsed.downstreams + }; +} + +function printUsage(): void { + console.log('Usage: npm run code-mode -- --config ./code-mode.config.json'); +} + +async function main(): Promise { + const configPath = parseArgs(process.argv.slice(2)); + if (!configPath) { + printUsage(); + process.exitCode = 1; + return; + } + + const config = await readConfig(configPath); + const wrapper = new CodeModeWrapper({ + serverInfo: config.server, + downstreams: config.downstreams + }); + + const transport = new StdioServerTransport(); + await wrapper.connect(transport); + console.log('Code Mode wrapper is running on stdio.'); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + await wrapper.close(); + }; + + process.on('SIGINT', () => { + void shutdown().finally(() => process.exit(0)); + }); + + process.on('SIGTERM', () => { + void shutdown().finally(() => process.exit(0)); + }); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/src/code-mode/downstream.ts b/src/code-mode/downstream.ts new file mode 100644 index 000000000..595f94ae2 --- /dev/null +++ b/src/code-mode/downstream.ts @@ -0,0 +1,112 @@ +import { Client } from '../client/index.js'; +import { StdioClientTransport, getDefaultEnvironment } from '../client/stdio.js'; +import { CallToolResultSchema, ToolListChangedNotificationSchema } from '../types.js'; +import type { CallToolResult, Implementation, Tool } from '../types.js'; + +export type DownstreamConfig = { + id: string; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + description?: string; +}; + +export interface DownstreamHandle { + readonly config: DownstreamConfig; + listTools(): Promise; + getTool(toolName: string): Promise; + callTool(toolName: string, args?: Record): Promise; + close(): Promise; +} + +export class DefaultDownstreamHandle implements DownstreamHandle { + private readonly _clientInfo: Implementation; + private _client?: Client; + private _toolsCache?: Tool[]; + private _listPromise?: Promise; + + constructor( + private readonly _config: DownstreamConfig, + clientInfo: Implementation + ) { + this._clientInfo = clientInfo; + } + + get config(): DownstreamConfig { + return this._config; + } + + async listTools(): Promise { + if (this._toolsCache) { + return this._toolsCache; + } + + if (this._listPromise) { + return this._listPromise; + } + + this._listPromise = this._ensureClient() + .then(async client => { + const result = await client.listTools(); + this._toolsCache = result.tools; + return this._toolsCache; + }) + .finally(() => { + this._listPromise = undefined; + }); + + return this._listPromise; + } + + async getTool(toolName: string): Promise { + const tools = await this.listTools(); + return tools.find(tool => tool.name === toolName); + } + + async callTool(toolName: string, args?: Record): Promise { + const client = await this._ensureClient(); + return client.callTool( + { + name: toolName, + arguments: args + }, + CallToolResultSchema + ) as Promise; + } + + async close(): Promise { + await this._client?.close(); + this._client = undefined; + this._toolsCache = undefined; + } + + private async _ensureClient(): Promise { + if (this._client) { + return this._client; + } + + const transport = new StdioClientTransport({ + command: this._config.command, + args: this._config.args, + env: { + ...getDefaultEnvironment(), + ...this._config.env + }, + cwd: this._config.cwd + }); + + const client = new Client({ + name: `code-mode:${this._config.id}`, + version: '0.1.0' + }); + + await client.connect(transport); + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + this._toolsCache = undefined; + }); + + this._client = client; + return client; + } +} diff --git a/src/code-mode/index.ts b/src/code-mode/index.ts new file mode 100644 index 000000000..26c3b185d --- /dev/null +++ b/src/code-mode/index.ts @@ -0,0 +1,13 @@ +export { CodeModeWrapper, type CodeModeWrapperOptions } from './wrapper.js'; +export type { DownstreamConfig, DownstreamHandle } from './downstream.js'; +export { + ListToolNamesInputSchema, + ListToolNamesOutputSchema, + type ListToolNamesResult, + type ToolSummary, + GetToolImplementationInputSchema, + GetToolImplementationOutputSchema, + type GetToolImplementationResult, + CallToolInputSchema, + type CallToolInput +} from './metaTools.js'; diff --git a/src/code-mode/metaTools.ts b/src/code-mode/metaTools.ts new file mode 100644 index 000000000..9da6db801 --- /dev/null +++ b/src/code-mode/metaTools.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +export const ServerSummarySchema = z.object({ + serverId: z.string(), + description: z.string().optional() +}); + +export const ListMcpServersOutputSchema = z.object({ + servers: z.array(ServerSummarySchema) +}); + +export type ListMcpServersResult = z.infer; + +export const ToolSummarySchema = z.object({ + serverId: z.string(), + toolName: z.string(), + description: z.string().optional() +}); + +export const ListToolNamesInputSchema = z.object({ + serverId: z.string() +}); + +export const ListToolNamesOutputSchema = z.object({ + tools: z.array(ToolSummarySchema) +}); + +export type ToolSummary = z.infer; +export type ListToolNamesResult = z.infer; + +export const GetToolImplementationInputSchema = z.object({ + serverId: z.string(), + toolName: z.string() +}); + +export const GetToolImplementationOutputSchema = z.object({ + serverId: z.string(), + toolName: z.string(), + signature: z.string(), + description: z.string().optional(), + annotations: z.record(z.unknown()).optional(), + inputSchema: z.record(z.unknown()).optional(), + outputSchema: z.record(z.unknown()).optional() +}); + +export type GetToolImplementationResult = z.infer; + +export const CallToolInputSchema = z.object({ + serverId: z.string(), + toolName: z.string(), + arguments: z.record(z.unknown()).optional() +}); + +export type CallToolInput = z.infer; diff --git a/src/code-mode/wrapper.test.ts b/src/code-mode/wrapper.test.ts new file mode 100644 index 000000000..8690641af --- /dev/null +++ b/src/code-mode/wrapper.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CallToolResult, Tool } from '../types.js'; +import type { DownstreamConfig, DownstreamHandle } from './downstream.js'; +import { CodeModeWrapper } from './wrapper.js'; + +class FakeHandle implements DownstreamHandle { + public listTools: () => Promise; + public getTool: (name: string) => Promise; + public callTool: (toolName: string, args?: Record) => Promise; + public close: () => Promise; + + constructor( + public readonly config: DownstreamConfig, + tools: Tool[], + callResult: CallToolResult + ) { + this.listTools = vi.fn(async () => tools); + this.getTool = vi.fn(async (name: string) => tools.find(tool => tool.name === name)); + this.callTool = vi.fn(async () => callResult); + this.close = vi.fn(async () => undefined); + } +} + +const SAMPLE_TOOL: Tool = { + name: 'demo-tool', + description: 'Demo tool', + inputSchema: { + type: 'object', + properties: { + message: { + type: 'string' + } + } + }, + annotations: undefined +}; + +const SAMPLE_RESULT: CallToolResult = { + content: [ + { + type: 'text', + text: 'ok' + } + ], + isError: false +}; + +describe('CodeModeWrapper', () => { + const downstreamConfig: DownstreamConfig = { + id: 'alpha', + description: 'Demo downstream', + command: 'node', + args: ['noop.js'] + }; + + it('lists tool summaries from downstream servers', async () => { + const handle = new FakeHandle(downstreamConfig, [SAMPLE_TOOL], SAMPLE_RESULT); + const wrapper = new CodeModeWrapper({ + downstreams: [downstreamConfig], + downstreamFactory: () => handle + }); + + const summaries = await wrapper.listToolSummaries('alpha'); + expect(summaries).toEqual([ + expect.objectContaining({ + serverId: 'alpha', + toolName: 'demo-tool', + description: 'Demo tool' + }) + ]); + }); + + it('returns implementation summaries with generated signatures', async () => { + const handle = new FakeHandle(downstreamConfig, [SAMPLE_TOOL], SAMPLE_RESULT); + const wrapper = new CodeModeWrapper({ + downstreams: [downstreamConfig], + downstreamFactory: () => handle + }); + + const implementation = await wrapper.getToolImplementationSummary('alpha', 'demo-tool'); + expect(implementation.signature).toContain('export async function demo_tool'); + }); + + it('proxies tool calls to the downstream handle', async () => { + const handle = new FakeHandle(downstreamConfig, [SAMPLE_TOOL], SAMPLE_RESULT); + const wrapper = new CodeModeWrapper({ + downstreams: [downstreamConfig], + downstreamFactory: () => handle + }); + + const result = await wrapper.callDownstreamTool('alpha', 'demo-tool', { message: 'hello' }); + expect(result).toEqual(SAMPLE_RESULT); + expect(handle.callTool).toHaveBeenCalledWith('demo-tool', { message: 'hello' }); + }); + + it('lists configured MCP servers', async () => { + const handle = new FakeHandle(downstreamConfig, [SAMPLE_TOOL], SAMPLE_RESULT); + const wrapper = new CodeModeWrapper({ + downstreams: [downstreamConfig], + downstreamFactory: () => handle + }); + + const servers = await wrapper.listMcpServers(); + expect(servers).toEqual([ + expect.objectContaining({ + serverId: 'alpha', + description: 'Demo downstream' + }) + ]); + }); +}); diff --git a/src/code-mode/wrapper.ts b/src/code-mode/wrapper.ts new file mode 100644 index 000000000..aeeabb2ad --- /dev/null +++ b/src/code-mode/wrapper.ts @@ -0,0 +1,267 @@ +import { McpServer } from '../server/mcp.js'; +import type { Transport } from '../shared/transport.js'; +import { ErrorCode, McpError, type CallToolResult, type Implementation, type Tool } from '../types.js'; +import { + CallToolInputSchema, + GetToolImplementationInputSchema, + GetToolImplementationOutputSchema, + ListMcpServersOutputSchema, + type ListMcpServersResult, + type GetToolImplementationResult, + ListToolNamesInputSchema, + ListToolNamesOutputSchema, + type ListToolNamesResult, + type ToolSummary +} from './metaTools.js'; +import { DefaultDownstreamHandle, type DownstreamConfig, type DownstreamHandle } from './downstream.js'; + +type DownstreamFactory = (config: DownstreamConfig, clientInfo: Implementation) => DownstreamHandle; + +export type CodeModeWrapperOptions = { + /** + * Downstream MCP servers that should be exposed through code-mode. + */ + downstreams: DownstreamConfig[]; + + /** + * Info advertised for the wrapper server itself. + */ + serverInfo?: Implementation; + + /** + * Info advertised when the wrapper connects to downstream servers. + */ + downstreamClientInfo?: Implementation; + + /** + * Used for testing to override how downstream handles are created. + */ + downstreamFactory?: DownstreamFactory; +}; + +const DEFAULT_SERVER_INFO: Implementation = { + name: 'code-mode-wrapper', + version: '0.1.0' +}; + +const DEFAULT_DOWNSTREAM_CLIENT_INFO: Implementation = { + name: 'code-mode-wrapper-client', + version: '0.1.0' +}; + +export class CodeModeWrapper { + public readonly server: McpServer; + + private readonly _handles: Map; + private readonly _downstreamFactory: DownstreamFactory; + private readonly _downstreamClientInfo: Implementation; + + constructor(private readonly _options: CodeModeWrapperOptions) { + if (!_options.downstreams.length) { + throw new Error('At least one downstream server must be configured.'); + } + + const serverInfo = _options.serverInfo ?? DEFAULT_SERVER_INFO; + this.server = new McpServer(serverInfo, { + capabilities: { + tools: {} + } + }); + + this._downstreamClientInfo = _options.downstreamClientInfo ?? DEFAULT_DOWNSTREAM_CLIENT_INFO; + this._downstreamFactory = + _options.downstreamFactory ?? + ((config: DownstreamConfig, clientInfo: Implementation) => new DefaultDownstreamHandle(config, clientInfo)); + + this._handles = new Map( + _options.downstreams.map(config => [config.id, this._downstreamFactory(config, this._downstreamClientInfo)]) + ); + + this.registerMetaTools(); + } + + async connect(transport: Transport): Promise { + await this.server.connect(transport); + } + + async close(): Promise { + await Promise.all([...this._handles.values()].map(handle => handle.close())); + await this.server.close(); + } + + /** + * Internal helper exposed for easier testing. + */ + async listToolSummaries(serverId?: string): Promise { + if (!serverId) { + throw new McpError(ErrorCode.InvalidParams, 'serverId is required'); + } + + const handle = this.getHandle(serverId); + const tools = await handle.listTools(); + return tools.map(tool => ({ + serverId: handle.config.id, + toolName: tool.name, + description: tool.description + })); + } + + async listMcpServers(): Promise { + return this._options.downstreams.map(config => ({ + serverId: config.id, + description: config.description + })); + } + + /** + * Internal helper exposed for easier testing. + */ + async getToolImplementationSummary(serverId: string, toolName: string): Promise { + const handle = this.getHandle(serverId); + const tool = await handle.getTool(toolName); + if (!tool) { + throw new McpError(ErrorCode.InvalidParams, `Tool ${toolName} not found on server ${serverId}`); + } + + return { + serverId, + toolName, + description: tool.description, + annotations: tool.annotations, + inputSchema: tool.inputSchema as Record | undefined, + outputSchema: tool.outputSchema as Record | undefined, + signature: generateToolSignature(tool) + }; + } + + /** + * Internal helper exposed for easier testing. + */ + async callDownstreamTool(serverId: string, toolName: string, args?: Record): Promise { + const handle = this.getHandle(serverId); + return handle.callTool(toolName, args); + } + + private registerMetaTools() { + this.server.registerTool( + 'list_mcp_servers', + { + description: 'List the available downstream MCP servers.', + outputSchema: ListMcpServersOutputSchema + }, + async () => { + const servers = await this.listMcpServers(); + const structuredContent: ListMcpServersResult = { servers }; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(structuredContent, null, 2) + } + ], + structuredContent + }; + } + ); + + this.server.registerTool( + 'list_tool_names', + { + description: 'List tools exposed by a specific downstream MCP server.', + inputSchema: ListToolNamesInputSchema, + outputSchema: ListToolNamesOutputSchema + }, + async ({ serverId }) => { + const tools = await this.listToolSummaries(serverId); + const structuredContent: ListToolNamesResult = { tools }; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(structuredContent, null, 2) + } + ], + structuredContent + }; + } + ); + + this.server.registerTool( + 'get_tool_implementation', + { + description: 'Inspect an individual downstream tool and generate a TypeScript stub.', + inputSchema: GetToolImplementationInputSchema, + outputSchema: GetToolImplementationOutputSchema + }, + async ({ serverId, toolName }) => { + const structuredContent = await this.getToolImplementationSummary(serverId, toolName); + return { + content: [ + { + type: 'text' as const, + text: structuredContent.signature + } + ], + structuredContent + }; + } + ); + + this.server.registerTool( + 'call_tool', + { + description: 'Invoke a downstream tool directly through the wrapper.', + inputSchema: CallToolInputSchema + }, + async ({ serverId, toolName, arguments: args }) => { + return this.callDownstreamTool(serverId, toolName, args); + } + ); + } + + private getHandle(serverId: string): DownstreamHandle { + const handle = this._handles.get(serverId); + if (!handle) { + throw new McpError(ErrorCode.InvalidParams, `Unknown server: ${serverId}`); + } + + return handle; + } +} + +function generateToolSignature(tool: Tool): string { + const lines = [ + `import { Client } from '@modelcontextprotocol/sdk/client';`, + '', + tool.description ? `// ${tool.description}` : undefined, + formatSchemaComment('inputSchema', tool.inputSchema), + formatSchemaComment('outputSchema', tool.outputSchema), + `export async function ${sanitizeIdentifier(tool.name)}(client: Client, args?: Record) {`, + ` return client.callTool({ name: '${tool.name}', arguments: args });`, + `}` + ].filter(Boolean) as string[]; + + return lines.join('\n'); +} + +function formatSchemaComment(label: string, schema: Record | undefined): string { + if (!schema) { + return `// ${label}: none`; + } + + const serialized = JSON.stringify(schema, null, 2) + .split('\n') + .map(line => `// ${line}`) + .join('\n'); + + return `// ${label}:\n${serialized}`; +} + +function sanitizeIdentifier(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_$]/g, '_'); + if (!cleaned.length) { + return 'tool'; + } + + return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned; +}