@ian-pascoe/pi-mcp 0.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.
@@ -0,0 +1,971 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import {
3
+ ProtocolError,
4
+ RegistrationRejectedError,
5
+ SdkError,
6
+ SdkErrorCode,
7
+ UnauthorizedError,
8
+ type AuthProvider,
9
+ type Client,
10
+ type LoggingLevel,
11
+ type OAuthClientProvider,
12
+ } from "@modelcontextprotocol/client";
13
+ import {
14
+ McpServerClient,
15
+ type McpServerClientConnectOptions,
16
+ type McpServerRunOptions,
17
+ } from "./mcp-server-client.js";
18
+ import type { McpSessionFiles } from "./mcp-session-files.js";
19
+ import type { McpServerDefinition, ResolvedMcpSettings } from "./pi-mcp-settings.js";
20
+
21
+ const DEFAULT_INSTRUCTION_DEADLINE_MS = 10_000;
22
+
23
+ const TERMINAL_MCP_SDK_ERROR_CODES: ReadonlySet<SdkErrorCode> = new Set([
24
+ SdkErrorCode.CapabilityNotSupported,
25
+ SdkErrorCode.InvalidResult,
26
+ SdkErrorCode.UnsupportedResultType,
27
+ SdkErrorCode.MethodNotSupportedByProtocolVersion,
28
+ SdkErrorCode.EraNegotiationFailed,
29
+ SdkErrorCode.ClientHttpNotImplemented,
30
+ SdkErrorCode.ClientHttpUnexpectedContent,
31
+ ]);
32
+
33
+ /** Clock boundary used for retry and first-request deadlines. */
34
+ export interface McpHostClock {
35
+ readonly now: number;
36
+ sleep(milliseconds: number, signal: AbortSignal): Promise<void>;
37
+ }
38
+
39
+ const systemClock: McpHostClock = {
40
+ get now() {
41
+ return Date.now();
42
+ },
43
+ sleep: (milliseconds, signal) => sleep(milliseconds, undefined, { signal }),
44
+ };
45
+
46
+ /** Core capabilities negotiated with one MCP Server. */
47
+ export interface McpHostClientCapabilities {
48
+ readonly logging?: boolean;
49
+ readonly prompts?: boolean;
50
+ readonly resources?: boolean;
51
+ readonly resourceSubscriptions?: boolean;
52
+ readonly resourceTemplates?: boolean;
53
+ readonly tools?: boolean;
54
+ }
55
+
56
+ /** Core capability names queried by Pi tool activation. */
57
+ export type McpHostCapabilityName = keyof McpHostClientCapabilities;
58
+
59
+ /** Structurally validated Server Tool retained without rewriting its JSON Schemas. */
60
+ export type McpHostServerTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
61
+
62
+ /** One advertised MCP Resource. */
63
+ export type McpHostResource = Awaited<ReturnType<Client["listResources"]>>["resources"][number];
64
+
65
+ /** One advertised MCP Resource Template. */
66
+ export type McpHostResourceTemplate = Awaited<
67
+ ReturnType<Client["listResourceTemplates"]>
68
+ >["resourceTemplates"][number];
69
+
70
+ /** One advertised MCP Prompt. */
71
+ export type McpHostPrompt = Awaited<ReturnType<Client["listPrompts"]>>["prompts"][number];
72
+
73
+ /** Arguments accepted by one Server Tool call. */
74
+ export type McpHostToolArguments = NonNullable<Parameters<Client["callTool"]>[0]["arguments"]>;
75
+
76
+ /** Result of calling one Server Tool. */
77
+ export type McpHostCallToolResult = Awaited<ReturnType<Client["callTool"]>>;
78
+
79
+ /** Request-local Pi context and Host callbacks for an active MCP operation. */
80
+ export type McpHostRequestContext<PiContext> = McpServerRunOptions<PiContext>;
81
+
82
+ /** Result of reading one Resource. */
83
+ export type McpHostReadResourceResult = Awaited<ReturnType<Client["readResource"]>>;
84
+
85
+ /** Role-faithful Prompt expansion returned by one MCP Server. */
86
+ export type McpHostGetPromptResult = Awaited<ReturnType<Client["getPrompt"]>>;
87
+
88
+ /** Prompt argument completion candidates. */
89
+ export type McpHostCompletionResult = Awaited<ReturnType<Client["complete"]>>["completion"];
90
+
91
+ /** Notifications and transport signals delivered to the owning Host. */
92
+ export interface McpHostClientEvents {
93
+ onCatalogChanged(kind: McpHostCatalogKind, toolNames?: readonly string[]): Promise<void> | void;
94
+ onClose(): void;
95
+ onError(error: Error): void;
96
+ onLog(message: string): Promise<void> | void;
97
+ onResourceUpdated(uri: string): Promise<void> | void;
98
+ }
99
+
100
+ /** Narrow connected-client boundary used by the Host and fake wire fixtures. */
101
+ export interface McpHostClient {
102
+ readonly capabilities: McpHostClientCapabilities;
103
+ readonly instructions: string | undefined;
104
+ callTool<PiContext = undefined>(
105
+ name: string,
106
+ args: McpHostToolArguments,
107
+ context?: McpHostRequestContext<PiContext>,
108
+ ): Promise<McpHostCallToolResult>;
109
+ close(): Promise<void>;
110
+ completePromptArgument(
111
+ promptName: string,
112
+ argumentName: string,
113
+ value: string,
114
+ ): Promise<McpHostCompletionResult>;
115
+ getPrompt<PiContext = undefined>(
116
+ name: string,
117
+ args?: Readonly<Record<string, string>>,
118
+ context?: McpHostRequestContext<PiContext>,
119
+ ): Promise<McpHostGetPromptResult>;
120
+ listPrompts(): Promise<readonly McpHostPrompt[]>;
121
+ listResources(): Promise<readonly McpHostResource[]>;
122
+ listResourceTemplates(): Promise<readonly McpHostResourceTemplate[]>;
123
+ listTools(): Promise<readonly McpHostServerTool[]>;
124
+ readResource<PiContext = undefined>(
125
+ uri: string,
126
+ context?: McpHostRequestContext<PiContext>,
127
+ ): Promise<McpHostReadResourceResult>;
128
+ setLoggingLevel(level: LoggingLevel): Promise<void>;
129
+ subscribeResource(uri: string): Promise<void>;
130
+ unsubscribeResource(uri: string): Promise<void>;
131
+ }
132
+
133
+ /** Authentication provider resolved from stored credentials for one remote Server. */
134
+ export type McpHostAuthProvider = AuthProvider | OAuthClientProvider;
135
+
136
+ /** Inputs for acquiring one connected MCP Client. */
137
+ export interface McpHostClientConnectOptions {
138
+ readonly authProvider?: McpHostAuthProvider;
139
+ readonly definition: McpServerDefinition;
140
+ readonly events: McpHostClientEvents;
141
+ readonly serverId: string;
142
+ }
143
+
144
+ /** Acquires the single client owned by one Server Definition. */
145
+ export interface McpHostClientFactory {
146
+ connect(options: McpHostClientConnectOptions): Promise<McpHostClient>;
147
+ }
148
+
149
+ /** Catalog refreshed by its matching MCP notification. */
150
+ export type McpHostCatalogKind = "prompts" | "resources" | "resourceTemplates" | "tools";
151
+
152
+ /** Live status of one configured MCP Server. */
153
+ export type McpServerStatus =
154
+ | { readonly state: "disabled" }
155
+ | { readonly attempt: number; readonly state: "connecting" }
156
+ | { readonly state: "connected" }
157
+ | { readonly error: string; readonly state: "needs_auth" }
158
+ | { readonly error: string; readonly state: "needs_client_registration" }
159
+ | {
160
+ readonly attempt: number;
161
+ readonly delayMs: number;
162
+ readonly error: string;
163
+ readonly retryAt: number;
164
+ readonly state: "retrying";
165
+ }
166
+ | { readonly attempts: number; readonly error: string; readonly state: "failed" };
167
+
168
+ interface McpHostCatalogItem<Item> {
169
+ readonly item: Item;
170
+ readonly serverId: string;
171
+ }
172
+
173
+ /** Provenance-labelled Resource. */
174
+ export interface McpHostResourceItem {
175
+ readonly resource: McpHostResource;
176
+ readonly serverId: string;
177
+ }
178
+
179
+ /** Provenance-labelled Resource Template. */
180
+ export interface McpHostResourceTemplateItem {
181
+ readonly resourceTemplate: McpHostResourceTemplate;
182
+ readonly serverId: string;
183
+ }
184
+
185
+ /** Provenance-labelled Prompt. */
186
+ export interface McpHostPromptItem {
187
+ readonly prompt: McpHostPrompt;
188
+ readonly serverId: string;
189
+ }
190
+
191
+ /** Provenance-labelled Server Tool. */
192
+ export interface McpHostToolItem {
193
+ readonly serverId: string;
194
+ readonly tool: McpHostServerTool;
195
+ }
196
+
197
+ /** One bounded human-facing stderr and MCP logging tail. */
198
+ export interface McpHostLogTail {
199
+ readonly serverId: string;
200
+ readonly text: string;
201
+ }
202
+
203
+ /** Desired Resource subscription persisted in the Pi session branch. */
204
+ export interface McpHostResourceSubscription {
205
+ readonly serverId: string;
206
+ readonly uri: string;
207
+ }
208
+
209
+ /** Frozen Server Instructions and tool names used by the first model request. */
210
+ export interface McpInstructionSnapshot {
211
+ readonly frozenAt: number;
212
+ readonly text: string;
213
+ }
214
+
215
+ /** Construction boundaries for one session-owned MCP Host. */
216
+ export interface McpHostOptions {
217
+ readonly clientFactory?: McpHostClientFactory;
218
+ readonly clock?: McpHostClock;
219
+ readonly initialSubscriptions?: readonly McpHostResourceSubscription[];
220
+ readonly instructionDeadlineMs?: number;
221
+ readonly onCatalogChanged?: (serverId: string, kind: McpHostCatalogKind) => void;
222
+ readonly onResourceUpdated?: (update: McpHostResourceSubscription) => void;
223
+ readonly persistSubscriptions?: (
224
+ subscriptions: readonly McpHostResourceSubscription[],
225
+ ) => Promise<void> | void;
226
+ readonly piCwd: string;
227
+ readonly resolveAuthProvider?: (
228
+ definition: Extract<McpServerDefinition, { readonly transport: "http" | "sse" }>,
229
+ ) => McpHostAuthProvider | undefined | Promise<McpHostAuthProvider | undefined>;
230
+ readonly sessionFiles: McpSessionFiles;
231
+ readonly settings: ResolvedMcpSettings;
232
+ }
233
+
234
+ interface McpHostServerEntry {
235
+ definition: McpServerDefinition;
236
+ instructionToolNames: readonly string[];
237
+ client?: McpHostClient;
238
+ failures: number;
239
+ generation: number;
240
+ pending?: Promise<void>;
241
+ retryAbort?: AbortController;
242
+ status: McpServerStatus;
243
+ }
244
+
245
+ function errorMessage(cause: unknown): string {
246
+ return cause instanceof Error ? cause.message : String(cause);
247
+ }
248
+
249
+ class SdkMcpHostClient implements McpHostClient {
250
+ private constructor(
251
+ private readonly owner: McpServerClient,
252
+ readonly capabilities: McpHostClientCapabilities,
253
+ readonly instructions: string | undefined,
254
+ ) {}
255
+
256
+ static async connect(
257
+ definition: McpServerDefinition,
258
+ events: McpHostClientEvents,
259
+ options: Pick<McpHostOptions, "piCwd" | "settings">,
260
+ authProvider: McpHostAuthProvider | undefined,
261
+ ): Promise<SdkMcpHostClient> {
262
+ const notifyCatalogChanged = (
263
+ error: Error | null,
264
+ kind: McpHostCatalogKind,
265
+ toolNames?: readonly string[],
266
+ ): void => {
267
+ if (error !== null) {
268
+ events.onError(error);
269
+ return;
270
+ }
271
+ void events.onCatalogChanged(kind, toolNames);
272
+ };
273
+ const listChanged = {
274
+ prompts: {
275
+ autoRefresh: true,
276
+ debounceMs: 0,
277
+ onChanged: (error) => notifyCatalogChanged(error, "prompts"),
278
+ },
279
+ resources: {
280
+ autoRefresh: true,
281
+ debounceMs: 0,
282
+ onChanged: (error) => {
283
+ notifyCatalogChanged(error, "resources");
284
+ if (error === null) notifyCatalogChanged(null, "resourceTemplates");
285
+ },
286
+ },
287
+ tools: {
288
+ autoRefresh: true,
289
+ debounceMs: 0,
290
+ onChanged: (error, tools) =>
291
+ notifyCatalogChanged(
292
+ error,
293
+ "tools",
294
+ tools?.map((tool) => tool.name),
295
+ ),
296
+ },
297
+ } satisfies NonNullable<McpServerClientConnectOptions["listChanged"]>;
298
+ const connectOptions = {
299
+ clientInfo: { name: "@ian-pascoe/pi-mcp", version: "0.1.0" },
300
+ connectTimeoutMs: options.settings.connectTimeoutMs,
301
+ definition,
302
+ listChanged,
303
+ onConnectionClose: () => events.onClose(),
304
+ onError: (error: Error) => events.onError(error),
305
+ onStderr: (text: string) => void events.onLog(text),
306
+ piCwd: options.piCwd,
307
+ requestTimeoutMs: options.settings.requestTimeoutMs,
308
+ };
309
+ const owner = await McpServerClient.connect(
310
+ authProvider === undefined ? connectOptions : { ...connectOptions, authProvider },
311
+ );
312
+ const capabilities = await owner.run(async (client) => {
313
+ const advertised = client.getServerCapabilities();
314
+ client.setNotificationHandler("notifications/resources/updated", (notification) =>
315
+ events.onResourceUpdated(notification.params.uri),
316
+ );
317
+ client.setNotificationHandler("notifications/message", (notification) =>
318
+ events.onLog(JSON.stringify(notification.params)),
319
+ );
320
+ return {
321
+ logging: advertised?.logging !== undefined,
322
+ prompts: advertised?.prompts !== undefined,
323
+ resources: advertised?.resources !== undefined,
324
+ resourceSubscriptions: advertised?.resources?.subscribe === true,
325
+ resourceTemplates: advertised?.resources !== undefined,
326
+ tools: advertised?.tools !== undefined,
327
+ };
328
+ });
329
+ return new SdkMcpHostClient(owner, capabilities, owner.instructions);
330
+ }
331
+
332
+ callTool<PiContext = undefined>(
333
+ name: string,
334
+ args: McpHostToolArguments,
335
+ context?: McpHostRequestContext<PiContext>,
336
+ ): Promise<McpHostCallToolResult> {
337
+ return this.owner.run(
338
+ (client, requestOptions) => client.callTool({ arguments: { ...args }, name }, requestOptions),
339
+ context,
340
+ );
341
+ }
342
+
343
+ close(): Promise<void> {
344
+ return this.owner.close();
345
+ }
346
+
347
+ completePromptArgument(
348
+ promptName: string,
349
+ argumentName: string,
350
+ value: string,
351
+ ): Promise<McpHostCompletionResult> {
352
+ return this.owner.run(async (client, requestOptions) => {
353
+ const result = await client.complete(
354
+ {
355
+ argument: { name: argumentName, value },
356
+ ref: { name: promptName, type: "ref/prompt" },
357
+ },
358
+ requestOptions,
359
+ );
360
+ return result.completion;
361
+ });
362
+ }
363
+
364
+ getPrompt<PiContext = undefined>(
365
+ name: string,
366
+ args?: Readonly<Record<string, string>>,
367
+ context?: McpHostRequestContext<PiContext>,
368
+ ): Promise<McpHostGetPromptResult> {
369
+ return this.owner.run(async (client, requestOptions) => {
370
+ const result = await client.getPrompt(
371
+ args === undefined ? { name } : { arguments: { ...args }, name },
372
+ requestOptions,
373
+ );
374
+ return result;
375
+ }, context);
376
+ }
377
+
378
+ listPrompts(): Promise<readonly McpHostPrompt[]> {
379
+ return this.owner.run((client, requestOptions) =>
380
+ client.listPrompts(undefined, requestOptions).then((result) => result.prompts),
381
+ );
382
+ }
383
+
384
+ listResources(): Promise<readonly McpHostResource[]> {
385
+ return this.owner.run((client, requestOptions) =>
386
+ client.listResources(undefined, requestOptions).then((result) => result.resources),
387
+ );
388
+ }
389
+
390
+ listResourceTemplates(): Promise<readonly McpHostResourceTemplate[]> {
391
+ return this.owner.run((client, requestOptions) =>
392
+ client
393
+ .listResourceTemplates(undefined, requestOptions)
394
+ .then((result) => result.resourceTemplates),
395
+ );
396
+ }
397
+
398
+ listTools(): Promise<readonly McpHostServerTool[]> {
399
+ return this.owner.run((client, requestOptions) =>
400
+ client.listTools(undefined, requestOptions).then((result) => result.tools),
401
+ );
402
+ }
403
+
404
+ readResource<PiContext = undefined>(
405
+ uri: string,
406
+ context?: McpHostRequestContext<PiContext>,
407
+ ): Promise<McpHostReadResourceResult> {
408
+ return this.owner.run(
409
+ (client, requestOptions) => client.readResource({ uri }, requestOptions),
410
+ context,
411
+ );
412
+ }
413
+
414
+ setLoggingLevel(level: LoggingLevel): Promise<void> {
415
+ return this.owner.run(async (client, requestOptions) => {
416
+ await client.setLoggingLevel(level, requestOptions);
417
+ });
418
+ }
419
+
420
+ subscribeResource(uri: string): Promise<void> {
421
+ return this.owner.run(async (client, requestOptions) => {
422
+ await client.subscribeResource({ uri }, requestOptions);
423
+ });
424
+ }
425
+
426
+ unsubscribeResource(uri: string): Promise<void> {
427
+ return this.owner.run(async (client, requestOptions) => {
428
+ await client.unsubscribeResource({ uri }, requestOptions);
429
+ });
430
+ }
431
+ }
432
+
433
+ /** Session-owned MCP server registry, catalogs, subscriptions, retries, and cleanup. */
434
+ export class McpHost {
435
+ private readonly clock: McpHostClock;
436
+ private readonly clientFactory: McpHostClientFactory;
437
+ private readonly entries = new Map<string, McpHostServerEntry>();
438
+ private readonly subscriptions = new Set<string>();
439
+ private initialConnections: readonly Promise<void>[] = [];
440
+ private instructionSnapshot: McpInstructionSnapshot | undefined;
441
+ private shutdownPromise: Promise<void> | undefined;
442
+ private started = false;
443
+ private shuttingDown = false;
444
+
445
+ constructor(private readonly options: McpHostOptions) {
446
+ this.clock = options.clock ?? systemClock;
447
+ this.clientFactory = options.clientFactory ?? {
448
+ connect: ({ authProvider, definition, events }) =>
449
+ SdkMcpHostClient.connect(definition, events, options, authProvider),
450
+ };
451
+ for (const definition of options.settings.servers.values()) {
452
+ this.entries.set(definition.id, {
453
+ definition,
454
+ failures: 0,
455
+ instructionToolNames: [],
456
+ generation: 0,
457
+ status: { state: "disabled" },
458
+ });
459
+ }
460
+ for (const subscription of options.initialSubscriptions ?? []) {
461
+ this.subscriptions.add(this.subscriptionKey(subscription.serverId, subscription.uri));
462
+ }
463
+ }
464
+
465
+ /** Launch enabled Server connections without awaiting network or process startup. */
466
+ start(): void {
467
+ if (this.started || this.shuttingDown) return;
468
+ this.started = true;
469
+ this.initialConnections = [...this.entries.values()].flatMap((entry) => {
470
+ if (!entry.definition.enabled) {
471
+ entry.status = { state: "disabled" };
472
+ return [];
473
+ }
474
+ return [this.connectEntry(entry)];
475
+ });
476
+ }
477
+
478
+ /** Wait only for each initial connection attempt, not scheduled retries. */
479
+ async waitForInitialConnections(): Promise<void> {
480
+ await Promise.allSettled(this.initialConnections);
481
+ }
482
+
483
+ /** Return a snapshot of one server's current status. */
484
+ getStatus(serverId: string): McpServerStatus | undefined {
485
+ const status = this.entries.get(serverId)?.status;
486
+ return status === undefined ? undefined : { ...status };
487
+ }
488
+
489
+ /** Return every server status in deterministic Server Definition order. */
490
+ listStatuses(): ReadonlyMap<string, McpServerStatus> {
491
+ return new Map(
492
+ [...this.entries]
493
+ .sort(([left], [right]) => left.localeCompare(right))
494
+ .map(([serverId, entry]) => [serverId, { ...entry.status }]),
495
+ );
496
+ }
497
+
498
+ /** Whether one or any connected MCP Server advertises a core capability. */
499
+ hasConnectedCapability(capability: McpHostCapabilityName, serverId?: string): boolean {
500
+ return this.connectedEntries(serverId).some(
501
+ (entry) => entry.client?.capabilities[capability] === true,
502
+ );
503
+ }
504
+
505
+ /** Call one Server Tool with request-scoped callbacks, cancellation, and progress. */
506
+ callTool<PiContext = undefined>(
507
+ serverId: string,
508
+ name: string,
509
+ args: McpHostToolArguments,
510
+ context?: McpHostRequestContext<PiContext>,
511
+ ): Promise<McpHostCallToolResult> {
512
+ const client = this.requireConnectedClient(serverId, "tools");
513
+ return client.callTool(name, args, context);
514
+ }
515
+
516
+ /** Return native SDK-listed Server Tools with provenance. */
517
+ async listTools(serverId?: string): Promise<readonly McpHostToolItem[]> {
518
+ return (await this.listCatalog("tools", (client) => client.listTools(), serverId)).map(
519
+ ({ item, serverId: id }) => ({ serverId: id, tool: item }),
520
+ );
521
+ }
522
+
523
+ /** Return native SDK-listed Resources with provenance. */
524
+ async listResources(serverId?: string): Promise<readonly McpHostResourceItem[]> {
525
+ return (await this.listCatalog("resources", (client) => client.listResources(), serverId)).map(
526
+ ({ item, serverId: id }) => ({ resource: item, serverId: id }),
527
+ );
528
+ }
529
+
530
+ /** Return native SDK-listed Resource Templates with provenance. */
531
+ async listResourceTemplates(serverId?: string): Promise<readonly McpHostResourceTemplateItem[]> {
532
+ return (
533
+ await this.listCatalog(
534
+ "resourceTemplates",
535
+ (client) => client.listResourceTemplates(),
536
+ serverId,
537
+ )
538
+ ).map(({ item, serverId: id }) => ({ resourceTemplate: item, serverId: id }));
539
+ }
540
+
541
+ /** Return native SDK-listed Prompts with provenance. */
542
+ async listPrompts(serverId?: string): Promise<readonly McpHostPromptItem[]> {
543
+ return (await this.listCatalog("prompts", (client) => client.listPrompts(), serverId)).map(
544
+ ({ item, serverId: id }) => ({ prompt: item, serverId: id }),
545
+ );
546
+ }
547
+
548
+ /** Read bounded stderr and MCP logging tails without adding them to model context. */
549
+ async readLogs(serverId?: string, level?: LoggingLevel): Promise<readonly McpHostLogTail[]> {
550
+ const entries =
551
+ serverId === undefined ? [...this.entries.values()] : [this.requireEntry(serverId)];
552
+ if (level !== undefined) {
553
+ const connected = entries.filter((entry) => entry.client !== undefined);
554
+ if (serverId !== undefined && connected[0]?.client?.capabilities.logging !== true) {
555
+ throw new Error(`MCP Server ${serverId} does not advertise logging`);
556
+ }
557
+ await Promise.all(
558
+ connected.flatMap((entry) =>
559
+ entry.client?.capabilities.logging === true ? [entry.client.setLoggingLevel(level)] : [],
560
+ ),
561
+ );
562
+ }
563
+ return Promise.all(
564
+ entries
565
+ .sort((left, right) => left.definition.id.localeCompare(right.definition.id))
566
+ .map(async (entry) => ({
567
+ serverId: entry.definition.id,
568
+ text: await this.options.sessionFiles.readServerLog(entry.definition.id),
569
+ })),
570
+ );
571
+ }
572
+
573
+ /** Read one Resource without injecting it into the model through a background path. */
574
+ readResource<PiContext = undefined>(
575
+ serverId: string,
576
+ uri: string,
577
+ context?: McpHostRequestContext<PiContext>,
578
+ ): Promise<McpHostReadResourceResult> {
579
+ return this.requireConnectedClient(serverId, "resources").readResource(uri, context);
580
+ }
581
+
582
+ /** Expand one MCP Prompt through the explicitly addressed server. */
583
+ getPrompt<PiContext = undefined>(
584
+ serverId: string,
585
+ name: string,
586
+ args?: Readonly<Record<string, string>>,
587
+ context?: McpHostRequestContext<PiContext>,
588
+ ): Promise<McpHostGetPromptResult> {
589
+ return this.requireConnectedClient(serverId, "prompts").getPrompt(name, args, context);
590
+ }
591
+
592
+ /** Complete one MCP Prompt argument through protocol completion. */
593
+ completePromptArgument(
594
+ serverId: string,
595
+ promptName: string,
596
+ argumentName: string,
597
+ value: string,
598
+ ): Promise<McpHostCompletionResult> {
599
+ return this.requireConnectedClient(serverId, "prompts").completePromptArgument(
600
+ promptName,
601
+ argumentName,
602
+ value,
603
+ );
604
+ }
605
+
606
+ /** Persist and establish one desired Resource subscription. */
607
+ async subscribeResource(serverId: string, uri: string): Promise<void> {
608
+ const client = this.requireConnectedClient(serverId, "resourceSubscriptions");
609
+ await client.subscribeResource(uri);
610
+ this.subscriptions.add(this.subscriptionKey(serverId, uri));
611
+ await this.persistSubscriptions();
612
+ }
613
+
614
+ /** Remove one desired Resource subscription after the server acknowledges it. */
615
+ async unsubscribeResource(serverId: string, uri: string): Promise<void> {
616
+ const client = this.requireConnectedClient(serverId, "resourceSubscriptions");
617
+ await client.unsubscribeResource(uri);
618
+ this.subscriptions.delete(this.subscriptionKey(serverId, uri));
619
+ await this.persistSubscriptions();
620
+ }
621
+
622
+ /** Add or replace one Server Definition and apply it to the current session immediately. */
623
+ async upsertServer(definition: McpServerDefinition): Promise<void> {
624
+ const existing = this.entries.get(definition.id);
625
+ const entry = existing ?? {
626
+ definition,
627
+ failures: 0,
628
+ generation: 0,
629
+ instructionToolNames: [],
630
+ status: { state: "disabled" as const },
631
+ };
632
+ if (existing !== undefined) await this.stopEntry(existing, "MCP Server Definition replaced");
633
+ entry.definition = definition;
634
+ entry.failures = 0;
635
+ this.entries.set(definition.id, entry);
636
+ if (!this.started || !definition.enabled || this.shuttingDown) {
637
+ entry.status = { state: "disabled" };
638
+ return;
639
+ }
640
+ await this.connectEntry(entry);
641
+ }
642
+
643
+ /** Disable one Server Definition and close its current or pending connection. */
644
+ async disableServer(serverId: string): Promise<void> {
645
+ const entry = this.requireEntry(serverId);
646
+ await this.stopEntry(entry, "MCP Server disabled");
647
+ entry.definition = { ...entry.definition, enabled: false };
648
+ entry.failures = 0;
649
+ entry.status = { state: "disabled" };
650
+ }
651
+
652
+ /** Remove one Server Definition and every ephemeral runtime value it owns. */
653
+ async removeServer(serverId: string): Promise<void> {
654
+ const entry = this.requireEntry(serverId);
655
+ await this.stopEntry(entry, "MCP Server removed");
656
+ this.entries.delete(serverId);
657
+ const prefix = `${serverId}\0`;
658
+ for (const subscription of this.subscriptions) {
659
+ if (subscription.startsWith(prefix)) this.subscriptions.delete(subscription);
660
+ }
661
+ await this.persistSubscriptions();
662
+ }
663
+
664
+ /** Reconnect one configured Server immediately, cancelling any pending retry. */
665
+ async reconnect(serverId: string): Promise<void> {
666
+ const entry = this.requireEntry(serverId);
667
+ await this.stopEntry(entry, "MCP reconnect requested");
668
+ entry.failures = 0;
669
+ if (!entry.definition.enabled) {
670
+ entry.status = { state: "disabled" };
671
+ return;
672
+ }
673
+ await this.connectEntry(entry);
674
+ }
675
+
676
+ /** Freeze Server Instructions and tool names after initial attempts or the first-request deadline. */
677
+ async freezeInstructionSnapshot(): Promise<McpInstructionSnapshot> {
678
+ if (this.instructionSnapshot !== undefined) return this.instructionSnapshot;
679
+ const deadline = new AbortController();
680
+ const timeout = this.clock
681
+ .sleep(this.options.instructionDeadlineMs ?? DEFAULT_INSTRUCTION_DEADLINE_MS, deadline.signal)
682
+ .catch(() => undefined);
683
+ await Promise.race([this.waitForInitialConnections(), timeout]);
684
+ deadline.abort(new Error("Instruction Snapshot frozen"));
685
+ const sections = [...this.entries.values()]
686
+ .filter((entry) => entry.client !== undefined && entry.status.state === "connected")
687
+ .sort((left, right) => left.definition.id.localeCompare(right.definition.id))
688
+ .flatMap((entry) => {
689
+ const instructions = entry.client?.instructions?.trim();
690
+ const toolNames = [...entry.instructionToolNames].sort();
691
+ if (instructions === undefined && toolNames.length === 0) return [];
692
+ return [
693
+ [
694
+ `## MCP Server: ${entry.definition.id}`,
695
+ instructions === undefined ? undefined : instructions,
696
+ toolNames.length === 0 ? undefined : `Tools: ${toolNames.join(", ")}`,
697
+ ]
698
+ .filter((line): line is string => line !== undefined)
699
+ .join("\n"),
700
+ ];
701
+ });
702
+ this.instructionSnapshot = {
703
+ frozenAt: this.clock.now,
704
+ text: sections.join("\n\n"),
705
+ };
706
+ return this.instructionSnapshot;
707
+ }
708
+
709
+ /** Stop retries, close all acquired clients including late arrivals, then remove session files. */
710
+ shutdown(): Promise<void> {
711
+ this.shutdownPromise ??= this.shutdownOwnedResources();
712
+ return this.shutdownPromise;
713
+ }
714
+
715
+ private connectEntry(entry: McpHostServerEntry): Promise<void> {
716
+ const generation = ++entry.generation;
717
+ entry.retryAbort?.abort(new Error("MCP connection attempt replaced"));
718
+ delete entry.retryAbort;
719
+ entry.status = { attempt: entry.failures + 1, state: "connecting" };
720
+ const pending = Promise.resolve()
721
+ .then(async () => {
722
+ const authProvider =
723
+ entry.definition.transport === "stdio"
724
+ ? undefined
725
+ : await this.options.resolveAuthProvider?.(entry.definition);
726
+ const connectOptions: McpHostClientConnectOptions = {
727
+ definition: entry.definition,
728
+ events: {
729
+ onCatalogChanged: async (kind, toolNames) => {
730
+ if (kind === "tools" && toolNames !== undefined) {
731
+ entry.instructionToolNames = [...toolNames];
732
+ }
733
+ await this.notifyCatalogChanged(entry, kind);
734
+ },
735
+ onClose: () => this.handleUnexpectedClose(entry, generation),
736
+ onError: (error) => void this.recordLog(entry.definition.id, error.message),
737
+ onLog: (message) => this.recordLog(entry.definition.id, message),
738
+ onResourceUpdated: async (uri) => {
739
+ try {
740
+ this.options.onResourceUpdated?.({ serverId: entry.definition.id, uri });
741
+ } catch (cause) {
742
+ await this.recordLog(entry.definition.id, errorMessage(cause));
743
+ }
744
+ },
745
+ },
746
+ serverId: entry.definition.id,
747
+ };
748
+ return this.clientFactory.connect(
749
+ authProvider === undefined ? connectOptions : { ...connectOptions, authProvider },
750
+ );
751
+ })
752
+ .then(async (client) => {
753
+ if (this.shuttingDown || generation !== entry.generation) {
754
+ await client.close();
755
+ return;
756
+ }
757
+ entry.client = client;
758
+ entry.failures = 0;
759
+ entry.status = { state: "connected" };
760
+ const initialized = await Promise.allSettled([
761
+ this.loadInstructionToolNames(entry, client),
762
+ this.restoreSubscriptions(entry),
763
+ ]);
764
+ for (const result of initialized) {
765
+ if (result.status === "rejected") {
766
+ await this.recordLog(entry.definition.id, errorMessage(result.reason));
767
+ }
768
+ }
769
+ await this.notifyCatalogChanged(entry, "tools");
770
+ })
771
+ .catch((cause: unknown) => {
772
+ if (this.shuttingDown || generation !== entry.generation) return;
773
+ this.handleConnectionFailure(entry, cause);
774
+ });
775
+ entry.pending = pending;
776
+ return pending;
777
+ }
778
+
779
+ private handleConnectionFailure(entry: McpHostServerEntry, cause: unknown): void {
780
+ const message = this.options.settings.secrets.redact(errorMessage(cause));
781
+ if (cause instanceof UnauthorizedError) {
782
+ entry.status = { error: message, state: "needs_auth" };
783
+ } else if (cause instanceof RegistrationRejectedError) {
784
+ entry.status = { error: message, state: "needs_client_registration" };
785
+ } else {
786
+ entry.failures += 1;
787
+ const terminal =
788
+ cause instanceof ProtocolError ||
789
+ (cause instanceof SdkError && TERMINAL_MCP_SDK_ERROR_CODES.has(cause.code));
790
+ if (terminal || entry.failures > this.options.settings.retry.maxRetries) {
791
+ entry.status = { attempts: entry.failures, error: message, state: "failed" };
792
+ } else {
793
+ const delayMs = Math.min(
794
+ this.options.settings.retry.maxDelayMs,
795
+ Math.round(
796
+ this.options.settings.retry.initialDelayMs *
797
+ this.options.settings.retry.backoffFactor ** (entry.failures - 1),
798
+ ),
799
+ );
800
+ const retryAbort = new AbortController();
801
+ entry.retryAbort = retryAbort;
802
+ entry.status = {
803
+ attempt: entry.failures + 1,
804
+ delayMs,
805
+ error: message,
806
+ retryAt: this.clock.now + delayMs,
807
+ state: "retrying",
808
+ };
809
+ void this.clock.sleep(delayMs, retryAbort.signal).then(
810
+ () => {
811
+ if (!this.shuttingDown && entry.retryAbort === retryAbort)
812
+ void this.connectEntry(entry);
813
+ },
814
+ () => undefined,
815
+ );
816
+ }
817
+ }
818
+ void this.notifyCatalogChanged(entry, "tools");
819
+ }
820
+
821
+ private handleUnexpectedClose(entry: McpHostServerEntry, generation: number): void {
822
+ if (this.shuttingDown || generation !== entry.generation) return;
823
+ delete entry.client;
824
+ entry.instructionToolNames = [];
825
+ this.handleConnectionFailure(entry, new Error("MCP connection closed unexpectedly"));
826
+ }
827
+
828
+ private async loadInstructionToolNames(
829
+ entry: McpHostServerEntry,
830
+ client: McpHostClient,
831
+ ): Promise<void> {
832
+ entry.instructionToolNames =
833
+ client.capabilities.tools === true ? (await client.listTools()).map((tool) => tool.name) : [];
834
+ }
835
+
836
+ private async listCatalog<Item extends { readonly name: string }>(
837
+ capability: McpHostCapabilityName,
838
+ list: (client: McpHostClient) => Promise<readonly Item[]>,
839
+ serverId?: string,
840
+ ): Promise<readonly McpHostCatalogItem<Item>[]> {
841
+ const catalogs = await Promise.all(
842
+ this.connectedEntries(serverId).map(async (entry) => {
843
+ const client = entry.client;
844
+ if (client === undefined || client.capabilities[capability] !== true) return [];
845
+ return (await list(client)).map((item) => ({ item, serverId: entry.definition.id }));
846
+ }),
847
+ );
848
+ return catalogs
849
+ .flat()
850
+ .sort(
851
+ (left, right) =>
852
+ left.serverId.localeCompare(right.serverId) ||
853
+ left.item.name.localeCompare(right.item.name),
854
+ );
855
+ }
856
+
857
+ private connectedEntries(serverId: string | undefined): McpHostServerEntry[] {
858
+ if (serverId !== undefined) {
859
+ const entry = this.requireEntry(serverId);
860
+ if (entry.status.state !== "connected" || entry.client === undefined) {
861
+ throw new Error(`MCP Server ${serverId} is not connected`);
862
+ }
863
+ return [entry];
864
+ }
865
+ return [...this.entries.values()].filter(
866
+ (entry) => entry.status.state === "connected" && entry.client !== undefined,
867
+ );
868
+ }
869
+
870
+ private requireConnectedClient(
871
+ serverId: string,
872
+ capability: keyof McpHostClientCapabilities,
873
+ ): McpHostClient {
874
+ const entry = this.requireEntry(serverId);
875
+ const client = entry.client;
876
+ if (client === undefined || entry.status.state !== "connected") {
877
+ throw new Error(`MCP Server ${serverId} is not connected`);
878
+ }
879
+ if (client.capabilities[capability] !== true) {
880
+ throw new Error(`MCP Server ${serverId} does not support ${capability}`);
881
+ }
882
+ return client;
883
+ }
884
+
885
+ private requireEntry(serverId: string): McpHostServerEntry {
886
+ const entry = this.entries.get(serverId);
887
+ if (entry === undefined) throw new Error(`Unknown MCP Server ${serverId}`);
888
+ return entry;
889
+ }
890
+
891
+ private async restoreSubscriptions(entry: McpHostServerEntry): Promise<void> {
892
+ const client = entry.client;
893
+ if (client?.capabilities.resourceSubscriptions !== true) return;
894
+ const prefix = `${entry.definition.id}\0`;
895
+ const uris = [...this.subscriptions]
896
+ .filter((key) => key.startsWith(prefix))
897
+ .map((key) => key.slice(prefix.length))
898
+ .sort();
899
+ await Promise.all(uris.map((uri) => client.subscribeResource(uri)));
900
+ }
901
+
902
+ private subscriptionKey(serverId: string, uri: string): string {
903
+ return `${serverId}\0${uri}`;
904
+ }
905
+
906
+ private async persistSubscriptions(): Promise<void> {
907
+ const subscriptions = [...this.subscriptions]
908
+ .map((key) => {
909
+ const separator = key.indexOf("\0");
910
+ return { serverId: key.slice(0, separator), uri: key.slice(separator + 1) };
911
+ })
912
+ .sort(
913
+ (left, right) =>
914
+ left.serverId.localeCompare(right.serverId) || left.uri.localeCompare(right.uri),
915
+ );
916
+ await this.options.persistSubscriptions?.(subscriptions);
917
+ }
918
+
919
+ private async stopEntry(entry: McpHostServerEntry, reason: string): Promise<void> {
920
+ entry.retryAbort?.abort(new Error(reason));
921
+ delete entry.retryAbort;
922
+ entry.generation += 1;
923
+ entry.instructionToolNames = [];
924
+ const client = entry.client;
925
+ delete entry.client;
926
+ entry.status = { state: "disabled" };
927
+ await this.notifyCatalogChanged(entry, "tools");
928
+ await client?.close();
929
+ }
930
+
931
+ private async notifyCatalogChanged(
932
+ entry: McpHostServerEntry,
933
+ kind: McpHostCatalogKind,
934
+ ): Promise<void> {
935
+ try {
936
+ this.options.onCatalogChanged?.(entry.definition.id, kind);
937
+ } catch (cause) {
938
+ await this.recordLog(entry.definition.id, errorMessage(cause));
939
+ }
940
+ }
941
+
942
+ private async recordLog(serverId: string, message: string): Promise<void> {
943
+ try {
944
+ await this.options.sessionFiles.appendServerLog(
945
+ serverId,
946
+ this.options.settings.secrets.redact(message),
947
+ );
948
+ } catch {
949
+ // Logging must not change protocol lifecycle behavior.
950
+ }
951
+ }
952
+
953
+ private async shutdownOwnedResources(): Promise<void> {
954
+ this.shuttingDown = true;
955
+ const clients: McpHostClient[] = [];
956
+ const pending: Promise<void>[] = [];
957
+ for (const entry of this.entries.values()) {
958
+ entry.retryAbort?.abort(new Error("MCP Host shutting down"));
959
+ delete entry.retryAbort;
960
+ entry.generation += 1;
961
+ if (entry.client !== undefined) {
962
+ clients.push(entry.client);
963
+ delete entry.client;
964
+ }
965
+ if (entry.pending !== undefined) pending.push(entry.pending);
966
+ }
967
+ await Promise.allSettled(clients.map((client) => client.close()));
968
+ await Promise.allSettled(pending);
969
+ await this.options.sessionFiles.close();
970
+ }
971
+ }