@phi-code-admin/phi-code 0.86.0 → 0.88.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/CHANGELOG.md +104 -0
- package/docs/adr/0001-phase-contract.md +57 -0
- package/docs/adr/0002-independent-review.md +47 -0
- package/docs/fork-policy.md +18 -0
- package/extensions/phi/agents.ts +7 -4
- package/extensions/phi/benchmark.ts +129 -49
- package/extensions/phi/browser.ts +10 -37
- package/extensions/phi/btw/btw.ts +1 -7
- package/extensions/phi/chrome/index.ts +1283 -741
- package/extensions/phi/commit.ts +9 -14
- package/extensions/phi/goal/index.ts +10 -33
- package/extensions/phi/init.ts +37 -47
- package/extensions/phi/keys.ts +2 -7
- package/extensions/phi/mcp/callback-server.ts +162 -165
- package/extensions/phi/mcp/config.ts +122 -136
- package/extensions/phi/mcp/errors.ts +18 -23
- package/extensions/phi/mcp/index.ts +322 -355
- package/extensions/phi/mcp/oauth-provider.ts +289 -289
- package/extensions/phi/mcp/server-manager.ts +390 -413
- package/extensions/phi/mcp/tool-bridge.ts +381 -415
- package/extensions/phi/memory.ts +2 -2
- package/extensions/phi/models.ts +27 -26
- package/extensions/phi/orchestrator.ts +451 -237
- package/extensions/phi/productivity.ts +4 -2
- package/extensions/phi/providers/agent-def.ts +1 -1
- package/extensions/phi/providers/alibaba.ts +56 -7
- package/extensions/phi/providers/context-window.ts +4 -1
- package/extensions/phi/providers/live-models.ts +5 -20
- package/extensions/phi/providers/orchestrator-helpers.ts +31 -5
- package/extensions/phi/providers/phase-machine.ts +283 -0
- package/extensions/phi/setup.ts +196 -169
- package/extensions/phi/skill-loader.ts +14 -17
- package/extensions/phi/smart-router.ts +6 -6
- package/extensions/phi/web-search.ts +90 -50
- package/package.json +1 -1
|
@@ -12,20 +12,20 @@
|
|
|
12
12
|
* - PID tracking for safety-net SIGKILL on shutdown failure
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
15
16
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
16
18
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
17
19
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
19
20
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
20
21
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
ListRootsRequestSchema,
|
|
23
|
+
LoggingMessageNotificationSchema,
|
|
24
|
+
ToolListChangedNotificationSchema,
|
|
24
25
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
26
|
+
import type { McpConfig, ServerConfig, Settings } from "./config.js";
|
|
25
27
|
import { McpError } from "./errors.js";
|
|
26
|
-
import
|
|
27
|
-
import { McpOAuthProvider, getAuthStatus, resetAuth } from "./oauth-provider.js";
|
|
28
|
-
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
28
|
+
import { getAuthStatus, McpOAuthProvider, resetAuth } from "./oauth-provider.js";
|
|
29
29
|
|
|
30
30
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
@@ -38,427 +38,404 @@ const RETRY_DELAYS_MS = [1000, 3000, 5000, 10000, 30000] as const;
|
|
|
38
38
|
const STDERR_BUFFER_SIZE = 100;
|
|
39
39
|
|
|
40
40
|
export interface ManagedServer {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
41
|
+
name: string;
|
|
42
|
+
config: ServerConfig;
|
|
43
|
+
state: ServerState;
|
|
44
|
+
client: Client | null;
|
|
45
|
+
/** PID of the child process (stdio transport only). Used for safety-net cleanup. */
|
|
46
|
+
childPid: number | null;
|
|
47
|
+
retryCount: number;
|
|
48
|
+
lastError: Error | null;
|
|
49
|
+
/** Recent stderr lines from the server subprocess. */
|
|
50
|
+
stderrLog: string[];
|
|
51
|
+
healthCheckTimer: ReturnType<typeof setInterval> | null;
|
|
52
|
+
/** Pending retry timeout — cleared on shutdown to prevent ghost reconnects. */
|
|
53
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** Called after tool list is refreshed for a server (e.g. on list_changed notification). */
|
|
57
57
|
export type ToolRefreshCallback = (serverName: string, client: Client) => Promise<void>;
|
|
58
58
|
|
|
59
59
|
export interface TransportAuthCallbacks {
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/** Called when OAuth authorization is required (browser redirect needed). */
|
|
61
|
+
onAuthRequired: (serverName: string, authorizationUrl: URL) => void | Promise<void>;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// ─── Transport Factory ────────────────────────────────────────────────────────
|
|
65
65
|
|
|
66
66
|
function createTransport(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
serverName: string,
|
|
68
|
+
config: ServerConfig,
|
|
69
|
+
onStderr: (line: string) => void,
|
|
70
|
+
authCallbacks?: TransportAuthCallbacks,
|
|
71
71
|
): Transport {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
) as unknown as Transport;
|
|
119
|
-
case "sse":
|
|
120
|
-
return new SSEClientTransport(
|
|
121
|
-
new URL(config.url!),
|
|
122
|
-
{
|
|
123
|
-
...(requestInit && { requestInit }),
|
|
124
|
-
...(authProvider && { authProvider }),
|
|
125
|
-
},
|
|
126
|
-
);
|
|
127
|
-
}
|
|
72
|
+
// Build requestInit for static headers (API keys, etc.)
|
|
73
|
+
const requestInit: RequestInit | undefined = config.headers ? { headers: config.headers } : undefined;
|
|
74
|
+
|
|
75
|
+
// Build OAuth authProvider if auth config is present
|
|
76
|
+
let authProvider: OAuthClientProvider | undefined;
|
|
77
|
+
if (config.auth && config.transport !== "stdio") {
|
|
78
|
+
authProvider = new McpOAuthProvider(
|
|
79
|
+
serverName,
|
|
80
|
+
config.auth,
|
|
81
|
+
authCallbacks ? (url: URL) => authCallbacks.onAuthRequired(serverName, url) : undefined,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (config.transport) {
|
|
86
|
+
case "stdio": {
|
|
87
|
+
// Build clean env: process.env may contain undefined values,
|
|
88
|
+
// child_process.spawn silently drops them, but let's be explicit
|
|
89
|
+
const env: Record<string, string> = {};
|
|
90
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
91
|
+
if (value !== undefined) env[key] = value;
|
|
92
|
+
}
|
|
93
|
+
Object.assign(env, config.env ?? {});
|
|
94
|
+
const transport = new StdioClientTransport({
|
|
95
|
+
command: config.command!,
|
|
96
|
+
args: config.args,
|
|
97
|
+
env,
|
|
98
|
+
stderr: "pipe",
|
|
99
|
+
});
|
|
100
|
+
// Capture stderr lines into the circular buffer
|
|
101
|
+
transport.stderr?.on("data", (chunk: Buffer) => {
|
|
102
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
103
|
+
for (const line of lines) onStderr(line);
|
|
104
|
+
});
|
|
105
|
+
return transport;
|
|
106
|
+
}
|
|
107
|
+
case "streamable-http":
|
|
108
|
+
return new StreamableHTTPClientTransport(new URL(config.url!), {
|
|
109
|
+
...(requestInit && { requestInit }),
|
|
110
|
+
...(authProvider && { authProvider }),
|
|
111
|
+
}) as unknown as Transport;
|
|
112
|
+
case "sse":
|
|
113
|
+
return new SSEClientTransport(new URL(config.url!), {
|
|
114
|
+
...(requestInit && { requestInit }),
|
|
115
|
+
...(authProvider && { authProvider }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
128
118
|
}
|
|
129
119
|
|
|
130
120
|
// ─── ServerManager ────────────────────────────────────────────────────────────
|
|
131
121
|
|
|
132
122
|
export class ServerManager {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
// - streamable-http/sse: closes HTTP connections
|
|
452
|
-
await client?.close();
|
|
453
|
-
} catch {
|
|
454
|
-
// If SDK cleanup fails, force kill the subprocess as a safety net
|
|
455
|
-
if (pid !== null) {
|
|
456
|
-
try {
|
|
457
|
-
process.kill(pid, "SIGKILL");
|
|
458
|
-
} catch {
|
|
459
|
-
// Process may already be dead
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
123
|
+
private readonly servers = new Map<string, ManagedServer>();
|
|
124
|
+
private settings: Settings;
|
|
125
|
+
private onToolRefresh: ToolRefreshCallback | null = null;
|
|
126
|
+
|
|
127
|
+
private authCallbacks: TransportAuthCallbacks | undefined;
|
|
128
|
+
|
|
129
|
+
constructor(config: McpConfig, authCallbacks?: TransportAuthCallbacks) {
|
|
130
|
+
this.settings = config.settings;
|
|
131
|
+
this.authCallbacks = authCallbacks;
|
|
132
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
133
|
+
this.servers.set(name, {
|
|
134
|
+
name,
|
|
135
|
+
config: serverConfig,
|
|
136
|
+
state: "stopped",
|
|
137
|
+
client: null,
|
|
138
|
+
childPid: null,
|
|
139
|
+
retryCount: 0,
|
|
140
|
+
lastError: null,
|
|
141
|
+
stderrLog: [],
|
|
142
|
+
healthCheckTimer: null,
|
|
143
|
+
retryTimer: null,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Register callback invoked after tool list changes for a server. */
|
|
149
|
+
setToolRefreshCallback(cb: ToolRefreshCallback): void {
|
|
150
|
+
this.onToolRefresh = cb;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getServer(name: string): ManagedServer | undefined {
|
|
154
|
+
return this.servers.get(name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getAllServers(): ManagedServer[] {
|
|
158
|
+
return Array.from(this.servers.values());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getReadyServers(): ManagedServer[] {
|
|
162
|
+
return this.getAllServers().filter((s) => s.state === "ready");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Status summary for /mcp command. */
|
|
166
|
+
getStatusSummary(): string {
|
|
167
|
+
const all = this.getAllServers();
|
|
168
|
+
if (all.length === 0) return "pi-mcp: No servers configured (create ~/.pi/agent/mcp.json)";
|
|
169
|
+
const lines = all.map((s) => {
|
|
170
|
+
const icon = s.state === "ready" ? "✓" : s.state === "starting" ? "⟳" : "✗";
|
|
171
|
+
const err = s.lastError ? ` — ${s.lastError.message}` : "";
|
|
172
|
+
return ` ${icon} ${s.name} (${s.state})${err}`;
|
|
173
|
+
});
|
|
174
|
+
const ready = all.filter((s) => s.state === "ready").length;
|
|
175
|
+
return [`MCP: ${ready}/${all.length} servers ready`, ...lines].join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Reset OAuth credentials for a server, forcing re-authorization on next connect. */
|
|
179
|
+
async resetServerAuth(name: string): Promise<void> {
|
|
180
|
+
await resetAuth(name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Get auth status for a server. */
|
|
184
|
+
async getServerAuthStatus(name: string): Promise<{
|
|
185
|
+
hasTokens: boolean;
|
|
186
|
+
hasClientInfo: boolean;
|
|
187
|
+
savedAt: string | undefined;
|
|
188
|
+
scope: string | undefined;
|
|
189
|
+
} | null> {
|
|
190
|
+
return getAuthStatus(name);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Get recent stderr output for a server. */
|
|
194
|
+
getServerLogs(name: string): string {
|
|
195
|
+
const server = this.servers.get(name);
|
|
196
|
+
if (!server) return `No server named "${name}"`;
|
|
197
|
+
if (server.stderrLog.length === 0) return `(no stderr output from ${name})`;
|
|
198
|
+
return server.stderrLog.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Start a server and connect to it.
|
|
205
|
+
* cwd is passed to roots/list — the workspace root exposed to the MCP server.
|
|
206
|
+
*/
|
|
207
|
+
async startServer(name: string, cwd: string): Promise<void> {
|
|
208
|
+
const server = this.servers.get(name);
|
|
209
|
+
if (!server) {
|
|
210
|
+
throw new McpError(`Unknown server "${name}"`, name, "config");
|
|
211
|
+
}
|
|
212
|
+
if (server.state !== "stopped") return; // Already starting or ready
|
|
213
|
+
// Reset retry count on explicit start — allows /mcp:start after exhaustion
|
|
214
|
+
server.retryCount = 0;
|
|
215
|
+
await this._connect(server, cwd);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async stopServer(name: string): Promise<void> {
|
|
219
|
+
const server = this.servers.get(name);
|
|
220
|
+
if (!server || server.state === "stopped") return;
|
|
221
|
+
await this._shutdown(server);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async shutdownAll(): Promise<void> {
|
|
225
|
+
await Promise.allSettled(Array.from(this.servers.values()).map((s) => this._shutdown(s)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Rebuild the server map from a new config.
|
|
230
|
+
* Must only be called after shutdownAll() — old servers are discarded.
|
|
231
|
+
* New servers that didn't exist before are added; servers removed from
|
|
232
|
+
* config are dropped (their tools should already be deactivated).
|
|
233
|
+
*/
|
|
234
|
+
rebuildServers(config: McpConfig): void {
|
|
235
|
+
this.settings = config.settings;
|
|
236
|
+
this.servers.clear();
|
|
237
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
238
|
+
this.servers.set(name, {
|
|
239
|
+
name,
|
|
240
|
+
config: serverConfig,
|
|
241
|
+
state: "stopped",
|
|
242
|
+
client: null,
|
|
243
|
+
childPid: null,
|
|
244
|
+
retryCount: 0,
|
|
245
|
+
lastError: null,
|
|
246
|
+
stderrLog: [],
|
|
247
|
+
healthCheckTimer: null,
|
|
248
|
+
retryTimer: null,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
private async _connect(server: ManagedServer, cwd: string): Promise<void> {
|
|
256
|
+
server.state = "starting";
|
|
257
|
+
server.lastError = null;
|
|
258
|
+
// Note: retryCount is NOT reset here — it's only reset after successful connect.
|
|
259
|
+
// This ensures the retry limit is respected across reconnection attempts.
|
|
260
|
+
// It IS reset when startServer() is called explicitly (e.g. /mcp:start)
|
|
261
|
+
// via the caller having already set retryCount = 0 before calling _connect.
|
|
262
|
+
|
|
263
|
+
const appendStderr = (line: string): void => {
|
|
264
|
+
server.stderrLog.push(line);
|
|
265
|
+
if (server.stderrLog.length > STDERR_BUFFER_SIZE) {
|
|
266
|
+
server.stderrLog.shift();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
let transport: Transport;
|
|
271
|
+
try {
|
|
272
|
+
transport = createTransport(server.name, server.config, appendStderr, this.authCallbacks);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
server.state = "stopped";
|
|
275
|
+
server.lastError = err instanceof Error ? err : new Error(String(err));
|
|
276
|
+
throw new McpError(`Failed to create transport: ${server.lastError.message}`, server.name, "connection", err);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const client = new Client(
|
|
280
|
+
{ name: "pi-mcp", version: "1.0.0" },
|
|
281
|
+
{
|
|
282
|
+
capabilities: {
|
|
283
|
+
// Expose workspace root to MCP servers
|
|
284
|
+
roots: { listChanged: true },
|
|
285
|
+
// Sampling: explicitly NOT declared — not supported in v1
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Handle roots/list requests from the server
|
|
291
|
+
client.setRequestHandler(ListRootsRequestSchema, async () => ({
|
|
292
|
+
// file:// URI for the workspace root. On Windows this produces
|
|
293
|
+
// file://C:\... which is technically non-standard but functional
|
|
294
|
+
// for the common case (MCP servers use roots as hints, not strict paths).
|
|
295
|
+
roots: [{ uri: `file://${cwd}`, name: "workspace" }],
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
// tools/list_changed: re-discover tools and update Pi registrations
|
|
299
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
300
|
+
if (this.onToolRefresh && server.client) {
|
|
301
|
+
try {
|
|
302
|
+
await this.onToolRefresh(server.name, server.client);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(`[pi-mcp] Failed to refresh tools for ${server.name}:`, err);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// notifications/message: structured logging from MCP servers
|
|
310
|
+
client.setNotificationHandler(LoggingMessageNotificationSchema, async (notification) => {
|
|
311
|
+
const { level = "info", logger = server.name, data } = notification.params ?? {};
|
|
312
|
+
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
313
|
+
console.error(`[pi-mcp:${server.name}] [${level}] ${logger}: ${msg}`);
|
|
314
|
+
appendStderr(`[${level}] ${logger}: ${msg}`);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await client.connect(transport);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
server.state = "stopped";
|
|
321
|
+
server.lastError = err instanceof Error ? err : new Error(String(err));
|
|
322
|
+
// Clean up the transport we created (client.close() also closes transport)
|
|
323
|
+
try {
|
|
324
|
+
await client.close();
|
|
325
|
+
} catch {
|
|
326
|
+
/* best effort */
|
|
327
|
+
}
|
|
328
|
+
// Attempt retry if under the limit
|
|
329
|
+
await this._scheduleRetry(server, cwd);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Guard against shutdown being called while we were connecting.
|
|
334
|
+
// _shutdown sets state to "stopped" but can't close the local client variable.
|
|
335
|
+
if (server.state !== "starting") {
|
|
336
|
+
try {
|
|
337
|
+
await client.close();
|
|
338
|
+
} catch {
|
|
339
|
+
/* best effort */
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Extract PID from stdio transport for safety-net cleanup
|
|
345
|
+
if (server.config.transport === "stdio") {
|
|
346
|
+
// StdioClientTransport exposes the underlying process
|
|
347
|
+
server.childPid = (transport as any).process?.pid ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
server.client = client;
|
|
351
|
+
server.state = "ready";
|
|
352
|
+
server.retryCount = 0;
|
|
353
|
+
server.lastError = null;
|
|
354
|
+
|
|
355
|
+
// Start opt-in health check
|
|
356
|
+
if (server.config.healthCheckIntervalMs) {
|
|
357
|
+
server.healthCheckTimer = setInterval(async () => {
|
|
358
|
+
try {
|
|
359
|
+
await client.ping();
|
|
360
|
+
} catch {
|
|
361
|
+
clearInterval(server.healthCheckTimer!);
|
|
362
|
+
server.healthCheckTimer = null;
|
|
363
|
+
console.error(`[pi-mcp] Health check failed for ${server.name}, reconnecting`);
|
|
364
|
+
server.state = "stopped";
|
|
365
|
+
await this._scheduleRetry(server, cwd);
|
|
366
|
+
}
|
|
367
|
+
}, server.config.healthCheckIntervalMs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Trigger initial tool registration
|
|
371
|
+
if (this.onToolRefresh) {
|
|
372
|
+
try {
|
|
373
|
+
await this.onToolRefresh(server.name, client);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
console.error(`[pi-mcp] Initial tool registration failed for ${server.name}:`, err);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private async _scheduleRetry(server: ManagedServer, cwd: string): Promise<void> {
|
|
381
|
+
const maxRetries = this.settings.maxRetries;
|
|
382
|
+
if (server.retryCount >= maxRetries) {
|
|
383
|
+
console.error(
|
|
384
|
+
`[pi-mcp] Server "${server.name}" failed after ${maxRetries} retries: ${server.lastError?.message}`,
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const delayMs = RETRY_DELAYS_MS[Math.min(server.retryCount, RETRY_DELAYS_MS.length - 1)] ?? 30000;
|
|
390
|
+
server.retryCount++;
|
|
391
|
+
console.error(`[pi-mcp] Retrying "${server.name}" in ${delayMs}ms (attempt ${server.retryCount}/${maxRetries})`);
|
|
392
|
+
|
|
393
|
+
await new Promise<void>((resolve) => {
|
|
394
|
+
server.retryTimer = setTimeout(() => {
|
|
395
|
+
server.retryTimer = null;
|
|
396
|
+
resolve();
|
|
397
|
+
}, delayMs);
|
|
398
|
+
});
|
|
399
|
+
if (server.state !== "stopped") return; // May have been stopped externally
|
|
400
|
+
await this._connect(server, cwd);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async _shutdown(server: ManagedServer): Promise<void> {
|
|
404
|
+
if (server.state === "stopped") return;
|
|
405
|
+
|
|
406
|
+
// Cancel pending retry
|
|
407
|
+
if (server.retryTimer) {
|
|
408
|
+
clearTimeout(server.retryTimer);
|
|
409
|
+
server.retryTimer = null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Stop health check
|
|
413
|
+
if (server.healthCheckTimer) {
|
|
414
|
+
clearInterval(server.healthCheckTimer);
|
|
415
|
+
server.healthCheckTimer = null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
server.state = "stopped";
|
|
419
|
+
server.lastError = null;
|
|
420
|
+
const client = server.client;
|
|
421
|
+
const pid = server.childPid;
|
|
422
|
+
server.client = null;
|
|
423
|
+
server.childPid = null;
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
// SDK handles transport-specific cleanup:
|
|
427
|
+
// - stdio: closes stdin, waits for process exit, sends SIGTERM/SIGKILL
|
|
428
|
+
// - streamable-http/sse: closes HTTP connections
|
|
429
|
+
await client?.close();
|
|
430
|
+
} catch {
|
|
431
|
+
// If SDK cleanup fails, force kill the subprocess as a safety net
|
|
432
|
+
if (pid !== null) {
|
|
433
|
+
try {
|
|
434
|
+
process.kill(pid, "SIGKILL");
|
|
435
|
+
} catch {
|
|
436
|
+
// Process may already be dead
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
464
441
|
}
|