@ikuma.cloud/pix-mcp 0.0.7 → 0.0.9
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 +121 -39
- package/mcp.schema.json +1 -1
- package/package.json +6 -2
- package/src/catalog.ts +1 -1
- package/src/client.ts +335 -9
- package/src/command.ts +69 -0
- package/src/config.ts +1 -1
- package/src/index.ts +416 -23
- package/src/output.ts +85 -5
- package/src/prompt-picker.ts +120 -0
- package/src/prompts.ts +41 -77
- package/src/resource-picker.ts +157 -0
- package/src/resources.ts +769 -0
package/src/index.ts
CHANGED
|
@@ -2,13 +2,39 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { Type } from "@earendil-works/pi-ai";
|
|
3
3
|
import type {
|
|
4
4
|
ExtensionAPI,
|
|
5
|
+
ExtensionCommandContext,
|
|
5
6
|
ExtensionContext,
|
|
6
7
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
Prompt,
|
|
10
|
+
Resource,
|
|
11
|
+
ResourceTemplate,
|
|
12
|
+
Tool,
|
|
13
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
14
|
import { compact, entry, search, summary, type Entry } from "./catalog.ts";
|
|
9
15
|
import { Connection } from "./client.ts";
|
|
10
16
|
import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
|
|
11
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
formatEditableContent,
|
|
19
|
+
formatResult,
|
|
20
|
+
removeEditablePromptGuard,
|
|
21
|
+
} from "./output.ts";
|
|
22
|
+
import { pickPrompt } from "./prompt-picker.ts";
|
|
23
|
+
import { pickResource } from "./resource-picker.ts";
|
|
24
|
+
import {
|
|
25
|
+
formatResourceList,
|
|
26
|
+
formatResourceResult,
|
|
27
|
+
parseResourceCommand,
|
|
28
|
+
resourceCompletions,
|
|
29
|
+
resourceEntryKey,
|
|
30
|
+
resourceKey,
|
|
31
|
+
resourceTemplateVariables,
|
|
32
|
+
resolveResourceUri,
|
|
33
|
+
type ResourceArgumentToken,
|
|
34
|
+
type ResourceCommand,
|
|
35
|
+
type ResourceEntry,
|
|
36
|
+
type TemplateResourceEntry,
|
|
37
|
+
} from "./resources.ts";
|
|
12
38
|
import {
|
|
13
39
|
formatPromptList,
|
|
14
40
|
formatPromptResult,
|
|
@@ -16,6 +42,8 @@ import {
|
|
|
16
42
|
promptCompletions,
|
|
17
43
|
promptKey,
|
|
18
44
|
resolvePromptArguments,
|
|
45
|
+
type PromptArgumentToken,
|
|
46
|
+
type PromptCommand,
|
|
19
47
|
type PromptEntry,
|
|
20
48
|
} from "./prompts.ts";
|
|
21
49
|
import { prepareSchema } from "./schema.ts";
|
|
@@ -31,15 +59,28 @@ interface Rejections {
|
|
|
31
59
|
}[];
|
|
32
60
|
}
|
|
33
61
|
|
|
62
|
+
interface PromptSelection {
|
|
63
|
+
command: PromptCommand;
|
|
64
|
+
item: PromptEntry;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ResourceSelection {
|
|
68
|
+
command: ResourceCommand;
|
|
69
|
+
item: ResourceEntry;
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
interface State {
|
|
35
73
|
alive: boolean;
|
|
36
74
|
status: string;
|
|
37
75
|
entries: Map<string, Entry>;
|
|
38
76
|
prompts: Map<string, PromptEntry>;
|
|
77
|
+
resources: Map<string, ResourceEntry>;
|
|
39
78
|
loaded: Map<string, string>;
|
|
40
79
|
connections: Map<string, Connection>;
|
|
41
80
|
rejected: Map<string, Rejections>;
|
|
42
81
|
configIssues: ConfigIssue[];
|
|
82
|
+
draftCleanups: Set<() => Promise<void>>;
|
|
83
|
+
draftGuards: Set<string>;
|
|
43
84
|
}
|
|
44
85
|
|
|
45
86
|
const discoveryParameters = Type.Object(
|
|
@@ -73,7 +114,7 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
73
114
|
"Read this MCP config file for this session (default: .mcp.json).",
|
|
74
115
|
});
|
|
75
116
|
pi.registerCommand("mcp-prompt", {
|
|
76
|
-
description: "
|
|
117
|
+
description: "Select, list, or run a user-controlled MCP prompt",
|
|
77
118
|
getArgumentCompletions: (prefix) =>
|
|
78
119
|
promptCompletions(prefix, [...(state?.prompts.values() ?? [])]),
|
|
79
120
|
async handler(input, ctx) {
|
|
@@ -81,7 +122,14 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
81
122
|
const owner = state;
|
|
82
123
|
if (!owner) throw new Error("MCP session has not started.");
|
|
83
124
|
current(owner);
|
|
84
|
-
const
|
|
125
|
+
const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
|
|
126
|
+
const selection = editBeforeSending
|
|
127
|
+
? await choosePrompt(owner, ctx)
|
|
128
|
+
: undefined;
|
|
129
|
+
const command =
|
|
130
|
+
selection?.command ??
|
|
131
|
+
(editBeforeSending ? undefined : parsePromptCommand(input));
|
|
132
|
+
if (!command) return;
|
|
85
133
|
if (command.action === "list") {
|
|
86
134
|
if (
|
|
87
135
|
command.server &&
|
|
@@ -113,43 +161,319 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
113
161
|
ctx.ui.notify(text, "info");
|
|
114
162
|
return;
|
|
115
163
|
}
|
|
116
|
-
const
|
|
164
|
+
const key = promptKey(command.server, command.name);
|
|
165
|
+
const item = selection?.item ?? owner.prompts.get(key);
|
|
117
166
|
if (!item)
|
|
118
167
|
throw new Error(
|
|
119
168
|
"Unknown or unavailable MCP prompt. List prompts again.",
|
|
120
169
|
);
|
|
170
|
+
const ensureSelectedPromptCurrent = () => {
|
|
171
|
+
if (selection && owner.prompts.get(key) !== item)
|
|
172
|
+
throw new Error(
|
|
173
|
+
"MCP prompt catalog changed. Open the picker again.",
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
ensureSelectedPromptCurrent();
|
|
121
177
|
const connection = owner.connections.get(command.server);
|
|
122
178
|
if (!connection) throw new Error("MCP connection unavailable.");
|
|
123
179
|
const args = resolvePromptArguments(
|
|
124
180
|
item.prompt,
|
|
125
181
|
command.argumentTokens,
|
|
126
182
|
);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
183
|
+
if (ctx.mode === "tui")
|
|
184
|
+
ctx.ui.setStatus(
|
|
185
|
+
"mcp-prompt",
|
|
186
|
+
`Loading ${item.server} / ${item.prompt.name}…`,
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
const result = await connection.getPrompt(
|
|
190
|
+
item.prompt.name,
|
|
191
|
+
args,
|
|
192
|
+
ctx.signal,
|
|
193
|
+
);
|
|
194
|
+
current(owner);
|
|
195
|
+
ensureSelectedPromptCurrent();
|
|
196
|
+
const formatted = await formatPromptResult(result);
|
|
197
|
+
current(owner);
|
|
198
|
+
ensureSelectedPromptCurrent();
|
|
199
|
+
ctx.signal?.throwIfAborted();
|
|
200
|
+
if (editBeforeSending) {
|
|
201
|
+
const draft = await formatEditableContent(formatted.content);
|
|
202
|
+
try {
|
|
203
|
+
current(owner);
|
|
204
|
+
ensureSelectedPromptCurrent();
|
|
205
|
+
ctx.signal?.throwIfAborted();
|
|
206
|
+
ctx.ui.pasteToEditor(draft.text);
|
|
207
|
+
owner.draftCleanups.add(draft.cleanup);
|
|
208
|
+
if (draft.guard) owner.draftGuards.add(draft.guard);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await draft.cleanup();
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
pi.sendUserMessage(
|
|
215
|
+
formatted.content,
|
|
216
|
+
ctx.isIdle() ? undefined : { deliverAs: "followUp" },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (ctx.mode === "tui") ctx.ui.setStatus("mcp-prompt", undefined);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message =
|
|
224
|
+
error instanceof Error ? error.message : "MCP prompt failed.";
|
|
225
|
+
if (!ctx.hasUI) throw new Error(message);
|
|
226
|
+
ctx.ui.notify(message, "error");
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
pi.registerCommand("mcp-resource", {
|
|
232
|
+
description: "Select, list, or read a user-controlled MCP resource",
|
|
233
|
+
getArgumentCompletions: (prefix) =>
|
|
234
|
+
resourceCompletions(
|
|
235
|
+
prefix,
|
|
236
|
+
[...(state?.resources.values() ?? [])],
|
|
237
|
+
state?.connections.keys() ?? [],
|
|
238
|
+
),
|
|
239
|
+
async handler(input, ctx) {
|
|
240
|
+
try {
|
|
241
|
+
const owner = state;
|
|
242
|
+
if (!owner) throw new Error("MCP session has not started.");
|
|
132
243
|
current(owner);
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
244
|
+
const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
|
|
245
|
+
const selection = editBeforeSending
|
|
246
|
+
? await chooseResource(owner, ctx)
|
|
247
|
+
: undefined;
|
|
248
|
+
const command =
|
|
249
|
+
selection?.command ??
|
|
250
|
+
(editBeforeSending ? undefined : parseResourceCommand(input));
|
|
251
|
+
if (!command) return;
|
|
252
|
+
if (command.action === "list") {
|
|
253
|
+
if (
|
|
254
|
+
command.server &&
|
|
255
|
+
!owner.connections.has(command.server) &&
|
|
256
|
+
!owner.configIssues.some((issue) => issue.name === command.server)
|
|
257
|
+
)
|
|
258
|
+
throw new Error("Unknown MCP server.");
|
|
259
|
+
const resourceStatus = [
|
|
260
|
+
...owner.configIssues
|
|
261
|
+
.filter(
|
|
262
|
+
(issue) => !command.server || issue.name === command.server,
|
|
263
|
+
)
|
|
264
|
+
.map((issue) => `${issue.name}: ${issue.message}`),
|
|
265
|
+
...[...owner.connections.values()]
|
|
266
|
+
.filter(
|
|
267
|
+
(connection) =>
|
|
268
|
+
!command.server || connection.config.name === command.server,
|
|
269
|
+
)
|
|
270
|
+
.map(
|
|
271
|
+
(connection) =>
|
|
272
|
+
`${connection.config.name}: ${connection.resourceStatus}`,
|
|
273
|
+
),
|
|
274
|
+
].join("\n");
|
|
275
|
+
const text = `${resourceStatus}${resourceStatus ? "\n\n" : ""}${formatResourceList(
|
|
276
|
+
[...owner.resources.values()],
|
|
277
|
+
command.server,
|
|
278
|
+
)}`;
|
|
279
|
+
if (!ctx.hasUI) throw new Error(text);
|
|
280
|
+
ctx.ui.notify(text, "info");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const direct = owner.resources.get(
|
|
285
|
+
resourceKey(command.server, "resource", command.target),
|
|
137
286
|
);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
287
|
+
const template = owner.resources.get(
|
|
288
|
+
resourceKey(command.server, "template", command.target),
|
|
289
|
+
);
|
|
290
|
+
const item =
|
|
291
|
+
selection?.item ??
|
|
292
|
+
(command.argumentTokens.length > 0 ? template : (direct ?? template));
|
|
293
|
+
const selectedKey = item ? resourceEntryKey(item) : undefined;
|
|
294
|
+
const ensureSelectedResourceCurrent = () => {
|
|
295
|
+
if (
|
|
296
|
+
selection &&
|
|
297
|
+
selectedKey &&
|
|
298
|
+
owner.resources.get(selectedKey) !== item
|
|
299
|
+
)
|
|
300
|
+
throw new Error(
|
|
301
|
+
"MCP resource catalog changed. Open the picker again.",
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
ensureSelectedResourceCurrent();
|
|
305
|
+
const templateEntry =
|
|
306
|
+
item?.kind === "template"
|
|
307
|
+
? item
|
|
308
|
+
: template?.kind === "template"
|
|
309
|
+
? template
|
|
310
|
+
: undefined;
|
|
311
|
+
const uri = resolveResourceUri(
|
|
312
|
+
command.target,
|
|
313
|
+
templateEntry,
|
|
314
|
+
command.argumentTokens,
|
|
143
315
|
);
|
|
316
|
+
const connection = owner.connections.get(command.server);
|
|
317
|
+
if (!connection) throw new Error("MCP connection unavailable.");
|
|
318
|
+
if (ctx.mode === "tui")
|
|
319
|
+
ctx.ui.setStatus(
|
|
320
|
+
"mcp-resource",
|
|
321
|
+
`Loading ${command.server} / ${compact(uri, 120)}…`,
|
|
322
|
+
);
|
|
323
|
+
try {
|
|
324
|
+
const result = await connection.readResource(uri, ctx.signal);
|
|
325
|
+
current(owner);
|
|
326
|
+
ensureSelectedResourceCurrent();
|
|
327
|
+
const formatted = await formatResourceResult(command.server, result);
|
|
328
|
+
current(owner);
|
|
329
|
+
ensureSelectedResourceCurrent();
|
|
330
|
+
ctx.signal?.throwIfAborted();
|
|
331
|
+
if (editBeforeSending) {
|
|
332
|
+
const draft = await formatEditableContent(formatted.content);
|
|
333
|
+
try {
|
|
334
|
+
current(owner);
|
|
335
|
+
ensureSelectedResourceCurrent();
|
|
336
|
+
ctx.signal?.throwIfAborted();
|
|
337
|
+
ctx.ui.pasteToEditor(draft.text);
|
|
338
|
+
owner.draftCleanups.add(draft.cleanup);
|
|
339
|
+
if (draft.guard) owner.draftGuards.add(draft.guard);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await draft.cleanup();
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
pi.sendUserMessage(
|
|
346
|
+
formatted.content,
|
|
347
|
+
ctx.isIdle() ? undefined : { deliverAs: "followUp" },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
if (ctx.mode === "tui") ctx.ui.setStatus("mcp-resource", undefined);
|
|
352
|
+
}
|
|
144
353
|
} catch (error) {
|
|
145
354
|
const message =
|
|
146
|
-
error instanceof Error ? error.message : "MCP
|
|
355
|
+
error instanceof Error ? error.message : "MCP resource failed.";
|
|
147
356
|
if (!ctx.hasUI) throw new Error(message);
|
|
148
357
|
ctx.ui.notify(message, "error");
|
|
149
358
|
}
|
|
150
359
|
},
|
|
151
360
|
});
|
|
152
361
|
|
|
362
|
+
async function chooseResource(
|
|
363
|
+
owner: State,
|
|
364
|
+
ctx: ExtensionCommandContext,
|
|
365
|
+
): Promise<ResourceSelection | undefined> {
|
|
366
|
+
const entries = [...owner.resources.values()].sort(
|
|
367
|
+
(a, b) =>
|
|
368
|
+
a.server.localeCompare(b.server, "en") ||
|
|
369
|
+
resourceEntryKey(a).localeCompare(resourceEntryKey(b), "en"),
|
|
370
|
+
);
|
|
371
|
+
if (entries.length === 0) {
|
|
372
|
+
ctx.ui.notify("No MCP resources are available.", "warning");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const item = await pickResource(entries, ctx);
|
|
377
|
+
if (!item) return;
|
|
378
|
+
ctx.signal?.throwIfAborted();
|
|
379
|
+
|
|
380
|
+
const key = resourceEntryKey(item);
|
|
381
|
+
const ensureCurrent = () => {
|
|
382
|
+
current(owner);
|
|
383
|
+
if (owner.resources.get(key) !== item)
|
|
384
|
+
throw new Error("MCP resource catalog changed. Open the picker again.");
|
|
385
|
+
};
|
|
386
|
+
ensureCurrent();
|
|
387
|
+
|
|
388
|
+
const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
|
|
389
|
+
const argumentTokens: ResourceArgumentToken[] = [];
|
|
390
|
+
if (item.kind === "template") {
|
|
391
|
+
for (const variable of item.variables) {
|
|
392
|
+
const value = await ctx.ui.input(
|
|
393
|
+
`${variable} (optional)`,
|
|
394
|
+
"Leave empty to omit",
|
|
395
|
+
dialogOptions,
|
|
396
|
+
);
|
|
397
|
+
if (value === undefined) return;
|
|
398
|
+
ctx.signal?.throwIfAborted();
|
|
399
|
+
ensureCurrent();
|
|
400
|
+
if (value !== "")
|
|
401
|
+
argumentTokens.push({
|
|
402
|
+
value: `${variable}=${value}`,
|
|
403
|
+
separator: variable.length,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
command: {
|
|
410
|
+
action: "read",
|
|
411
|
+
server: item.server,
|
|
412
|
+
target:
|
|
413
|
+
item.kind === "resource"
|
|
414
|
+
? item.resource.uri
|
|
415
|
+
: item.template.uriTemplate,
|
|
416
|
+
argumentTokens,
|
|
417
|
+
},
|
|
418
|
+
item,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function choosePrompt(
|
|
423
|
+
owner: State,
|
|
424
|
+
ctx: ExtensionCommandContext,
|
|
425
|
+
): Promise<PromptSelection | undefined> {
|
|
426
|
+
const entries = [...owner.prompts.values()].sort(
|
|
427
|
+
(a, b) =>
|
|
428
|
+
a.server.localeCompare(b.server, "en") ||
|
|
429
|
+
a.prompt.name.localeCompare(b.prompt.name, "en"),
|
|
430
|
+
);
|
|
431
|
+
if (entries.length === 0) {
|
|
432
|
+
ctx.ui.notify("No MCP prompts are available.", "warning");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const item = await pickPrompt(entries, ctx);
|
|
437
|
+
if (!item) return;
|
|
438
|
+
ctx.signal?.throwIfAborted();
|
|
439
|
+
|
|
440
|
+
const key = promptKey(item.server, item.prompt.name);
|
|
441
|
+
const ensureCurrent = () => {
|
|
442
|
+
current(owner);
|
|
443
|
+
if (owner.prompts.get(key) !== item)
|
|
444
|
+
throw new Error("MCP prompt catalog changed. Open the picker again.");
|
|
445
|
+
};
|
|
446
|
+
ensureCurrent();
|
|
447
|
+
|
|
448
|
+
const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
|
|
449
|
+
const argumentTokens: PromptArgumentToken[] = [];
|
|
450
|
+
for (const argument of item.prompt.arguments ?? []) {
|
|
451
|
+
const value = await ctx.ui.input(
|
|
452
|
+
`${argument.name} (${argument.required ? "required" : "optional"})`,
|
|
453
|
+
argument.required ? "Enter a value" : "Leave empty to omit",
|
|
454
|
+
dialogOptions,
|
|
455
|
+
);
|
|
456
|
+
if (value === undefined) return;
|
|
457
|
+
ctx.signal?.throwIfAborted();
|
|
458
|
+
ensureCurrent();
|
|
459
|
+
if (argument.required || value !== "")
|
|
460
|
+
argumentTokens.push({
|
|
461
|
+
value: `${argument.name}=${value}`,
|
|
462
|
+
separator: argument.name.length,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
command: {
|
|
468
|
+
action: "run",
|
|
469
|
+
server: item.server,
|
|
470
|
+
name: item.prompt.name,
|
|
471
|
+
argumentTokens,
|
|
472
|
+
},
|
|
473
|
+
item,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
153
477
|
function deactivate(names: Iterable<string>) {
|
|
154
478
|
const removed = new Set(names);
|
|
155
479
|
pi.setActiveTools(pi.getActiveTools().filter((name) => !removed.has(name)));
|
|
@@ -334,6 +658,35 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
334
658
|
}
|
|
335
659
|
}
|
|
336
660
|
|
|
661
|
+
function synchronizeResources(
|
|
662
|
+
owner: State,
|
|
663
|
+
config: ServerConfig,
|
|
664
|
+
resources: Resource[],
|
|
665
|
+
templates: ResourceTemplate[],
|
|
666
|
+
) {
|
|
667
|
+
if (!owner.alive || state !== owner) return;
|
|
668
|
+
for (const [key, item] of owner.resources) {
|
|
669
|
+
if (item.server === config.name) owner.resources.delete(key);
|
|
670
|
+
}
|
|
671
|
+
for (const resource of resources) {
|
|
672
|
+
const item: ResourceEntry = {
|
|
673
|
+
kind: "resource",
|
|
674
|
+
server: config.name,
|
|
675
|
+
resource,
|
|
676
|
+
};
|
|
677
|
+
owner.resources.set(resourceEntryKey(item), item);
|
|
678
|
+
}
|
|
679
|
+
for (const template of templates) {
|
|
680
|
+
const item: TemplateResourceEntry = {
|
|
681
|
+
kind: "template",
|
|
682
|
+
server: config.name,
|
|
683
|
+
template,
|
|
684
|
+
variables: resourceTemplateVariables(template.uriTemplate),
|
|
685
|
+
};
|
|
686
|
+
owner.resources.set(resourceEntryKey(item), item);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
337
690
|
function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
|
|
338
691
|
if (!owner.alive || state !== owner) return;
|
|
339
692
|
const previous = new Map(
|
|
@@ -444,9 +797,15 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
444
797
|
owner.alive = false;
|
|
445
798
|
state = undefined;
|
|
446
799
|
deactivate(owned);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
);
|
|
800
|
+
const draftCleanups = [...owner.draftCleanups];
|
|
801
|
+
owner.draftCleanups.clear();
|
|
802
|
+
owner.draftGuards.clear();
|
|
803
|
+
await Promise.all([
|
|
804
|
+
...[...owner.connections.values()].map((connection) =>
|
|
805
|
+
connection.close(),
|
|
806
|
+
),
|
|
807
|
+
...draftCleanups.map((cleanup) => cleanup()),
|
|
808
|
+
]);
|
|
450
809
|
}
|
|
451
810
|
|
|
452
811
|
async function start(ctx: ExtensionContext) {
|
|
@@ -456,10 +815,13 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
456
815
|
status: "No MCP servers configured.",
|
|
457
816
|
entries: new Map(),
|
|
458
817
|
prompts: new Map(),
|
|
818
|
+
resources: new Map(),
|
|
459
819
|
loaded: new Map(),
|
|
460
820
|
connections: new Map(),
|
|
461
821
|
rejected: new Map(),
|
|
462
822
|
configIssues: [],
|
|
823
|
+
draftCleanups: new Set(),
|
|
824
|
+
draftGuards: new Set(),
|
|
463
825
|
};
|
|
464
826
|
state = owner;
|
|
465
827
|
try {
|
|
@@ -491,6 +853,8 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
491
853
|
server,
|
|
492
854
|
(tools) => synchronize(owner, server, tools),
|
|
493
855
|
(prompts) => synchronizePrompts(owner, server, prompts),
|
|
856
|
+
(resources, templates) =>
|
|
857
|
+
synchronizeResources(owner, server, resources, templates),
|
|
494
858
|
),
|
|
495
859
|
);
|
|
496
860
|
}
|
|
@@ -514,6 +878,35 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
514
878
|
}
|
|
515
879
|
}
|
|
516
880
|
|
|
881
|
+
// Keep each invisible editor guard through Pi's command/template dispatch,
|
|
882
|
+
// then remove only guards created by this session before model context.
|
|
883
|
+
pi.on("message_end", (event) => {
|
|
884
|
+
const owner = state;
|
|
885
|
+
if (!owner || event.message.role !== "user") return;
|
|
886
|
+
const consumed = new Set<string>();
|
|
887
|
+
const removeKnownGuards = (text: string) => {
|
|
888
|
+
let next = text;
|
|
889
|
+
for (const guard of owner.draftGuards) {
|
|
890
|
+
if (!next.includes(guard)) continue;
|
|
891
|
+
next = removeEditablePromptGuard(next, guard);
|
|
892
|
+
consumed.add(guard);
|
|
893
|
+
}
|
|
894
|
+
return next;
|
|
895
|
+
};
|
|
896
|
+
let content: typeof event.message.content;
|
|
897
|
+
if (typeof event.message.content === "string") {
|
|
898
|
+
content = removeKnownGuards(event.message.content);
|
|
899
|
+
} else {
|
|
900
|
+
content = event.message.content.map((block) =>
|
|
901
|
+
block.type === "text"
|
|
902
|
+
? { ...block, text: removeKnownGuards(block.text) }
|
|
903
|
+
: block,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
for (const guard of consumed) owner.draftGuards.delete(guard);
|
|
907
|
+
if (consumed.size > 0) return { message: { ...event.message, content } };
|
|
908
|
+
});
|
|
909
|
+
|
|
517
910
|
// Any extension's registerTool can reactivate allowlisted tools in Pi 0.87.
|
|
518
911
|
// Independently track loaded fingerprints, enforce them in execute, and scrub
|
|
519
912
|
// request-local declarations at the last provider-neutral context boundary.
|
package/src/output.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
@@ -28,6 +29,7 @@ interface ContentOptions {
|
|
|
28
29
|
structuredContent?: unknown;
|
|
29
30
|
preserveErrorImages?: boolean;
|
|
30
31
|
preserveOrder?: boolean;
|
|
32
|
+
artifactRequired?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export async function formatContent(
|
|
@@ -70,16 +72,19 @@ export async function formatContent(
|
|
|
70
72
|
`Structured content:\n${JSON.stringify(options.structuredContent)}`,
|
|
71
73
|
);
|
|
72
74
|
}
|
|
73
|
-
const fullText =
|
|
75
|
+
const fullText =
|
|
76
|
+
text.join("\n\n") || (options.preserveOrder ? "" : "(No text output)");
|
|
74
77
|
const bounded = preview(fullText);
|
|
75
78
|
const structured = options.structuredContent;
|
|
76
79
|
const largeDetails =
|
|
77
80
|
structured !== undefined &&
|
|
78
81
|
Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
|
|
82
|
+
const textTruncated = bounded !== fullText;
|
|
79
83
|
const truncated =
|
|
80
|
-
|
|
84
|
+
textTruncated ||
|
|
81
85
|
omitted ||
|
|
82
86
|
largeDetails ||
|
|
87
|
+
options.artifactRequired === true ||
|
|
83
88
|
(options.preserveErrorImages === true && images.length > 0);
|
|
84
89
|
let fullOutputPath: string | undefined;
|
|
85
90
|
if (truncated) {
|
|
@@ -99,7 +104,7 @@ export async function formatContent(
|
|
|
99
104
|
let textIndex = 0;
|
|
100
105
|
for (const block of ordered) {
|
|
101
106
|
if (block.type === "image") {
|
|
102
|
-
content.push(block);
|
|
107
|
+
if (!textTruncated || offset < bounded.length) content.push(block);
|
|
103
108
|
continue;
|
|
104
109
|
}
|
|
105
110
|
const value = `${textIndex++ > 0 ? "\n\n" : ""}${block.text}`;
|
|
@@ -112,7 +117,8 @@ export async function formatContent(
|
|
|
112
117
|
if (previous?.type === "text") previous.text += visible;
|
|
113
118
|
else content.push({ type: "text", text: visible });
|
|
114
119
|
}
|
|
115
|
-
if (textIndex === 0
|
|
120
|
+
if (textIndex === 0 && bounded)
|
|
121
|
+
content.unshift({ type: "text", text: bounded });
|
|
116
122
|
if (artifactNotice)
|
|
117
123
|
content.push({ type: "text", text: `\n\n${artifactNotice}` });
|
|
118
124
|
} else {
|
|
@@ -137,6 +143,80 @@ export async function formatContent(
|
|
|
137
143
|
};
|
|
138
144
|
}
|
|
139
145
|
|
|
146
|
+
function createEditableContentGuard(): string {
|
|
147
|
+
const bytes = randomBytes(8);
|
|
148
|
+
let guard = "\u2060\u2063";
|
|
149
|
+
for (const byte of bytes)
|
|
150
|
+
for (let bit = 7; bit >= 0; bit--)
|
|
151
|
+
guard += byte & (1 << bit) ? "\u2063" : "\u2060";
|
|
152
|
+
return guard;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function removeEditablePromptGuard(text: string, guard: string): string {
|
|
156
|
+
return text.replaceAll(guard, "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function prepareEditableContent(text: string): {
|
|
160
|
+
text: string;
|
|
161
|
+
guard?: string;
|
|
162
|
+
} {
|
|
163
|
+
// The TUI interprets leading / and ! as commands. An invisible guard keeps
|
|
164
|
+
// untrusted staged text inert in the editor and is removed at message_end.
|
|
165
|
+
const sanitized = text
|
|
166
|
+
.replace(/\r\n?/gu, "\n")
|
|
167
|
+
.replace(/\p{Cc}/gu, (character) =>
|
|
168
|
+
character === "\n" || character === "\t" ? character : "",
|
|
169
|
+
)
|
|
170
|
+
.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
|
|
171
|
+
if (!/^[!/]/u.test(sanitized.trimStart())) return { text: sanitized };
|
|
172
|
+
const guard = createEditableContentGuard();
|
|
173
|
+
return { text: `${guard}${sanitized}`, guard };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const imageExtensions: Record<string, string> = {
|
|
177
|
+
"image/png": "png",
|
|
178
|
+
"image/jpeg": "jpg",
|
|
179
|
+
"image/gif": "gif",
|
|
180
|
+
"image/webp": "webp",
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export async function formatEditableContent(
|
|
184
|
+
content: (TextContent | ImageContent)[],
|
|
185
|
+
): Promise<{ text: string; guard?: string; cleanup: () => Promise<void> }> {
|
|
186
|
+
let text = "";
|
|
187
|
+
let imageDirectory: string | undefined;
|
|
188
|
+
let imageIndex = 0;
|
|
189
|
+
try {
|
|
190
|
+
for (const block of content) {
|
|
191
|
+
if (block.type === "text") {
|
|
192
|
+
text += block.text;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-draft-"));
|
|
196
|
+
const extension = imageExtensions[block.mimeType] ?? "img";
|
|
197
|
+
const path = join(imageDirectory, `image-${++imageIndex}.${extension}`);
|
|
198
|
+
await writeFile(path, Buffer.from(block.data, "base64"), { mode: 0o600 });
|
|
199
|
+
const separator = text.endsWith("\n\n")
|
|
200
|
+
? ""
|
|
201
|
+
: text.endsWith("\n")
|
|
202
|
+
? "\n"
|
|
203
|
+
: "\n\n";
|
|
204
|
+
text += `${separator}@${JSON.stringify(path.replaceAll("\\", "/"))}`;
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (imageDirectory)
|
|
208
|
+
await rm(imageDirectory, { recursive: true, force: true });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
...prepareEditableContent(text),
|
|
213
|
+
cleanup: async () => {
|
|
214
|
+
if (imageDirectory)
|
|
215
|
+
await rm(imageDirectory, { recursive: true, force: true });
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
140
220
|
export function formatResult(result: CallToolResult) {
|
|
141
221
|
// Pi 0.87 turns thrown tool errors into text only. Preserve error images in
|
|
142
222
|
// the full-result artifact before the native execution wrapper throws.
|