agent-gear 1.3.0 → 2.1.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 +102 -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 +25 -0
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +115 -4
- package/dist/socketio-client.js.map +1 -1
- package/package.json +2 -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 +106 -0
- package/src/mcp/relay.test.ts +91 -0
- package/src/mcp/relay.ts +94 -0
- package/src/socketio-client.ts +128 -7
- package/tests/acp-wire-mcp.test.ts +4 -4
- package/tests/endpoint-compat.test.ts +192 -0
- package/tests/socketio-client.test.ts +193 -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.1.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;
|
|
@@ -201,20 +203,93 @@ interface GitCommitPayload {
|
|
|
201
203
|
message: string;
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
export interface ParsedEndpoint {
|
|
207
|
+
/** 服务源(协议 + host + port),不含 path/query,例如 `wss://cloud.example.com`。 */
|
|
208
|
+
origin: string;
|
|
209
|
+
/** Socket.IO namespace:旧 endpoint 为 `/`,带 pathname 的 endpoint 取其路径(如 `/worker`)。 */
|
|
210
|
+
namespace: string;
|
|
211
|
+
/** 握手 query 参数(如 `?token=xxx`);旧 endpoint 为空对象。 */
|
|
212
|
+
query: Record<string, string>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 解析 controller endpoint,区分「旧 controller-api endpoint」与「带 path/query 的 cloud-controller endpoint」。
|
|
217
|
+
*
|
|
218
|
+
* - `http://host:4001` → namespace `/`、无 query(对 controller-api 旧行为零变化)
|
|
219
|
+
* - `wss://host/worker?token=x` → namespace `/worker`、query `{ token: 'x' }`
|
|
220
|
+
*
|
|
221
|
+
* socket.io-client v4 原生即按此规则解析 URL(pathname→namespace、search→handshake query),
|
|
222
|
+
* 因此 `connectToServer` 仍直接把 endpoint 透传给 `io()`。本函数用于单测锚定该解析口径,
|
|
223
|
+
* 并为日志输出提供不含凭据的连接目标。
|
|
224
|
+
*/
|
|
225
|
+
export function parseEndpoint(endpoint: string): ParsedEndpoint {
|
|
226
|
+
const result: ParsedEndpoint = { origin: endpoint, namespace: '/', query: {} };
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(endpoint);
|
|
229
|
+
result.origin = url.origin;
|
|
230
|
+
const pathname = url.pathname.replace(/\/+$/, '');
|
|
231
|
+
result.namespace = pathname === '' ? '/' : pathname;
|
|
232
|
+
for (const [name, value] of url.searchParams) {
|
|
233
|
+
result.query[name] = value;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// 非法 URL:保持透传(origin=原始字符串、默认 namespace、无 query),与既有行为一致
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function extractHandshakeToken(endpoint: string): string | undefined {
|
|
242
|
+
try {
|
|
243
|
+
return new URL(endpoint).searchParams.get('token') ?? undefined;
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 从 endpoint 移除一次性 token query(问题 11:token 不进 URL/access log,改由 Socket.IO
|
|
251
|
+
* 握手 `auth.token` 承载)。无 token 时原样返回。
|
|
252
|
+
*/
|
|
253
|
+
export function stripTokenFromEndpoint(endpoint: string): string {
|
|
254
|
+
try {
|
|
255
|
+
const url = new URL(endpoint);
|
|
256
|
+
if (!url.searchParams.has('token')) return endpoint;
|
|
257
|
+
url.searchParams.delete('token');
|
|
258
|
+
return url.toString();
|
|
259
|
+
} catch {
|
|
260
|
+
return endpoint;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
204
264
|
export function connectToServer(
|
|
205
265
|
endpoint: string,
|
|
206
266
|
key: string,
|
|
207
267
|
options?: { serverName?: string },
|
|
208
268
|
): Socket {
|
|
209
|
-
|
|
269
|
+
// 一次性 token 优先取 endpoint query(兼容旧注入),否则用 key(cloud-controller 场景 key=token)。
|
|
270
|
+
// token 不再放进 URL,改经 Socket.IO 握手 auth 传递,避免进入 ingress/中间层访问日志。
|
|
271
|
+
const handshakeToken = extractHandshakeToken(endpoint) ?? (key || undefined);
|
|
272
|
+
const connectEndpoint = stripTokenFromEndpoint(endpoint);
|
|
273
|
+
|
|
274
|
+
const socket = createSocketIOClient(connectEndpoint, {
|
|
210
275
|
transports: ['websocket'],
|
|
211
276
|
reconnection: true,
|
|
212
277
|
reconnectionDelay: 1000,
|
|
213
278
|
reconnectionDelayMax: 60000,
|
|
279
|
+
auth: handshakeToken ? { token: handshakeToken } : {},
|
|
214
280
|
});
|
|
215
281
|
|
|
216
282
|
const heartbeat = new Heartbeat();
|
|
217
283
|
const controllerName = options?.serverName || hostname();
|
|
284
|
+
const mcpRelay = new McpRelay((payload) => socket.emit('mcp:request', payload));
|
|
285
|
+
const localServer = new LocalMcpServer(mcpRelay);
|
|
286
|
+
// 回环 HTTP 服务在守护进程内只启动一次、进程退出前不停止(提案 Phase 2 要求全守护进程共享一个端口)。
|
|
287
|
+
// 断线快速失败由 mcpRelay.handleDisconnect()(503)承担,无需随 socket 连接启停 HTTP 服务。
|
|
288
|
+
// ready promise 保证 acp:start/acp:resume 读取 localServer.port 前服务已 listening(启动只 await 一次)。
|
|
289
|
+
const mcpServerReady = localServer.start();
|
|
290
|
+
mcpServerReady.catch((err: Error) => {
|
|
291
|
+
console.error(`[MCP] failed to start local MCP endpoint: ${err.message}`);
|
|
292
|
+
});
|
|
218
293
|
|
|
219
294
|
// Seed the fs:remove-dir whitelist with cwds of ACP processes that survived
|
|
220
295
|
// a controller restart (their worktrees remain controller-managed).
|
|
@@ -223,7 +298,9 @@ export function connectToServer(
|
|
|
223
298
|
}
|
|
224
299
|
|
|
225
300
|
socket.on('connect', () => {
|
|
226
|
-
|
|
301
|
+
// 不直接打印 endpoint:cloud-controller 场景下 endpoint 含一次性 token,避免凭据进入日志
|
|
302
|
+
const { origin, namespace } = parseEndpoint(connectEndpoint);
|
|
303
|
+
console.log(`Connected to ${origin}${namespace === '/' ? '' : namespace}`);
|
|
227
304
|
const resumeToken = getResumeTokenFromConfig();
|
|
228
305
|
socket.emit('register', {
|
|
229
306
|
key,
|
|
@@ -246,6 +323,13 @@ export function connectToServer(
|
|
|
246
323
|
|
|
247
324
|
socket.on('error', (data: ErrorPayload) => {
|
|
248
325
|
console.error(`Server error: ${data.code} - ${data.message}`);
|
|
326
|
+
if (data.code === 'VERSION_MISMATCH') {
|
|
327
|
+
console.error('The agent-controller version is not supported by the server.');
|
|
328
|
+
console.error('Please upgrade agent-controller to 2.x (npm i -g agent-gear@2 or the matching release).');
|
|
329
|
+
socket.disconnect();
|
|
330
|
+
process.exit(1);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
249
333
|
if (data.code === 'KEY_ALREADY_ONLINE') {
|
|
250
334
|
console.log('Clearing resume token and retrying connection...');
|
|
251
335
|
clearResumeTokenFromConfig();
|
|
@@ -319,11 +403,23 @@ export function connectToServer(
|
|
|
319
403
|
await existingBridge.stop().catch(() => {});
|
|
320
404
|
}
|
|
321
405
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
406
|
+
if (data.mcpEndpoint) {
|
|
407
|
+
// 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
|
|
408
|
+
await mcpServerReady;
|
|
409
|
+
if (localServer.port <= 0) {
|
|
410
|
+
throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
|
|
411
|
+
}
|
|
412
|
+
mcpRelay.registerSession(data.sessionId, {
|
|
413
|
+
token: data.mcpEndpoint.token,
|
|
414
|
+
serverNames: data.mcpEndpoint.serverNames,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
322
417
|
const bridge = new AcpSessionBridge(socket, {
|
|
323
418
|
sessionId: data.sessionId,
|
|
324
419
|
cwd: data.cwd,
|
|
325
420
|
mcpServers: data.mcpServers,
|
|
326
421
|
mcpEndpoint: data.mcpEndpoint,
|
|
422
|
+
mcpPort: localServer.port,
|
|
327
423
|
additionalDirectories: data.additionalDirectories,
|
|
328
424
|
initialModeId: data.modeId,
|
|
329
425
|
initialModelId: data.modelId,
|
|
@@ -338,6 +434,7 @@ export function connectToServer(
|
|
|
338
434
|
bridge.on('acp:stopped', (payload: { sessionId: string }) => {
|
|
339
435
|
if (payload.sessionId === data.sessionId) {
|
|
340
436
|
processRegistry.deregister(data.sessionId);
|
|
437
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
341
438
|
}
|
|
342
439
|
});
|
|
343
440
|
|
|
@@ -406,6 +503,7 @@ export function connectToServer(
|
|
|
406
503
|
bridge.off('acp:stopped', onStopped);
|
|
407
504
|
acpBridges.delete(data.sessionId);
|
|
408
505
|
processRegistry.deregister(data.sessionId);
|
|
506
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
409
507
|
console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
410
508
|
socket.emit('acp:start:result', {
|
|
411
509
|
requestId: data.requestId,
|
|
@@ -422,6 +520,7 @@ export function connectToServer(
|
|
|
422
520
|
await bridge.start();
|
|
423
521
|
} catch (error) {
|
|
424
522
|
console.error(`[ACP ctrl] bridge.start() failed: ${(error as Error).message}`);
|
|
523
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
425
524
|
socket.emit('acp:start:result', {
|
|
426
525
|
requestId: data.requestId,
|
|
427
526
|
sessionId: data.sessionId,
|
|
@@ -443,10 +542,22 @@ export function connectToServer(
|
|
|
443
542
|
await existingBridge.stop().catch(() => {});
|
|
444
543
|
}
|
|
445
544
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
545
|
+
if (data.mcpEndpoint) {
|
|
546
|
+
// 确保回环服务已 listening 后再读取 port 并注入(启动失败时 fail loudly,不静默注入端口 0)
|
|
547
|
+
await mcpServerReady;
|
|
548
|
+
if (localServer.port <= 0) {
|
|
549
|
+
throw new Error(`MCP endpoint unavailable: local loopback server not listening (port=${localServer.port})`);
|
|
550
|
+
}
|
|
551
|
+
mcpRelay.registerSession(data.sessionId, {
|
|
552
|
+
token: data.mcpEndpoint.token,
|
|
553
|
+
serverNames: data.mcpEndpoint.serverNames,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
446
556
|
const bridge = new AcpSessionBridge(socket, {
|
|
447
557
|
sessionId: data.sessionId,
|
|
448
558
|
cwd: data.cwd,
|
|
449
559
|
mcpEndpoint: data.mcpEndpoint,
|
|
560
|
+
mcpPort: localServer.port,
|
|
450
561
|
additionalDirectories: data.additionalDirectories,
|
|
451
562
|
// 初始 mode/model:resume 完成后、acp:initialized 之前应用(时序同 acp:start)
|
|
452
563
|
initialModeId: data.modeId,
|
|
@@ -459,6 +570,7 @@ export function connectToServer(
|
|
|
459
570
|
bridge.on('acp:stopped', (payload: { sessionId: string }) => {
|
|
460
571
|
if (payload.sessionId === data.sessionId) {
|
|
461
572
|
processRegistry.deregister(data.sessionId);
|
|
573
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
462
574
|
}
|
|
463
575
|
});
|
|
464
576
|
|
|
@@ -497,6 +609,7 @@ export function connectToServer(
|
|
|
497
609
|
bridge.off('acp:stopped', onStopped);
|
|
498
610
|
acpBridges.delete(data.sessionId);
|
|
499
611
|
processRegistry.deregister(data.sessionId);
|
|
612
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
500
613
|
console.log(`[ACP ctrl] emitting acp:resume:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
501
614
|
socket.emit('acp:resume:result', {
|
|
502
615
|
requestId: data.requestId,
|
|
@@ -512,6 +625,7 @@ export function connectToServer(
|
|
|
512
625
|
await bridge.resume(data.externalSessionId);
|
|
513
626
|
} catch (error) {
|
|
514
627
|
console.error(`[ACP ctrl] bridge.resume() failed: ${(error as Error).message}`);
|
|
628
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
515
629
|
socket.emit('acp:resume:result', {
|
|
516
630
|
requestId: data.requestId,
|
|
517
631
|
sessionId: data.sessionId,
|
|
@@ -693,6 +807,7 @@ export function connectToServer(
|
|
|
693
807
|
processRegistry.deregister(data.sessionId);
|
|
694
808
|
}
|
|
695
809
|
}
|
|
810
|
+
mcpRelay.unregisterSession(data.sessionId);
|
|
696
811
|
if (data.ackRequired) {
|
|
697
812
|
socket.emit('acp:stopped', {
|
|
698
813
|
sessionId: data.sessionId,
|
|
@@ -967,6 +1082,7 @@ export function connectToServer(
|
|
|
967
1082
|
}
|
|
968
1083
|
} catch { /* process may already be dead */ }
|
|
969
1084
|
processRegistry.deregister(entry.sessionId);
|
|
1085
|
+
mcpRelay.unregisterSession(entry.sessionId);
|
|
970
1086
|
// Also clean up bridge if present
|
|
971
1087
|
const bridge = acpBridges.get(entry.sessionId);
|
|
972
1088
|
if (bridge) {
|
|
@@ -1014,12 +1130,17 @@ export function connectToServer(
|
|
|
1014
1130
|
}
|
|
1015
1131
|
});
|
|
1016
1132
|
|
|
1133
|
+
socket.on('mcp:response', (data: { requestId: string; status: number; body: unknown }) => {
|
|
1134
|
+
mcpRelay.handleMcpResponse(data.requestId, data.status, data.body);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1017
1137
|
socket.on('disconnect', (reason) => {
|
|
1018
1138
|
console.log(`Disconnected: ${reason}`);
|
|
1019
1139
|
heartbeat.stop();
|
|
1020
1140
|
gatewayManager.closeAll();
|
|
1021
1141
|
permissionRequestBridge.clear();
|
|
1022
1142
|
elicitationRequestBridge.clear();
|
|
1143
|
+
mcpRelay.handleDisconnect();
|
|
1023
1144
|
});
|
|
1024
1145
|
|
|
1025
1146
|
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
|
|