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