@ian-pascoe/pi-mcp 0.1.1 → 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.
@@ -1,7 +1,13 @@
1
1
  // oxlint-disable anti-slop/no-conditional-empty-object-spread -- Exact optional protocol and Pi fields must be omitted when absent at this composition boundary.
2
2
  // oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters -- This Pi composition root parses persisted custom-entry and custom-message replay data before restoring it.
3
3
  import { pathToFileURL } from "node:url";
4
- import { fromJsonSchema } from "@modelcontextprotocol/client";
4
+ import {
5
+ fromJsonSchema,
6
+ ProtocolError,
7
+ RegistrationRejectedError,
8
+ SdkError,
9
+ UnauthorizedError,
10
+ } from "@modelcontextprotocol/client";
5
11
  import type {
6
12
  CreateMessageRequest,
7
13
  CreateMessageResult,
@@ -16,8 +22,11 @@ import type {
16
22
  import type { AssistantMessage, Message, UserMessage } from "@earendil-works/pi-ai";
17
23
  import type { TSchema } from "typebox";
18
24
  import {
25
+ DEFAULT_MAX_BYTES,
26
+ DEFAULT_MAX_LINES,
19
27
  getAgentDir,
20
28
  SettingsManager,
29
+ truncateTail,
21
30
  type ContextEvent,
22
31
  type ExtensionAPI,
23
32
  type ExtensionCommandContext,
@@ -25,20 +34,36 @@ import {
25
34
  type ExtensionFactory,
26
35
  } from "@earendil-works/pi-coding-agent";
27
36
  import { McpAuthStore } from "./mcp-auth-store.js";
28
- import { runMcpCommandLine } from "./mcp-command.js";
29
37
  import {
30
- createMcpContentResult,
31
- type McpContentBlock,
32
- type McpModelContent,
33
- } from "./mcp-content.js";
38
+ completeMcpCommandArguments,
39
+ type McpCommandCompletionItem,
40
+ } from "./mcp-command-completion.js";
41
+ import {
42
+ runMcpCommandLine,
43
+ type McpCommandAdapterResult,
44
+ type McpCommandExitCategory,
45
+ } from "./mcp-command.js";
46
+ import { createMcpContentResult, type McpContentBlock } from "./mcp-content.js";
34
47
  import {
35
48
  McpHost,
36
49
  type McpHostGetPromptResult,
50
+ type McpHostLogTail,
51
+ McpHostOperationError,
37
52
  type McpHostRequestContext,
38
53
  type McpHostResourceSubscription,
39
54
  type McpHostServerTool,
55
+ type McpServerStatus,
40
56
  } from "./mcp-host.js";
41
57
  import { McpOAuthProvider } from "./mcp-oauth.js";
58
+ import { McpObserverUiController } from "./mcp-observer-ui.js";
59
+ import {
60
+ parseMcpPromptReplayMessages,
61
+ renderMcpPromptMessage,
62
+ renderMcpResourceUpdateMessage,
63
+ sanitizeMcpPresentationText,
64
+ type McpPromptMessageDetails,
65
+ type McpPromptReplayMessage,
66
+ } from "./mcp-presentation.js";
42
67
  import { createMcpSessionFiles, type McpSessionFiles } from "./mcp-session-files.js";
43
68
  import {
44
69
  McpToolCatalog,
@@ -58,17 +83,13 @@ export interface PiMcpExtensionCommandResult {
58
83
  }
59
84
 
60
85
  /** Slash-command completion item returned without importing Pi TUI internals. */
61
- export interface PiMcpAutocompleteItem {
62
- readonly description?: string;
63
- readonly label: string;
64
- readonly value: string;
65
- }
86
+ export type PiMcpAutocompleteItem = McpCommandCompletionItem;
66
87
 
67
88
  /** Session-owned MCP Host behavior consumed by the Pi lifecycle adapter. */
68
89
  export interface PiMcpExtensionSession {
69
90
  /** Close all processes, transports, subscriptions, listeners, timers, and session files once. */
70
91
  close(): Promise<void>;
71
- /** Complete `/mcp prompt` server, prompt, and argument values through the live Host. */
92
+ /** Complete `/mcp` commands and arguments through the live Host. */
72
93
  completeCommandArguments?(prefix: string): Promise<PiMcpAutocompleteItem[] | null>;
73
94
  /** Execute one `/mcp` command without throwing an expected failure through Pi. */
74
95
  executeCommand(
@@ -77,6 +98,8 @@ export interface PiMcpExtensionSession {
77
98
  ): Promise<PiMcpExtensionCommandResult>;
78
99
  /** Return the bounded, immutable Server Instructions snapshot for this Pi session. */
79
100
  instructionSnapshot(): Promise<string | undefined>;
101
+ /** Redact exact configured values from human-only MCP presentation copy. */
102
+ redactPresentationText(text: string): string;
80
103
  /** Start enabled MCP Servers without making `session_start` await their connections. */
81
104
  start(): Promise<void>;
82
105
  /** Expand persisted MCP Prompt custom messages at their active-branch positions. */
@@ -95,20 +118,9 @@ interface ActivePiMcpSession {
95
118
  }
96
119
 
97
120
  const MCP_PROMPT_MESSAGE_TYPE = "pi-mcp-prompt";
121
+ const MCP_RESOURCE_UPDATE_MESSAGE_TYPE = "pi-mcp-resource-update";
98
122
  const MCP_SUBSCRIPTIONS_ENTRY_TYPE = "pi-mcp-subscriptions";
99
123
 
100
- interface McpPromptReplayMessage {
101
- readonly content: readonly McpModelContent[];
102
- readonly role: "assistant" | "user";
103
- readonly timestamp: number;
104
- }
105
-
106
- interface McpPromptMessageDetails {
107
- readonly mcpMessages: readonly unknown[];
108
- readonly replayMessages: readonly McpPromptReplayMessage[];
109
- readonly version: 1;
110
- }
111
-
112
124
  function toMcpJsonValue(value: unknown): JSONValue {
113
125
  if (
114
126
  value === null ||
@@ -125,6 +137,104 @@ function toMcpJsonValue(value: unknown): JSONValue {
125
137
  );
126
138
  }
127
139
 
140
+ function formatMcpServerStatus(status: McpServerStatus): string {
141
+ const safeError = "error" in status ? sanitizeMcpPresentationText(status.error) : undefined;
142
+ switch (status.state) {
143
+ case "disabled":
144
+ case "connected":
145
+ return status.state;
146
+ case "connecting":
147
+ return `connecting (attempt ${status.attempt})`;
148
+ case "needs_auth":
149
+ return `needs_auth (${safeError})`;
150
+ case "needs_client_registration":
151
+ return `needs_client_registration (${safeError})`;
152
+ case "retrying":
153
+ return `retrying (attempt ${status.attempt}, retryAt ${status.retryAt}, delay ${status.delayMs} ms, ${safeError})`;
154
+ case "failed":
155
+ return `failed (${status.attempts} attempts, ${safeError})`;
156
+ }
157
+ }
158
+
159
+ function formatMcpStatus(
160
+ statuses: ReadonlyMap<string, McpServerStatus>,
161
+ subscriptions: readonly McpHostResourceSubscription[],
162
+ invalidSettings: readonly string[],
163
+ ): string {
164
+ const sections: string[] = [];
165
+ if (invalidSettings.length > 0) {
166
+ sections.push(
167
+ `Invalid MCP settings:\n- ${invalidSettings.map(sanitizeMcpPresentationText).join("\n- ")}`,
168
+ );
169
+ } else if (statuses.size === 0) {
170
+ sections.push("No MCP Server Definitions configured");
171
+ } else {
172
+ sections.push(
173
+ [...statuses]
174
+ .map(
175
+ ([serverId, status]) =>
176
+ `${sanitizeMcpPresentationText(serverId)}: ${formatMcpServerStatus(status)}`,
177
+ )
178
+ .join("\n"),
179
+ );
180
+ }
181
+ if (subscriptions.length > 0) {
182
+ sections.push(
183
+ `Active Resource subscriptions:\n${subscriptions
184
+ .map(
185
+ ({ serverId, uri }) =>
186
+ `- ${sanitizeMcpPresentationText(serverId)}: ${sanitizeMcpPresentationText(uri)}`,
187
+ )
188
+ .join("\n")}`,
189
+ );
190
+ }
191
+ return sections.join("\n\n");
192
+ }
193
+
194
+ function formatMcpLogs(tails: readonly McpHostLogTail[]): string {
195
+ if (tails.length === 0) return "No MCP logs retained";
196
+ const complete = sanitizeMcpPresentationText(
197
+ tails.map(({ serverId, text }) => `## ${serverId}\n${text || "(empty)"}`).join("\n\n"),
198
+ );
199
+ const limits = { maxBytes: DEFAULT_MAX_BYTES - 1, maxLines: DEFAULT_MAX_LINES - 1 };
200
+ const visible = truncateTail(complete, limits);
201
+ if (!visible.truncated) return visible.content;
202
+ const retainedPaths = sanitizeMcpPresentationText(
203
+ tails.map(({ path, serverId }) => `${serverId}: ${path}`).join(", "),
204
+ );
205
+ return truncateTail(
206
+ `${visible.content}\n\n[Combined logs truncated; complete retained tails: ${retainedPaths}]`,
207
+ limits,
208
+ ).content;
209
+ }
210
+
211
+ async function runExpectedMcpLiveCommand(
212
+ operation: () => Promise<McpCommandAdapterResult>,
213
+ category: Exclude<McpCommandExitCategory, "success" | "usage">,
214
+ redact: (value: string) => string,
215
+ ): Promise<McpCommandAdapterResult> {
216
+ try {
217
+ return await operation();
218
+ } catch (cause) {
219
+ if (
220
+ !(
221
+ cause instanceof ProtocolError ||
222
+ cause instanceof RegistrationRejectedError ||
223
+ cause instanceof SdkError ||
224
+ cause instanceof UnauthorizedError ||
225
+ cause instanceof McpHostOperationError
226
+ )
227
+ ) {
228
+ throw cause;
229
+ }
230
+ return {
231
+ category,
232
+ message: sanitizeMcpPresentationText(redact(cause.message)),
233
+ ok: false,
234
+ };
235
+ }
236
+ }
237
+
128
238
  function parseSubscriptionEntry(data: unknown): readonly McpHostResourceSubscription[] | undefined {
129
239
  if (data === null || typeof data !== "object" || !("version" in data) || data.version !== 1) {
130
240
  return undefined;
@@ -157,55 +267,6 @@ function replaySubscriptions(context: ExtensionContext): readonly McpHostResourc
157
267
  return subscriptions;
158
268
  }
159
269
 
160
- function parseReplayMessage(value: unknown): McpPromptReplayMessage | undefined {
161
- if (
162
- value === null ||
163
- typeof value !== "object" ||
164
- !("role" in value) ||
165
- (value.role !== "user" && value.role !== "assistant") ||
166
- !("timestamp" in value) ||
167
- typeof value.timestamp !== "number" ||
168
- !("content" in value) ||
169
- !Array.isArray(value.content)
170
- ) {
171
- return undefined;
172
- }
173
- const content: McpModelContent[] = [];
174
- for (const block of value.content) {
175
- if (block === null || typeof block !== "object" || !("type" in block)) return undefined;
176
- if (block.type === "text" && "text" in block && typeof block.text === "string") {
177
- content.push({ text: block.text, type: "text" });
178
- continue;
179
- }
180
- if (
181
- block.type === "image" &&
182
- "data" in block &&
183
- typeof block.data === "string" &&
184
- "mimeType" in block &&
185
- typeof block.mimeType === "string"
186
- ) {
187
- content.push({ data: block.data, mimeType: block.mimeType, type: "image" });
188
- continue;
189
- }
190
- return undefined;
191
- }
192
- return { content, role: value.role, timestamp: value.timestamp };
193
- }
194
-
195
- function promptReplayMessages(value: unknown): readonly McpPromptReplayMessage[] | undefined {
196
- if (value === null || typeof value !== "object" || !("version" in value) || value.version !== 1) {
197
- return undefined;
198
- }
199
- if (!("replayMessages" in value) || !Array.isArray(value.replayMessages)) return undefined;
200
- const messages: McpPromptReplayMessage[] = [];
201
- for (const item of value.replayMessages) {
202
- const parsed = parseReplayMessage(item);
203
- if (parsed === undefined) return undefined;
204
- messages.push(parsed);
205
- }
206
- return messages;
207
- }
208
-
209
270
  function agentPromptReplayMessage(
210
271
  message: McpPromptReplayMessage,
211
272
  ): ContextEvent["messages"][number] {
@@ -244,7 +305,7 @@ function transformPromptMessages(messages: ContextEvent["messages"]): ContextEve
244
305
  if (message.role !== "custom" || message.customType !== MCP_PROMPT_MESSAGE_TYPE) {
245
306
  return [message];
246
307
  }
247
- return (promptReplayMessages(message.details) ?? []).map(agentPromptReplayMessage);
308
+ return (parseMcpPromptReplayMessages(message.details) ?? []).map(agentPromptReplayMessage);
248
309
  });
249
310
  }
250
311
 
@@ -584,69 +645,24 @@ function catalogServerTool(tool: McpHostServerTool): McpServerToolDefinition {
584
645
  class ProductionPiMcpSession implements PiMcpExtensionSession {
585
646
  constructor(
586
647
  private readonly host: McpHost,
648
+ private readonly observer: McpObserverUiController,
649
+ private readonly redact: (text: string) => string,
587
650
  private readonly adapters: Awaited<ReturnType<typeof createStandaloneMcpCommandAdapters>>,
588
651
  private readonly synchronizeInitialCatalog: () => Promise<void>,
589
652
  private readonly interactionContext: { current: ExtensionContext },
590
653
  ) {}
591
654
 
592
655
  close(): Promise<void> {
656
+ this.observer.dispose();
593
657
  return this.host.shutdown();
594
658
  }
595
659
 
660
+ redactPresentationText(text: string): string {
661
+ return this.redact(text);
662
+ }
663
+
596
664
  async completeCommandArguments(prefix: string): Promise<PiMcpAutocompleteItem[] | null> {
597
- const endsWithSpace = prefix.endsWith(" ");
598
- const words = prefix.trim().split(/\s+/u);
599
- if (words[0] !== "prompt") return null;
600
- const prompts = await this.host.listPrompts();
601
- if (words.length === 1 || (words.length === 2 && !endsWithSpace)) {
602
- const serverPrefix = words[1] ?? "";
603
- return [...new Set(prompts.map(({ serverId }) => serverId))]
604
- .filter((serverId) => serverId.startsWith(serverPrefix))
605
- .map((serverId) => ({ label: serverId, value: serverId }));
606
- }
607
- const serverId = words[1] ?? "";
608
- if (words.length === 2 || (words.length === 3 && !endsWithSpace)) {
609
- const promptPrefix = words[2] ?? "";
610
- return prompts
611
- .filter(
612
- ({ prompt, serverId: owner }) =>
613
- owner === serverId && prompt.name.startsWith(promptPrefix),
614
- )
615
- .map(({ prompt }) => ({
616
- ...(prompt.description === undefined ? {} : { description: prompt.description }),
617
- label: prompt.name,
618
- value: prompt.name,
619
- }));
620
- }
621
- const promptName = words[2] ?? "";
622
- const argumentMarker = words.lastIndexOf("--arg");
623
- if (argumentMarker === -1) return null;
624
- const argument = words[argumentMarker + 1] ?? "";
625
- const separator = argument.indexOf("=");
626
- if (separator === -1) {
627
- const definition = prompts.find(
628
- ({ prompt, serverId: owner }) => owner === serverId && prompt.name === promptName,
629
- )?.prompt;
630
- return (definition?.arguments ?? [])
631
- .filter(({ name }) => name.startsWith(argument))
632
- .map(({ description, name }) => ({
633
- ...(description === undefined ? {} : { description }),
634
- label: name,
635
- value: `${name}=`,
636
- }));
637
- }
638
- const argumentName = argument.slice(0, separator);
639
- const valuePrefix = argument.slice(separator + 1);
640
- const completion = await this.host.completePromptArgument(
641
- serverId,
642
- promptName,
643
- argumentName,
644
- valuePrefix,
645
- );
646
- return completion.values.map((value) => ({
647
- label: value,
648
- value: `${argumentName}=${value}`,
649
- }));
665
+ return completeMcpCommandArguments(prefix, this.host);
650
666
  }
651
667
 
652
668
  async executeCommand(
@@ -685,100 +701,9 @@ const productionPiMcpExtensionEffects: PiMcpExtensionEffects = {
685
701
  projectTrusted: context.isProjectTrusted(),
686
702
  });
687
703
  const settings = resolveMcpSettings(settingsManager);
688
- if (!settings.valid && context.hasUI) {
689
- context.ui.notify(
690
- `Pi MCP settings:\n- ${settings.errors.map((error) => error.message).join("\n- ")}`,
691
- "warning",
692
- );
693
- }
694
- const sessionFiles = await createMcpSessionFiles(context.sessionManager.getSessionDir());
695
- const authStore = new McpAuthStore(agentDirectory);
696
- const resourceServers = new Set<string>();
697
- let catalog: McpToolCatalog | undefined;
698
- const host = new McpHost({
699
- initialSubscriptions: replaySubscriptions(context),
700
- onCatalogChanged: (serverId, kind) => {
701
- if (kind === "tools") void synchronizeServerCatalog(serverId);
702
- else if (kind === "resources" || kind === "resourceTemplates") {
703
- void synchronizeServerCatalog(serverId);
704
- }
705
- },
706
- onResourceUpdated: ({ serverId, uri }) => {
707
- pi.sendMessage(
708
- {
709
- content: `MCP Resource updated on ${serverId}: ${uri}. Read it explicitly before using the new content.`,
710
- customType: "pi-mcp-resource-update",
711
- display: true,
712
- },
713
- { deliverAs: "nextTurn" },
714
- );
715
- },
716
- persistSubscriptions: (subscriptions) => {
717
- pi.appendEntry(MCP_SUBSCRIPTIONS_ENTRY_TYPE, { subscriptions, version: 1 });
718
- },
719
- piCwd: context.cwd,
720
- resolveAuthProvider: (definition) => {
721
- if (definition.auth?.type === "none" || definition.auth?.type === "bearer") {
722
- return undefined;
723
- }
724
- const oauth = definition.auth?.type === "oauth" ? definition.auth : undefined;
725
- return new McpOAuthProvider({
726
- authStore,
727
- clientIdentity: oauth?.clientId ?? "@ian-pascoe/pi-mcp",
728
- ...(oauth?.clientId === undefined ? {} : { clientId: oauth.clientId }),
729
- ...(oauth?.clientSecret === undefined ? {} : { clientSecret: oauth.clientSecret }),
730
- onAuthorizationUrl: () => undefined,
731
- redirectUrl: oauth?.redirectUri ?? "http://127.0.0.1:19876/mcp/oauth/callback",
732
- scopes: oauth?.scopes ?? [],
733
- serverUrl: definition.url,
734
- });
735
- },
736
- sessionFiles,
737
- settings,
738
- });
739
- const synchronizeServerCatalog = async (serverId: string): Promise<void> => {
740
- const activeCatalog = catalog;
741
- if (activeCatalog === undefined) return;
742
- if (host.getStatus(serverId)?.state !== "connected") {
743
- activeCatalog.setServerActive(serverId, false);
744
- resourceServers.delete(serverId);
745
- activeCatalog.setResourceToolsActive(resourceServers.size > 0);
746
- return;
747
- }
748
- try {
749
- const tools = await host.listTools(serverId);
750
- activeCatalog.replaceServerTools(
751
- serverId,
752
- tools.map(({ tool }) => catalogServerTool(tool)),
753
- );
754
- if (host.hasConnectedCapability("resources", serverId)) resourceServers.add(serverId);
755
- else resourceServers.delete(serverId);
756
- activeCatalog.setResourceToolsActive(resourceServers.size > 0);
757
- } catch {
758
- activeCatalog.setServerActive(serverId, false);
759
- resourceServers.delete(serverId);
760
- activeCatalog.setResourceToolsActive(resourceServers.size > 0);
761
- }
762
- };
763
- catalog = new McpToolCatalog(pi, createMcpToolCatalogRuntime(host, sessionFiles, pi));
764
- const resolveCurrentSettings = () =>
765
- resolveMcpSettings(
766
- SettingsManager.create(context.cwd, agentDirectory, {
767
- projectTrusted: context.isProjectTrusted(),
768
- }),
769
- );
770
- const applyPersistedServer = async (serverId: string): Promise<void> => {
771
- const definition = resolveCurrentSettings().servers.get(serverId);
772
- if (definition === undefined) {
773
- if (host.getStatus(serverId) !== undefined) await host.removeServer(serverId);
774
- catalog?.setServerActive(serverId, false);
775
- resourceServers.delete(serverId);
776
- catalog?.setResourceToolsActive(resourceServers.size > 0);
777
- return;
778
- }
779
- await host.upsertServer(definition);
780
- await synchronizeServerCatalog(serverId);
781
- };
704
+ const invalidSettings = settings.valid
705
+ ? []
706
+ : settings.errors.map((error) => settings.secrets.redact(error.message));
782
707
  const adapters = await createStandaloneMcpCommandAdapters({
783
708
  agentDirectory,
784
709
  cwd: context.cwd,
@@ -797,96 +722,222 @@ const productionPiMcpExtensionEffects: PiMcpExtensionEffects = {
797
722
  if (context.hasUI) context.ui.notify(`MCP OAuth authorization URL: ${url}`, "info");
798
723
  },
799
724
  });
800
- const persistentAuth = adapters.auth;
801
- adapters.auth = {
802
- ...persistentAuth,
803
- authenticate: async (options) => {
804
- const result = await persistentAuth.authenticate(options);
805
- if (result.ok) void host.reconnect(options.server).catch(() => undefined);
806
- return result;
807
- },
808
- };
809
- adapters.live = {
810
- connectInBackground: (server) => {
811
- void applyPersistedServer(server).catch(() => undefined);
812
- },
813
- disconnect: applyPersistedServer,
814
- logs: async (options) => {
815
- const tails = await host.readLogs(options.server, options.level);
816
- return {
817
- data: toMcpJsonValue(tails),
818
- message:
819
- tails.length === 0
820
- ? "No MCP logs retained"
821
- : tails
822
- .map(({ serverId, text }) => `## ${serverId}\n${text || "(empty)"}`)
823
- .join("\n\n"),
824
- ok: true,
825
- };
826
- },
827
- prompt: async (options) => {
828
- const promptExecution: McpToolExecution = {
829
- context: interactionContext.current,
830
- onUpdate: undefined,
831
- signal: interactionContext.current.signal,
832
- toolCallId: "mcp-prompt",
833
- };
834
- const result = await host.getPrompt(
835
- options.server,
836
- options.prompt,
837
- options.arguments,
838
- createMcpRequestContext(promptExecution, pi),
839
- );
840
- const replayMessages = await mapPromptResult(result, sessionFiles);
841
- pi.sendMessage(
842
- {
843
- content: `MCP Prompt ${options.server}/${options.prompt}`,
844
- customType: MCP_PROMPT_MESSAGE_TYPE,
845
- details: {
846
- mcpMessages: result.messages,
847
- replayMessages,
848
- version: 1,
849
- } satisfies McpPromptMessageDetails,
850
- display: true,
851
- },
852
- { triggerTurn: true },
725
+ const sessionFiles = await createMcpSessionFiles(context.sessionManager.getSessionDir());
726
+ const authStore = new McpAuthStore(agentDirectory);
727
+ const resourceServers = new Set<string>();
728
+ let catalog: McpToolCatalog | undefined;
729
+ let observer: McpObserverUiController | undefined;
730
+ let ownedHost: McpHost | undefined;
731
+ try {
732
+ const host = new McpHost({
733
+ initialSubscriptions: replaySubscriptions(context),
734
+ onCatalogChanged: (serverId, kind) => {
735
+ if (kind === "tools") void synchronizeServerCatalog(serverId);
736
+ else if (kind === "resources" || kind === "resourceTemplates") {
737
+ void synchronizeServerCatalog(serverId);
738
+ }
739
+ },
740
+ onResourceUpdated: ({ serverId, uri }) => {
741
+ pi.sendMessage(
742
+ {
743
+ content: `MCP Resource updated on ${serverId}: ${uri}. Read it explicitly before using the new content.`,
744
+ customType: MCP_RESOURCE_UPDATE_MESSAGE_TYPE,
745
+ display: true,
746
+ },
747
+ { deliverAs: "nextTurn" },
748
+ );
749
+ },
750
+ onStatusChange: (statuses) => observer?.update(statuses, invalidSettings),
751
+ persistSubscriptions: (subscriptions) => {
752
+ pi.appendEntry(MCP_SUBSCRIPTIONS_ENTRY_TYPE, { subscriptions, version: 1 });
753
+ },
754
+ piCwd: context.cwd,
755
+ resolveAuthProvider: (definition) => {
756
+ if (definition.auth?.type === "none" || definition.auth?.type === "bearer") {
757
+ return undefined;
758
+ }
759
+ const oauth = definition.auth?.type === "oauth" ? definition.auth : undefined;
760
+ return new McpOAuthProvider({
761
+ authStore,
762
+ clientIdentity: oauth?.clientId ?? "@ian-pascoe/pi-mcp",
763
+ ...(oauth?.clientId === undefined ? {} : { clientId: oauth.clientId }),
764
+ ...(oauth?.clientSecret === undefined ? {} : { clientSecret: oauth.clientSecret }),
765
+ onAuthorizationUrl: () => undefined,
766
+ redirectUrl: oauth?.redirectUri ?? "http://127.0.0.1:19876/mcp/oauth/callback",
767
+ scopes: oauth?.scopes ?? [],
768
+ serverUrl: definition.url,
769
+ });
770
+ },
771
+ sessionFiles,
772
+ settings,
773
+ });
774
+ ownedHost = host;
775
+ observer = new McpObserverUiController(context, (value) => settings.secrets.redact(value));
776
+ observer.update(host.listStatuses(), invalidSettings);
777
+ const synchronizeServerCatalog = async (serverId: string): Promise<void> => {
778
+ const activeCatalog = catalog;
779
+ if (activeCatalog === undefined) return;
780
+ if (host.getStatus(serverId)?.state !== "connected") {
781
+ activeCatalog.setServerActive(serverId, false);
782
+ resourceServers.delete(serverId);
783
+ activeCatalog.setResourceToolsActive(resourceServers.size > 0);
784
+ return;
785
+ }
786
+ try {
787
+ const tools = await host.listTools(serverId);
788
+ activeCatalog.replaceServerTools(
789
+ serverId,
790
+ tools.map(({ tool }) => catalogServerTool(tool)),
791
+ );
792
+ if (host.hasConnectedCapability("resources", serverId)) resourceServers.add(serverId);
793
+ else resourceServers.delete(serverId);
794
+ activeCatalog.setResourceToolsActive(resourceServers.size > 0);
795
+ } catch {
796
+ activeCatalog.setServerActive(serverId, false);
797
+ resourceServers.delete(serverId);
798
+ activeCatalog.setResourceToolsActive(resourceServers.size > 0);
799
+ }
800
+ };
801
+ catalog = new McpToolCatalog(
802
+ pi,
803
+ createMcpToolCatalogRuntime(host, sessionFiles, pi),
804
+ (text) => settings.secrets.redact(text),
805
+ );
806
+ const resolveCurrentSettings = () =>
807
+ resolveMcpSettings(
808
+ SettingsManager.create(context.cwd, agentDirectory, {
809
+ projectTrusted: context.isProjectTrusted(),
810
+ }),
853
811
  );
854
- return { message: `Expanded MCP Prompt ${options.server}/${options.prompt}`, ok: true };
855
- },
856
- reconnect: async (server) => {
857
- await host.reconnect(server);
858
- return { message: `Reconnected MCP Server ${server}`, ok: true };
859
- },
860
- status: async () => {
861
- const statuses = Object.fromEntries(host.listStatuses());
862
- return {
863
- data: toMcpJsonValue(statuses),
864
- message:
865
- Object.keys(statuses).length === 0
866
- ? "No MCP Server Definitions configured"
867
- : Object.entries(statuses)
868
- .map(([server, status]) => `${server}: ${status.state}`)
869
- .join("\n"),
870
- ok: true,
871
- };
872
- },
873
- subscribe: async (options) => {
874
- await host.subscribeResource(options.server, options.uri);
875
- return { message: `Subscribed to ${options.server}: ${options.uri}`, ok: true };
876
- },
877
- unsubscribe: async (options) => {
878
- await host.unsubscribeResource(options.server, options.uri);
879
- return { message: `Unsubscribed from ${options.server}: ${options.uri}`, ok: true };
880
- },
881
- };
882
- return new ProductionPiMcpSession(
883
- host,
884
- adapters,
885
- async () => {
886
- await Promise.all([...settings.servers.keys()].map(synchronizeServerCatalog));
887
- },
888
- interactionContext,
889
- );
812
+ const applyPersistedServer = async (serverId: string): Promise<void> => {
813
+ const definition = resolveCurrentSettings().servers.get(serverId);
814
+ if (definition === undefined) {
815
+ if (host.getStatus(serverId) !== undefined) await host.removeServer(serverId);
816
+ catalog?.setServerActive(serverId, false);
817
+ resourceServers.delete(serverId);
818
+ catalog?.setResourceToolsActive(resourceServers.size > 0);
819
+ return;
820
+ }
821
+ await host.upsertServer(definition);
822
+ await synchronizeServerCatalog(serverId);
823
+ };
824
+ const persistentAuth = adapters.auth;
825
+ adapters.auth = {
826
+ ...persistentAuth,
827
+ authenticate: async (options) => {
828
+ const result = await persistentAuth.authenticate(options);
829
+ if (result.ok) void host.reconnect(options.server).catch(() => undefined);
830
+ return result;
831
+ },
832
+ };
833
+ adapters.live = {
834
+ connectInBackground: (server) => {
835
+ void applyPersistedServer(server).catch(() => undefined);
836
+ },
837
+ disconnect: applyPersistedServer,
838
+ logs: (options) =>
839
+ runExpectedMcpLiveCommand(
840
+ async () => {
841
+ const tails = await host.readLogs(options.server);
842
+ return { data: toMcpJsonValue(tails), message: formatMcpLogs(tails), ok: true };
843
+ },
844
+ "connection",
845
+ (value) => settings.secrets.redact(value),
846
+ ),
847
+ prompt: (options) =>
848
+ runExpectedMcpLiveCommand(
849
+ async () => {
850
+ const promptExecution: McpToolExecution = {
851
+ context: interactionContext.current,
852
+ onUpdate: undefined,
853
+ signal: interactionContext.current.signal,
854
+ toolCallId: "mcp-prompt",
855
+ };
856
+ const result = await host.getPrompt(
857
+ options.server,
858
+ options.prompt,
859
+ options.arguments,
860
+ createMcpRequestContext(promptExecution, pi),
861
+ );
862
+ const replayMessages = await mapPromptResult(result, sessionFiles);
863
+ pi.sendMessage(
864
+ {
865
+ content: `MCP Prompt ${options.server}/${options.prompt}`,
866
+ customType: MCP_PROMPT_MESSAGE_TYPE,
867
+ details: {
868
+ mcpMessages: result.messages,
869
+ replayMessages,
870
+ version: 1,
871
+ } satisfies McpPromptMessageDetails,
872
+ display: true,
873
+ },
874
+ { triggerTurn: true },
875
+ );
876
+ return {
877
+ message: `Expanded MCP Prompt ${options.server}/${options.prompt}`,
878
+ ok: true,
879
+ };
880
+ },
881
+ "runtime",
882
+ (value) => settings.secrets.redact(value),
883
+ ),
884
+ reconnect: (server) =>
885
+ runExpectedMcpLiveCommand(
886
+ async () => {
887
+ await host.reconnect(server);
888
+ return { message: `Reconnected MCP Server ${server}`, ok: true };
889
+ },
890
+ "connection",
891
+ (value) => settings.secrets.redact(value),
892
+ ),
893
+ status: async () => {
894
+ const statuses = host.listStatuses();
895
+ const subscriptions = host.listSubscriptions();
896
+ return {
897
+ data: toMcpJsonValue({
898
+ invalidSettings,
899
+ servers: Object.fromEntries(statuses),
900
+ subscriptions,
901
+ }),
902
+ message: formatMcpStatus(statuses, subscriptions, invalidSettings),
903
+ ok: true,
904
+ };
905
+ },
906
+ subscribe: (options) =>
907
+ runExpectedMcpLiveCommand(
908
+ async () => {
909
+ await host.subscribeResource(options.server, options.uri);
910
+ return { message: `Subscribed to ${options.server}: ${options.uri}`, ok: true };
911
+ },
912
+ "connection",
913
+ (value) => settings.secrets.redact(value),
914
+ ),
915
+ unsubscribe: (options) =>
916
+ runExpectedMcpLiveCommand(
917
+ async () => {
918
+ await host.unsubscribeResource(options.server, options.uri);
919
+ return { message: `Unsubscribed from ${options.server}: ${options.uri}`, ok: true };
920
+ },
921
+ "connection",
922
+ (value) => settings.secrets.redact(value),
923
+ ),
924
+ };
925
+ return new ProductionPiMcpSession(
926
+ host,
927
+ observer,
928
+ (text) => settings.secrets.redact(text),
929
+ adapters,
930
+ async () => {
931
+ await Promise.all([...settings.servers.keys()].map(synchronizeServerCatalog));
932
+ },
933
+ interactionContext,
934
+ );
935
+ } catch (cause) {
936
+ observer?.dispose();
937
+ if (ownedHost === undefined) await sessionFiles.close();
938
+ else await ownedHost.shutdown();
939
+ throw cause;
940
+ }
890
941
  },
891
942
  };
892
943
 
@@ -903,6 +954,14 @@ export class PiMcpLifecycleController {
903
954
 
904
955
  /** Register inert handlers and the `/mcp` command without opening external resources. */
905
956
  register(): void {
957
+ const redact = (text: string) =>
958
+ this.activeSession?.runtime.redactPresentationText(text) ?? text;
959
+ this.pi.registerMessageRenderer(MCP_PROMPT_MESSAGE_TYPE, (message, options, theme) =>
960
+ renderMcpPromptMessage(message, options, theme, redact),
961
+ );
962
+ this.pi.registerMessageRenderer(MCP_RESOURCE_UPDATE_MESSAGE_TYPE, (message, options, theme) =>
963
+ renderMcpResourceUpdateMessage(message, options, theme, redact),
964
+ );
906
965
  this.pi.registerCommand("mcp", {
907
966
  description: "Configure and inspect MCP Servers",
908
967
  getArgumentCompletions: (prefix) =>