pi-soly 1.9.3 → 1.11.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.
Files changed (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
@@ -0,0 +1,545 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
6
+ import {
7
+ ElicitationCompleteNotificationSchema,
8
+ type CallToolRequest,
9
+ type CallToolResult,
10
+ type ReadResourceResult,
11
+ type UrlElicitationRequiredError,
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+ import type {
14
+ McpTool,
15
+ McpResource,
16
+ ServerDefinition,
17
+ ServerStreamResultPatchNotification,
18
+ Transport,
19
+ } from "./types.ts";
20
+ import { serverStreamResultPatchNotificationSchema } from "./types.ts";
21
+ import { resolveNpxBinary } from "./npx-resolver.ts";
22
+ import { logger } from "./logger.ts";
23
+ import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
24
+ import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts";
25
+ import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
26
+ import {
27
+ handleUrlElicitation,
28
+ registerElicitationHandler,
29
+ type ServerElicitationConfig,
30
+ } from "./elicitation-handler.ts";
31
+ import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
32
+
33
+ interface ServerConnection {
34
+ client: Client;
35
+ transport: Transport;
36
+ definition: ServerDefinition;
37
+ tools: McpTool[];
38
+ resources: McpResource[];
39
+ lastUsedAt: number;
40
+ inFlight: number;
41
+ status: "connected" | "closed" | "needs-auth";
42
+ }
43
+
44
+ type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
45
+
46
+ export class McpServerManager {
47
+ private connections = new Map<string, ServerConnection>();
48
+ private connectPromises = new Map<string, Promise<ServerConnection>>();
49
+ private uiStreamListeners = new Map<string, UiStreamListener>();
50
+ private samplingConfig: ServerSamplingConfig | undefined;
51
+ private elicitationConfig: ServerElicitationConfig | undefined;
52
+ private acceptedUrlElicitations = new Map<string, Set<string>>();
53
+ /** Called after an auto-recovery attempt: args are (serverName, ok, errorMsg).
54
+ * Fired from `callTool()` when a session-expired error triggered close+reconnect.
55
+ * Use to surface notifications ("session auto-recovered" / "recovery failed"). */
56
+ onSessionRecover?: (serverName: string, ok: boolean, errorMsg: string) => void;
57
+
58
+ setSamplingConfig(config: ServerSamplingConfig | undefined): void {
59
+ this.samplingConfig = config;
60
+ }
61
+
62
+ setElicitationConfig(config: ServerElicitationConfig | undefined): void {
63
+ this.elicitationConfig = config;
64
+ }
65
+
66
+ async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
67
+ // Dedupe concurrent connection attempts
68
+ if (this.connectPromises.has(name)) {
69
+ return this.connectPromises.get(name)!;
70
+ }
71
+
72
+ // Reuse existing connection if healthy
73
+ const existing = this.connections.get(name);
74
+ if (existing?.status === "connected") {
75
+ existing.lastUsedAt = Date.now();
76
+ return existing;
77
+ }
78
+
79
+ const promise = this.createConnection(name, definition);
80
+ this.connectPromises.set(name, promise);
81
+
82
+ try {
83
+ const connection = await promise;
84
+ this.connections.set(name, connection);
85
+ return connection;
86
+ } finally {
87
+ this.connectPromises.delete(name);
88
+ }
89
+ }
90
+
91
+ private async createConnection(
92
+ name: string,
93
+ definition: ServerDefinition
94
+ ): Promise<ServerConnection> {
95
+ const client = this.createClient(name);
96
+
97
+ let transport: Transport;
98
+
99
+ if (definition.command) {
100
+ let command = definition.command;
101
+ let args = definition.args ?? [];
102
+
103
+ if (command === "npx" || command === "npm") {
104
+ const resolved = await resolveNpxBinary(command, args);
105
+ if (resolved) {
106
+ command = resolved.isJs ? "node" : resolved.binPath;
107
+ args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
108
+ logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
109
+ }
110
+ }
111
+
112
+ transport = new StdioClientTransport({
113
+ command,
114
+ args,
115
+ env: resolveEnv(definition.env),
116
+ cwd: resolveConfigPath(definition.cwd),
117
+ stderr: definition.debug ? "inherit" : "ignore",
118
+ });
119
+ } else if (definition.url) {
120
+ // HTTP transport with fallback
121
+ transport = await this.createHttpTransport(definition, name);
122
+ } else {
123
+ throw new Error(`Server ${name} has no command or url`);
124
+ }
125
+
126
+ try {
127
+ await client.connect(transport);
128
+ this.attachAdapterNotificationHandlers(name, client);
129
+
130
+ // Discover tools and resources
131
+ const [tools, resources] = await Promise.all([
132
+ this.fetchAllTools(client),
133
+ this.fetchAllResources(client),
134
+ ]);
135
+
136
+ return {
137
+ client,
138
+ transport,
139
+ definition,
140
+ tools,
141
+ resources,
142
+ lastUsedAt: Date.now(),
143
+ inFlight: 0,
144
+ status: "connected",
145
+ };
146
+ } catch (error) {
147
+ // Check for UnauthorizedError - server requires OAuth
148
+ if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
149
+ // Clean up both client and transport before reporting needs-auth.
150
+ await client.close().catch(() => {});
151
+ await transport.close().catch(() => {});
152
+
153
+ return {
154
+ client,
155
+ transport,
156
+ definition,
157
+ tools: [],
158
+ resources: [],
159
+ lastUsedAt: Date.now(),
160
+ inFlight: 0,
161
+ status: "needs-auth",
162
+ };
163
+ }
164
+
165
+ // Clean up both client and transport on any error
166
+ await client.close().catch(() => {});
167
+ await transport.close().catch(() => {});
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ private buildClientCapabilities() {
173
+ return {
174
+ ...(this.samplingConfig ? { sampling: {} } : {}),
175
+ ...(this.elicitationConfig
176
+ ? {
177
+ elicitation: {
178
+ form: {},
179
+ ...(this.elicitationConfig.allowUrl ? { url: {} } : {}),
180
+ },
181
+ }
182
+ : {}),
183
+ };
184
+ }
185
+
186
+ private createClient(serverName: string): Client {
187
+ const capabilities = this.buildClientCapabilities();
188
+ const client = new Client(
189
+ { name: `pi-mcp-${serverName}`, version: "1.0.0" },
190
+ Object.keys(capabilities).length > 0 ? { capabilities } : undefined,
191
+ );
192
+ if (this.samplingConfig) {
193
+ registerSamplingHandler(client, { ...this.samplingConfig, serverName });
194
+ }
195
+ if (this.elicitationConfig) {
196
+ registerElicitationHandler(client, {
197
+ ...this.elicitationConfig,
198
+ serverName,
199
+ onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
200
+ });
201
+ if (this.elicitationConfig.allowUrl) {
202
+ client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
203
+ const accepted = this.acceptedUrlElicitations.get(serverName);
204
+ if (!accepted?.delete(notification.params.elicitationId)) return;
205
+ this.elicitationConfig?.ui.notify(
206
+ `MCP browser interaction for ${serverName} completed. You can retry the tool now.`,
207
+ "info",
208
+ );
209
+ });
210
+ }
211
+ }
212
+ return client;
213
+ }
214
+
215
+ async handleUrlElicitationRequired(
216
+ serverName: string,
217
+ error: UrlElicitationRequiredError,
218
+ ): Promise<"accept" | "decline" | "cancel"> {
219
+ if (!this.elicitationConfig?.allowUrl) return "cancel";
220
+ for (const params of error.elicitations) {
221
+ const result = await handleUrlElicitation({
222
+ ...this.elicitationConfig,
223
+ serverName,
224
+ onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
225
+ }, params);
226
+ if (result.action !== "accept") return result.action;
227
+ }
228
+ return "accept";
229
+ }
230
+
231
+ private rememberUrlElicitation(serverName: string, elicitationId: string): void {
232
+ let accepted = this.acceptedUrlElicitations.get(serverName);
233
+ if (!accepted) {
234
+ accepted = new Set();
235
+ this.acceptedUrlElicitations.set(serverName, accepted);
236
+ }
237
+ accepted.add(elicitationId);
238
+ }
239
+
240
+ private async createHttpTransport(
241
+ definition: ServerDefinition,
242
+ serverName: string
243
+ ): Promise<Transport> {
244
+ const url = new URL(definition.url!);
245
+
246
+ // Build headers first (including any bearer token)
247
+ const headers = resolveHeaders(definition.headers) ?? {};
248
+
249
+ // For bearer auth, add the token to headers BEFORE creating requestInit
250
+ if (definition.auth === "bearer") {
251
+ const token = resolveBearerToken(definition);
252
+ if (token) {
253
+ headers["Authorization"] = `Bearer ${token}`;
254
+ }
255
+ }
256
+
257
+ // Create request init with headers (Authorization now included for bearer auth)
258
+ const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
259
+
260
+ // For OAuth servers, create an auth provider
261
+ let authProvider: McpOAuthProvider | undefined;
262
+ if (supportsOAuth(definition)) {
263
+ const oauthConfig = extractOAuthConfig(definition);
264
+ authProvider = new McpOAuthProvider(
265
+ serverName,
266
+ definition.url!,
267
+ oauthConfig,
268
+ {
269
+ onRedirect: async (_authUrl) => {
270
+ // URL is captured by startAuth, no need to log
271
+ },
272
+ }
273
+ );
274
+ }
275
+
276
+ // Try StreamableHTTP first (modern MCP servers)
277
+ const streamableTransport = new StreamableHTTPClientTransport(url, {
278
+ requestInit,
279
+ authProvider,
280
+ });
281
+
282
+ try {
283
+ // Create a test client to verify the transport works
284
+ const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" });
285
+ await testClient.connect(streamableTransport);
286
+ await testClient.close().catch(() => {});
287
+ // Close probe transport before creating fresh one
288
+ await streamableTransport.close().catch(() => {});
289
+
290
+ // StreamableHTTP works - create fresh transport for actual use
291
+ return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
292
+ } catch (error) {
293
+ // StreamableHTTP failed, close and try SSE fallback
294
+ await streamableTransport.close().catch(() => {});
295
+
296
+ // If this was an UnauthorizedError, don't try SSE - the server needs auth
297
+ if (error instanceof UnauthorizedError) {
298
+ throw error;
299
+ }
300
+
301
+ // SSE is the legacy transport
302
+ return new SSEClientTransport(url, { requestInit, authProvider });
303
+ }
304
+ }
305
+
306
+ private async fetchAllTools(client: Client): Promise<McpTool[]> {
307
+ const allTools: McpTool[] = [];
308
+ let cursor: string | undefined;
309
+
310
+ do {
311
+ const result = await client.listTools(cursor ? { cursor } : undefined);
312
+ allTools.push(...(result.tools ?? []));
313
+ cursor = result.nextCursor;
314
+ } while (cursor);
315
+
316
+ return allTools;
317
+ }
318
+
319
+ private async fetchAllResources(client: Client): Promise<McpResource[]> {
320
+ try {
321
+ const allResources: McpResource[] = [];
322
+ let cursor: string | undefined;
323
+
324
+ do {
325
+ const result = await client.listResources(cursor ? { cursor } : undefined);
326
+ allResources.push(...(result.resources ?? []));
327
+ cursor = result.nextCursor;
328
+ } while (cursor);
329
+
330
+ return allResources;
331
+ } catch {
332
+ // Server may not support resources
333
+ return [];
334
+ }
335
+ }
336
+
337
+ private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
338
+ client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => {
339
+ const listener = this.uiStreamListeners.get(notification.params.streamToken);
340
+ if (!listener) return;
341
+ listener(serverName, notification.params);
342
+ });
343
+ }
344
+
345
+ registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
346
+ this.uiStreamListeners.set(streamToken, listener);
347
+ }
348
+
349
+ removeUiStreamListener(streamToken: string): void {
350
+ this.uiStreamListeners.delete(streamToken);
351
+ }
352
+
353
+ async readResource(name: string, uri: string): Promise<ReadResourceResult> {
354
+ const connection = this.connections.get(name);
355
+ if (!connection || connection.status !== "connected") {
356
+ throw new Error(`Server "${name}" is not connected`);
357
+ }
358
+
359
+ try {
360
+ this.touch(name);
361
+ this.incrementInFlight(name);
362
+ return await connection.client.readResource({ uri });
363
+ } finally {
364
+ this.decrementInFlight(name);
365
+ this.touch(name);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Call a tool with automatic session-recovery.
371
+ *
372
+ * Some MCP servers (notably UE5) return a session id in the `Mcp-Session-Id`
373
+ * response header on `initialize`, then immediately forget it. The next
374
+ * `tools/call` fails with "Unknown session id ... client should reinitialize".
375
+ *
376
+ * This wrapper catches that specific error pattern, closes + reconnects the
377
+ * server (which triggers a fresh `initialize` and new session id), and
378
+ * retries the call once. Other errors are propagated unchanged.
379
+ *
380
+ * Note: this is a per-call recovery. The MCP SDK's transport caches the
381
+ * session id internally; our fix works because `close()` + `connect()`
382
+ * creates a brand new transport + client, so the SDK starts fresh.
383
+ */
384
+ async callTool(
385
+ name: string,
386
+ params: CallToolRequest["params"],
387
+ options?: { signal?: AbortSignal; _bookkeep?: boolean },
388
+ ): Promise<CallToolResult> {
389
+ const doCall = async (): Promise<CallToolResult> => {
390
+ const connection = this.connections.get(name);
391
+ if (!connection || connection.status !== "connected") {
392
+ throw new Error(`Server "${name}" is not connected`);
393
+ }
394
+ // Skip bookkeeping if the caller already did it (legacy path).
395
+ const bookkeeping = options?._bookkeep !== false;
396
+ if (bookkeeping) {
397
+ this.touch(name);
398
+ this.incrementInFlight(name);
399
+ }
400
+ try {
401
+ // The SDK's callTool signature changed in 1.28 to accept an optional
402
+ // resultSchema; we still target 1.27.1. Cast through unknown to
403
+ // bridge the type skew without losing type safety elsewhere.
404
+ const result = await (connection.client.callTool as (
405
+ p: CallToolRequest["params"],
406
+ o?: unknown,
407
+ ) => Promise<CallToolResult>)(
408
+ params as CallToolRequest["params"],
409
+ options,
410
+ );
411
+ return result;
412
+ } finally {
413
+ if (bookkeeping) {
414
+ this.decrementInFlight(name);
415
+ this.touch(name);
416
+ }
417
+ }
418
+ };
419
+
420
+ try {
421
+ return await doCall();
422
+ } catch (firstError) {
423
+ if (!this.isSessionExpiredError(firstError)) throw firstError;
424
+ // Session expired — reconnect and retry once. Caller can listen via
425
+ // the optional onSessionRecover callback to show a notification.
426
+ const connection = this.connections.get(name);
427
+ if (!connection) throw firstError;
428
+ const errMsg = firstError instanceof Error ? firstError.message : String(firstError);
429
+ try {
430
+ await this.close(name);
431
+ } catch {
432
+ // ignore close errors during recovery
433
+ }
434
+ await this.connect(name, connection.definition);
435
+ try {
436
+ const result = await (connection.client.callTool as (
437
+ p: CallToolRequest["params"],
438
+ o?: unknown,
439
+ ) => Promise<CallToolResult>)(
440
+ params as CallToolRequest["params"],
441
+ options,
442
+ );
443
+ this.onSessionRecover?.(name, true, errMsg);
444
+ return result;
445
+ } catch (retryErr) {
446
+ this.onSessionRecover?.(name, false, errMsg);
447
+ throw retryErr;
448
+ }
449
+ }
450
+ }
451
+
452
+ /** Detect the UE5-style "session expired" error pattern. */
453
+ private isSessionExpiredError(error: unknown): boolean {
454
+ if (!error) return false;
455
+ const msg =
456
+ error instanceof Error
457
+ ? error.message
458
+ : typeof error === "object" && error !== null && "message" in error
459
+ ? String((error as { message: unknown }).message)
460
+ : String(error);
461
+ return /unknown session id|client should reinitialize|session.*expired|session.*invalid/i.test(
462
+ msg,
463
+ );
464
+ }
465
+
466
+ async close(name: string): Promise<void> {
467
+ const connection = this.connections.get(name);
468
+ if (!connection) return;
469
+
470
+ // Delete from map BEFORE async cleanup to prevent a race where a
471
+ // concurrent connect() creates a new connection that our deferred
472
+ // delete() would then remove, orphaning the new server process.
473
+ connection.status = "closed";
474
+ this.connections.delete(name);
475
+ this.acceptedUrlElicitations.delete(name);
476
+ await connection.client.close().catch(() => {});
477
+ await connection.transport.close().catch(() => {});
478
+ }
479
+
480
+ async closeAll(): Promise<void> {
481
+ const names = [...this.connections.keys()];
482
+ await Promise.all(names.map(name => this.close(name)));
483
+ }
484
+
485
+ getConnection(name: string): ServerConnection | undefined {
486
+ return this.connections.get(name);
487
+ }
488
+
489
+ getAllConnections(): Map<string, ServerConnection> {
490
+ return new Map(this.connections);
491
+ }
492
+
493
+ touch(name: string): void {
494
+ const connection = this.connections.get(name);
495
+ if (connection) {
496
+ connection.lastUsedAt = Date.now();
497
+ }
498
+ }
499
+
500
+ incrementInFlight(name: string): void {
501
+ const connection = this.connections.get(name);
502
+ if (connection) {
503
+ connection.inFlight = (connection.inFlight ?? 0) + 1;
504
+ }
505
+ }
506
+
507
+ decrementInFlight(name: string): void {
508
+ const connection = this.connections.get(name);
509
+ if (connection && connection.inFlight) {
510
+ connection.inFlight--;
511
+ }
512
+ }
513
+
514
+ isIdle(name: string, timeoutMs: number): boolean {
515
+ const connection = this.connections.get(name);
516
+ if (!connection || connection.status !== "connected") return false;
517
+ if (connection.inFlight > 0) return false;
518
+ return (Date.now() - connection.lastUsedAt) > timeoutMs;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Resolve environment variables with interpolation.
524
+ */
525
+ function resolveEnv(env?: Record<string, string>): Record<string, string> {
526
+ // Copy process.env, filtering out undefined values
527
+ const resolved: Record<string, string> = {};
528
+ for (const [key, value] of Object.entries(process.env)) {
529
+ if (value !== undefined) {
530
+ resolved[key] = value;
531
+ }
532
+ }
533
+
534
+ if (!env) return resolved;
535
+
536
+ const overrides = interpolateEnvRecord(env);
537
+ return overrides ? { ...resolved, ...overrides } : resolved;
538
+ }
539
+
540
+ /**
541
+ * Resolve headers with environment variable interpolation.
542
+ */
543
+ function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
544
+ return interpolateEnvRecord(headers);
545
+ }
package/mcp/state.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ConsentManager } from "./consent-manager.ts";
3
+ import type { McpLifecycleManager } from "./lifecycle.ts";
4
+ import type { McpServerManager } from "./server-manager.ts";
5
+ import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.ts";
6
+ import type { UiResourceHandler } from "./ui-resource-handler.ts";
7
+ import type { UiServerHandle } from "./ui-server.ts";
8
+
9
+ export interface CompletedUiSession {
10
+ serverName: string;
11
+ toolName: string;
12
+ completedAt: Date;
13
+ reason: string;
14
+ messages: UiSessionMessages;
15
+ stream?: UiStreamSummary;
16
+ }
17
+
18
+ export type SendMessageFn = (
19
+ message: {
20
+ customType: string;
21
+ content: Array<{ type: "text"; text: string }>;
22
+ display?: string;
23
+ details?: unknown;
24
+ },
25
+ options?: { triggerTurn?: boolean }
26
+ ) => void;
27
+
28
+ export interface McpExtensionState {
29
+ manager: McpServerManager;
30
+ lifecycle: McpLifecycleManager;
31
+ toolMetadata: Map<string, ToolMetadata[]>;
32
+ config: McpConfig;
33
+ failureTracker: Map<string, number>;
34
+ uiResourceHandler: UiResourceHandler;
35
+ consentManager: ConsentManager;
36
+ uiServer: UiServerHandle | null;
37
+ completedUiSessions: CompletedUiSession[];
38
+ openBrowser: (url: string) => Promise<void>;
39
+ ui?: ExtensionContext["ui"];
40
+ sendMessage?: SendMessageFn;
41
+ }