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.
- package/dist/acp/bridge.d.ts +13 -0
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +125 -3
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +130 -7
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/acp/spawner.js +4 -4
- package/dist/acp/spawner.js.map +1 -1
- package/dist/acp/sync-bridge.d.ts +2 -0
- package/dist/acp/sync-bridge.d.ts.map +1 -1
- package/dist/acp/sync-bridge.js +6 -0
- package/dist/acp/sync-bridge.js.map +1 -1
- package/dist/commands/mcp-proxy.d.ts +10 -0
- package/dist/commands/mcp-proxy.d.ts.map +1 -1
- package/dist/commands/mcp-proxy.js +78 -24
- package/dist/commands/mcp-proxy.js.map +1 -1
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +33 -3
- package/dist/socketio-client.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +159 -7
- package/src/acp/bridge.ts +152 -3
- package/src/acp/spawner.ts +4 -4
- package/src/acp/sync-bridge.ts +8 -0
- package/src/commands/mcp-proxy.ts +91 -28
- package/src/socketio-client.ts +43 -3
- package/tests/acp-bridge.test.ts +119 -0
- package/tests/acp-spawner.test.ts +28 -1
- package/tests/acp-wire-elicitation.test.ts +158 -0
- package/tests/acp-wire-mcp.test.ts +114 -0
- package/tests/commands/mcp-proxy-dispatcher.test.ts +117 -0
- package/tests/fixtures/fake-acp-agent.mjs +122 -0
package/src/acp/bridge.test.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
|
|
3
|
-
vi.
|
|
4
|
-
|
|
5
|
-
|
|
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:
|
|
8
|
-
newSession:
|
|
9
|
-
resumeSession:
|
|
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
|
|
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:
|
|
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',
|
package/src/acp/spawner.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
56
|
+
console.error(`ACP process error (session=${this.sessionId}, cmd=${cmd}):`, error);
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
|
package/src/acp/sync-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
154
|
+
const dispatcher = createStdioDispatcher(child);
|
|
155
|
+
socket.on('proxy:request', async (data: { requestId: string; request: JsonRpcRequest }) => {
|
|
73
156
|
try {
|
|
74
|
-
const response = await
|
|
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
|
-
}
|