agent-gear 1.2.1 → 2.0.0
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 +3 -1
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +13 -6
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +26 -11
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/mcp/local-server.d.ts +11 -0
- package/dist/mcp/local-server.d.ts.map +1 -0
- package/dist/mcp/local-server.js +99 -0
- package/dist/mcp/local-server.js.map +1 -0
- package/dist/mcp/local-server.test.d.ts +2 -0
- package/dist/mcp/local-server.test.d.ts.map +1 -0
- package/dist/mcp/local-server.test.js +101 -0
- package/dist/mcp/local-server.test.js.map +1 -0
- package/dist/mcp/relay.d.ts +30 -0
- package/dist/mcp/relay.d.ts.map +1 -0
- package/dist/mcp/relay.js +65 -0
- package/dist/mcp/relay.js.map +1 -0
- package/dist/mcp/relay.test.d.ts +2 -0
- package/dist/mcp/relay.test.d.ts.map +1 -0
- package/dist/mcp/relay.test.js +76 -0
- package/dist/mcp/relay.test.js.map +1 -0
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +58 -2
- package/dist/socketio-client.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +30 -12
- package/src/acp/bridge.ts +22 -7
- package/src/mcp/local-server.test.ts +133 -0
- package/src/mcp/local-server.ts +103 -0
- package/src/mcp/relay.test.ts +91 -0
- package/src/mcp/relay.ts +94 -0
- package/src/socketio-client.ts +62 -5
- package/tests/acp-wire-mcp.test.ts +4 -4
- package/tests/socketio-client.test.ts +196 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { McpRelay, type McpRelayResult } from './relay.js';
|
|
3
|
+
|
|
4
|
+
function createRelay() {
|
|
5
|
+
const emitted: Array<{ requestId: string; sessionId: string; serverName: string; token: string; body: unknown }> = [];
|
|
6
|
+
const relay = new McpRelay((payload) => emitted.push(payload));
|
|
7
|
+
return { relay, emitted };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('McpRelay', () => {
|
|
15
|
+
it('T-C6 resolves a pending request by requestId on mcp:response', async () => {
|
|
16
|
+
const { relay, emitted } = createRelay();
|
|
17
|
+
relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['github-mcp'] });
|
|
18
|
+
|
|
19
|
+
const promise = relay.forward('sess-1', 'github-mcp', { method: 'tools/list' });
|
|
20
|
+
expect(emitted).toHaveLength(1);
|
|
21
|
+
expect(emitted[0].requestId).toMatch(/^mcpreq_/);
|
|
22
|
+
expect(emitted[0]).toMatchObject({
|
|
23
|
+
sessionId: 'sess-1',
|
|
24
|
+
serverName: 'github-mcp',
|
|
25
|
+
token: 'tok-1',
|
|
26
|
+
body: { method: 'tools/list' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
relay.handleMcpResponse(emitted[0].requestId, 200, { result: [] });
|
|
30
|
+
await expect(promise).resolves.toEqual({ status: 200, body: { result: [] } });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('T-C7 times out after 60s with 504 and cleans up pending', async () => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
const { relay, emitted } = createRelay();
|
|
36
|
+
relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['github-mcp'] });
|
|
37
|
+
|
|
38
|
+
const first = relay.forward('sess-1', 'github-mcp', { method: 'tools/list' });
|
|
39
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
40
|
+
await expect(first).resolves.toEqual(expect.objectContaining({ status: 504 }));
|
|
41
|
+
|
|
42
|
+
const second = relay.forward('sess-1', 'github-mcp', { method: 'tools/call' });
|
|
43
|
+
emitted[1] && relay.handleMcpResponse(emitted[1].requestId, 200, { ok: true });
|
|
44
|
+
await expect(second).resolves.toEqual({ status: 200, body: { ok: true } });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('T-C8 fails all pending immediately with 503 on disconnect', async () => {
|
|
48
|
+
const { relay, emitted } = createRelay();
|
|
49
|
+
relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['a-mcp'] });
|
|
50
|
+
const promises = [
|
|
51
|
+
relay.forward('sess-1', 'a-mcp', { id: 1 }),
|
|
52
|
+
relay.forward('sess-1', 'a-mcp', { id: 2 }),
|
|
53
|
+
];
|
|
54
|
+
expect(emitted).toHaveLength(2);
|
|
55
|
+
|
|
56
|
+
relay.handleDisconnect();
|
|
57
|
+
const results = await Promise.all(promises);
|
|
58
|
+
expect(results).toEqual([
|
|
59
|
+
expect.objectContaining({ status: 503 }),
|
|
60
|
+
expect.objectContaining({ status: 503 }),
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('T-C9 unregistering a session fails its pending with 410, other sessions unaffected', async () => {
|
|
65
|
+
const { relay, emitted } = createRelay();
|
|
66
|
+
relay.registerSession('sess-1', { token: 'tok-1', serverNames: ['a-mcp'] });
|
|
67
|
+
relay.registerSession('sess-2', { token: 'tok-2', serverNames: ['b-mcp'] });
|
|
68
|
+
|
|
69
|
+
const sess1 = relay.forward('sess-1', 'a-mcp', { id: 1 });
|
|
70
|
+
const sess2 = relay.forward('sess-2', 'b-mcp', { id: 2 });
|
|
71
|
+
|
|
72
|
+
relay.unregisterSession('sess-1');
|
|
73
|
+
await expect(sess1).resolves.toEqual(expect.objectContaining({ status: 410 }));
|
|
74
|
+
expect(relay.hasSession('sess-1')).toBe(false);
|
|
75
|
+
expect(relay.hasSession('sess-2')).toBe(true);
|
|
76
|
+
|
|
77
|
+
emitted[1] && relay.handleMcpResponse(emitted[1].requestId, 200, { ok: true });
|
|
78
|
+
await expect(sess2).resolves.toEqual({ status: 200, body: { ok: true } });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('T-C10 ignores mcp:response for an unknown requestId', async () => {
|
|
82
|
+
const { relay } = createRelay();
|
|
83
|
+
expect(() => relay.handleMcpResponse('mcpreq_unknown', 200, {})).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('forward returns 404 for an unregistered session', async () => {
|
|
87
|
+
const { relay } = createRelay();
|
|
88
|
+
const result: McpRelayResult = await relay.forward('missing', 'a-mcp', {});
|
|
89
|
+
expect(result.status).toBe(404);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/mcp/relay.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export interface SessionEndpoint {
|
|
2
|
+
token: string;
|
|
3
|
+
serverNames: string[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface McpRelayResult {
|
|
7
|
+
status: number;
|
|
8
|
+
body: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface McpRequestPayload {
|
|
12
|
+
requestId: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
serverName: string;
|
|
15
|
+
token: string;
|
|
16
|
+
body: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PendingRequest {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
resolve: (result: McpRelayResult) => void;
|
|
22
|
+
timer: NodeJS.Timeout;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
export class McpRelay {
|
|
28
|
+
private pendingRequests = new Map<string, PendingRequest>();
|
|
29
|
+
private sessionEndpoints = new Map<string, SessionEndpoint>();
|
|
30
|
+
private emitMcpRequest: (payload: McpRequestPayload) => void;
|
|
31
|
+
|
|
32
|
+
constructor(emitMcpRequest: (payload: McpRequestPayload) => void) {
|
|
33
|
+
this.emitMcpRequest = emitMcpRequest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
registerSession(sessionId: string, endpoint: SessionEndpoint): void {
|
|
37
|
+
this.sessionEndpoints.set(sessionId, endpoint);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
hasSession(sessionId: string): boolean {
|
|
41
|
+
return this.sessionEndpoints.has(sessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
hasServer(sessionId: string, serverName: string): boolean {
|
|
45
|
+
const endpoint = this.sessionEndpoints.get(sessionId);
|
|
46
|
+
return endpoint ? endpoint.serverNames.includes(serverName) : false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
unregisterSession(sessionId: string): void {
|
|
50
|
+
if (!this.sessionEndpoints.delete(sessionId)) return;
|
|
51
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
52
|
+
if (pending.sessionId !== sessionId) continue;
|
|
53
|
+
clearTimeout(pending.timer);
|
|
54
|
+
this.pendingRequests.delete(requestId);
|
|
55
|
+
pending.resolve({ status: 410, body: { error: 'SESSION_ENDED', message: 'MCP session has ended' } });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async forward(sessionId: string, serverName: string, body: unknown): Promise<McpRelayResult> {
|
|
60
|
+
const endpoint = this.sessionEndpoints.get(sessionId);
|
|
61
|
+
if (!endpoint) return { status: 404, body: { error: 'SESSION_NOT_FOUND' } };
|
|
62
|
+
const requestId = `mcpreq_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
63
|
+
console.log(`[MCP relay] forward requestId=${requestId} session=${sessionId} server=${serverName} method=${(body as { method?: string })?.method ?? '?'}`);
|
|
64
|
+
return new Promise<McpRelayResult>((resolve) => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
this.pendingRequests.delete(requestId);
|
|
67
|
+
console.log(`[MCP relay] requestId=${requestId} -> 504 (timeout after ${REQUEST_TIMEOUT_MS}ms, no mcp:response received)`);
|
|
68
|
+
resolve({ status: 504, body: { error: 'TIMEOUT', message: 'MCP request timed out' } });
|
|
69
|
+
}, REQUEST_TIMEOUT_MS);
|
|
70
|
+
this.pendingRequests.set(requestId, { sessionId, resolve, timer });
|
|
71
|
+
this.emitMcpRequest({ requestId, sessionId, serverName, token: endpoint.token, body });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
handleMcpResponse(requestId: string, status: number, body: unknown): void {
|
|
76
|
+
const pending = this.pendingRequests.get(requestId);
|
|
77
|
+
if (!pending) {
|
|
78
|
+
console.log(`[MCP relay] response requestId=${requestId} status=${status} IGNORED (no pending request)`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(`[MCP relay] response requestId=${requestId} status=${status}`);
|
|
82
|
+
clearTimeout(pending.timer);
|
|
83
|
+
this.pendingRequests.delete(requestId);
|
|
84
|
+
pending.resolve({ status, body });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
handleDisconnect(): void {
|
|
88
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
89
|
+
clearTimeout(pending.timer);
|
|
90
|
+
this.pendingRequests.delete(requestId);
|
|
91
|
+
pending.resolve({ status: 503, body: { error: 'CONTROLLER_DISCONNECTED', message: 'Controller disconnected' } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/socketio-client.ts
CHANGED
|
@@ -16,8 +16,10 @@ import { worktreeHandler } from './worktree-handler.js';
|
|
|
16
16
|
import { fsHandler } from './fs-handler.js';
|
|
17
17
|
import { gatewayManager } from './gateway.js';
|
|
18
18
|
import { processRegistry, isProcessAlive } from './acp/process-registry.js';
|
|
19
|
+
import { McpRelay } from './mcp/relay.js';
|
|
20
|
+
import { LocalMcpServer } from './mcp/local-server.js';
|
|
19
21
|
|
|
20
|
-
const CONTROLLER_VERSION = '
|
|
22
|
+
const CONTROLLER_VERSION = '2.0.0';
|
|
21
23
|
const acpBridges = new Map<string, AcpSessionBridge>();
|
|
22
24
|
const permissionRequestBridge = new Map<string, AcpSessionBridge>();
|
|
23
25
|
const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
|
|
@@ -60,7 +62,7 @@ interface AcpStartPayload {
|
|
|
60
62
|
serverType: string;
|
|
61
63
|
cwd: string;
|
|
62
64
|
mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
|
|
63
|
-
mcpEndpoint?: {
|
|
65
|
+
mcpEndpoint?: { token: string; serverNames: string[] };
|
|
64
66
|
additionalDirectories?: string[];
|
|
65
67
|
modeId?: string;
|
|
66
68
|
modelId?: string;
|
|
@@ -73,13 +75,13 @@ interface AcpMessagePayload {
|
|
|
73
75
|
|
|
74
76
|
// Summarize the MCP configuration of an acp:start/acp:resume payload for logs,
|
|
75
77
|
// without leaking the endpoint token.
|
|
76
|
-
function describeMcpConfig(data: { mcpServers?: unknown[]; mcpEndpoint?: {
|
|
78
|
+
function describeMcpConfig(data: { mcpServers?: unknown[]; mcpEndpoint?: { serverNames?: string[] } }): string {
|
|
77
79
|
const parts: string[] = [];
|
|
78
80
|
if (data.mcpServers && data.mcpServers.length > 0) {
|
|
79
81
|
parts.push(`mcpServers=${data.mcpServers.length}`);
|
|
80
82
|
}
|
|
81
83
|
if (data.mcpEndpoint) {
|
|
82
|
-
parts.push(`mcpEndpoint(
|
|
84
|
+
parts.push(`mcpEndpoint(serverNames=${JSON.stringify(data.mcpEndpoint.serverNames ?? null)})`);
|
|
83
85
|
}
|
|
84
86
|
return parts.length > 0 ? `mcp=[${parts.join(' ')}]` : 'mcp=[none]';
|
|
85
87
|
}
|
|
@@ -132,7 +134,7 @@ interface AcpResumePayload {
|
|
|
132
134
|
externalSessionId: string;
|
|
133
135
|
serverType: string;
|
|
134
136
|
cwd: string;
|
|
135
|
-
mcpEndpoint?: {
|
|
137
|
+
mcpEndpoint?: { token: string; serverNames: string[] };
|
|
136
138
|
additionalDirectories?: string[];
|
|
137
139
|
modeId?: string;
|
|
138
140
|
modelId?: string;
|
|
@@ -215,6 +217,15 @@ export function connectToServer(
|
|
|
215
217
|
|
|
216
218
|
const heartbeat = new Heartbeat();
|
|
217
219
|
const controllerName = options?.serverName || hostname();
|
|
220
|
+
const mcpRelay = new McpRelay((payload) => socket.emit('mcp:request', payload));
|
|
221
|
+
const localServer = new LocalMcpServer(mcpRelay);
|
|
222
|
+
// 回环 HTTP 服务在守护进程内只启动一次、进程退出前不停止(提案 Phase 2 要求全守护进程共享一个端口)。
|
|
223
|
+
// 断线快速失败由 mcpRelay.handleDisconnect()(503)承担,无需随 socket 连接启停 HTTP 服务。
|
|
224
|
+
// ready promise 保证 acp:start/acp:resume 读取 localServer.port 前服务已 listening(启动只 await 一次)。
|
|
225
|
+
const mcpServerReady = localServer.start();
|
|
226
|
+
mcpServerReady.catch((err: Error) => {
|
|
227
|
+
console.error(`[MCP] failed to start local MCP endpoint: ${err.message}`);
|
|
228
|
+
});
|
|
218
229
|
|
|
219
230
|
// Seed the fs:remove-dir whitelist with cwds of ACP processes that survived
|
|
220
231
|
// a controller restart (their worktrees remain controller-managed).
|
|
@@ -230,6 +241,8 @@ export function connectToServer(
|
|
|
230
241
|
hostname: controllerName,
|
|
231
242
|
version: CONTROLLER_VERSION,
|
|
232
243
|
resumeToken,
|
|
244
|
+
homeDir: homedir(),
|
|
245
|
+
platform: platform(),
|
|
233
246
|
});
|
|
234
247
|
});
|
|
235
248
|
|
|
@@ -244,6 +257,13 @@ export function connectToServer(
|
|
|
244
257
|
|
|
245
258
|
socket.on('error', (data: ErrorPayload) => {
|
|
246
259
|
console.error(`Server error: ${data.code} - ${data.message}`);
|
|
260
|
+
if (data.code === 'VERSION_MISMATCH') {
|
|
261
|
+
console.error('The agent-controller version is not supported by the server.');
|
|
262
|
+
console.error('Please upgrade agent-controller to 2.x (npm i -g agent-gear@2 or the matching release).');
|
|
263
|
+
socket.disconnect();
|
|
264
|
+
process.exit(1);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
247
267
|
if (data.code === 'KEY_ALREADY_ONLINE') {
|
|
248
268
|
console.log('Clearing resume token and retrying connection...');
|
|
249
269
|
clearResumeTokenFromConfig();
|
|
@@ -317,11 +337,23 @@ export function connectToServer(
|
|
|
317
337
|
await existingBridge.stop().catch(() => {});
|
|
318
338
|
}
|
|
319
339
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
340
|
+
if (data.mcpEndpoint) {
|
|
341
|
+
// 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
|
|
342
|
+
await mcpServerReady;
|
|
343
|
+
if (localServer.port <= 0) {
|
|
344
|
+
throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
|
|
345
|
+
}
|
|
346
|
+
mcpRelay.registerSession(data.sessionId, {
|
|
347
|
+
token: data.mcpEndpoint.token,
|
|
348
|
+
serverNames: data.mcpEndpoint.serverNames,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
320
351
|
const bridge = new AcpSessionBridge(socket, {
|
|
321
352
|
sessionId: data.sessionId,
|
|
322
353
|
cwd: data.cwd,
|
|
323
354
|
mcpServers: data.mcpServers,
|
|
324
355
|
mcpEndpoint: data.mcpEndpoint,
|
|
356
|
+
mcpPort: localServer.port,
|
|
325
357
|
additionalDirectories: data.additionalDirectories,
|
|
326
358
|
initialModeId: data.modeId,
|
|
327
359
|
initialModelId: data.modelId,
|
|
@@ -336,6 +368,7 @@ export function connectToServer(
|
|
|
336
368
|
bridge.on('acp:stopped', (payload: { sessionId: string }) => {
|
|
337
369
|
if (payload.sessionId === data.sessionId) {
|
|
338
370
|
processRegistry.deregister(data.sessionId);
|
|
371
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
339
372
|
}
|
|
340
373
|
});
|
|
341
374
|
|
|
@@ -404,6 +437,7 @@ export function connectToServer(
|
|
|
404
437
|
bridge.off('acp:stopped', onStopped);
|
|
405
438
|
acpBridges.delete(data.sessionId);
|
|
406
439
|
processRegistry.deregister(data.sessionId);
|
|
440
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
407
441
|
console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
408
442
|
socket.emit('acp:start:result', {
|
|
409
443
|
requestId: data.requestId,
|
|
@@ -420,6 +454,7 @@ export function connectToServer(
|
|
|
420
454
|
await bridge.start();
|
|
421
455
|
} catch (error) {
|
|
422
456
|
console.error(`[ACP ctrl] bridge.start() failed: ${(error as Error).message}`);
|
|
457
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
423
458
|
socket.emit('acp:start:result', {
|
|
424
459
|
requestId: data.requestId,
|
|
425
460
|
sessionId: data.sessionId,
|
|
@@ -441,10 +476,22 @@ export function connectToServer(
|
|
|
441
476
|
await existingBridge.stop().catch(() => {});
|
|
442
477
|
}
|
|
443
478
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
479
|
+
if (data.mcpEndpoint) {
|
|
480
|
+
// 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
|
|
481
|
+
await mcpServerReady;
|
|
482
|
+
if (localServer.port <= 0) {
|
|
483
|
+
throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
|
|
484
|
+
}
|
|
485
|
+
mcpRelay.registerSession(data.sessionId, {
|
|
486
|
+
token: data.mcpEndpoint.token,
|
|
487
|
+
serverNames: data.mcpEndpoint.serverNames,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
444
490
|
const bridge = new AcpSessionBridge(socket, {
|
|
445
491
|
sessionId: data.sessionId,
|
|
446
492
|
cwd: data.cwd,
|
|
447
493
|
mcpEndpoint: data.mcpEndpoint,
|
|
494
|
+
mcpPort: localServer.port,
|
|
448
495
|
additionalDirectories: data.additionalDirectories,
|
|
449
496
|
// 初始 mode/model:resume 完成后、acp:initialized 之前应用(时序同 acp:start)
|
|
450
497
|
initialModeId: data.modeId,
|
|
@@ -457,6 +504,7 @@ export function connectToServer(
|
|
|
457
504
|
bridge.on('acp:stopped', (payload: { sessionId: string }) => {
|
|
458
505
|
if (payload.sessionId === data.sessionId) {
|
|
459
506
|
processRegistry.deregister(data.sessionId);
|
|
507
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
460
508
|
}
|
|
461
509
|
});
|
|
462
510
|
|
|
@@ -495,6 +543,7 @@ export function connectToServer(
|
|
|
495
543
|
bridge.off('acp:stopped', onStopped);
|
|
496
544
|
acpBridges.delete(data.sessionId);
|
|
497
545
|
processRegistry.deregister(data.sessionId);
|
|
546
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
498
547
|
console.log(`[ACP ctrl] emitting acp:resume:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
499
548
|
socket.emit('acp:resume:result', {
|
|
500
549
|
requestId: data.requestId,
|
|
@@ -510,6 +559,7 @@ export function connectToServer(
|
|
|
510
559
|
await bridge.resume(data.externalSessionId);
|
|
511
560
|
} catch (error) {
|
|
512
561
|
console.error(`[ACP ctrl] bridge.resume() failed: ${(error as Error).message}`);
|
|
562
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
513
563
|
socket.emit('acp:resume:result', {
|
|
514
564
|
requestId: data.requestId,
|
|
515
565
|
sessionId: data.sessionId,
|
|
@@ -691,6 +741,7 @@ export function connectToServer(
|
|
|
691
741
|
processRegistry.deregister(data.sessionId);
|
|
692
742
|
}
|
|
693
743
|
}
|
|
744
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
694
745
|
if (data.ackRequired) {
|
|
695
746
|
socket.emit('acp:stopped', {
|
|
696
747
|
sessionId: data.sessionId,
|
|
@@ -965,6 +1016,7 @@ export function connectToServer(
|
|
|
965
1016
|
}
|
|
966
1017
|
} catch { /* process may already be dead */ }
|
|
967
1018
|
processRegistry.deregister(entry.sessionId);
|
|
1019
|
+
mcpRelay.unregisterSession(entry.sessionId);
|
|
968
1020
|
// Also clean up bridge if present
|
|
969
1021
|
const bridge = acpBridges.get(entry.sessionId);
|
|
970
1022
|
if (bridge) {
|
|
@@ -1012,12 +1064,17 @@ export function connectToServer(
|
|
|
1012
1064
|
}
|
|
1013
1065
|
});
|
|
1014
1066
|
|
|
1067
|
+
socket.on('mcp:response', (data: { requestId: string; status: number; body: unknown }) => {
|
|
1068
|
+
mcpRelay.handleMcpResponse(data.requestId, data.status, data.body);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1015
1071
|
socket.on('disconnect', (reason) => {
|
|
1016
1072
|
console.log(`Disconnected: ${reason}`);
|
|
1017
1073
|
heartbeat.stop();
|
|
1018
1074
|
gatewayManager.closeAll();
|
|
1019
1075
|
permissionRequestBridge.clear();
|
|
1020
1076
|
elicitationRequestBridge.clear();
|
|
1077
|
+
mcpRelay.handleDisconnect();
|
|
1021
1078
|
});
|
|
1022
1079
|
|
|
1023
1080
|
socket.on('connect_error', (err) => {
|
|
@@ -49,10 +49,10 @@ describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
|
|
|
49
49
|
sessionId: 'sess-wire',
|
|
50
50
|
cwd: workdir,
|
|
51
51
|
mcpEndpoint: {
|
|
52
|
-
url: 'http://gateway/mcp/sess-wire',
|
|
53
52
|
token: 'wire-token',
|
|
54
53
|
serverNames: ['fluentui-mcp', 'fs-mcp'],
|
|
55
54
|
},
|
|
55
|
+
mcpPort: 41234,
|
|
56
56
|
}, spawner);
|
|
57
57
|
return socket;
|
|
58
58
|
}
|
|
@@ -76,13 +76,13 @@ describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
|
|
|
76
76
|
{
|
|
77
77
|
type: 'http',
|
|
78
78
|
name: 'fluentui-mcp',
|
|
79
|
-
url: 'http://
|
|
79
|
+
url: 'http://127.0.0.1:41234/mcp/sess-wire/fluentui-mcp',
|
|
80
80
|
headers: [{ name: 'Authorization', value: 'Bearer wire-token' }],
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
type: 'http',
|
|
84
84
|
name: 'fs-mcp',
|
|
85
|
-
url: 'http://
|
|
85
|
+
url: 'http://127.0.0.1:41234/mcp/sess-wire/fs-mcp',
|
|
86
86
|
headers: [{ name: 'Authorization', value: 'Bearer wire-token' }],
|
|
87
87
|
},
|
|
88
88
|
]);
|
|
@@ -92,7 +92,7 @@ describe('AcpSessionBridge - MCP wire format (ACP spec)', () => {
|
|
|
92
92
|
const logLines = logSpy.mock.calls.map((c) => String(c[0]));
|
|
93
93
|
const mcpLog = logLines.find((l) => l.includes('passing MCP servers via session/new'));
|
|
94
94
|
expect(mcpLog).toBeDefined();
|
|
95
|
-
expect(mcpLog).toContain('fluentui-mcp(type=http, url=http://
|
|
95
|
+
expect(mcpLog).toContain('fluentui-mcp(type=http, url=http://127.0.0.1:41234/mcp/sess-wire/fluentui-mcp, headers=Authorization)');
|
|
96
96
|
expect(mcpLog).not.toContain('wire-token');
|
|
97
97
|
}, 15000);
|
|
98
98
|
|
|
@@ -9,6 +9,11 @@ const { mockStart, mockStop, MockHeartbeat } = vi.hoisted(() => {
|
|
|
9
9
|
return { mockStart: start, mockStop: stop, MockHeartbeat: HB };
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
const { relayInstances, localServerInstances } = vi.hoisted(() => ({
|
|
13
|
+
relayInstances: [] as Array<Record<string, ReturnType<typeof vi.fn>>>,
|
|
14
|
+
localServerInstances: [] as Array<{ start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; port: number }>,
|
|
15
|
+
}));
|
|
16
|
+
|
|
12
17
|
const { MockAcpSyncBridge, mockBridge } = vi.hoisted(() => {
|
|
13
18
|
const bridge = {
|
|
14
19
|
connect: vi.fn().mockResolvedValue(undefined),
|
|
@@ -106,6 +111,32 @@ vi.mock('../src/heartbeat.js', () => ({
|
|
|
106
111
|
Heartbeat: MockHeartbeat,
|
|
107
112
|
}));
|
|
108
113
|
|
|
114
|
+
vi.mock('../src/mcp/relay.js', () => ({
|
|
115
|
+
McpRelay: class {
|
|
116
|
+
registerSession = vi.fn();
|
|
117
|
+
unregisterSession = vi.fn();
|
|
118
|
+
handleDisconnect = vi.fn();
|
|
119
|
+
handleMcpResponse = vi.fn();
|
|
120
|
+
hasSession = vi.fn(() => true);
|
|
121
|
+
hasServer = vi.fn(() => true);
|
|
122
|
+
forward = vi.fn();
|
|
123
|
+
constructor() {
|
|
124
|
+
relayInstances.push(this as never);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
vi.mock('../src/mcp/local-server.js', () => ({
|
|
130
|
+
LocalMcpServer: class {
|
|
131
|
+
port = 0;
|
|
132
|
+
start = vi.fn(async () => {});
|
|
133
|
+
stop = vi.fn(async () => {});
|
|
134
|
+
constructor() {
|
|
135
|
+
localServerInstances.push(this as never);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}));
|
|
139
|
+
|
|
109
140
|
vi.mock('../src/config.js', () => ({
|
|
110
141
|
clearConfigKey: vi.fn(),
|
|
111
142
|
saveKeyToConfig: vi.fn(),
|
|
@@ -159,6 +190,8 @@ beforeEach(() => {
|
|
|
159
190
|
vi.clearAllMocks();
|
|
160
191
|
mockStart.mockClear();
|
|
161
192
|
mockStop.mockClear();
|
|
193
|
+
relayInstances.length = 0;
|
|
194
|
+
localServerInstances.length = 0;
|
|
162
195
|
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
163
196
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
164
197
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
@@ -195,8 +228,10 @@ describe('SocketIO Client', () => {
|
|
|
195
228
|
expect(mockSocket.emit).toHaveBeenCalledWith('register', {
|
|
196
229
|
key: 'zag_k_1_abc123',
|
|
197
230
|
hostname: 'my-machine',
|
|
198
|
-
version: '
|
|
231
|
+
version: '2.0.0',
|
|
199
232
|
resumeToken: undefined,
|
|
233
|
+
homeDir: '/home/user',
|
|
234
|
+
platform: 'linux',
|
|
200
235
|
});
|
|
201
236
|
});
|
|
202
237
|
|
|
@@ -211,8 +246,10 @@ describe('SocketIO Client', () => {
|
|
|
211
246
|
expect(mockSocket.emit).toHaveBeenCalledWith('register', {
|
|
212
247
|
key: 'zag_k_1_abc123',
|
|
213
248
|
hostname: 'test-machine',
|
|
214
|
-
version: '
|
|
249
|
+
version: '2.0.0',
|
|
215
250
|
resumeToken: undefined,
|
|
251
|
+
homeDir: '/home/user',
|
|
252
|
+
platform: 'linux',
|
|
216
253
|
});
|
|
217
254
|
});
|
|
218
255
|
|
|
@@ -815,4 +852,161 @@ describe('SocketIO Client', () => {
|
|
|
815
852
|
expect(killSpy).not.toHaveBeenCalled();
|
|
816
853
|
});
|
|
817
854
|
});
|
|
855
|
+
|
|
856
|
+
describe('MCP local endpoint lifecycle', () => {
|
|
857
|
+
function setup() {
|
|
858
|
+
bridgeInstances.length = 0;
|
|
859
|
+
const mockSocket = createMockSocket();
|
|
860
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
861
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
862
|
+
return mockSocket;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
it('starts the local loopback server exactly once at daemon startup and never stops on disconnect', async () => {
|
|
866
|
+
const mockSocket = setup();
|
|
867
|
+
const server = localServerInstances[0];
|
|
868
|
+
expect(server).toBeDefined();
|
|
869
|
+
expect(server.start).toHaveBeenCalledTimes(1);
|
|
870
|
+
|
|
871
|
+
// 断线→重连后服务不停止、端口不变(存活会话注入的旧 URL 仍可用)
|
|
872
|
+
mockSocket._trigger('disconnect', 'transport close');
|
|
873
|
+
expect(server.stop).not.toHaveBeenCalled();
|
|
874
|
+
mockSocket._trigger('connect');
|
|
875
|
+
expect(server.start).toHaveBeenCalledTimes(1);
|
|
876
|
+
// disconnect 仅触发 relay 快速失败(503),不触碰 HTTP 服务
|
|
877
|
+
const relay = relayInstances[0];
|
|
878
|
+
expect(relay.handleDisconnect).toHaveBeenCalled();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('registers the session endpoint and injects the loopback port on acp:start', async () => {
|
|
882
|
+
const mockSocket = setup();
|
|
883
|
+
localServerInstances[0].port = 34567;
|
|
884
|
+
const relay = relayInstances[0];
|
|
885
|
+
|
|
886
|
+
mockSocket._trigger('acp:start', {
|
|
887
|
+
requestId: 'req-mcp',
|
|
888
|
+
sessionId: 'sess-mcp',
|
|
889
|
+
serverType: 'opencode',
|
|
890
|
+
cwd: '/home/user/wt',
|
|
891
|
+
mcpEndpoint: { token: 'tok-1', serverNames: ['tools', 'proxy'] },
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
await vi.waitFor(() => {
|
|
895
|
+
expect(relay.registerSession).toHaveBeenCalledWith('sess-mcp', {
|
|
896
|
+
token: 'tok-1',
|
|
897
|
+
serverNames: ['tools', 'proxy'],
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
await vi.waitFor(() => {
|
|
901
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-mcp', success: true }));
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('fails loudly (does not inject port 0) when the loopback server is not listening', async () => {
|
|
906
|
+
const mockSocket = setup();
|
|
907
|
+
localServerInstances[0].port = 0;
|
|
908
|
+
const relay = relayInstances[0];
|
|
909
|
+
|
|
910
|
+
mockSocket._trigger('acp:start', {
|
|
911
|
+
requestId: 'req-mcp0',
|
|
912
|
+
sessionId: 'sess-mcp0',
|
|
913
|
+
serverType: 'opencode',
|
|
914
|
+
cwd: '/home/user/wt',
|
|
915
|
+
mcpEndpoint: { token: 'tok-0', serverNames: ['tools'] },
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
await vi.waitFor(() => {
|
|
919
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({
|
|
920
|
+
sessionId: 'sess-mcp0',
|
|
921
|
+
success: false,
|
|
922
|
+
}));
|
|
923
|
+
});
|
|
924
|
+
// registerSession 不得被调用(端口校验失败,不注入坏 URL)
|
|
925
|
+
expect(relay.registerSession).not.toHaveBeenCalledWith('sess-mcp0', expect.anything());
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('routes mcp:response to the relay', async () => {
|
|
929
|
+
const mockSocket = setup();
|
|
930
|
+
const relay = relayInstances[0];
|
|
931
|
+
|
|
932
|
+
mockSocket._trigger('mcp:response', { requestId: 'mcpreq_1', status: 200, body: { jsonrpc: '2.0', id: 1, result: {} } });
|
|
933
|
+
|
|
934
|
+
expect(relay.handleMcpResponse).toHaveBeenCalledWith('mcpreq_1', 200, { jsonrpc: '2.0', id: 1, result: {} });
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('outputs an upgrade guide and exits on VERSION_MISMATCH', () => {
|
|
938
|
+
const mockSocket = setup();
|
|
939
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
940
|
+
|
|
941
|
+
mockSocket._trigger('error', { code: 'VERSION_MISMATCH', message: 'Version mismatch' });
|
|
942
|
+
|
|
943
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('upgrade agent-controller'));
|
|
944
|
+
expect(mockSocket.disconnect).toHaveBeenCalled();
|
|
945
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('unregisters the session endpoint when the agent process exits on its own (onStopped)', async () => {
|
|
949
|
+
const mockSocket = setup();
|
|
950
|
+
localServerInstances[0].port = 34567;
|
|
951
|
+
const relay = relayInstances[0];
|
|
952
|
+
|
|
953
|
+
mockSocket._trigger('acp:start', {
|
|
954
|
+
requestId: 'req-exit',
|
|
955
|
+
sessionId: 'sess-exit',
|
|
956
|
+
serverType: 'opencode',
|
|
957
|
+
cwd: '/home/user/wt',
|
|
958
|
+
mcpEndpoint: { token: 'tok-x', serverNames: ['tools'] },
|
|
959
|
+
});
|
|
960
|
+
await vi.waitFor(() => {
|
|
961
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-exit', success: true }));
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
vi.mocked(relay.unregisterSession).mockClear();
|
|
965
|
+
bridgeInstances[0].emit('acp:stopped', { sessionId: 'sess-exit', reason: 'user_close' });
|
|
966
|
+
|
|
967
|
+
expect(relay.unregisterSession).toHaveBeenCalledWith('sess-exit');
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('unregisters the session endpoint on acp:stop', async () => {
|
|
971
|
+
const mockSocket = setup();
|
|
972
|
+
localServerInstances[0].port = 34567;
|
|
973
|
+
const relay = relayInstances[0];
|
|
974
|
+
|
|
975
|
+
mockSocket._trigger('acp:start', {
|
|
976
|
+
requestId: 'req-stop',
|
|
977
|
+
sessionId: 'sess-stop',
|
|
978
|
+
serverType: 'opencode',
|
|
979
|
+
cwd: '/home/user/wt',
|
|
980
|
+
mcpEndpoint: { token: 'tok-s', serverNames: ['tools'] },
|
|
981
|
+
});
|
|
982
|
+
await vi.waitFor(() => {
|
|
983
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId: 'sess-stop', success: true }));
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
vi.mocked(relay.unregisterSession).mockClear();
|
|
987
|
+
mockSocket._trigger('acp:stop', { sessionId: 'sess-stop' });
|
|
988
|
+
|
|
989
|
+
await vi.waitFor(() => {
|
|
990
|
+
expect(relay.unregisterSession).toHaveBeenCalledWith('sess-stop');
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('unregisters the session endpoint on acp:reconcile kill', async () => {
|
|
995
|
+
const mockSocket = setup();
|
|
996
|
+
vi.mocked(processRegistry.listEntries).mockReturnValue([
|
|
997
|
+
{ sessionId: 's-recon', pid: 4444, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
|
|
998
|
+
]);
|
|
999
|
+
const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
|
|
1000
|
+
vi.mocked(isProcessAlive).mockReturnValue(false);
|
|
1001
|
+
const relay = relayInstances[0];
|
|
1002
|
+
vi.mocked(relay.unregisterSession).mockClear();
|
|
1003
|
+
|
|
1004
|
+
mockSocket._trigger('acp:reconcile', { activeSessionIds: [], pendingKillSessionIds: [] });
|
|
1005
|
+
|
|
1006
|
+
await vi.waitFor(() => {
|
|
1007
|
+
expect(relay.unregisterSession).toHaveBeenCalledWith('s-recon');
|
|
1008
|
+
});
|
|
1009
|
+
expect(killSpy).toHaveBeenCalledWith(-4444, 'SIGTERM');
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
818
1012
|
});
|