@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/README.md +16 -3
- package/dist/pi-mcp-cli.js +137 -101
- package/package.json +8 -8
- package/src/mcp-command-completion.ts +572 -0
- package/src/mcp-command.ts +272 -131
- package/src/mcp-host.ts +56 -49
- package/src/mcp-observer-ui.ts +195 -0
- package/src/mcp-presentation.ts +660 -0
- package/src/mcp-session-files.ts +15 -6
- package/src/mcp-tool-catalog.ts +59 -44
- package/src/pi-mcp-cli.ts +7 -4
- package/src/pi-mcp-extension.ts +369 -310
- package/src/pi-mcp-settings.ts +23 -7
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
781
|
+
this.setEntryStatus(entry, { error: message, state: "needs_auth" });
|
|
783
782
|
} else if (cause instanceof RegistrationRejectedError) {
|
|
784
|
-
entry
|
|
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
|
-
|
|
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
|
|
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
|
|
880
|
+
throw new McpHostOperationError(`MCP Server ${serverId} is not connected`);
|
|
878
881
|
}
|
|
879
882
|
if (client.capabilities[capability] !== true) {
|
|
880
|
-
throw new
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|