Skip to content

Commit 9d8a259

Browse files
committed
PoC: Simulating Code Execution via MCP Meta-Tools
1 parent 5bcf53f commit 9d8a259

File tree

8 files changed

+711
-1
lines changed

8 files changed

+711
-1
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,39 @@ const transport = new StdioServerTransport();
784784
await server.connect(transport);
785785
```
786786

787+
### Code Mode Wrapper (experimental)
788+
789+
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:
790+
791+
```json
792+
{
793+
"downstreams": [
794+
{
795+
"id": "playwright",
796+
"description": "Browser automation via Playwright",
797+
"command": "node",
798+
"args": ["/Users/you/Desktop/playwright-mcp/cli.js", "--headless", "--browser=chromium"]
799+
}
800+
]
801+
}
802+
```
803+
804+
Then launch the wrapper:
805+
806+
```bash
807+
pnpm code-mode -- --config ./code-config.mcp-servers.json
808+
```
809+
810+
Point your MCP client (Cursor, VS Code, etc.) at the wrapper command instead of individual servers. The wrapper publishes four meta-tools:
811+
812+
1. `list_mcp_servers` — enumerate the configured downstream servers (IDs + descriptions).
813+
2. `list_tool_names` — requires a `serverId` and returns just the tool names/descriptions for that server.
814+
3. `get_tool_implementation` — loads the full schema and a generated TypeScript stub for a specific tool.
815+
4. `call_tool` — proxies the downstream tool call unchanged.
816+
817+
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
818+
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.
819+
787820
### Testing and Debugging
788821

789822
To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@
7575
"test:watch": "vitest",
7676
"start": "npm run server",
7777
"server": "tsx watch --clear-screen=false scripts/cli.ts server",
78-
"client": "tsx scripts/cli.ts client"
78+
"client": "tsx scripts/cli.ts client",
79+
"code-mode": "tsx scripts/code-mode.ts"
7980
},
8081
"dependencies": {
8182
"ajv": "^8.17.1",

scripts/code-mode.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { readFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import process from 'node:process';
4+
import { CodeModeWrapper } from '../src/code-mode/index.js';
5+
import type { DownstreamConfig } from '../src/code-mode/downstream.js';
6+
import { StdioServerTransport } from '../src/server/stdio.js';
7+
import type { Implementation } from '../src/types.js';
8+
9+
type CodeModeConfig = {
10+
server?: Implementation;
11+
downstreams: DownstreamConfig[];
12+
};
13+
14+
function parseArgs(argv: string[]): string | undefined {
15+
for (let i = 0; i < argv.length; i += 1) {
16+
const current = argv[i];
17+
if (current === '--config' || current === '-c') {
18+
return argv[i + 1];
19+
}
20+
21+
if (current?.startsWith('--config=')) {
22+
return current.split('=')[1];
23+
}
24+
}
25+
26+
return undefined;
27+
}
28+
29+
function assertDownstreamConfig(value: unknown): asserts value is DownstreamConfig[] {
30+
if (!Array.isArray(value) || value.length === 0) {
31+
throw new Error('Config must include a non-empty "downstreams" array.');
32+
}
33+
34+
for (const entry of value) {
35+
if (!entry || typeof entry !== 'object') {
36+
throw new Error('Invalid downstream entry.');
37+
}
38+
39+
const { id, command, args, env, cwd } = entry as DownstreamConfig;
40+
if (!id || typeof id !== 'string') {
41+
throw new Error('Each downstream requires a string "id".');
42+
}
43+
44+
if (!command || typeof command !== 'string') {
45+
throw new Error(`Downstream "${id}" is missing a "command".`);
46+
}
47+
48+
if (args && !Array.isArray(args)) {
49+
throw new Error(`Downstream "${id}" has invalid "args"; expected an array.`);
50+
}
51+
52+
if (env && typeof env !== 'object') {
53+
throw new Error(`Downstream "${id}" has invalid "env"; expected an object.`);
54+
}
55+
56+
if (cwd && typeof cwd !== 'string') {
57+
throw new Error(`Downstream "${id}" has invalid "cwd"; expected a string.`);
58+
}
59+
}
60+
}
61+
62+
async function readConfig(configPath: string): Promise<CodeModeConfig> {
63+
const resolved = path.resolve(process.cwd(), configPath);
64+
const raw = await readFile(resolved, 'utf8');
65+
const parsed = JSON.parse(raw);
66+
67+
assertDownstreamConfig(parsed.downstreams);
68+
69+
return {
70+
server: parsed.server,
71+
downstreams: parsed.downstreams
72+
};
73+
}
74+
75+
function printUsage(): void {
76+
console.log('Usage: npm run code-mode -- --config ./code-mode.config.json');
77+
}
78+
79+
async function main(): Promise<void> {
80+
const configPath = parseArgs(process.argv.slice(2));
81+
if (!configPath) {
82+
printUsage();
83+
process.exitCode = 1;
84+
return;
85+
}
86+
87+
const config = await readConfig(configPath);
88+
const wrapper = new CodeModeWrapper({
89+
serverInfo: config.server,
90+
downstreams: config.downstreams
91+
});
92+
93+
const transport = new StdioServerTransport();
94+
await wrapper.connect(transport);
95+
console.log('Code Mode wrapper is running on stdio.');
96+
97+
let shuttingDown = false;
98+
const shutdown = async () => {
99+
if (shuttingDown) {
100+
return;
101+
}
102+
103+
shuttingDown = true;
104+
await wrapper.close();
105+
};
106+
107+
process.on('SIGINT', () => {
108+
void shutdown().finally(() => process.exit(0));
109+
});
110+
111+
process.on('SIGTERM', () => {
112+
void shutdown().finally(() => process.exit(0));
113+
});
114+
}
115+
116+
main().catch(error => {
117+
console.error(error);
118+
process.exit(1);
119+
});

src/code-mode/downstream.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Client } from '../client/index.js';
2+
import { StdioClientTransport, getDefaultEnvironment } from '../client/stdio.js';
3+
import { CallToolResultSchema, ToolListChangedNotificationSchema } from '../types.js';
4+
import type { CallToolResult, Implementation, Tool } from '../types.js';
5+
6+
export type DownstreamConfig = {
7+
id: string;
8+
command: string;
9+
args?: string[];
10+
env?: Record<string, string>;
11+
cwd?: string;
12+
description?: string;
13+
};
14+
15+
export interface DownstreamHandle {
16+
readonly config: DownstreamConfig;
17+
listTools(): Promise<Tool[]>;
18+
getTool(toolName: string): Promise<Tool | undefined>;
19+
callTool(toolName: string, args?: Record<string, unknown>): Promise<CallToolResult>;
20+
close(): Promise<void>;
21+
}
22+
23+
export class DefaultDownstreamHandle implements DownstreamHandle {
24+
private readonly _clientInfo: Implementation;
25+
private _client?: Client;
26+
private _toolsCache?: Tool[];
27+
private _listPromise?: Promise<Tool[]>;
28+
29+
constructor(
30+
private readonly _config: DownstreamConfig,
31+
clientInfo: Implementation
32+
) {
33+
this._clientInfo = clientInfo;
34+
}
35+
36+
get config(): DownstreamConfig {
37+
return this._config;
38+
}
39+
40+
async listTools(): Promise<Tool[]> {
41+
if (this._toolsCache) {
42+
return this._toolsCache;
43+
}
44+
45+
if (this._listPromise) {
46+
return this._listPromise;
47+
}
48+
49+
this._listPromise = this._ensureClient()
50+
.then(async client => {
51+
const result = await client.listTools();
52+
this._toolsCache = result.tools;
53+
return this._toolsCache;
54+
})
55+
.finally(() => {
56+
this._listPromise = undefined;
57+
});
58+
59+
return this._listPromise;
60+
}
61+
62+
async getTool(toolName: string): Promise<Tool | undefined> {
63+
const tools = await this.listTools();
64+
return tools.find(tool => tool.name === toolName);
65+
}
66+
67+
async callTool(toolName: string, args?: Record<string, unknown>): Promise<CallToolResult> {
68+
const client = await this._ensureClient();
69+
return client.callTool(
70+
{
71+
name: toolName,
72+
arguments: args
73+
},
74+
CallToolResultSchema
75+
) as Promise<CallToolResult>;
76+
}
77+
78+
async close(): Promise<void> {
79+
await this._client?.close();
80+
this._client = undefined;
81+
this._toolsCache = undefined;
82+
}
83+
84+
private async _ensureClient(): Promise<Client> {
85+
if (this._client) {
86+
return this._client;
87+
}
88+
89+
const transport = new StdioClientTransport({
90+
command: this._config.command,
91+
args: this._config.args,
92+
env: {
93+
...getDefaultEnvironment(),
94+
...this._config.env
95+
},
96+
cwd: this._config.cwd
97+
});
98+
99+
const client = new Client({
100+
name: `code-mode:${this._config.id}`,
101+
version: '0.1.0'
102+
});
103+
104+
await client.connect(transport);
105+
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
106+
this._toolsCache = undefined;
107+
});
108+
109+
this._client = client;
110+
return client;
111+
}
112+
}

src/code-mode/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export { CodeModeWrapper, type CodeModeWrapperOptions } from './wrapper.js';
2+
export type { DownstreamConfig, DownstreamHandle } from './downstream.js';
3+
export {
4+
ListToolNamesInputSchema,
5+
ListToolNamesOutputSchema,
6+
type ListToolNamesResult,
7+
type ToolSummary,
8+
GetToolImplementationInputSchema,
9+
GetToolImplementationOutputSchema,
10+
type GetToolImplementationResult,
11+
CallToolInputSchema,
12+
type CallToolInput
13+
} from './metaTools.js';

src/code-mode/metaTools.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { z } from 'zod';
2+
3+
export const ServerSummarySchema = z.object({
4+
serverId: z.string(),
5+
description: z.string().optional()
6+
});
7+
8+
export const ListMcpServersOutputSchema = z.object({
9+
servers: z.array(ServerSummarySchema)
10+
});
11+
12+
export type ListMcpServersResult = z.infer<typeof ListMcpServersOutputSchema>;
13+
14+
export const ToolSummarySchema = z.object({
15+
serverId: z.string(),
16+
toolName: z.string(),
17+
description: z.string().optional()
18+
});
19+
20+
export const ListToolNamesInputSchema = z.object({
21+
serverId: z.string()
22+
});
23+
24+
export const ListToolNamesOutputSchema = z.object({
25+
tools: z.array(ToolSummarySchema)
26+
});
27+
28+
export type ToolSummary = z.infer<typeof ToolSummarySchema>;
29+
export type ListToolNamesResult = z.infer<typeof ListToolNamesOutputSchema>;
30+
31+
export const GetToolImplementationInputSchema = z.object({
32+
serverId: z.string(),
33+
toolName: z.string()
34+
});
35+
36+
export const GetToolImplementationOutputSchema = z.object({
37+
serverId: z.string(),
38+
toolName: z.string(),
39+
signature: z.string(),
40+
description: z.string().optional(),
41+
annotations: z.record(z.unknown()).optional(),
42+
inputSchema: z.record(z.unknown()).optional(),
43+
outputSchema: z.record(z.unknown()).optional()
44+
});
45+
46+
export type GetToolImplementationResult = z.infer<typeof GetToolImplementationOutputSchema>;
47+
48+
export const CallToolInputSchema = z.object({
49+
serverId: z.string(),
50+
toolName: z.string(),
51+
arguments: z.record(z.unknown()).optional()
52+
});
53+
54+
export type CallToolInput = z.infer<typeof CallToolInputSchema>;

0 commit comments

Comments
 (0)