fix: actually wire STDIO attach for docker-image MCP servers
All checks were successful
CI/CD / typecheck (pull_request) Successful in 52s
CI/CD / lint (pull_request) Successful in 1m43s
CI/CD / test (pull_request) Successful in 1m2s
CI/CD / build (pull_request) Successful in 1m45s
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
CI/CD / smoke (pull_request) Successful in 9m51s

Commit 1bd5087 added attachInteractive to the orchestrator interface
but never hooked it up in mcp-proxy-service — sendViaPersistentAttach
was promised in the commit message but missing from the diff. Servers
with a distroless image whose entrypoint IS the MCP server (gitea-mcp)
ended up needing a bogus `command: [node, dist/index.js]` workaround
that silently failed on every exec, leaving clients with empty tool
lists.

Changes:
- PersistentStdioClient: take a StdioMode discriminated union. Exec
  mode runs a command via execInteractive; attach mode talks to PID 1
  via attachInteractive.
- mcp-proxy-service: dispatch by config — command → exec; packageName
  → exec via runtime runner; dockerImage-only → attach. Error
  serialization no longer drops non-Error objects as "[object Object]".
- templates/gitea.yaml: remove the command workaround; the image CMD
  runs as PID 1 and mcpd attaches.
- Add unit tests covering both modes and the unsupported-orchestrator
  paths.

Also required (separate repo): mcpd's k8s Role needed pods/attach
added alongside pods/exec; updated in kubernetes-deployment/…/mcpctl/server.ts
and kubectl-patched on the live cluster.

Verified end-to-end against mcpctl.ad.itaz.eu:
- gitea (attach): 49 tools listed, real tools/call round-trip.
- aws-docs (exec via packageName): 4 tools, no regression.
- docmost (exec via command): 11 tools, no regression.
- mcpd suite: 634/634 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-12 22:26:26 +01:00
parent 857f8c72ae
commit 9ff2dcc3d9
4 changed files with 192 additions and 33 deletions

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest';
import { PassThrough } from 'node:stream';
import { PersistentStdioClient } from '../src/services/transport/persistent-stdio.js';
import type { InteractiveExec, McpOrchestrator } from '../src/services/orchestrator.js';
function makeFakeExec(): {
iexec: InteractiveExec;
written: string[];
emit: (line: unknown) => void;
} {
const stdout = new PassThrough();
const written: string[] = [];
const iexec: InteractiveExec = {
stdout,
write(data) { written.push(data); },
close() { stdout.destroy(); },
};
const emit = (msg: unknown) => {
stdout.write(JSON.stringify(msg) + '\n');
};
return { iexec, written, emit };
}
function makeOrchestrator(overrides: Partial<McpOrchestrator> = {}): McpOrchestrator {
return {
pullImage: vi.fn(),
createContainer: vi.fn(),
stopContainer: vi.fn(),
removeContainer: vi.fn(),
inspectContainer: vi.fn(),
getContainerLogs: vi.fn(),
execInContainer: vi.fn(),
ping: vi.fn(),
...overrides,
} as McpOrchestrator;
}
describe('PersistentStdioClient', () => {
it('exec mode calls execInteractive with the command', async () => {
const fake = makeFakeExec();
const execInteractive = vi.fn(async () => fake.iexec);
const orch = makeOrchestrator({ execInteractive });
const client = new PersistentStdioClient(
orch,
'container-1',
{ kind: 'exec', command: ['node', 'index.js'] },
);
// Drive the handshake: respond to the first init request (id=1)
// then to the subsequent tools/list request (id=2).
const sendPromise = client.send('tools/list');
await new Promise((r) => setTimeout(r, 10));
const init = JSON.parse(fake.written[0]!);
expect(init.method).toBe('initialize');
fake.emit({ jsonrpc: '2.0', id: init.id, result: { capabilities: {} } });
await new Promise((r) => setTimeout(r, 150));
// Second written msg is notifications/initialized; third is tools/list
const toolsReq = JSON.parse(fake.written[2]!);
expect(toolsReq.method).toBe('tools/list');
fake.emit({ jsonrpc: '2.0', id: toolsReq.id, result: { tools: [] } });
const res = await sendPromise;
expect(res.result).toEqual({ tools: [] });
expect(execInteractive).toHaveBeenCalledWith('container-1', ['node', 'index.js']);
client.close();
});
it('attach mode calls attachInteractive and never execInteractive', async () => {
const fake = makeFakeExec();
const attachInteractive = vi.fn(async () => fake.iexec);
const execInteractive = vi.fn();
const orch = makeOrchestrator({ attachInteractive, execInteractive });
const client = new PersistentStdioClient(
orch,
'container-gitea',
{ kind: 'attach' },
);
const sendPromise = client.send('tools/list');
await new Promise((r) => setTimeout(r, 10));
const init = JSON.parse(fake.written[0]!);
fake.emit({ jsonrpc: '2.0', id: init.id, result: { capabilities: {} } });
await new Promise((r) => setTimeout(r, 150));
const req = JSON.parse(fake.written[2]!);
fake.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 'list_repos' }] } });
const res = await sendPromise;
expect((res.result as { tools: unknown[] }).tools).toHaveLength(1);
expect(attachInteractive).toHaveBeenCalledWith('container-gitea');
expect(execInteractive).not.toHaveBeenCalled();
client.close();
});
it('attach mode throws if orchestrator does not support attach', async () => {
const orch = makeOrchestrator({}); // no attachInteractive
const client = new PersistentStdioClient(orch, 'c', { kind: 'attach' });
await expect(client.send('tools/list')).rejects.toThrow(/attach/i);
});
it('exec mode throws if orchestrator does not support execInteractive', async () => {
const orch = makeOrchestrator({}); // no execInteractive
const client = new PersistentStdioClient(orch, 'c', { kind: 'exec', command: ['x'] });
await expect(client.send('tools/list')).rejects.toThrow(/interactive exec/i);
});
});