@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/pi-mcp-extension.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
31
|
-
type
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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) =>
|