@ian-pascoe/pi-mcp 0.1.0 → 0.2.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/src/mcp-host.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  UnauthorizedError,
8
8
  type AuthProvider,
9
9
  type Client,
10
- type LoggingLevel,
11
10
  type OAuthClientProvider,
12
11
  } from "@modelcontextprotocol/client";
13
12
  import {
@@ -125,7 +124,6 @@ export interface McpHostClient {
125
124
  uri: string,
126
125
  context?: McpHostRequestContext<PiContext>,
127
126
  ): Promise<McpHostReadResourceResult>;
128
- setLoggingLevel(level: LoggingLevel): Promise<void>;
129
127
  subscribeResource(uri: string): Promise<void>;
130
128
  unsubscribeResource(uri: string): Promise<void>;
131
129
  }
@@ -165,6 +163,9 @@ export type McpServerStatus =
165
163
  }
166
164
  | { readonly attempts: number; readonly error: string; readonly state: "failed" };
167
165
 
166
+ /** Expected live MCP Host lookup or capability failure returned to command adapters. */
167
+ export class McpHostOperationError extends Error {}
168
+
168
169
  interface McpHostCatalogItem<Item> {
169
170
  readonly item: Item;
170
171
  readonly serverId: string;
@@ -196,6 +197,8 @@ export interface McpHostToolItem {
196
197
 
197
198
  /** One bounded human-facing stderr and MCP logging tail. */
198
199
  export interface McpHostLogTail {
200
+ /** Private session path containing the complete retained tail for this Server. */
201
+ readonly path: string;
199
202
  readonly serverId: string;
200
203
  readonly text: string;
201
204
  }
@@ -220,6 +223,8 @@ export interface McpHostOptions {
220
223
  readonly instructionDeadlineMs?: number;
221
224
  readonly onCatalogChanged?: (serverId: string, kind: McpHostCatalogKind) => void;
222
225
  readonly onResourceUpdated?: (update: McpHostResourceSubscription) => void;
226
+ /** Observe copied, sorted status after each status or Server Definition change. */
227
+ readonly onStatusChange?: (statuses: ReadonlyMap<string, McpServerStatus>) => void;
223
228
  readonly persistSubscriptions?: (
224
229
  subscriptions: readonly McpHostResourceSubscription[],
225
230
  ) => Promise<void> | void;
@@ -411,12 +416,6 @@ class SdkMcpHostClient implements McpHostClient {
411
416
  );
412
417
  }
413
418
 
414
- setLoggingLevel(level: LoggingLevel): Promise<void> {
415
- return this.owner.run(async (client, requestOptions) => {
416
- await client.setLoggingLevel(level, requestOptions);
417
- });
418
- }
419
-
420
419
  subscribeResource(uri: string): Promise<void> {
421
420
  return this.owner.run(async (client, requestOptions) => {
422
421
  await client.subscribeResource({ uri }, requestOptions);
@@ -460,6 +459,7 @@ export class McpHost {
460
459
  for (const subscription of options.initialSubscriptions ?? []) {
461
460
  this.subscriptions.add(this.subscriptionKey(subscription.serverId, subscription.uri));
462
461
  }
462
+ this.publishStatuses();
463
463
  }
464
464
 
465
465
  /** Launch enabled Server connections without awaiting network or process startup. */
@@ -468,7 +468,7 @@ export class McpHost {
468
468
  this.started = true;
469
469
  this.initialConnections = [...this.entries.values()].flatMap((entry) => {
470
470
  if (!entry.definition.enabled) {
471
- entry.status = { state: "disabled" };
471
+ this.setEntryStatus(entry, { state: "disabled" });
472
472
  return [];
473
473
  }
474
474
  return [this.connectEntry(entry)];
@@ -495,6 +495,19 @@ export class McpHost {
495
495
  );
496
496
  }
497
497
 
498
+ /** Return desired Resource subscriptions without sending an MCP request. */
499
+ listSubscriptions(): readonly McpHostResourceSubscription[] {
500
+ return [...this.subscriptions]
501
+ .map((key) => {
502
+ const separator = key.indexOf("\0");
503
+ return { serverId: key.slice(0, separator), uri: key.slice(separator + 1) };
504
+ })
505
+ .sort(
506
+ (left, right) =>
507
+ left.serverId.localeCompare(right.serverId) || left.uri.localeCompare(right.uri),
508
+ );
509
+ }
510
+
498
511
  /** Whether one or any connected MCP Server advertises a core capability. */
499
512
  hasConnectedCapability(capability: McpHostCapabilityName, serverId?: string): boolean {
500
513
  return this.connectedEntries(serverId).some(
@@ -546,26 +559,15 @@ export class McpHost {
546
559
  }
547
560
 
548
561
  /** Read bounded stderr and MCP logging tails without adding them to model context. */
549
- async readLogs(serverId?: string, level?: LoggingLevel): Promise<readonly McpHostLogTail[]> {
562
+ async readLogs(serverId?: string): Promise<readonly McpHostLogTail[]> {
550
563
  const entries =
551
564
  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
565
  return Promise.all(
564
566
  entries
565
567
  .sort((left, right) => left.definition.id.localeCompare(right.definition.id))
566
568
  .map(async (entry) => ({
569
+ ...(await this.options.sessionFiles.readServerLog(entry.definition.id)),
567
570
  serverId: entry.definition.id,
568
- text: await this.options.sessionFiles.readServerLog(entry.definition.id),
569
571
  })),
570
572
  );
571
573
  }
@@ -634,7 +636,7 @@ export class McpHost {
634
636
  entry.failures = 0;
635
637
  this.entries.set(definition.id, entry);
636
638
  if (!this.started || !definition.enabled || this.shuttingDown) {
637
- entry.status = { state: "disabled" };
639
+ this.setEntryStatus(entry, { state: "disabled" });
638
640
  return;
639
641
  }
640
642
  await this.connectEntry(entry);
@@ -646,7 +648,6 @@ export class McpHost {
646
648
  await this.stopEntry(entry, "MCP Server disabled");
647
649
  entry.definition = { ...entry.definition, enabled: false };
648
650
  entry.failures = 0;
649
- entry.status = { state: "disabled" };
650
651
  }
651
652
 
652
653
  /** Remove one Server Definition and every ephemeral runtime value it owns. */
@@ -654,6 +655,7 @@ export class McpHost {
654
655
  const entry = this.requireEntry(serverId);
655
656
  await this.stopEntry(entry, "MCP Server removed");
656
657
  this.entries.delete(serverId);
658
+ this.publishStatuses();
657
659
  const prefix = `${serverId}\0`;
658
660
  for (const subscription of this.subscriptions) {
659
661
  if (subscription.startsWith(prefix)) this.subscriptions.delete(subscription);
@@ -666,10 +668,7 @@ export class McpHost {
666
668
  const entry = this.requireEntry(serverId);
667
669
  await this.stopEntry(entry, "MCP reconnect requested");
668
670
  entry.failures = 0;
669
- if (!entry.definition.enabled) {
670
- entry.status = { state: "disabled" };
671
- return;
672
- }
671
+ if (!entry.definition.enabled) return;
673
672
  await this.connectEntry(entry);
674
673
  }
675
674
 
@@ -716,7 +715,7 @@ export class McpHost {
716
715
  const generation = ++entry.generation;
717
716
  entry.retryAbort?.abort(new Error("MCP connection attempt replaced"));
718
717
  delete entry.retryAbort;
719
- entry.status = { attempt: entry.failures + 1, state: "connecting" };
718
+ this.setEntryStatus(entry, { attempt: entry.failures + 1, state: "connecting" });
720
719
  const pending = Promise.resolve()
721
720
  .then(async () => {
722
721
  const authProvider =
@@ -756,7 +755,7 @@ export class McpHost {
756
755
  }
757
756
  entry.client = client;
758
757
  entry.failures = 0;
759
- entry.status = { state: "connected" };
758
+ this.setEntryStatus(entry, { state: "connected" });
760
759
  const initialized = await Promise.allSettled([
761
760
  this.loadInstructionToolNames(entry, client),
762
761
  this.restoreSubscriptions(entry),
@@ -779,16 +778,20 @@ export class McpHost {
779
778
  private handleConnectionFailure(entry: McpHostServerEntry, cause: unknown): void {
780
779
  const message = this.options.settings.secrets.redact(errorMessage(cause));
781
780
  if (cause instanceof UnauthorizedError) {
782
- entry.status = { error: message, state: "needs_auth" };
781
+ this.setEntryStatus(entry, { error: message, state: "needs_auth" });
783
782
  } else if (cause instanceof RegistrationRejectedError) {
784
- entry.status = { error: message, state: "needs_client_registration" };
783
+ this.setEntryStatus(entry, { error: message, state: "needs_client_registration" });
785
784
  } else {
786
785
  entry.failures += 1;
787
786
  const terminal =
788
787
  cause instanceof ProtocolError ||
789
788
  (cause instanceof SdkError && TERMINAL_MCP_SDK_ERROR_CODES.has(cause.code));
790
789
  if (terminal || entry.failures > this.options.settings.retry.maxRetries) {
791
- entry.status = { attempts: entry.failures, error: message, state: "failed" };
790
+ this.setEntryStatus(entry, {
791
+ attempts: entry.failures,
792
+ error: message,
793
+ state: "failed",
794
+ });
792
795
  } else {
793
796
  const delayMs = Math.min(
794
797
  this.options.settings.retry.maxDelayMs,
@@ -799,13 +802,13 @@ export class McpHost {
799
802
  );
800
803
  const retryAbort = new AbortController();
801
804
  entry.retryAbort = retryAbort;
802
- entry.status = {
805
+ this.setEntryStatus(entry, {
803
806
  attempt: entry.failures + 1,
804
807
  delayMs,
805
808
  error: message,
806
809
  retryAt: this.clock.now + delayMs,
807
810
  state: "retrying",
808
- };
811
+ });
809
812
  void this.clock.sleep(delayMs, retryAbort.signal).then(
810
813
  () => {
811
814
  if (!this.shuttingDown && entry.retryAbort === retryAbort)
@@ -874,17 +877,17 @@ export class McpHost {
874
877
  const entry = this.requireEntry(serverId);
875
878
  const client = entry.client;
876
879
  if (client === undefined || entry.status.state !== "connected") {
877
- throw new Error(`MCP Server ${serverId} is not connected`);
880
+ throw new McpHostOperationError(`MCP Server ${serverId} is not connected`);
878
881
  }
879
882
  if (client.capabilities[capability] !== true) {
880
- throw new Error(`MCP Server ${serverId} does not support ${capability}`);
883
+ throw new McpHostOperationError(`MCP Server ${serverId} does not support ${capability}`);
881
884
  }
882
885
  return client;
883
886
  }
884
887
 
885
888
  private requireEntry(serverId: string): McpHostServerEntry {
886
889
  const entry = this.entries.get(serverId);
887
- if (entry === undefined) throw new Error(`Unknown MCP Server ${serverId}`);
890
+ if (entry === undefined) throw new McpHostOperationError(`Unknown MCP Server ${serverId}`);
888
891
  return entry;
889
892
  }
890
893
 
@@ -904,16 +907,7 @@ export class McpHost {
904
907
  }
905
908
 
906
909
  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);
910
+ await this.options.persistSubscriptions?.(this.listSubscriptions());
917
911
  }
918
912
 
919
913
  private async stopEntry(entry: McpHostServerEntry, reason: string): Promise<void> {
@@ -923,7 +917,7 @@ export class McpHost {
923
917
  entry.instructionToolNames = [];
924
918
  const client = entry.client;
925
919
  delete entry.client;
926
- entry.status = { state: "disabled" };
920
+ this.setEntryStatus(entry, { state: "disabled" });
927
921
  await this.notifyCatalogChanged(entry, "tools");
928
922
  await client?.close();
929
923
  }
@@ -939,6 +933,19 @@ export class McpHost {
939
933
  }
940
934
  }
941
935
 
936
+ private setEntryStatus(entry: McpHostServerEntry, status: McpServerStatus): void {
937
+ entry.status = status;
938
+ this.publishStatuses();
939
+ }
940
+
941
+ private publishStatuses(): void {
942
+ try {
943
+ this.options.onStatusChange?.(this.listStatuses());
944
+ } catch {
945
+ // Observer failures cannot change MCP Host lifecycle behavior.
946
+ }
947
+ }
948
+
942
949
  private async recordLog(serverId: string, message: string): Promise<void> {
943
950
  try {
944
951
  await this.options.sessionFiles.appendServerLog(
@@ -0,0 +1,195 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { McpServerStatus } from "./mcp-host.js";
4
+ import { sanitizeMcpPresentationText } from "./mcp-presentation.js";
5
+
6
+ const MCP_OBSERVER_STATUS_KEY = "pi-mcp";
7
+
8
+ /** Minimal TUI context used by the read-only MCP Observer UI. */
9
+ export type McpObserverUiContext = {
10
+ readonly mode: "json" | "print" | "rpc" | "tui";
11
+ readonly ui: {
12
+ readonly notify: (message: string, level: "error" | "warning") => void;
13
+ readonly setStatus: (key: string, status: string | undefined) => void;
14
+ readonly theme: Pick<Theme, "fg">;
15
+ };
16
+ };
17
+
18
+ /** One bounded MCP Attention Notice for an actionable Host condition. */
19
+ export type McpAttentionNotice =
20
+ | {
21
+ readonly action: "/mcp status";
22
+ readonly cause: string;
23
+ readonly condition: "invalid_settings";
24
+ readonly level: "warning";
25
+ }
26
+ | {
27
+ readonly action: `/mcp auth ${string}`;
28
+ readonly cause: string;
29
+ readonly condition: "needs_auth" | "needs_client_registration";
30
+ readonly level: "warning";
31
+ readonly serverId: string;
32
+ }
33
+ | {
34
+ readonly action: `/mcp reconnect ${string}`;
35
+ readonly cause: string;
36
+ readonly condition: "failed";
37
+ readonly level: "error";
38
+ readonly serverId: string;
39
+ };
40
+
41
+ /** One footer status row with its required theme role. */
42
+ export type McpObserverFooter = {
43
+ readonly color: "dim" | "error" | "warning";
44
+ readonly text: string;
45
+ };
46
+
47
+ /** Read-only MCP Observer Snapshot derived from copied Host status. */
48
+ export type McpObserverSnapshot = {
49
+ readonly footer?: McpObserverFooter;
50
+ readonly notices: readonly McpAttentionNotice[];
51
+ };
52
+
53
+ function actionableNotice(
54
+ serverId: string,
55
+ status: McpServerStatus,
56
+ ): McpAttentionNotice | undefined {
57
+ const commandServerId = sanitizeMcpPresentationText(serverId).replace(/\s+/g, " ").trim();
58
+ const commandArgument = /^[A-Za-z0-9._-]+$/u.test(commandServerId)
59
+ ? commandServerId
60
+ : JSON.stringify(commandServerId);
61
+ switch (status.state) {
62
+ case "needs_auth":
63
+ case "needs_client_registration":
64
+ return {
65
+ action: `/mcp auth ${commandArgument}`,
66
+ cause: status.error,
67
+ condition: status.state,
68
+ level: "warning",
69
+ serverId,
70
+ };
71
+ case "failed":
72
+ return {
73
+ action: `/mcp reconnect ${commandArgument}`,
74
+ cause: status.error,
75
+ condition: "failed",
76
+ level: "error",
77
+ serverId,
78
+ };
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** Project copied MCP Host status into the bounded, read-only MCP Observer Snapshot. */
85
+ export function buildMcpObserverSnapshot(
86
+ statuses: ReadonlyMap<string, McpServerStatus>,
87
+ invalidSettings: readonly string[] = [],
88
+ ): McpObserverSnapshot {
89
+ const entries = [...statuses].sort(([left], [right]) => left.localeCompare(right));
90
+ const enabled = entries.filter(([, status]) => status.state !== "disabled");
91
+ const connected = enabled.filter(([, status]) => status.state === "connected").length;
92
+ const counts = new Map<string, number>();
93
+ for (const [, status] of enabled) {
94
+ if (status.state !== "connected") counts.set(status.state, (counts.get(status.state) ?? 0) + 1);
95
+ }
96
+ const stateLabels = [
97
+ ["connecting", "connecting"],
98
+ ["retrying", "retrying"],
99
+ ["needs_auth", "authentication"],
100
+ ["needs_client_registration", "registration"],
101
+ ["failed", "failed"],
102
+ ] as const;
103
+ const degraded = stateLabels
104
+ .flatMap(([state, label]) => {
105
+ const count = counts.get(state);
106
+ return count === undefined ? [] : [`${label} ${count}`];
107
+ })
108
+ .join(" · ");
109
+ const footer =
110
+ enabled.length === 0
111
+ ? undefined
112
+ : degraded.length === 0
113
+ ? { color: "dim" as const, text: `MCP ${connected}/${enabled.length}` }
114
+ : {
115
+ color: counts.has("failed") ? ("error" as const) : ("warning" as const),
116
+ text: `MCP ${connected}/${enabled.length} · ${degraded}`,
117
+ };
118
+ const notices = entries.flatMap(([serverId, status]) => {
119
+ const notice = actionableNotice(serverId, status);
120
+ return notice === undefined ? [] : [notice];
121
+ });
122
+ if (invalidSettings.length > 0) {
123
+ notices.unshift({
124
+ action: "/mcp status",
125
+ cause: invalidSettings.join("\n"),
126
+ condition: "invalid_settings",
127
+ level: "warning",
128
+ });
129
+ }
130
+ return footer === undefined ? { notices } : { footer, notices };
131
+ }
132
+
133
+ /** Own TUI-only MCP footer status and deduplicated MCP Attention Notices for one session. */
134
+ export class McpObserverUiController {
135
+ private disposed = false;
136
+ private readonly activeNoticeKeys = new Set<string>();
137
+
138
+ /** Build a controller with the session's exact-value redactor. */
139
+ constructor(
140
+ private readonly context: McpObserverUiContext,
141
+ private readonly redact: (value: string) => string,
142
+ ) {}
143
+
144
+ /** Render a copied Host status map without changing MCP Host state or sending protocol requests. */
145
+ update(
146
+ statuses: ReadonlyMap<string, McpServerStatus>,
147
+ invalidSettings: readonly string[] = [],
148
+ ): void {
149
+ if (this.disposed || this.context.mode !== "tui") return;
150
+ const snapshot = buildMcpObserverSnapshot(statuses, invalidSettings);
151
+ this.setFooter(snapshot.footer);
152
+ const nextNoticeKeys = new Set<string>();
153
+ for (const notice of snapshot.notices) {
154
+ const safeCause = sanitizeMcpPresentationText(this.redact(notice.cause)).trim();
155
+ const cause = truncateToWidth(safeCause.replace(/\s+/g, " "), 240, "…");
156
+ const key = `${notice.condition === "invalid_settings" ? "settings" : notice.serverId}\0${notice.action}\0${safeCause}`;
157
+ nextNoticeKeys.add(key);
158
+ if (this.activeNoticeKeys.has(key)) continue;
159
+ const subject =
160
+ notice.condition === "invalid_settings"
161
+ ? "MCP settings need attention"
162
+ : `MCP Server ${sanitizeMcpPresentationText(notice.serverId).replace(/\s+/g, " ").trim()} ${notice.condition === "failed" ? "failed" : notice.condition === "needs_auth" ? "needs authentication" : "needs client registration"}`;
163
+ this.notify(`${subject}: ${cause}\nRun ${notice.action}`, notice.level);
164
+ }
165
+ this.activeNoticeKeys.clear();
166
+ for (const key of nextNoticeKeys) this.activeNoticeKeys.add(key);
167
+ }
168
+
169
+ /** Clear the footer before Host teardown; repeated disposal is safe. */
170
+ dispose(): void {
171
+ if (this.disposed) return;
172
+ this.disposed = true;
173
+ this.activeNoticeKeys.clear();
174
+ if (this.context.mode === "tui") this.setFooter(undefined);
175
+ }
176
+
177
+ private setFooter(footer: McpObserverFooter | undefined): void {
178
+ try {
179
+ this.context.ui.setStatus(
180
+ MCP_OBSERVER_STATUS_KEY,
181
+ footer === undefined ? undefined : this.context.ui.theme.fg(footer.color, footer.text),
182
+ );
183
+ } catch {
184
+ // Observer rendering failures must not affect MCP Host lifecycle.
185
+ }
186
+ }
187
+
188
+ private notify(message: string, level: "error" | "warning"): void {
189
+ try {
190
+ this.context.ui.notify(message, level);
191
+ } catch {
192
+ // Observer rendering failures must not affect MCP Host lifecycle.
193
+ }
194
+ }
195
+ }