agent-gear 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,27 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
- vi.mock('@agentclientprotocol/sdk', () => ({
4
- ndJsonStream: vi.fn(() => ({})),
5
- ClientSideConnection: vi.fn(function () {
3
+ const { mockNewSession, mockResumeSession, mockInitialize, mockClientSideConnection } = vi.hoisted(() => {
4
+ const mockNewSession = vi.fn().mockResolvedValue({ sessionId: 'acp-sess-abc' });
5
+ const mockResumeSession = vi.fn().mockResolvedValue({});
6
+ const mockInitialize = vi.fn().mockResolvedValue({ protocolVersion: 1 });
7
+ const mockClientSideConnection = vi.fn(function () {
6
8
  return {
7
- initialize: vi.fn().mockResolvedValue({ protocolVersion: 1 }),
8
- newSession: vi.fn().mockResolvedValue({ sessionId: 'acp-sess-abc' }),
9
- resumeSession: vi.fn().mockResolvedValue({}),
9
+ initialize: mockInitialize,
10
+ newSession: mockNewSession,
11
+ resumeSession: mockResumeSession,
10
12
  prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
11
13
  cancel: vi.fn().mockResolvedValue(undefined),
12
14
  closeSession: vi.fn().mockResolvedValue({}),
13
15
  setSessionMode: vi.fn().mockResolvedValue({}),
14
16
  setSessionConfigOption: vi.fn().mockResolvedValue({}),
15
17
  };
16
- }),
18
+ });
19
+ return { mockNewSession, mockResumeSession, mockInitialize, mockClientSideConnection };
20
+ });
21
+
22
+ vi.mock('@agentclientprotocol/sdk', () => ({
23
+ ndJsonStream: vi.fn(() => ({})),
24
+ ClientSideConnection: mockClientSideConnection,
17
25
  }));
18
26
 
19
27
  import { AcpSessionBridge } from './bridge.js';
@@ -194,3 +202,147 @@ describe('AcpSessionBridge - requestPermission', () => {
194
202
  warnSpy.mockRestore();
195
203
  });
196
204
  });
205
+
206
+ describe('AcpSessionBridge - mcpEndpoint injection', () => {
207
+ let mockSocket: ReturnType<typeof createMockSocket>;
208
+ let mockSpawner: MockAcpSpawner;
209
+
210
+ function createBridge(mcpEndpoint?: { url: string; token: string; serverNames: string[] }) {
211
+ return new AcpSessionBridge(
212
+ mockSocket as never,
213
+ { sessionId: 'sess-1', cwd: '/home/user', mcpEndpoint },
214
+ mockSpawner as never,
215
+ );
216
+ }
217
+
218
+ beforeEach(() => {
219
+ vi.clearAllMocks();
220
+ vi.useFakeTimers();
221
+ mockSocket = createMockSocket();
222
+ mockSpawner = new MockAcpSpawner();
223
+ });
224
+
225
+ afterEach(() => {
226
+ vi.useRealTimers();
227
+ vi.restoreAllMocks();
228
+ });
229
+
230
+ it('should pass http mcpServers to newSession when mcpEndpoint provided', async () => {
231
+ const bridge = createBridge({
232
+ url: 'http://gateway/mcp/sess-1',
233
+ token: 'tok-1',
234
+ serverNames: ['github-mcp', 'fs-mcp'],
235
+ });
236
+
237
+ await bridge.start();
238
+
239
+ expect(mockNewSession).toHaveBeenCalledWith(expect.objectContaining({
240
+ mcpServers: [
241
+ { type: 'http', name: 'github-mcp', url: 'http://gateway/mcp/sess-1/github-mcp', headers: [{ name: 'Authorization', value: 'Bearer tok-1' }] },
242
+ { type: 'http', name: 'fs-mcp', url: 'http://gateway/mcp/sess-1/fs-mcp', headers: [{ name: 'Authorization', value: 'Bearer tok-1' }] },
243
+ ],
244
+ }));
245
+ });
246
+
247
+ it('should not set mcpServers when mcpEndpoint absent', async () => {
248
+ const bridge = createBridge();
249
+ await bridge.start();
250
+
251
+ expect(mockNewSession).toHaveBeenCalledWith(expect.objectContaining({
252
+ mcpServers: [],
253
+ }));
254
+ });
255
+
256
+ it('should pass http mcpServers to resumeSession when mcpEndpoint provided', async () => {
257
+ const bridge = createBridge({
258
+ url: 'http://gateway/mcp/sess-1',
259
+ token: 'tok-1',
260
+ serverNames: ['github-mcp'],
261
+ });
262
+
263
+ await bridge.resume('ext-123');
264
+
265
+ expect(mockResumeSession).toHaveBeenCalledWith(expect.objectContaining({
266
+ mcpServers: [
267
+ { type: 'http', name: 'github-mcp', url: 'http://gateway/mcp/sess-1/github-mcp', headers: [{ name: 'Authorization', value: 'Bearer tok-1' }] },
268
+ ],
269
+ }));
270
+ });
271
+
272
+ it('should fail with a diagnosable error when mcpEndpoint.serverNames is missing', async () => {
273
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
274
+ const bridge = createBridge({
275
+ url: 'http://gateway/mcp/sess-1',
276
+ token: 'tok-1',
277
+ // simulate an upstream payload that drifts from the contract
278
+ serverNames: undefined as never,
279
+ });
280
+
281
+ await bridge.start();
282
+
283
+ expect(mockNewSession).not.toHaveBeenCalled();
284
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', expect.objectContaining({
285
+ sessionId: 'sess-1',
286
+ reason: 'error',
287
+ error: expect.stringContaining('serverNames'),
288
+ }));
289
+ // the failure must be visible in local logs, not only via socket events
290
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('serverNames'));
291
+ expect(mockSpawner.stop).toHaveBeenCalled();
292
+ });
293
+
294
+ it('should fail with a diagnosable error when mcpEndpoint.serverNames is empty', async () => {
295
+ const bridge = createBridge({
296
+ url: 'http://gateway/mcp/sess-1',
297
+ token: 'tok-1',
298
+ serverNames: [],
299
+ });
300
+
301
+ await bridge.start();
302
+
303
+ expect(mockNewSession).not.toHaveBeenCalled();
304
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', expect.objectContaining({
305
+ sessionId: 'sess-1',
306
+ reason: 'error',
307
+ error: expect.stringContaining('serverNames'),
308
+ }));
309
+ });
310
+
311
+ it('should log the MCP servers passed to session/new without leaking the token', async () => {
312
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
313
+ const bridge = createBridge({
314
+ url: 'http://gateway/mcp/sess-1',
315
+ token: 'secret-token',
316
+ serverNames: ['fluentui-mcp'],
317
+ });
318
+
319
+ await bridge.start();
320
+
321
+ const logLines = logSpy.mock.calls.map((c) => String(c[0]));
322
+ const mcpLog = logLines.find((l) => l.includes('passing MCP servers via session/new'));
323
+ expect(mcpLog).toBeDefined();
324
+ expect(mcpLog).toContain('fluentui-mcp(type=http, url=http://gateway/mcp/sess-1/fluentui-mcp');
325
+ expect(mcpLog).not.toContain('secret-token');
326
+ });
327
+
328
+ it('should warn when passing http MCP servers to an agent without http MCP capability', async () => {
329
+ mockInitialize.mockResolvedValueOnce({
330
+ protocolVersion: 1,
331
+ agentCapabilities: { mcpCapabilities: { http: false, sse: true } },
332
+ });
333
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
334
+ const bridge = createBridge({
335
+ url: 'http://gateway/mcp/sess-1',
336
+ token: 'tok-1',
337
+ serverNames: ['fluentui-mcp'],
338
+ });
339
+
340
+ await bridge.start();
341
+
342
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('mcpCapabilities.http'));
343
+ // still passes the servers through — the agent decides
344
+ expect(mockNewSession).toHaveBeenCalledWith(expect.objectContaining({
345
+ mcpServers: [expect.objectContaining({ type: 'http', name: 'fluentui-mcp' })],
346
+ }));
347
+ });
348
+ });
package/src/acp/bridge.ts CHANGED
@@ -10,6 +10,7 @@ export interface AcpBridgeOptions {
10
10
  cwd: string;
11
11
  additionalDirectories?: string[];
12
12
  mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
13
+ mcpEndpoint?: { url: string; token: string; serverNames: string[] };
13
14
  }
14
15
 
15
16
  interface QueuedInput {
@@ -59,6 +60,39 @@ function toMcpServers(servers: AcpBridgeOptions['mcpServers']): acp.McpServer[]
59
60
  }));
60
61
  }
61
62
 
63
+ // Sanitized, token-free summary of the MCP servers about to be sent to the
64
+ // agent, so launch failures are diagnosable from local logs alone.
65
+ function describeMcpServersForLog(servers: acp.McpServer[]): string {
66
+ if (servers.length === 0) return 'none';
67
+ return servers.map((s) => {
68
+ if ('url' in s) {
69
+ const headerNames = (s.headers ?? []).map((h) => h.name).join('+');
70
+ return `${s.name}(type=${(s as { type?: string }).type ?? 'http'}, url=${s.url}${headerNames ? `, headers=${headerNames}` : ''})`;
71
+ }
72
+ return `${s.name}(type=stdio, cmd=${(s as { command?: string }).command})`;
73
+ }).join(' ');
74
+ }
75
+
76
+ function toMcpEndpointServers(mcpEndpoint: NonNullable<AcpBridgeOptions['mcpEndpoint']>): acp.McpServer[] {
77
+ // Fail loudly with a diagnosable message instead of a raw TypeError when the
78
+ // upstream payload drifts from the contract (serverNames is required).
79
+ if (!Array.isArray(mcpEndpoint.serverNames) || mcpEndpoint.serverNames.length === 0) {
80
+ throw new Error(
81
+ `Invalid mcpEndpoint payload: "serverNames" must be a non-empty array ` +
82
+ `(received: ${JSON.stringify(mcpEndpoint.serverNames)}, url: ${mcpEndpoint.url})`,
83
+ );
84
+ }
85
+ const headers: acp.HttpHeader[] = [{ name: 'Authorization', value: `Bearer ${mcpEndpoint.token}` }];
86
+ // Each assembled server gets its own URL so the entry point can route
87
+ // standard MCP clients (which never send a serverName param) correctly.
88
+ return mcpEndpoint.serverNames.map((name) => ({
89
+ type: 'http' as const,
90
+ name,
91
+ url: `${mcpEndpoint.url}/${encodeURIComponent(name)}`,
92
+ headers,
93
+ }));
94
+ }
95
+
62
96
  export function extractConfigOptions(
63
97
  configOptions: acp.SessionConfigOption[] | null | undefined,
64
98
  ): SessionConfigOptionData[] {
@@ -151,6 +185,7 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
151
185
  private cwd: string;
152
186
  private additionalDirectories: string[];
153
187
  private mcpServers: AcpBridgeOptions['mcpServers'];
188
+ private mcpEndpoint: AcpBridgeOptions['mcpEndpoint'];
154
189
  private connection: acp.ClientSideConnection | null = null;
155
190
  private acpSessionId: string | null = null;
156
191
  private activePrompt: Promise<acp.PromptResponse> | null = null;
@@ -164,6 +199,10 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
164
199
  resolve: (response: acp.RequestPermissionResponse) => void;
165
200
  timeout: NodeJS.Timeout;
166
201
  }>();
202
+ private pendingElicitations = new Map<string, {
203
+ resolve: (response: acp.CreateElicitationResponse) => void;
204
+ timeout: NodeJS.Timeout;
205
+ }>();
167
206
 
168
207
  // Capability snapshot from initialize / session/new / resume
169
208
  private configOptions: SessionConfigOptionData[] = [];
@@ -172,6 +211,7 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
172
211
  private authMethods: AcpAuthMethodData[] = [];
173
212
  private supportsAdditionalDirectories = false;
174
213
  private agentProtocolVersion: number | null = null;
214
+ private agentMcpCapabilities: { http?: boolean | null; sse?: boolean | null } | null = null;
175
215
 
176
216
  constructor(socket: Socket, options: AcpBridgeOptions, spawner?: AcpSpawner) {
177
217
  super();
@@ -180,9 +220,31 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
180
220
  this.cwd = options.cwd;
181
221
  this.additionalDirectories = options.additionalDirectories ?? [];
182
222
  this.mcpServers = options.mcpServers;
223
+ this.mcpEndpoint = options.mcpEndpoint;
183
224
  this.spawner = spawner ?? new AcpSpawner({ sessionId: options.sessionId, cwd: options.cwd });
184
225
  }
185
226
 
227
+ private resolveMcpServers(): acp.McpServer[] {
228
+ if (this.mcpEndpoint) {
229
+ return toMcpEndpointServers(this.mcpEndpoint);
230
+ }
231
+ return toMcpServers(this.mcpServers);
232
+ }
233
+
234
+ // Log the MCP configuration that is about to be handed to the agent via
235
+ // session/new (or session/resume), and warn when the agent did not
236
+ // advertise the required MCP transport capability in initialize.
237
+ private logMcpServersForSession(method: 'session/new' | 'session/resume', servers: acp.McpServer[]): void {
238
+ console.log(`[ACP bridge] session ${this.sessionId}: passing MCP servers via ${method}: ${describeMcpServersForLog(servers)}`);
239
+ const httpServers = servers.filter((s) => 'url' in s && (s as { type?: string }).type === 'http');
240
+ if (httpServers.length > 0 && this.agentMcpCapabilities && this.agentMcpCapabilities.http === false) {
241
+ console.warn(
242
+ `[ACP bridge] session ${this.sessionId}: agent did not advertise mcpCapabilities.http ` +
243
+ `but ${httpServers.length} http MCP server(s) are being passed: ${httpServers.map((s) => s.name).join(', ')}`,
244
+ );
245
+ }
246
+ }
247
+
186
248
  async start(): Promise<void> {
187
249
  if (this.initializing) return;
188
250
  this.initializing = true;
@@ -190,9 +252,11 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
190
252
  this.spawner.start();
191
253
  try {
192
254
  await this.initConnection();
255
+ const mcpServers = this.resolveMcpServers();
256
+ this.logMcpServersForSession('session/new', mcpServers);
193
257
  const result = await this.connection!.newSession({
194
258
  cwd: this.cwd,
195
- mcpServers: toMcpServers(this.mcpServers),
259
+ mcpServers,
196
260
  additionalDirectories: this.additionalDirectories.length > 0 ? this.additionalDirectories : undefined,
197
261
  });
198
262
  this.acpSessionId = result.sessionId;
@@ -213,9 +277,12 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
213
277
  this.spawner.start();
214
278
  try {
215
279
  await this.initConnection();
280
+ const mcpServers = this.resolveMcpServers();
281
+ this.logMcpServersForSession('session/resume', mcpServers);
216
282
  const result = await this.connection!.resumeSession({
217
283
  sessionId: externalSessionId,
218
284
  cwd: this.cwd,
285
+ mcpServers,
219
286
  additionalDirectories: this.additionalDirectories.length > 0 ? this.additionalDirectories : undefined,
220
287
  });
221
288
  this.acpSessionId = externalSessionId;
@@ -228,7 +295,7 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
228
295
  try {
229
296
  const result = await this.connection!.newSession({
230
297
  cwd: this.cwd,
231
- mcpServers: toMcpServers(this.mcpServers),
298
+ mcpServers: this.resolveMcpServers(),
232
299
  additionalDirectories: this.additionalDirectories.length > 0 ? this.additionalDirectories : undefined,
233
300
  });
234
301
  this.acpSessionId = result.sessionId;
@@ -399,6 +466,7 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
399
466
  session: {
400
467
  configOptions: { boolean: {} },
401
468
  },
469
+ elicitation: { form: {}, url: {} },
402
470
  },
403
471
  clientInfo: {
404
472
  name: 'agent-gear',
@@ -427,6 +495,12 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
427
495
  ? { name: result.agentInfo.name, version: result.agentInfo.version ?? null }
428
496
  : null;
429
497
  this.authMethods = toAuthMethods(result.authMethods);
498
+ this.agentMcpCapabilities = result.agentCapabilities?.mcpCapabilities ?? null;
499
+ console.log(
500
+ `[ACP bridge] session ${this.sessionId}: agent capabilities: ` +
501
+ `protocolVersion=${result.protocolVersion ?? 'n/a'} mcp=${JSON.stringify(this.agentMcpCapabilities)} ` +
502
+ `agent=${this.agentInfo ? `${this.agentInfo.name}@${this.agentInfo.version ?? 'n/a'}` : 'n/a'}`,
503
+ );
430
504
  this.supportsAdditionalDirectories =
431
505
  result.agentCapabilities?.sessionCapabilities?.additionalDirectories !== undefined &&
432
506
  result.agentCapabilities?.sessionCapabilities?.additionalDirectories !== null;
@@ -580,6 +654,78 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
580
654
  pending.resolve({ outcome });
581
655
  }
582
656
 
657
+ async unstable_createElicitation(
658
+ params: acp.CreateElicitationRequest,
659
+ ): Promise<acp.CreateElicitationResponse> {
660
+ // custom/未知模式不支持:立即取消,避免挂起 Agent
661
+ if (!acp.CreateElicitationRequest.isForm(params) && !acp.CreateElicitationRequest.isUrl(params)) {
662
+ return { action: 'cancel' };
663
+ }
664
+
665
+ const requestId = `elic_${Date.now()}_${Math.random().toString(36).slice(2)}`;
666
+ const toolCallId = 'toolCallId' in params ? params.toolCallId : undefined;
667
+
668
+ return new Promise((resolve) => {
669
+ const timeout = setTimeout(() => {
670
+ this.pendingElicitations.delete(requestId);
671
+ resolve({ action: 'cancel' });
672
+ }, 65_000);
673
+
674
+ this.pendingElicitations.set(requestId, { resolve, timeout });
675
+
676
+ if (acp.CreateElicitationRequest.isForm(params)) {
677
+ this.socket.emit('acp:elicitation', {
678
+ requestId,
679
+ sessionId: this.sessionId,
680
+ mode: 'form',
681
+ message: params.message,
682
+ ...(toolCallId != null ? { toolCallId } : {}),
683
+ requestedSchema: params.requestedSchema,
684
+ });
685
+ } else {
686
+ this.socket.emit('acp:elicitation', {
687
+ requestId,
688
+ sessionId: this.sessionId,
689
+ mode: 'url',
690
+ message: params.message,
691
+ ...(toolCallId != null ? { toolCallId } : {}),
692
+ elicitationId: params.elicitationId,
693
+ url: params.url,
694
+ });
695
+ }
696
+
697
+ this.emit('elicitation:sent', requestId);
698
+ });
699
+ }
700
+
701
+ handleElicitationResponse(
702
+ requestId: string,
703
+ action: 'accept' | 'decline' | 'cancel',
704
+ content?: Record<string, unknown>,
705
+ ): void {
706
+ const pending = this.pendingElicitations.get(requestId);
707
+ if (!pending) {
708
+ console.warn(`Elicitation request ${requestId} not found`);
709
+ return;
710
+ }
711
+
712
+ clearTimeout(pending.timeout);
713
+ this.pendingElicitations.delete(requestId);
714
+ pending.resolve({
715
+ action,
716
+ ...(action === 'accept' && content
717
+ ? { content: content as Record<string, acp.ElicitationContentValue> }
718
+ : {}),
719
+ });
720
+ }
721
+
722
+ async unstable_completeElicitation(params: acp.CompleteElicitationNotification): Promise<void> {
723
+ this.socket.emit('acp:elicitation:complete', {
724
+ sessionId: this.sessionId,
725
+ elicitationId: params.elicitationId,
726
+ });
727
+ }
728
+
583
729
  // ── text buffering ──
584
730
 
585
731
  private scheduleTextFlush(): void {
@@ -613,14 +759,17 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
613
759
  }
614
760
 
615
761
  private emitStopped(reason: string, exitCode?: number): void {
762
+ console.log(`[ACP bridge] session ${this.sessionId} stopped: reason=${reason} exitCode=${exitCode ?? 'n/a'}`);
616
763
  this.stopped = true;
617
764
  this.socket.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
618
765
  this.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
619
766
  }
620
767
 
621
768
  private handleError(error: unknown): void {
622
- this.stopped = true;
623
769
  const msg = error instanceof Error ? error.message : String(error);
770
+ const stack = error instanceof Error ? error.stack : undefined;
771
+ console.error(`[ACP bridge] session ${this.sessionId} failed: ${msg}${stack ? `\n${stack}` : ''}`);
772
+ this.stopped = true;
624
773
  this.socket.emit('acp:stopped', {
625
774
  sessionId: this.sessionId,
626
775
  reason: 'error',
@@ -29,7 +29,7 @@ export class AcpSpawner {
29
29
 
30
30
  start(): void {
31
31
  const cmd = this.resolveCommand();
32
- console.log(`Starting ACP server ${cmd}`)
32
+ console.log(`Starting ACP server: session=${this.sessionId} cmd=${cmd} args=${JSON.stringify(this.args)} cwd=${this.cwd}`);
33
33
  mkdirSync(this.cwd, { recursive: true });
34
34
 
35
35
  const spawnOptions: SpawnOptions = {
@@ -43,17 +43,17 @@ export class AcpSpawner {
43
43
  this.process = spawn(cmd, this.args, spawnOptions);
44
44
 
45
45
  this.process.stderr?.on('data', (data: Buffer) => {
46
- console.error(`ACP stderr: ${data.toString()}`);
46
+ console.error(`ACP stderr (session=${this.sessionId}): ${data.toString()}`);
47
47
  });
48
48
 
49
49
  this.process.on('exit', (code, signal) => {
50
- console.log(`ACP process exited with code ${code} and signal ${signal}`);
50
+ console.log(`ACP process exited: session=${this.sessionId} code=${code} signal=${signal} cmd=${cmd} args=${JSON.stringify(this.args)}`);
51
51
  this.process = null;
52
52
  this.resolveClosed?.();
53
53
  });
54
54
 
55
55
  this.process.on('error', (error) => {
56
- console.error('ACP process error:', error);
56
+ console.error(`ACP process error (session=${this.sessionId}, cmd=${cmd}):`, error);
57
57
  });
58
58
  }
59
59
 
@@ -124,4 +124,12 @@ export class AcpSyncBridge implements acp.Client {
124
124
  async requestPermission(): Promise<acp.RequestPermissionResponse> {
125
125
  return { outcome: { outcome: 'cancelled' } };
126
126
  }
127
+
128
+ async unstable_createElicitation(): Promise<acp.CreateElicitationResponse> {
129
+ return { action: 'cancel' };
130
+ }
131
+
132
+ async unstable_completeElicitation(): Promise<void> {
133
+ // no-op
134
+ }
127
135
  }
@@ -7,6 +7,92 @@ interface McpProxyOptions {
7
7
  command: string;
8
8
  }
9
9
 
10
+ interface JsonRpcRequest {
11
+ jsonrpc?: string;
12
+ id?: unknown;
13
+ method?: string;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ const FORWARD_TIMEOUT_MS = 30_000;
18
+
19
+ // JSON-RPC notifications have no id and MUST NOT be waited on: per the MCP
20
+ // spec the server never responds to them. Waiting would stall every hop of
21
+ // the forward chain for the full timeout.
22
+ function isNotification(request: JsonRpcRequest): boolean {
23
+ return request.id === undefined || request.id === null;
24
+ }
25
+
26
+ // Persistent line-buffered JSON-RPC dispatcher over a child process' stdio.
27
+ // Responses are matched to requests by id, so chunked responses (a single
28
+ // 'data' event is NOT guaranteed to contain exactly one complete JSON
29
+ // message) and concurrent in-flight requests are handled correctly.
30
+ export function createStdioDispatcher(child: ChildProcess) {
31
+ let buffer = '';
32
+ const pending = new Map<unknown, { resolve: (value: unknown) => void; reject: (reason: Error) => void; timeout: NodeJS.Timeout; method: string }>();
33
+
34
+ child.stdout?.on('data', (data: Buffer) => {
35
+ buffer += data.toString();
36
+ let idx;
37
+ while ((idx = buffer.indexOf('\n')) >= 0) {
38
+ const line = buffer.slice(0, idx).trim();
39
+ buffer = buffer.slice(idx + 1);
40
+ if (!line) continue;
41
+ let msg: { id?: unknown; error?: { message?: string } };
42
+ try {
43
+ msg = JSON.parse(line);
44
+ } catch {
45
+ console.error(`[MCP proxy] ignoring non-JSON line from child: ${line.slice(0, 200)}`);
46
+ continue;
47
+ }
48
+ const entry = pending.get(msg.id);
49
+ if (!entry) {
50
+ console.error(`[MCP proxy] ignoring response with unknown id: ${JSON.stringify(msg.id)}`);
51
+ continue;
52
+ }
53
+ clearTimeout(entry.timeout);
54
+ pending.delete(msg.id);
55
+ console.log(`[MCP proxy] response: method=${entry.method} id=${JSON.stringify(msg.id)}${msg.error ? ` error=${msg.error.message}` : ''}`);
56
+ entry.resolve(msg);
57
+ }
58
+ });
59
+
60
+ const rejectAll = (reason: Error) => {
61
+ for (const [, entry] of pending) {
62
+ clearTimeout(entry.timeout);
63
+ entry.reject(reason);
64
+ }
65
+ pending.clear();
66
+ };
67
+
68
+ child.on('exit', (code) => {
69
+ console.log(`[MCP proxy] MCP command exited with code ${code}, rejecting ${pending.size} pending request(s)`);
70
+ rejectAll(new Error(`MCP command exited with code ${code}`));
71
+ });
72
+
73
+ return {
74
+ forward(request: JsonRpcRequest): Promise<unknown> {
75
+ const method = request.method ?? '(unknown)';
76
+ if (isNotification(request)) {
77
+ // Fire-and-forget: deliver the notification (e.g. notifications/initialized
78
+ // completes the MCP handshake) but do not wait for a response.
79
+ console.log(`[MCP proxy] forwarding notification: method=${method}`);
80
+ child.stdin?.write(JSON.stringify(request) + '\n');
81
+ return Promise.resolve(null);
82
+ }
83
+ console.log(`[MCP proxy] forwarding request: method=${method} id=${JSON.stringify(request.id)}`);
84
+ return new Promise((resolve, reject) => {
85
+ const timeout = setTimeout(() => {
86
+ pending.delete(request.id);
87
+ reject(new Error(`Request timeout waiting for ${method}`));
88
+ }, FORWARD_TIMEOUT_MS);
89
+ pending.set(request.id, { resolve, reject, timeout, method });
90
+ child.stdin?.write(JSON.stringify(request) + '\n');
91
+ });
92
+ },
93
+ };
94
+ }
95
+
10
96
  export async function startMcpRegistration(options: McpProxyOptions): Promise<void> {
11
97
  const { server, key, command } = options;
12
98
 
@@ -45,10 +131,6 @@ export async function startMcpRegistration(options: McpProxyOptions): Promise<vo
45
131
  console.error('Failed to start MCP command:', err.message);
46
132
  });
47
133
 
48
- child.on('exit', (code) => {
49
- console.log(`MCP command exited with code ${code}`);
50
- });
51
-
52
134
  // 3. Establish WebSocket connection
53
135
  const socket = ioClient(`${server}/mcp-proxy`, {
54
136
  transports: ['websocket'],
@@ -69,16 +151,18 @@ export async function startMcpRegistration(options: McpProxyOptions): Promise<vo
69
151
  });
70
152
 
71
153
  // 4. Bridge requests
72
- socket.on('proxy:request', async (data: { requestId: string; request: any }) => {
154
+ const dispatcher = createStdioDispatcher(child);
155
+ socket.on('proxy:request', async (data: { requestId: string; request: JsonRpcRequest }) => {
73
156
  try {
74
- const response = await forwardToChild(child, data.request);
157
+ const response = await dispatcher.forward(data.request);
75
158
  socket.emit('proxy:response', { requestId: data.requestId, response });
76
159
  } catch (err: any) {
160
+ console.error(`[MCP proxy] forward failed: method=${data.request?.method} id=${JSON.stringify(data.request?.id)} error=${err.message}`);
77
161
  socket.emit('proxy:response', {
78
162
  requestId: data.requestId,
79
163
  response: {
80
164
  jsonrpc: '2.0',
81
- id: data.request.id,
165
+ id: data.request.id ?? null,
82
166
  error: { code: -32603, message: err.message },
83
167
  },
84
168
  });
@@ -105,24 +189,3 @@ export async function startMcpRegistration(options: McpProxyOptions): Promise<vo
105
189
  process.on('SIGINT', shutdown);
106
190
  process.on('SIGTERM', shutdown);
107
191
  }
108
-
109
- function forwardToChild(child: ChildProcess, request: any): Promise<any> {
110
- return new Promise((resolve, reject) => {
111
- const timeout = setTimeout(() => {
112
- reject(new Error('Request timeout'));
113
- }, 30000);
114
-
115
- const onData = (data: Buffer) => {
116
- clearTimeout(timeout);
117
- child.stdout?.removeListener('data', onData);
118
- try {
119
- resolve(JSON.parse(data.toString()));
120
- } catch {
121
- reject(new Error('Invalid JSON response from MCP command'));
122
- }
123
- };
124
-
125
- child.stdout?.on('data', onData);
126
- child.stdin?.write(JSON.stringify(request) + '\n');
127
- });
128
- }