@ian-pascoe/pi-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/pi-mcp-cli.js +10948 -0
- package/package.json +64 -0
- package/src/index.ts +2 -0
- package/src/mcp-auth-store.ts +393 -0
- package/src/mcp-command.ts +893 -0
- package/src/mcp-content.ts +212 -0
- package/src/mcp-host.ts +971 -0
- package/src/mcp-oauth.ts +740 -0
- package/src/mcp-server-client.ts +375 -0
- package/src/mcp-session-files.ts +127 -0
- package/src/mcp-settings-store.ts +455 -0
- package/src/mcp-tool-catalog.ts +464 -0
- package/src/pi-mcp-cli.ts +507 -0
- package/src/pi-mcp-extension.ts +1013 -0
- package/src/pi-mcp-settings.ts +619 -0
|
@@ -0,0 +1,1013 @@
|
|
|
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
|
+
// 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
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { fromJsonSchema } from "@modelcontextprotocol/client";
|
|
5
|
+
import type {
|
|
6
|
+
CreateMessageRequest,
|
|
7
|
+
CreateMessageResult,
|
|
8
|
+
CreateMessageResultWithTools,
|
|
9
|
+
ElicitRequest,
|
|
10
|
+
ElicitResult,
|
|
11
|
+
JSONValue,
|
|
12
|
+
JsonSchemaType,
|
|
13
|
+
TextContent,
|
|
14
|
+
ToolUseContent,
|
|
15
|
+
} from "@modelcontextprotocol/client";
|
|
16
|
+
import type { AssistantMessage, Message, UserMessage } from "@earendil-works/pi-ai";
|
|
17
|
+
import type { TSchema } from "typebox";
|
|
18
|
+
import {
|
|
19
|
+
getAgentDir,
|
|
20
|
+
SettingsManager,
|
|
21
|
+
type ContextEvent,
|
|
22
|
+
type ExtensionAPI,
|
|
23
|
+
type ExtensionCommandContext,
|
|
24
|
+
type ExtensionContext,
|
|
25
|
+
type ExtensionFactory,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { McpAuthStore } from "./mcp-auth-store.js";
|
|
28
|
+
import { runMcpCommandLine } from "./mcp-command.js";
|
|
29
|
+
import {
|
|
30
|
+
createMcpContentResult,
|
|
31
|
+
type McpContentBlock,
|
|
32
|
+
type McpModelContent,
|
|
33
|
+
} from "./mcp-content.js";
|
|
34
|
+
import {
|
|
35
|
+
McpHost,
|
|
36
|
+
type McpHostGetPromptResult,
|
|
37
|
+
type McpHostRequestContext,
|
|
38
|
+
type McpHostResourceSubscription,
|
|
39
|
+
type McpHostServerTool,
|
|
40
|
+
} from "./mcp-host.js";
|
|
41
|
+
import { McpOAuthProvider } from "./mcp-oauth.js";
|
|
42
|
+
import { createMcpSessionFiles, type McpSessionFiles } from "./mcp-session-files.js";
|
|
43
|
+
import {
|
|
44
|
+
McpToolCatalog,
|
|
45
|
+
type McpListResourcesParameters,
|
|
46
|
+
type McpServerToolDefinition,
|
|
47
|
+
type McpToolCatalogRuntime,
|
|
48
|
+
type McpToolExecution,
|
|
49
|
+
type McpToolOperationResult,
|
|
50
|
+
} from "./mcp-tool-catalog.js";
|
|
51
|
+
import { createStandaloneMcpCommandAdapters } from "./pi-mcp-cli.js";
|
|
52
|
+
import { resolveMcpSettings } from "./pi-mcp-settings.js";
|
|
53
|
+
|
|
54
|
+
/** Notification returned by the shared MCP command surface to the Pi adapter. */
|
|
55
|
+
export interface PiMcpExtensionCommandResult {
|
|
56
|
+
readonly level: "error" | "info" | "warning";
|
|
57
|
+
readonly message: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 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
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Session-owned MCP Host behavior consumed by the Pi lifecycle adapter. */
|
|
68
|
+
export interface PiMcpExtensionSession {
|
|
69
|
+
/** Close all processes, transports, subscriptions, listeners, timers, and session files once. */
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
/** Complete `/mcp prompt` server, prompt, and argument values through the live Host. */
|
|
72
|
+
completeCommandArguments?(prefix: string): Promise<PiMcpAutocompleteItem[] | null>;
|
|
73
|
+
/** Execute one `/mcp` command without throwing an expected failure through Pi. */
|
|
74
|
+
executeCommand(
|
|
75
|
+
arguments_: string,
|
|
76
|
+
context: ExtensionCommandContext,
|
|
77
|
+
): Promise<PiMcpExtensionCommandResult>;
|
|
78
|
+
/** Return the bounded, immutable Server Instructions snapshot for this Pi session. */
|
|
79
|
+
instructionSnapshot(): Promise<string | undefined>;
|
|
80
|
+
/** Start enabled MCP Servers without making `session_start` await their connections. */
|
|
81
|
+
start(): Promise<void>;
|
|
82
|
+
/** Expand persisted MCP Prompt custom messages at their active-branch positions. */
|
|
83
|
+
transformContext(messages: ContextEvent["messages"]): ContextEvent["messages"];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Construction boundary for trust-aware, session-owned MCP Host state. */
|
|
87
|
+
export interface PiMcpExtensionEffects {
|
|
88
|
+
/** Create one inert session generation from Pi's already resolved trust context. */
|
|
89
|
+
createSession(context: ExtensionContext, pi: ExtensionAPI): Promise<PiMcpExtensionSession>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ActivePiMcpSession {
|
|
93
|
+
readonly runtime: PiMcpExtensionSession;
|
|
94
|
+
instructionSnapshot?: Promise<string | undefined>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const MCP_PROMPT_MESSAGE_TYPE = "pi-mcp-prompt";
|
|
98
|
+
const MCP_SUBSCRIPTIONS_ENTRY_TYPE = "pi-mcp-subscriptions";
|
|
99
|
+
|
|
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
|
+
function toMcpJsonValue(value: unknown): JSONValue {
|
|
113
|
+
if (
|
|
114
|
+
value === null ||
|
|
115
|
+
typeof value === "boolean" ||
|
|
116
|
+
typeof value === "string" ||
|
|
117
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
118
|
+
) {
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(value)) return value.map(toMcpJsonValue);
|
|
122
|
+
if (typeof value !== "object") return "unsupported value";
|
|
123
|
+
return Object.fromEntries(
|
|
124
|
+
Object.entries(value).map(([key, item]) => [key, toMcpJsonValue(item)]),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseSubscriptionEntry(data: unknown): readonly McpHostResourceSubscription[] | undefined {
|
|
129
|
+
if (data === null || typeof data !== "object" || !("version" in data) || data.version !== 1) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (!("subscriptions" in data) || !Array.isArray(data.subscriptions)) return undefined;
|
|
133
|
+
const subscriptions: McpHostResourceSubscription[] = [];
|
|
134
|
+
for (const item of data.subscriptions) {
|
|
135
|
+
if (
|
|
136
|
+
item === null ||
|
|
137
|
+
typeof item !== "object" ||
|
|
138
|
+
!("serverId" in item) ||
|
|
139
|
+
typeof item.serverId !== "string" ||
|
|
140
|
+
!("uri" in item) ||
|
|
141
|
+
typeof item.uri !== "string"
|
|
142
|
+
) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
subscriptions.push({ serverId: item.serverId, uri: item.uri });
|
|
146
|
+
}
|
|
147
|
+
return subscriptions;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function replaySubscriptions(context: ExtensionContext): readonly McpHostResourceSubscription[] {
|
|
151
|
+
let subscriptions: readonly McpHostResourceSubscription[] = [];
|
|
152
|
+
for (const entry of context.sessionManager.getBranch()) {
|
|
153
|
+
if (entry.type !== "custom" || entry.customType !== MCP_SUBSCRIPTIONS_ENTRY_TYPE) continue;
|
|
154
|
+
const parsed = parseSubscriptionEntry(entry.data);
|
|
155
|
+
if (parsed !== undefined) subscriptions = parsed;
|
|
156
|
+
}
|
|
157
|
+
return subscriptions;
|
|
158
|
+
}
|
|
159
|
+
|
|
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
|
+
function agentPromptReplayMessage(
|
|
210
|
+
message: McpPromptReplayMessage,
|
|
211
|
+
): ContextEvent["messages"][number] {
|
|
212
|
+
if (message.role === "user") {
|
|
213
|
+
return { content: [...message.content], role: "user", timestamp: message.timestamp };
|
|
214
|
+
}
|
|
215
|
+
const assistantContent = message.content.map((block) =>
|
|
216
|
+
block.type === "text"
|
|
217
|
+
? block
|
|
218
|
+
: {
|
|
219
|
+
text: `[MCP Prompt image (${block.mimeType})]\ndata:${block.mimeType};base64,${block.data}`,
|
|
220
|
+
type: "text" as const,
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
api: "mcp-prompt",
|
|
225
|
+
content: assistantContent,
|
|
226
|
+
model: "mcp-prompt",
|
|
227
|
+
provider: "pi-mcp",
|
|
228
|
+
role: "assistant",
|
|
229
|
+
stopReason: "stop",
|
|
230
|
+
timestamp: message.timestamp,
|
|
231
|
+
usage: {
|
|
232
|
+
cacheRead: 0,
|
|
233
|
+
cacheWrite: 0,
|
|
234
|
+
cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0, total: 0 },
|
|
235
|
+
input: 0,
|
|
236
|
+
output: 0,
|
|
237
|
+
totalTokens: 0,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function transformPromptMessages(messages: ContextEvent["messages"]): ContextEvent["messages"] {
|
|
243
|
+
return messages.flatMap((message) => {
|
|
244
|
+
if (message.role !== "custom" || message.customType !== MCP_PROMPT_MESSAGE_TYPE) {
|
|
245
|
+
return [message];
|
|
246
|
+
}
|
|
247
|
+
return (promptReplayMessages(message.details) ?? []).map(agentPromptReplayMessage);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isMcpContentBlock(value: unknown): value is McpContentBlock {
|
|
252
|
+
if (value === null || typeof value !== "object" || !("type" in value)) return false;
|
|
253
|
+
if (value.type === "text") return "text" in value && typeof value.text === "string";
|
|
254
|
+
if (value.type === "image" || value.type === "audio") {
|
|
255
|
+
return (
|
|
256
|
+
"data" in value &&
|
|
257
|
+
typeof value.data === "string" &&
|
|
258
|
+
"mimeType" in value &&
|
|
259
|
+
typeof value.mimeType === "string"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (value.type === "resource_link") {
|
|
263
|
+
return (
|
|
264
|
+
"name" in value &&
|
|
265
|
+
typeof value.name === "string" &&
|
|
266
|
+
"uri" in value &&
|
|
267
|
+
typeof value.uri === "string"
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return (
|
|
271
|
+
value.type === "resource" &&
|
|
272
|
+
"resource" in value &&
|
|
273
|
+
value.resource !== null &&
|
|
274
|
+
typeof value.resource === "object"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function mapPromptResult(
|
|
279
|
+
result: McpHostGetPromptResult,
|
|
280
|
+
sessionFiles: McpSessionFiles,
|
|
281
|
+
): Promise<readonly McpPromptReplayMessage[]> {
|
|
282
|
+
const timestamp = Date.now();
|
|
283
|
+
const replay: McpPromptReplayMessage[] = [];
|
|
284
|
+
for (const value of result.messages) {
|
|
285
|
+
if (
|
|
286
|
+
value === null ||
|
|
287
|
+
typeof value !== "object" ||
|
|
288
|
+
!("role" in value) ||
|
|
289
|
+
(value.role !== "user" && value.role !== "assistant") ||
|
|
290
|
+
!("content" in value)
|
|
291
|
+
) {
|
|
292
|
+
throw new Error("Pi MCP Prompt result contains an invalid message");
|
|
293
|
+
}
|
|
294
|
+
const values = Array.isArray(value.content) ? value.content : [value.content];
|
|
295
|
+
if (!values.every(isMcpContentBlock)) {
|
|
296
|
+
throw new Error("Pi MCP Prompt result contains invalid content");
|
|
297
|
+
}
|
|
298
|
+
const mapped = await createMcpContentResult(values, undefined, sessionFiles);
|
|
299
|
+
replay.push({ content: mapped.content, role: value.role, timestamp });
|
|
300
|
+
}
|
|
301
|
+
return replay;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function selectedResourceServer(parameters: McpListResourcesParameters): string | undefined {
|
|
305
|
+
return typeof parameters.server === "string" ? parameters.server : undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function toolProgressUpdate(execution: McpToolExecution, progress: unknown): void {
|
|
309
|
+
execution.onUpdate?.({
|
|
310
|
+
content: [{ text: `MCP progress: ${JSON.stringify(progress)}`, type: "text" }],
|
|
311
|
+
details: { progress: toMcpJsonValue(progress) },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function samplingUserContent(value: unknown): UserMessage["content"] {
|
|
316
|
+
const blocks = Array.isArray(value) ? value : [value];
|
|
317
|
+
return blocks.map((block) => {
|
|
318
|
+
if (block !== null && typeof block === "object" && "type" in block) {
|
|
319
|
+
if (block.type === "text" && "text" in block && typeof block.text === "string") {
|
|
320
|
+
return { text: block.text, type: "text" as const };
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
block.type === "image" &&
|
|
324
|
+
"data" in block &&
|
|
325
|
+
typeof block.data === "string" &&
|
|
326
|
+
"mimeType" in block &&
|
|
327
|
+
typeof block.mimeType === "string"
|
|
328
|
+
) {
|
|
329
|
+
return { data: block.data, mimeType: block.mimeType, type: "image" as const };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { text: `[MCP sampling content]\n${JSON.stringify(block)}`, type: "text" as const };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function samplingAssistantContent(value: unknown): AssistantMessage["content"] {
|
|
337
|
+
const blocks = Array.isArray(value) ? value : [value];
|
|
338
|
+
return blocks.map((block) => {
|
|
339
|
+
if (block !== null && typeof block === "object" && "type" in block) {
|
|
340
|
+
if (block.type === "text" && "text" in block && typeof block.text === "string") {
|
|
341
|
+
return { text: block.text, type: "text" as const };
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
block.type === "tool_use" &&
|
|
345
|
+
"id" in block &&
|
|
346
|
+
typeof block.id === "string" &&
|
|
347
|
+
"name" in block &&
|
|
348
|
+
typeof block.name === "string" &&
|
|
349
|
+
"input" in block &&
|
|
350
|
+
block.input !== null &&
|
|
351
|
+
typeof block.input === "object" &&
|
|
352
|
+
!Array.isArray(block.input)
|
|
353
|
+
) {
|
|
354
|
+
return {
|
|
355
|
+
arguments: block.input,
|
|
356
|
+
id: block.id,
|
|
357
|
+
name: block.name,
|
|
358
|
+
type: "toolCall" as const,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { text: `[MCP sampling content]\n${JSON.stringify(block)}`, type: "text" as const };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function samplingMessages(request: CreateMessageRequest): Message[] {
|
|
367
|
+
const timestamp = Date.now();
|
|
368
|
+
return request.params.messages.map((message): Message => {
|
|
369
|
+
if (message.role === "user") {
|
|
370
|
+
return { content: samplingUserContent(message.content), role: "user", timestamp };
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
api: "mcp-sampling",
|
|
374
|
+
content: samplingAssistantContent(message.content),
|
|
375
|
+
model: "mcp-sampling",
|
|
376
|
+
provider: "pi-mcp",
|
|
377
|
+
role: "assistant",
|
|
378
|
+
stopReason: "stop",
|
|
379
|
+
timestamp,
|
|
380
|
+
usage: {
|
|
381
|
+
cacheRead: 0,
|
|
382
|
+
cacheWrite: 0,
|
|
383
|
+
cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0, total: 0 },
|
|
384
|
+
input: 0,
|
|
385
|
+
output: 0,
|
|
386
|
+
totalTokens: 0,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function completeMcpSampling(
|
|
393
|
+
request: CreateMessageRequest,
|
|
394
|
+
execution: McpToolExecution,
|
|
395
|
+
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
|
|
396
|
+
const model = execution.context.model;
|
|
397
|
+
if (model === undefined) throw new Error("Pi MCP sampling requires an active Pi model");
|
|
398
|
+
const tools = request.params.tools?.map((tool) => {
|
|
399
|
+
// SAFETY: The official MCP Client parsed each sampling tool inputSchema as JSON Schema. Pi accepts the same exact structural schema.
|
|
400
|
+
const parameters = tool.inputSchema as TSchema;
|
|
401
|
+
return { description: tool.description ?? tool.name, name: tool.name, parameters };
|
|
402
|
+
});
|
|
403
|
+
const response = await execution.context.modelRegistry.complete(
|
|
404
|
+
model,
|
|
405
|
+
{
|
|
406
|
+
messages: samplingMessages(request),
|
|
407
|
+
...(request.params.systemPrompt === undefined
|
|
408
|
+
? {}
|
|
409
|
+
: { systemPrompt: request.params.systemPrompt }),
|
|
410
|
+
...(tools === undefined ? {} : { tools }),
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
maxTokens: request.params.maxTokens,
|
|
414
|
+
...(execution.signal === undefined ? {} : { signal: execution.signal }),
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
const content: Array<TextContent | ToolUseContent> = response.content.flatMap<
|
|
418
|
+
TextContent | ToolUseContent
|
|
419
|
+
>((block) => {
|
|
420
|
+
if (block.type === "text") return [{ text: block.text, type: "text" as const }];
|
|
421
|
+
if (block.type === "toolCall") {
|
|
422
|
+
return [
|
|
423
|
+
{
|
|
424
|
+
id: block.id,
|
|
425
|
+
input: block.arguments,
|
|
426
|
+
name: block.name,
|
|
427
|
+
type: "tool_use" as const,
|
|
428
|
+
},
|
|
429
|
+
];
|
|
430
|
+
}
|
|
431
|
+
return [{ text: block.thinking, type: "text" as const }];
|
|
432
|
+
});
|
|
433
|
+
const stopReason =
|
|
434
|
+
response.stopReason === "length"
|
|
435
|
+
? "maxTokens"
|
|
436
|
+
: response.stopReason === "toolUse"
|
|
437
|
+
? "toolUse"
|
|
438
|
+
: "endTurn";
|
|
439
|
+
if (request.params.tools !== undefined) {
|
|
440
|
+
return { content, model: response.model, role: "assistant", stopReason };
|
|
441
|
+
}
|
|
442
|
+
const onlyContent = content[0];
|
|
443
|
+
return {
|
|
444
|
+
content:
|
|
445
|
+
content.length === 1 && onlyContent !== undefined && onlyContent.type !== "tool_use"
|
|
446
|
+
? onlyContent
|
|
447
|
+
: { text: JSON.stringify(content), type: "text" },
|
|
448
|
+
model: response.model,
|
|
449
|
+
role: "assistant",
|
|
450
|
+
stopReason,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function fulfilMcpElicitation(
|
|
455
|
+
request: ElicitRequest,
|
|
456
|
+
execution: McpToolExecution,
|
|
457
|
+
pi: ExtensionAPI,
|
|
458
|
+
): Promise<ElicitResult> {
|
|
459
|
+
if (!execution.context.hasUI) return { action: "decline" };
|
|
460
|
+
if (request.params.mode === "url") {
|
|
461
|
+
const accepted = await execution.context.ui.confirm(
|
|
462
|
+
"MCP URL elicitation",
|
|
463
|
+
`${request.params.message}\n\n${request.params.url}`,
|
|
464
|
+
);
|
|
465
|
+
if (!accepted) return { action: "decline" };
|
|
466
|
+
const [command, args] =
|
|
467
|
+
process.platform === "darwin"
|
|
468
|
+
? ["open", [request.params.url]]
|
|
469
|
+
: process.platform === "win32"
|
|
470
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", request.params.url]]
|
|
471
|
+
: ["xdg-open", [request.params.url]];
|
|
472
|
+
await pi.exec(command, args, { timeout: 10_000 }).catch(() => undefined);
|
|
473
|
+
return { action: "accept" };
|
|
474
|
+
}
|
|
475
|
+
const input = await execution.context.ui.editor(
|
|
476
|
+
"MCP form elicitation",
|
|
477
|
+
`${request.params.message}\n\nEnter a JSON object matching:\n${JSON.stringify(request.params.requestedSchema, undefined, 2)}\n\n{}`,
|
|
478
|
+
);
|
|
479
|
+
if (input === undefined) return { action: "cancel" };
|
|
480
|
+
try {
|
|
481
|
+
const content: unknown = JSON.parse(input.slice(input.lastIndexOf("\n\n") + 2));
|
|
482
|
+
// SAFETY: The official MCP Client parsed requestedSchema as the flat elicitation JSON Schema before invoking this Host callback; the cast only reconciles exact-optional SDK declarations.
|
|
483
|
+
const requestedSchema = request.params.requestedSchema as JsonSchemaType;
|
|
484
|
+
const validation =
|
|
485
|
+
await fromJsonSchema<Record<string, string | number | boolean | string[]>>(requestedSchema)[
|
|
486
|
+
"~standard"
|
|
487
|
+
].validate(content);
|
|
488
|
+
return validation.issues === undefined
|
|
489
|
+
? { action: "accept", content: validation.value }
|
|
490
|
+
: { action: "decline" };
|
|
491
|
+
} catch {
|
|
492
|
+
return { action: "decline" };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function createMcpRequestContext(
|
|
497
|
+
execution: McpToolExecution,
|
|
498
|
+
pi: ExtensionAPI,
|
|
499
|
+
): McpHostRequestContext<ExtensionContext> {
|
|
500
|
+
return {
|
|
501
|
+
callbacks: {
|
|
502
|
+
onElicitation: (request) => fulfilMcpElicitation(request, execution, pi),
|
|
503
|
+
onListRoots: () => ({
|
|
504
|
+
roots: [
|
|
505
|
+
{
|
|
506
|
+
name: "Pi working directory",
|
|
507
|
+
uri: pathToFileURL(execution.context.cwd).href,
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
}),
|
|
511
|
+
onSampling: (request) => completeMcpSampling(request, execution),
|
|
512
|
+
},
|
|
513
|
+
onProgress: (progress) => toolProgressUpdate(execution, progress),
|
|
514
|
+
piContext: execution.context,
|
|
515
|
+
...(execution.signal === undefined ? {} : { signal: execution.signal }),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function createMcpToolCatalogRuntime(
|
|
520
|
+
host: McpHost,
|
|
521
|
+
sessionFiles: McpSessionFiles,
|
|
522
|
+
pi: ExtensionAPI,
|
|
523
|
+
): McpToolCatalogRuntime {
|
|
524
|
+
const mappedTextResult = async (value: unknown): Promise<McpToolOperationResult> => {
|
|
525
|
+
const mapped = await createMcpContentResult(
|
|
526
|
+
[{ text: JSON.stringify(value, undefined, 2), type: "text" }],
|
|
527
|
+
undefined,
|
|
528
|
+
sessionFiles,
|
|
529
|
+
);
|
|
530
|
+
return { content: [...mapped.content], details: toMcpJsonValue(mapped.details) };
|
|
531
|
+
};
|
|
532
|
+
const requestContext = (execution: McpToolExecution): McpHostRequestContext<ExtensionContext> =>
|
|
533
|
+
createMcpRequestContext(execution, pi);
|
|
534
|
+
return {
|
|
535
|
+
callServerTool: async (serverId, toolName, arguments_, execution) => {
|
|
536
|
+
const result = await host.callTool(serverId, toolName, arguments_, requestContext(execution));
|
|
537
|
+
const structuredContent =
|
|
538
|
+
result.structuredContent === undefined
|
|
539
|
+
? undefined
|
|
540
|
+
: toMcpJsonValue(result.structuredContent);
|
|
541
|
+
const mapped = await createMcpContentResult(result.content, structuredContent, sessionFiles);
|
|
542
|
+
return {
|
|
543
|
+
content: [...mapped.content],
|
|
544
|
+
details: toMcpJsonValue(mapped.details),
|
|
545
|
+
...(result.isError === undefined ? {} : { isError: result.isError }),
|
|
546
|
+
...(structuredContent === undefined ? {} : { structuredContent }),
|
|
547
|
+
};
|
|
548
|
+
},
|
|
549
|
+
listResources: async (parameters) =>
|
|
550
|
+
mappedTextResult(await host.listResources(selectedResourceServer(parameters))),
|
|
551
|
+
listResourceTemplates: async (parameters) =>
|
|
552
|
+
mappedTextResult(await host.listResourceTemplates(selectedResourceServer(parameters))),
|
|
553
|
+
readResource: async (parameters, execution) => {
|
|
554
|
+
const result = await host.readResource(
|
|
555
|
+
parameters.server,
|
|
556
|
+
parameters.uri,
|
|
557
|
+
requestContext(execution),
|
|
558
|
+
);
|
|
559
|
+
const mapped = await createMcpContentResult(
|
|
560
|
+
result.contents.map((resource) => ({ resource, type: "resource" })),
|
|
561
|
+
undefined,
|
|
562
|
+
sessionFiles,
|
|
563
|
+
);
|
|
564
|
+
return { content: [...mapped.content], details: toMcpJsonValue(mapped.details) };
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function catalogServerTool(tool: McpHostServerTool): McpServerToolDefinition {
|
|
570
|
+
// SAFETY: McpHostServerTool is derived from the official Client listTools result. The SDK parsed both schema values; these casts only reconcile exact-optional declarations between public SDK exports.
|
|
571
|
+
const inputSchema = tool.inputSchema as JsonSchemaType;
|
|
572
|
+
// SAFETY: The same validated SDK boundary applies to an optional output schema.
|
|
573
|
+
const outputSchema = tool.outputSchema as JsonSchemaType | undefined;
|
|
574
|
+
return {
|
|
575
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
576
|
+
...(tool.description === undefined ? {} : { description: tool.description }),
|
|
577
|
+
inputSchema,
|
|
578
|
+
name: tool.name,
|
|
579
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
580
|
+
...(tool.title === undefined ? {} : { title: tool.title }),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
class ProductionPiMcpSession implements PiMcpExtensionSession {
|
|
585
|
+
constructor(
|
|
586
|
+
private readonly host: McpHost,
|
|
587
|
+
private readonly adapters: Awaited<ReturnType<typeof createStandaloneMcpCommandAdapters>>,
|
|
588
|
+
private readonly synchronizeInitialCatalog: () => Promise<void>,
|
|
589
|
+
private readonly interactionContext: { current: ExtensionContext },
|
|
590
|
+
) {}
|
|
591
|
+
|
|
592
|
+
close(): Promise<void> {
|
|
593
|
+
return this.host.shutdown();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
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
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async executeCommand(
|
|
653
|
+
arguments_: string,
|
|
654
|
+
context: ExtensionCommandContext,
|
|
655
|
+
): Promise<PiMcpExtensionCommandResult> {
|
|
656
|
+
this.interactionContext.current = context;
|
|
657
|
+
const result = await runMcpCommandLine(arguments_, "runtime", this.adapters);
|
|
658
|
+
return {
|
|
659
|
+
level: result.ok ? "info" : result.category === "usage" ? "warning" : "error",
|
|
660
|
+
message: result.output.trimEnd(),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async instructionSnapshot(): Promise<string | undefined> {
|
|
665
|
+
const snapshot = await this.host.freezeInstructionSnapshot();
|
|
666
|
+
return snapshot.text.length === 0 ? undefined : snapshot.text;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async start(): Promise<void> {
|
|
670
|
+
this.host.start();
|
|
671
|
+
await this.host.waitForInitialConnections();
|
|
672
|
+
await this.synchronizeInitialCatalog();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
transformContext(messages: ContextEvent["messages"]): ContextEvent["messages"] {
|
|
676
|
+
return transformPromptMessages(messages);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const productionPiMcpExtensionEffects: PiMcpExtensionEffects = {
|
|
681
|
+
createSession: async (context, pi) => {
|
|
682
|
+
const interactionContext = { current: context };
|
|
683
|
+
const agentDirectory = getAgentDir();
|
|
684
|
+
const settingsManager = SettingsManager.create(context.cwd, agentDirectory, {
|
|
685
|
+
projectTrusted: context.isProjectTrusted(),
|
|
686
|
+
});
|
|
687
|
+
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
|
+
};
|
|
782
|
+
const adapters = await createStandaloneMcpCommandAdapters({
|
|
783
|
+
agentDirectory,
|
|
784
|
+
cwd: context.cwd,
|
|
785
|
+
projectTrusted: context.isProjectTrusted(),
|
|
786
|
+
waitForOAuthPaste: async (signal) => {
|
|
787
|
+
if (!context.hasUI) throw new Error("Pi MCP OAuth callback input requires UI");
|
|
788
|
+
const input = await context.ui.input(
|
|
789
|
+
"MCP OAuth callback",
|
|
790
|
+
"Paste the full callback URL, or code and state",
|
|
791
|
+
{ signal },
|
|
792
|
+
);
|
|
793
|
+
if (input === undefined) throw new Error("Pi MCP OAuth callback input cancelled");
|
|
794
|
+
return input;
|
|
795
|
+
},
|
|
796
|
+
writeAuthorizationUrl: (url) => {
|
|
797
|
+
if (context.hasUI) context.ui.notify(`MCP OAuth authorization URL: ${url}`, "info");
|
|
798
|
+
},
|
|
799
|
+
});
|
|
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 },
|
|
853
|
+
);
|
|
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
|
+
);
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
/** Own `/mcp`, Pi session generations, Instruction Snapshot reuse, and complete shutdown. */
|
|
894
|
+
export class PiMcpLifecycleController {
|
|
895
|
+
private activeSession: ActivePiMcpSession | undefined;
|
|
896
|
+
private shutdownPromise: Promise<void> | undefined;
|
|
897
|
+
|
|
898
|
+
/** Bind MCP lifecycle handlers to one Pi extension instance. */
|
|
899
|
+
constructor(
|
|
900
|
+
private readonly pi: ExtensionAPI,
|
|
901
|
+
private readonly effects: PiMcpExtensionEffects,
|
|
902
|
+
) {}
|
|
903
|
+
|
|
904
|
+
/** Register inert handlers and the `/mcp` command without opening external resources. */
|
|
905
|
+
register(): void {
|
|
906
|
+
this.pi.registerCommand("mcp", {
|
|
907
|
+
description: "Configure and inspect MCP Servers",
|
|
908
|
+
getArgumentCompletions: (prefix) =>
|
|
909
|
+
this.activeSession?.runtime.completeCommandArguments?.(prefix) ?? null,
|
|
910
|
+
handler: (arguments_, context) => this.executeCommand(arguments_, context),
|
|
911
|
+
});
|
|
912
|
+
this.pi.on("session_start", (_event, context) => this.startSession(context));
|
|
913
|
+
this.pi.on("before_agent_start", (event, context) =>
|
|
914
|
+
this.beforeAgentStart(event.systemPrompt, context),
|
|
915
|
+
);
|
|
916
|
+
this.pi.on("context", (event) => this.transformContext(event));
|
|
917
|
+
this.pi.on("session_shutdown", () => this.shutdownSession());
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private async startSession(context: ExtensionContext): Promise<void> {
|
|
921
|
+
await this.shutdownSession();
|
|
922
|
+
let runtime: PiMcpExtensionSession;
|
|
923
|
+
try {
|
|
924
|
+
runtime = await this.effects.createSession(context, this.pi);
|
|
925
|
+
} catch (cause) {
|
|
926
|
+
this.notifyFailure(context, "Pi MCP startup failed", cause);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const activeSession = { runtime };
|
|
930
|
+
this.activeSession = activeSession;
|
|
931
|
+
void runtime.start().catch((cause: unknown) => {
|
|
932
|
+
if (this.activeSession === activeSession) {
|
|
933
|
+
this.notifyFailure(context, "Pi MCP background startup failed", cause);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private async beforeAgentStart(
|
|
939
|
+
systemPrompt: string,
|
|
940
|
+
context: ExtensionContext,
|
|
941
|
+
): Promise<{ readonly systemPrompt: string } | undefined> {
|
|
942
|
+
const activeSession = this.activeSession;
|
|
943
|
+
if (activeSession === undefined) return undefined;
|
|
944
|
+
activeSession.instructionSnapshot ??= activeSession.runtime.instructionSnapshot();
|
|
945
|
+
try {
|
|
946
|
+
const snapshot = await activeSession.instructionSnapshot;
|
|
947
|
+
return snapshot === undefined || snapshot.length === 0
|
|
948
|
+
? undefined
|
|
949
|
+
: { systemPrompt: `${systemPrompt}\n\n${snapshot}` };
|
|
950
|
+
} catch (cause) {
|
|
951
|
+
this.notifyFailure(context, "Pi MCP Instruction Snapshot failed", cause);
|
|
952
|
+
return undefined;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private transformContext(
|
|
957
|
+
event: ContextEvent,
|
|
958
|
+
): { readonly messages: ContextEvent["messages"] } | undefined {
|
|
959
|
+
const runtime = this.activeSession?.runtime;
|
|
960
|
+
if (runtime === undefined) return undefined;
|
|
961
|
+
return { messages: runtime.transformContext(event.messages) };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
private async executeCommand(
|
|
965
|
+
arguments_: string,
|
|
966
|
+
context: ExtensionCommandContext,
|
|
967
|
+
): Promise<void> {
|
|
968
|
+
const runtime = this.activeSession?.runtime;
|
|
969
|
+
if (runtime === undefined) {
|
|
970
|
+
if (context.hasUI) context.ui.notify("Pi MCP has no active session", "error");
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
try {
|
|
974
|
+
const result = await runtime.executeCommand(arguments_, context);
|
|
975
|
+
if (context.hasUI) context.ui.notify(result.message, result.level);
|
|
976
|
+
} catch (cause) {
|
|
977
|
+
this.notifyFailure(context, "Pi MCP command failed", cause);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private notifyFailure(context: ExtensionContext, prefix: string, cause: unknown): void {
|
|
982
|
+
if (!context.hasUI) return;
|
|
983
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
984
|
+
context.ui.notify(`${prefix}: ${message}`, "error");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private async shutdownSession(): Promise<void> {
|
|
988
|
+
const activeSession = this.activeSession;
|
|
989
|
+
if (activeSession === undefined) {
|
|
990
|
+
await this.shutdownPromise;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.activeSession = undefined;
|
|
994
|
+
const shutdown = activeSession.runtime.close();
|
|
995
|
+
this.shutdownPromise = shutdown;
|
|
996
|
+
try {
|
|
997
|
+
await shutdown;
|
|
998
|
+
} finally {
|
|
999
|
+
if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Compose the source-TypeScript Pi MCP extension without starting MCP runtime work at load time. */
|
|
1005
|
+
export function createPiMcpExtension(
|
|
1006
|
+
effects: PiMcpExtensionEffects = productionPiMcpExtensionEffects,
|
|
1007
|
+
): ExtensionFactory {
|
|
1008
|
+
return (pi) => new PiMcpLifecycleController(pi, effects).register();
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const piMcpExtension = createPiMcpExtension();
|
|
1012
|
+
|
|
1013
|
+
export default piMcpExtension;
|