- Wait for stdout.write callback before process.exit in STDIO transport to prevent truncation of large responses (e.g. grafana tools/list) - Handle MCP notification methods (notifications/initialized, etc.) in router instead of returning "Method not found" error - Use -p shorthand in config claude output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
120 lines
3.8 KiB
TypeScript
120 lines
3.8 KiB
TypeScript
import type { McpOrchestrator } from '../orchestrator.js';
|
|
import type { McpProxyResponse } from '../mcp-proxy-service.js';
|
|
|
|
/**
|
|
* STDIO transport client for MCP servers running as Docker containers.
|
|
*
|
|
* Runs `docker exec` with an inline Node.js script that spawns the MCP server
|
|
* binary, pipes JSON-RPC messages via stdin/stdout, and returns the response.
|
|
*
|
|
* Each call is self-contained: initialize → notifications/initialized → request → response.
|
|
*/
|
|
export async function sendViaStdio(
|
|
orchestrator: McpOrchestrator,
|
|
containerId: string,
|
|
packageName: string,
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
timeoutMs = 30_000,
|
|
): Promise<McpProxyResponse> {
|
|
const initMsg = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' },
|
|
},
|
|
});
|
|
const initializedMsg = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method: 'notifications/initialized',
|
|
});
|
|
|
|
const requestBody: Record<string, unknown> = {
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method,
|
|
};
|
|
if (params !== undefined) {
|
|
requestBody.params = params;
|
|
}
|
|
const requestMsg = JSON.stringify(requestBody);
|
|
|
|
// Inline Node.js script that:
|
|
// 1. Spawns the MCP server binary via npx
|
|
// 2. Sends initialize → initialized → actual request via stdin
|
|
// 3. Reads stdout for JSON-RPC response with id: 2
|
|
// 4. Outputs the full JSON-RPC response to stdout
|
|
const probeScript = `
|
|
const { spawn } = require('child_process');
|
|
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let output = '';
|
|
let responded = false;
|
|
proc.stdout.on('data', d => {
|
|
output += d;
|
|
const lines = output.split('\\n');
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
if (msg.id === 2) {
|
|
responded = true;
|
|
process.stdout.write(JSON.stringify(msg), () => {
|
|
proc.kill();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
output = lines[lines.length - 1] || '';
|
|
});
|
|
proc.stderr.on('data', () => {});
|
|
proc.on('error', e => { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:e.message}})); process.exit(1); });
|
|
proc.on('exit', (code) => { if (!responded) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:'process exited '+code}})); process.exit(1); } });
|
|
setTimeout(() => { if (!responded) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:'timeout'}})); proc.kill(); process.exit(1); } }, ${timeoutMs - 2000});
|
|
proc.stdin.write(${JSON.stringify(initMsg)} + '\\n');
|
|
setTimeout(() => {
|
|
proc.stdin.write(${JSON.stringify(initializedMsg)} + '\\n');
|
|
setTimeout(() => {
|
|
proc.stdin.write(${JSON.stringify(requestMsg)} + '\\n');
|
|
}, 500);
|
|
}, 500);
|
|
`.trim();
|
|
|
|
try {
|
|
const result = await orchestrator.execInContainer(
|
|
containerId,
|
|
['node', '-e', probeScript],
|
|
{ timeoutMs },
|
|
);
|
|
|
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
try {
|
|
return JSON.parse(result.stdout.trim()) as McpProxyResponse;
|
|
} catch {
|
|
return errorResponse(`Failed to parse STDIO response: ${result.stdout.slice(0, 200)}`);
|
|
}
|
|
}
|
|
|
|
// Try to parse error response from stdout
|
|
try {
|
|
return JSON.parse(result.stdout.trim()) as McpProxyResponse;
|
|
} catch {
|
|
const errorMsg = result.stderr.trim() || `docker exec exit code ${result.exitCode}`;
|
|
return errorResponse(errorMsg);
|
|
}
|
|
} catch (err) {
|
|
return errorResponse(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
function errorResponse(message: string): McpProxyResponse {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
error: { code: -32000, message },
|
|
};
|
|
}
|