@ikuma.cloud/pix-mcp 0.0.8 → 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/src/index.ts CHANGED
@@ -5,7 +5,12 @@ import type {
5
5
  ExtensionCommandContext,
6
6
  ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
- import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js";
8
+ import type {
9
+ Prompt,
10
+ Resource,
11
+ ResourceTemplate,
12
+ Tool,
13
+ } from "@modelcontextprotocol/sdk/types.js";
9
14
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
10
15
  import { Connection } from "./client.ts";
11
16
  import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
@@ -15,6 +20,21 @@ import {
15
20
  removeEditablePromptGuard,
16
21
  } from "./output.ts";
17
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";
18
38
  import {
19
39
  formatPromptList,
20
40
  formatPromptResult,
@@ -44,17 +64,23 @@ interface PromptSelection {
44
64
  item: PromptEntry;
45
65
  }
46
66
 
67
+ interface ResourceSelection {
68
+ command: ResourceCommand;
69
+ item: ResourceEntry;
70
+ }
71
+
47
72
  interface State {
48
73
  alive: boolean;
49
74
  status: string;
50
75
  entries: Map<string, Entry>;
51
76
  prompts: Map<string, PromptEntry>;
77
+ resources: Map<string, ResourceEntry>;
52
78
  loaded: Map<string, string>;
53
79
  connections: Map<string, Connection>;
54
80
  rejected: Map<string, Rejections>;
55
81
  configIssues: ConfigIssue[];
56
- promptDraftCleanups: Set<() => Promise<void>>;
57
- promptDraftGuards: Set<string>;
82
+ draftCleanups: Set<() => Promise<void>>;
83
+ draftGuards: Set<string>;
58
84
  }
59
85
 
60
86
  const discoveryParameters = Type.Object(
@@ -178,8 +204,8 @@ export default function mcp(pi: ExtensionAPI) {
178
204
  ensureSelectedPromptCurrent();
179
205
  ctx.signal?.throwIfAborted();
180
206
  ctx.ui.pasteToEditor(draft.text);
181
- owner.promptDraftCleanups.add(draft.cleanup);
182
- if (draft.guard) owner.promptDraftGuards.add(draft.guard);
207
+ owner.draftCleanups.add(draft.cleanup);
208
+ if (draft.guard) owner.draftGuards.add(draft.guard);
183
209
  } catch (error) {
184
210
  await draft.cleanup();
185
211
  throw error;
@@ -202,6 +228,197 @@ export default function mcp(pi: ExtensionAPI) {
202
228
  },
203
229
  });
204
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.");
243
+ current(owner);
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),
286
+ );
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,
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
+ }
353
+ } catch (error) {
354
+ const message =
355
+ error instanceof Error ? error.message : "MCP resource failed.";
356
+ if (!ctx.hasUI) throw new Error(message);
357
+ ctx.ui.notify(message, "error");
358
+ }
359
+ },
360
+ });
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
+
205
422
  async function choosePrompt(
206
423
  owner: State,
207
424
  ctx: ExtensionCommandContext,
@@ -441,6 +658,35 @@ export default function mcp(pi: ExtensionAPI) {
441
658
  }
442
659
  }
443
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
+
444
690
  function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
445
691
  if (!owner.alive || state !== owner) return;
446
692
  const previous = new Map(
@@ -551,14 +797,14 @@ export default function mcp(pi: ExtensionAPI) {
551
797
  owner.alive = false;
552
798
  state = undefined;
553
799
  deactivate(owned);
554
- const promptDraftCleanups = [...owner.promptDraftCleanups];
555
- owner.promptDraftCleanups.clear();
556
- owner.promptDraftGuards.clear();
800
+ const draftCleanups = [...owner.draftCleanups];
801
+ owner.draftCleanups.clear();
802
+ owner.draftGuards.clear();
557
803
  await Promise.all([
558
804
  ...[...owner.connections.values()].map((connection) =>
559
805
  connection.close(),
560
806
  ),
561
- ...promptDraftCleanups.map((cleanup) => cleanup()),
807
+ ...draftCleanups.map((cleanup) => cleanup()),
562
808
  ]);
563
809
  }
564
810
 
@@ -569,12 +815,13 @@ export default function mcp(pi: ExtensionAPI) {
569
815
  status: "No MCP servers configured.",
570
816
  entries: new Map(),
571
817
  prompts: new Map(),
818
+ resources: new Map(),
572
819
  loaded: new Map(),
573
820
  connections: new Map(),
574
821
  rejected: new Map(),
575
822
  configIssues: [],
576
- promptDraftCleanups: new Set(),
577
- promptDraftGuards: new Set(),
823
+ draftCleanups: new Set(),
824
+ draftGuards: new Set(),
578
825
  };
579
826
  state = owner;
580
827
  try {
@@ -606,6 +853,8 @@ export default function mcp(pi: ExtensionAPI) {
606
853
  server,
607
854
  (tools) => synchronize(owner, server, tools),
608
855
  (prompts) => synchronizePrompts(owner, server, prompts),
856
+ (resources, templates) =>
857
+ synchronizeResources(owner, server, resources, templates),
609
858
  ),
610
859
  );
611
860
  }
@@ -637,7 +886,7 @@ export default function mcp(pi: ExtensionAPI) {
637
886
  const consumed = new Set<string>();
638
887
  const removeKnownGuards = (text: string) => {
639
888
  let next = text;
640
- for (const guard of owner.promptDraftGuards) {
889
+ for (const guard of owner.draftGuards) {
641
890
  if (!next.includes(guard)) continue;
642
891
  next = removeEditablePromptGuard(next, guard);
643
892
  consumed.add(guard);
@@ -654,7 +903,7 @@ export default function mcp(pi: ExtensionAPI) {
654
903
  : block,
655
904
  );
656
905
  }
657
- for (const guard of consumed) owner.promptDraftGuards.delete(guard);
906
+ for (const guard of consumed) owner.draftGuards.delete(guard);
658
907
  if (consumed.size > 0) return { message: { ...event.message, content } };
659
908
  });
660
909
 
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 createEditablePromptGuard(): string {
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 prepareEditablePrompt(text: string): {
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 prompt text inert in the editor and is removed at message_end.
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 = createEditablePromptGuard();
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-prompt-"));
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
- ...prepareEditablePrompt(text),
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 interface PromptArgumentToken {
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 = tokenize(input);
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
- ? `<${quote(argument.name)}>`
124
- : `[${quote(argument.name)}]`,
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} ${quote(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
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 = tokenize(prefix);
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 ${quote(server)} ${quote(entry.prompt.name)}`,
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
- quote(server),
241
- quote(selected.prompt.name),
242
- ...supplied.map((token) => quote(token.value)),
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} ${quote(argument.name)}=`,
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}${quote(value)}`,
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 {