@ikuma.cloud/pix-mcp 0.0.8 → 0.0.10
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 +154 -63
- package/mcp.schema.json +1 -1
- package/package.json +4 -2
- package/src/client.ts +335 -9
- package/src/command.ts +69 -0
- package/src/config.ts +51 -6
- package/src/index.ts +323 -25
- package/src/output.ts +9 -10
- package/src/prompts.ts +18 -70
- package/src/resource-picker.ts +157 -0
- package/src/resources.ts +769 -0
package/src/index.ts
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { Type } from "@earendil-works/pi-ai";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import {
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionCommandContext,
|
|
7
|
+
type ExtensionContext,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
Prompt,
|
|
11
|
+
Resource,
|
|
12
|
+
ResourceTemplate,
|
|
13
|
+
Tool,
|
|
14
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
15
|
import { compact, entry, search, summary, type Entry } from "./catalog.ts";
|
|
10
16
|
import { Connection } from "./client.ts";
|
|
11
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
mergeConfigs,
|
|
19
|
+
readConfig,
|
|
20
|
+
type ConfigIssue,
|
|
21
|
+
type ServerConfig,
|
|
22
|
+
} from "./config.ts";
|
|
12
23
|
import {
|
|
13
24
|
formatEditableContent,
|
|
14
25
|
formatResult,
|
|
15
26
|
removeEditablePromptGuard,
|
|
16
27
|
} from "./output.ts";
|
|
17
28
|
import { pickPrompt } from "./prompt-picker.ts";
|
|
29
|
+
import { pickResource } from "./resource-picker.ts";
|
|
30
|
+
import {
|
|
31
|
+
formatResourceList,
|
|
32
|
+
formatResourceResult,
|
|
33
|
+
parseResourceCommand,
|
|
34
|
+
resourceCompletions,
|
|
35
|
+
resourceEntryKey,
|
|
36
|
+
resourceKey,
|
|
37
|
+
resourceTemplateVariables,
|
|
38
|
+
resolveResourceUri,
|
|
39
|
+
type ResourceArgumentToken,
|
|
40
|
+
type ResourceCommand,
|
|
41
|
+
type ResourceEntry,
|
|
42
|
+
type TemplateResourceEntry,
|
|
43
|
+
} from "./resources.ts";
|
|
18
44
|
import {
|
|
19
45
|
formatPromptList,
|
|
20
46
|
formatPromptResult,
|
|
@@ -44,17 +70,29 @@ interface PromptSelection {
|
|
|
44
70
|
item: PromptEntry;
|
|
45
71
|
}
|
|
46
72
|
|
|
73
|
+
interface ResourceSelection {
|
|
74
|
+
command: ResourceCommand;
|
|
75
|
+
item: ResourceEntry;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type ConfigScope = "explicit" | "global" | "project";
|
|
79
|
+
interface ConfigSource {
|
|
80
|
+
scope: ConfigScope;
|
|
81
|
+
path: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
47
84
|
interface State {
|
|
48
85
|
alive: boolean;
|
|
49
86
|
status: string;
|
|
50
87
|
entries: Map<string, Entry>;
|
|
51
88
|
prompts: Map<string, PromptEntry>;
|
|
89
|
+
resources: Map<string, ResourceEntry>;
|
|
52
90
|
loaded: Map<string, string>;
|
|
53
91
|
connections: Map<string, Connection>;
|
|
54
92
|
rejected: Map<string, Rejections>;
|
|
55
93
|
configIssues: ConfigIssue[];
|
|
56
|
-
|
|
57
|
-
|
|
94
|
+
draftCleanups: Set<() => Promise<void>>;
|
|
95
|
+
draftGuards: Set<string>;
|
|
58
96
|
}
|
|
59
97
|
|
|
60
98
|
const discoveryParameters = Type.Object(
|
|
@@ -85,7 +123,7 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
85
123
|
pi.registerFlag("mcp-config", {
|
|
86
124
|
type: "string",
|
|
87
125
|
description:
|
|
88
|
-
"Read this MCP config file for this session (
|
|
126
|
+
"Read only this MCP config file for this session (otherwise merge the global and project configs).",
|
|
89
127
|
});
|
|
90
128
|
pi.registerCommand("mcp-prompt", {
|
|
91
129
|
description: "Select, list, or run a user-controlled MCP prompt",
|
|
@@ -178,8 +216,8 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
178
216
|
ensureSelectedPromptCurrent();
|
|
179
217
|
ctx.signal?.throwIfAborted();
|
|
180
218
|
ctx.ui.pasteToEditor(draft.text);
|
|
181
|
-
owner.
|
|
182
|
-
if (draft.guard) owner.
|
|
219
|
+
owner.draftCleanups.add(draft.cleanup);
|
|
220
|
+
if (draft.guard) owner.draftGuards.add(draft.guard);
|
|
183
221
|
} catch (error) {
|
|
184
222
|
await draft.cleanup();
|
|
185
223
|
throw error;
|
|
@@ -202,6 +240,197 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
202
240
|
},
|
|
203
241
|
});
|
|
204
242
|
|
|
243
|
+
pi.registerCommand("mcp-resource", {
|
|
244
|
+
description: "Select, list, or read a user-controlled MCP resource",
|
|
245
|
+
getArgumentCompletions: (prefix) =>
|
|
246
|
+
resourceCompletions(
|
|
247
|
+
prefix,
|
|
248
|
+
[...(state?.resources.values() ?? [])],
|
|
249
|
+
state?.connections.keys() ?? [],
|
|
250
|
+
),
|
|
251
|
+
async handler(input, ctx) {
|
|
252
|
+
try {
|
|
253
|
+
const owner = state;
|
|
254
|
+
if (!owner) throw new Error("MCP session has not started.");
|
|
255
|
+
current(owner);
|
|
256
|
+
const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
|
|
257
|
+
const selection = editBeforeSending
|
|
258
|
+
? await chooseResource(owner, ctx)
|
|
259
|
+
: undefined;
|
|
260
|
+
const command =
|
|
261
|
+
selection?.command ??
|
|
262
|
+
(editBeforeSending ? undefined : parseResourceCommand(input));
|
|
263
|
+
if (!command) return;
|
|
264
|
+
if (command.action === "list") {
|
|
265
|
+
if (
|
|
266
|
+
command.server &&
|
|
267
|
+
!owner.connections.has(command.server) &&
|
|
268
|
+
!owner.configIssues.some((issue) => issue.name === command.server)
|
|
269
|
+
)
|
|
270
|
+
throw new Error("Unknown MCP server.");
|
|
271
|
+
const resourceStatus = [
|
|
272
|
+
...owner.configIssues
|
|
273
|
+
.filter(
|
|
274
|
+
(issue) => !command.server || issue.name === command.server,
|
|
275
|
+
)
|
|
276
|
+
.map((issue) => `${issue.name}: ${issue.message}`),
|
|
277
|
+
...[...owner.connections.values()]
|
|
278
|
+
.filter(
|
|
279
|
+
(connection) =>
|
|
280
|
+
!command.server || connection.config.name === command.server,
|
|
281
|
+
)
|
|
282
|
+
.map(
|
|
283
|
+
(connection) =>
|
|
284
|
+
`${connection.config.name}: ${connection.resourceStatus}`,
|
|
285
|
+
),
|
|
286
|
+
].join("\n");
|
|
287
|
+
const text = `${resourceStatus}${resourceStatus ? "\n\n" : ""}${formatResourceList(
|
|
288
|
+
[...owner.resources.values()],
|
|
289
|
+
command.server,
|
|
290
|
+
)}`;
|
|
291
|
+
if (!ctx.hasUI) throw new Error(text);
|
|
292
|
+
ctx.ui.notify(text, "info");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const direct = owner.resources.get(
|
|
297
|
+
resourceKey(command.server, "resource", command.target),
|
|
298
|
+
);
|
|
299
|
+
const template = owner.resources.get(
|
|
300
|
+
resourceKey(command.server, "template", command.target),
|
|
301
|
+
);
|
|
302
|
+
const item =
|
|
303
|
+
selection?.item ??
|
|
304
|
+
(command.argumentTokens.length > 0 ? template : (direct ?? template));
|
|
305
|
+
const selectedKey = item ? resourceEntryKey(item) : undefined;
|
|
306
|
+
const ensureSelectedResourceCurrent = () => {
|
|
307
|
+
if (
|
|
308
|
+
selection &&
|
|
309
|
+
selectedKey &&
|
|
310
|
+
owner.resources.get(selectedKey) !== item
|
|
311
|
+
)
|
|
312
|
+
throw new Error(
|
|
313
|
+
"MCP resource catalog changed. Open the picker again.",
|
|
314
|
+
);
|
|
315
|
+
};
|
|
316
|
+
ensureSelectedResourceCurrent();
|
|
317
|
+
const templateEntry =
|
|
318
|
+
item?.kind === "template"
|
|
319
|
+
? item
|
|
320
|
+
: template?.kind === "template"
|
|
321
|
+
? template
|
|
322
|
+
: undefined;
|
|
323
|
+
const uri = resolveResourceUri(
|
|
324
|
+
command.target,
|
|
325
|
+
templateEntry,
|
|
326
|
+
command.argumentTokens,
|
|
327
|
+
);
|
|
328
|
+
const connection = owner.connections.get(command.server);
|
|
329
|
+
if (!connection) throw new Error("MCP connection unavailable.");
|
|
330
|
+
if (ctx.mode === "tui")
|
|
331
|
+
ctx.ui.setStatus(
|
|
332
|
+
"mcp-resource",
|
|
333
|
+
`Loading ${command.server} / ${compact(uri, 120)}…`,
|
|
334
|
+
);
|
|
335
|
+
try {
|
|
336
|
+
const result = await connection.readResource(uri, ctx.signal);
|
|
337
|
+
current(owner);
|
|
338
|
+
ensureSelectedResourceCurrent();
|
|
339
|
+
const formatted = await formatResourceResult(command.server, result);
|
|
340
|
+
current(owner);
|
|
341
|
+
ensureSelectedResourceCurrent();
|
|
342
|
+
ctx.signal?.throwIfAborted();
|
|
343
|
+
if (editBeforeSending) {
|
|
344
|
+
const draft = await formatEditableContent(formatted.content);
|
|
345
|
+
try {
|
|
346
|
+
current(owner);
|
|
347
|
+
ensureSelectedResourceCurrent();
|
|
348
|
+
ctx.signal?.throwIfAborted();
|
|
349
|
+
ctx.ui.pasteToEditor(draft.text);
|
|
350
|
+
owner.draftCleanups.add(draft.cleanup);
|
|
351
|
+
if (draft.guard) owner.draftGuards.add(draft.guard);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await draft.cleanup();
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
pi.sendUserMessage(
|
|
358
|
+
formatted.content,
|
|
359
|
+
ctx.isIdle() ? undefined : { deliverAs: "followUp" },
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
if (ctx.mode === "tui") ctx.ui.setStatus("mcp-resource", undefined);
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const message =
|
|
367
|
+
error instanceof Error ? error.message : "MCP resource failed.";
|
|
368
|
+
if (!ctx.hasUI) throw new Error(message);
|
|
369
|
+
ctx.ui.notify(message, "error");
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
async function chooseResource(
|
|
375
|
+
owner: State,
|
|
376
|
+
ctx: ExtensionCommandContext,
|
|
377
|
+
): Promise<ResourceSelection | undefined> {
|
|
378
|
+
const entries = [...owner.resources.values()].sort(
|
|
379
|
+
(a, b) =>
|
|
380
|
+
a.server.localeCompare(b.server, "en") ||
|
|
381
|
+
resourceEntryKey(a).localeCompare(resourceEntryKey(b), "en"),
|
|
382
|
+
);
|
|
383
|
+
if (entries.length === 0) {
|
|
384
|
+
ctx.ui.notify("No MCP resources are available.", "warning");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const item = await pickResource(entries, ctx);
|
|
389
|
+
if (!item) return;
|
|
390
|
+
ctx.signal?.throwIfAborted();
|
|
391
|
+
|
|
392
|
+
const key = resourceEntryKey(item);
|
|
393
|
+
const ensureCurrent = () => {
|
|
394
|
+
current(owner);
|
|
395
|
+
if (owner.resources.get(key) !== item)
|
|
396
|
+
throw new Error("MCP resource catalog changed. Open the picker again.");
|
|
397
|
+
};
|
|
398
|
+
ensureCurrent();
|
|
399
|
+
|
|
400
|
+
const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
|
|
401
|
+
const argumentTokens: ResourceArgumentToken[] = [];
|
|
402
|
+
if (item.kind === "template") {
|
|
403
|
+
for (const variable of item.variables) {
|
|
404
|
+
const value = await ctx.ui.input(
|
|
405
|
+
`${variable} (optional)`,
|
|
406
|
+
"Leave empty to omit",
|
|
407
|
+
dialogOptions,
|
|
408
|
+
);
|
|
409
|
+
if (value === undefined) return;
|
|
410
|
+
ctx.signal?.throwIfAborted();
|
|
411
|
+
ensureCurrent();
|
|
412
|
+
if (value !== "")
|
|
413
|
+
argumentTokens.push({
|
|
414
|
+
value: `${variable}=${value}`,
|
|
415
|
+
separator: variable.length,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
command: {
|
|
422
|
+
action: "read",
|
|
423
|
+
server: item.server,
|
|
424
|
+
target:
|
|
425
|
+
item.kind === "resource"
|
|
426
|
+
? item.resource.uri
|
|
427
|
+
: item.template.uriTemplate,
|
|
428
|
+
argumentTokens,
|
|
429
|
+
},
|
|
430
|
+
item,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
205
434
|
async function choosePrompt(
|
|
206
435
|
owner: State,
|
|
207
436
|
ctx: ExtensionCommandContext,
|
|
@@ -441,6 +670,35 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
441
670
|
}
|
|
442
671
|
}
|
|
443
672
|
|
|
673
|
+
function synchronizeResources(
|
|
674
|
+
owner: State,
|
|
675
|
+
config: ServerConfig,
|
|
676
|
+
resources: Resource[],
|
|
677
|
+
templates: ResourceTemplate[],
|
|
678
|
+
) {
|
|
679
|
+
if (!owner.alive || state !== owner) return;
|
|
680
|
+
for (const [key, item] of owner.resources) {
|
|
681
|
+
if (item.server === config.name) owner.resources.delete(key);
|
|
682
|
+
}
|
|
683
|
+
for (const resource of resources) {
|
|
684
|
+
const item: ResourceEntry = {
|
|
685
|
+
kind: "resource",
|
|
686
|
+
server: config.name,
|
|
687
|
+
resource,
|
|
688
|
+
};
|
|
689
|
+
owner.resources.set(resourceEntryKey(item), item);
|
|
690
|
+
}
|
|
691
|
+
for (const template of templates) {
|
|
692
|
+
const item: TemplateResourceEntry = {
|
|
693
|
+
kind: "template",
|
|
694
|
+
server: config.name,
|
|
695
|
+
template,
|
|
696
|
+
variables: resourceTemplateVariables(template.uriTemplate),
|
|
697
|
+
};
|
|
698
|
+
owner.resources.set(resourceEntryKey(item), item);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
444
702
|
function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
|
|
445
703
|
if (!owner.alive || state !== owner) return;
|
|
446
704
|
const previous = new Map(
|
|
@@ -551,45 +809,80 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
551
809
|
owner.alive = false;
|
|
552
810
|
state = undefined;
|
|
553
811
|
deactivate(owned);
|
|
554
|
-
const
|
|
555
|
-
owner.
|
|
556
|
-
owner.
|
|
812
|
+
const draftCleanups = [...owner.draftCleanups];
|
|
813
|
+
owner.draftCleanups.clear();
|
|
814
|
+
owner.draftGuards.clear();
|
|
557
815
|
await Promise.all([
|
|
558
816
|
...[...owner.connections.values()].map((connection) =>
|
|
559
817
|
connection.close(),
|
|
560
818
|
),
|
|
561
|
-
...
|
|
819
|
+
...draftCleanups.map((cleanup) => cleanup()),
|
|
562
820
|
]);
|
|
563
821
|
}
|
|
564
822
|
|
|
565
823
|
async function start(ctx: ExtensionContext) {
|
|
566
824
|
await stop();
|
|
825
|
+
const showConfigNotices =
|
|
826
|
+
ctx.hasUI && (ctx.mode === "tui" || ctx.mode === "rpc");
|
|
567
827
|
const owner: State = {
|
|
568
828
|
alive: true,
|
|
569
829
|
status: "No MCP servers configured.",
|
|
570
830
|
entries: new Map(),
|
|
571
831
|
prompts: new Map(),
|
|
832
|
+
resources: new Map(),
|
|
572
833
|
loaded: new Map(),
|
|
573
834
|
connections: new Map(),
|
|
574
835
|
rejected: new Map(),
|
|
575
836
|
configIssues: [],
|
|
576
|
-
|
|
577
|
-
|
|
837
|
+
draftCleanups: new Set(),
|
|
838
|
+
draftGuards: new Set(),
|
|
578
839
|
};
|
|
579
840
|
state = owner;
|
|
841
|
+
let failureNotice: string | undefined;
|
|
580
842
|
try {
|
|
581
843
|
const flag = pi.getFlag("mcp-config");
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
844
|
+
if (flag === "") {
|
|
845
|
+
failureNotice = "MCP config (explicit) failed: no path provided";
|
|
846
|
+
throw new Error(
|
|
847
|
+
"The explicitly selected MCP configuration path is empty.",
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const explicit = typeof flag === "string";
|
|
851
|
+
const sources: ConfigSource[] = explicit
|
|
852
|
+
? [{ scope: "explicit", path: resolve(ctx.cwd, flag) }]
|
|
853
|
+
: [
|
|
854
|
+
{
|
|
855
|
+
scope: "global",
|
|
856
|
+
path: resolve(getAgentDir(), "mcp.json"),
|
|
857
|
+
},
|
|
858
|
+
{ scope: "project", path: resolve(ctx.cwd, ".mcp.json") },
|
|
859
|
+
];
|
|
860
|
+
const loaded = [];
|
|
861
|
+
for (const source of sources) {
|
|
862
|
+
failureNotice = `MCP config (${source.scope}) failed: ${source.path}`;
|
|
863
|
+
const config = await readConfig(source.path);
|
|
864
|
+
current(owner);
|
|
865
|
+
failureNotice = undefined;
|
|
866
|
+
if (config) {
|
|
867
|
+
loaded.push({ ...source, config });
|
|
868
|
+
if (showConfigNotices)
|
|
869
|
+
ctx.ui.notify(
|
|
870
|
+
`MCP config (${source.scope}): ${source.path}`,
|
|
871
|
+
"info",
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
585
875
|
current(owner);
|
|
586
|
-
if (
|
|
876
|
+
if (loaded.length === 0) {
|
|
587
877
|
if (explicit)
|
|
588
878
|
owner.status =
|
|
589
879
|
"The explicitly selected MCP configuration does not exist.";
|
|
880
|
+
if (showConfigNotices) ctx.ui.notify("MCP config: none found", "info");
|
|
590
881
|
return;
|
|
591
882
|
}
|
|
592
|
-
|
|
883
|
+
failureNotice = "MCP configuration merge failed.";
|
|
884
|
+
const config = mergeConfigs(loaded.map((source) => source.config));
|
|
885
|
+
failureNotice = undefined;
|
|
593
886
|
owner.configIssues = config.issues;
|
|
594
887
|
owner.status =
|
|
595
888
|
config.issues.length > 0
|
|
@@ -606,6 +899,8 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
606
899
|
server,
|
|
607
900
|
(tools) => synchronize(owner, server, tools),
|
|
608
901
|
(prompts) => synchronizePrompts(owner, server, prompts),
|
|
902
|
+
(resources, templates) =>
|
|
903
|
+
synchronizeResources(owner, server, resources, templates),
|
|
609
904
|
),
|
|
610
905
|
);
|
|
611
906
|
}
|
|
@@ -621,9 +916,12 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
621
916
|
}),
|
|
622
917
|
);
|
|
623
918
|
} catch (error) {
|
|
624
|
-
if (owner.alive)
|
|
919
|
+
if (owner.alive) {
|
|
625
920
|
owner.status =
|
|
626
921
|
error instanceof Error ? error.message : "MCP initialization failed.";
|
|
922
|
+
if (showConfigNotices)
|
|
923
|
+
ctx.ui.notify(failureNotice ?? "MCP initialization failed.", "error");
|
|
924
|
+
}
|
|
627
925
|
} finally {
|
|
628
926
|
if (owner.alive && state === owner) registerDiscovery();
|
|
629
927
|
}
|
|
@@ -637,7 +935,7 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
637
935
|
const consumed = new Set<string>();
|
|
638
936
|
const removeKnownGuards = (text: string) => {
|
|
639
937
|
let next = text;
|
|
640
|
-
for (const guard of owner.
|
|
938
|
+
for (const guard of owner.draftGuards) {
|
|
641
939
|
if (!next.includes(guard)) continue;
|
|
642
940
|
next = removeEditablePromptGuard(next, guard);
|
|
643
941
|
consumed.add(guard);
|
|
@@ -654,7 +952,7 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
654
952
|
: block,
|
|
655
953
|
);
|
|
656
954
|
}
|
|
657
|
-
for (const guard of consumed) owner.
|
|
955
|
+
for (const guard of consumed) owner.draftGuards.delete(guard);
|
|
658
956
|
if (consumed.size > 0) return { message: { ...event.message, content } };
|
|
659
957
|
});
|
|
660
958
|
|
package/src/output.ts
CHANGED
|
@@ -29,6 +29,7 @@ interface ContentOptions {
|
|
|
29
29
|
structuredContent?: unknown;
|
|
30
30
|
preserveErrorImages?: boolean;
|
|
31
31
|
preserveOrder?: boolean;
|
|
32
|
+
artifactRequired?: boolean;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export async function formatContent(
|
|
@@ -83,6 +84,7 @@ export async function formatContent(
|
|
|
83
84
|
textTruncated ||
|
|
84
85
|
omitted ||
|
|
85
86
|
largeDetails ||
|
|
87
|
+
options.artifactRequired === true ||
|
|
86
88
|
(options.preserveErrorImages === true && images.length > 0);
|
|
87
89
|
let fullOutputPath: string | undefined;
|
|
88
90
|
if (truncated) {
|
|
@@ -141,7 +143,7 @@ export async function formatContent(
|
|
|
141
143
|
};
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
function
|
|
146
|
+
function createEditableContentGuard(): string {
|
|
145
147
|
const bytes = randomBytes(8);
|
|
146
148
|
let guard = "\u2060\u2063";
|
|
147
149
|
for (const byte of bytes)
|
|
@@ -154,12 +156,12 @@ export function removeEditablePromptGuard(text: string, guard: string): string {
|
|
|
154
156
|
return text.replaceAll(guard, "");
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
function
|
|
159
|
+
function prepareEditableContent(text: string): {
|
|
158
160
|
text: string;
|
|
159
161
|
guard?: string;
|
|
160
162
|
} {
|
|
161
163
|
// The TUI interprets leading / and ! as commands. An invisible guard keeps
|
|
162
|
-
// untrusted
|
|
164
|
+
// untrusted staged text inert in the editor and is removed at message_end.
|
|
163
165
|
const sanitized = text
|
|
164
166
|
.replace(/\r\n?/gu, "\n")
|
|
165
167
|
.replace(/\p{Cc}/gu, (character) =>
|
|
@@ -167,7 +169,7 @@ function prepareEditablePrompt(text: string): {
|
|
|
167
169
|
)
|
|
168
170
|
.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
|
|
169
171
|
if (!/^[!/]/u.test(sanitized.trimStart())) return { text: sanitized };
|
|
170
|
-
const guard =
|
|
172
|
+
const guard = createEditableContentGuard();
|
|
171
173
|
return { text: `${guard}${sanitized}`, guard };
|
|
172
174
|
}
|
|
173
175
|
|
|
@@ -190,12 +192,9 @@ export async function formatEditableContent(
|
|
|
190
192
|
text += block.text;
|
|
191
193
|
continue;
|
|
192
194
|
}
|
|
193
|
-
imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-
|
|
195
|
+
imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-draft-"));
|
|
194
196
|
const extension = imageExtensions[block.mimeType] ?? "img";
|
|
195
|
-
const path = join(
|
|
196
|
-
imageDirectory,
|
|
197
|
-
`prompt-image-${++imageIndex}.${extension}`,
|
|
198
|
-
);
|
|
197
|
+
const path = join(imageDirectory, `image-${++imageIndex}.${extension}`);
|
|
199
198
|
await writeFile(path, Buffer.from(block.data, "base64"), { mode: 0o600 });
|
|
200
199
|
const separator = text.endsWith("\n\n")
|
|
201
200
|
? ""
|
|
@@ -210,7 +209,7 @@ export async function formatEditableContent(
|
|
|
210
209
|
throw error;
|
|
211
210
|
}
|
|
212
211
|
return {
|
|
213
|
-
...
|
|
212
|
+
...prepareEditableContent(text),
|
|
214
213
|
cleanup: async () => {
|
|
215
214
|
if (imageDirectory)
|
|
216
215
|
await rm(imageDirectory, { recursive: true, force: true });
|
package/src/prompts.ts
CHANGED
|
@@ -4,6 +4,12 @@ import type {
|
|
|
4
4
|
Prompt,
|
|
5
5
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
6
6
|
import { compact } from "./catalog.ts";
|
|
7
|
+
import {
|
|
8
|
+
quoteCommandArgument,
|
|
9
|
+
renderCommandToken,
|
|
10
|
+
tokenizeCommand,
|
|
11
|
+
type CommandToken,
|
|
12
|
+
} from "./command.ts";
|
|
7
13
|
import { formatContent } from "./output.ts";
|
|
8
14
|
|
|
9
15
|
export interface PromptEntry {
|
|
@@ -11,10 +17,7 @@ export interface PromptEntry {
|
|
|
11
17
|
prompt: Prompt;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export
|
|
15
|
-
value: string;
|
|
16
|
-
separator?: number;
|
|
17
|
-
}
|
|
20
|
+
export type PromptArgumentToken = CommandToken;
|
|
18
21
|
|
|
19
22
|
export type PromptCommand =
|
|
20
23
|
| { action: "list"; server?: string }
|
|
@@ -30,7 +33,7 @@ export function promptKey(server: string, name: string): string {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export function parsePromptCommand(input: string): PromptCommand {
|
|
33
|
-
const tokens =
|
|
36
|
+
const tokens = tokenizeCommand(input, "prompt");
|
|
34
37
|
const action = tokens.shift()?.value;
|
|
35
38
|
if (action === "list") {
|
|
36
39
|
if (tokens.length > 1) throw new Error("Usage: /mcp-prompt list [server]");
|
|
@@ -120,15 +123,15 @@ export function formatPromptList(
|
|
|
120
123
|
const argumentsHint = (entry.prompt.arguments ?? [])
|
|
121
124
|
.map((argument) =>
|
|
122
125
|
argument.required
|
|
123
|
-
? `<${
|
|
124
|
-
: `[${
|
|
126
|
+
? `<${quoteCommandArgument(argument.name)}>`
|
|
127
|
+
: `[${quoteCommandArgument(argument.name)}]`,
|
|
125
128
|
)
|
|
126
129
|
.join(" ");
|
|
127
130
|
const description = compact(
|
|
128
131
|
entry.prompt.description ?? entry.prompt.title ?? "",
|
|
129
132
|
160,
|
|
130
133
|
);
|
|
131
|
-
const line = `${entry.server} ${
|
|
134
|
+
const line = `${entry.server} ${quoteCommandArgument(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
|
|
132
135
|
if (shown >= 100 || lines.join("\n").length + line.length > 15_000) {
|
|
133
136
|
lines.push(`${selected.length - shown} additional prompts omitted.`);
|
|
134
137
|
break;
|
|
@@ -149,7 +152,7 @@ export function promptCompletions(
|
|
|
149
152
|
const trailingSpace = /\s$/.test(prefix);
|
|
150
153
|
let tokens: PromptArgumentToken[];
|
|
151
154
|
try {
|
|
152
|
-
tokens =
|
|
155
|
+
tokens = tokenizeCommand(prefix);
|
|
153
156
|
} catch {
|
|
154
157
|
return null;
|
|
155
158
|
}
|
|
@@ -191,7 +194,7 @@ export function promptCompletions(
|
|
|
191
194
|
)
|
|
192
195
|
.sort((a, b) => a.prompt.name.localeCompare(b.prompt.name, "en"));
|
|
193
196
|
return prompts.map((entry) => ({
|
|
194
|
-
value: `run ${
|
|
197
|
+
value: `run ${quoteCommandArgument(server)} ${quoteCommandArgument(entry.prompt.name)}`,
|
|
195
198
|
label: entry.prompt.name,
|
|
196
199
|
...(entry.prompt.description || entry.prompt.title
|
|
197
200
|
? {
|
|
@@ -237,9 +240,9 @@ export function promptCompletions(
|
|
|
237
240
|
}
|
|
238
241
|
const base = [
|
|
239
242
|
"run",
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
...supplied.map(
|
|
243
|
+
quoteCommandArgument(server),
|
|
244
|
+
quoteCommandArgument(selected.prompt.name),
|
|
245
|
+
...supplied.map(renderCommandToken),
|
|
243
246
|
].join(" ");
|
|
244
247
|
const arguments_ = definitions.filter(
|
|
245
248
|
(argument) =>
|
|
@@ -247,7 +250,7 @@ export function promptCompletions(
|
|
|
247
250
|
);
|
|
248
251
|
return arguments_.length > 0
|
|
249
252
|
? arguments_.map((argument) => ({
|
|
250
|
-
value: `${base} ${
|
|
253
|
+
value: `${base} ${quoteCommandArgument(argument.name)}=`,
|
|
251
254
|
label: `${argument.name}=`,
|
|
252
255
|
...(argument.description
|
|
253
256
|
? { description: compact(argument.description, 100) }
|
|
@@ -262,66 +265,11 @@ function completionItems(
|
|
|
262
265
|
label: (value: string) => string,
|
|
263
266
|
) {
|
|
264
267
|
return values.map((value) => ({
|
|
265
|
-
value: `${prefix}${
|
|
268
|
+
value: `${prefix}${quoteCommandArgument(value)}`,
|
|
266
269
|
label: label(value),
|
|
267
270
|
}));
|
|
268
271
|
}
|
|
269
272
|
|
|
270
|
-
function quote(value: string): string {
|
|
271
|
-
return /^[A-Za-z0-9_.:/-]+$/.test(value)
|
|
272
|
-
? value
|
|
273
|
-
: `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function tokenize(input: string): PromptArgumentToken[] {
|
|
277
|
-
const tokens: PromptArgumentToken[] = [];
|
|
278
|
-
let token = "";
|
|
279
|
-
let separator: number | undefined;
|
|
280
|
-
let quote: '"' | "'" | undefined;
|
|
281
|
-
let escaped = false;
|
|
282
|
-
let started = false;
|
|
283
|
-
for (const character of input) {
|
|
284
|
-
if (escaped) {
|
|
285
|
-
token += character;
|
|
286
|
-
escaped = false;
|
|
287
|
-
started = true;
|
|
288
|
-
} else if (character === "\\" && quote !== "'") {
|
|
289
|
-
escaped = true;
|
|
290
|
-
started = true;
|
|
291
|
-
} else if (quote) {
|
|
292
|
-
if (character === quote) quote = undefined;
|
|
293
|
-
else token += character;
|
|
294
|
-
started = true;
|
|
295
|
-
} else if (character === '"' || character === "'") {
|
|
296
|
-
quote = character;
|
|
297
|
-
started = true;
|
|
298
|
-
} else if (/\s/u.test(character)) {
|
|
299
|
-
if (started) {
|
|
300
|
-
tokens.push({
|
|
301
|
-
value: token,
|
|
302
|
-
...(separator !== undefined ? { separator } : {}),
|
|
303
|
-
});
|
|
304
|
-
token = "";
|
|
305
|
-
separator = undefined;
|
|
306
|
-
started = false;
|
|
307
|
-
}
|
|
308
|
-
} else {
|
|
309
|
-
if (character === "=" && separator === undefined)
|
|
310
|
-
separator = token.length;
|
|
311
|
-
token += character;
|
|
312
|
-
started = true;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (quote) throw new Error("Unterminated quote in prompt arguments.");
|
|
316
|
-
if (escaped) throw new Error("Trailing escape in prompt arguments.");
|
|
317
|
-
if (started)
|
|
318
|
-
tokens.push({
|
|
319
|
-
value: token,
|
|
320
|
-
...(separator !== undefined ? { separator } : {}),
|
|
321
|
-
});
|
|
322
|
-
return tokens;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
273
|
function hasUsablePromptContent(
|
|
326
274
|
content: GetPromptResult["messages"][number]["content"],
|
|
327
275
|
): boolean {
|