@ikuma.cloud/pix-mcp 0.0.7 → 0.0.8

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 CHANGED
@@ -266,10 +266,21 @@ the stable `/mcp-prompt` command so catalog changes do not leave stale slash
266
266
  commands behind:
267
267
 
268
268
  ```text
269
+ /mcp-prompt
269
270
  /mcp-prompt list [server]
270
271
  /mcp-prompt run <server> <prompt> [name=value ...]
271
272
  ```
272
273
 
274
+ In TUI mode, `/mcp-prompt` without arguments opens a native prompt selector
275
+ that shows the focused prompt's title and description, followed by native input
276
+ dialogs. Required and optional arguments are requested in declaration order;
277
+ leaving an optional input empty omits it. The rendered prompt is placed in Pi's
278
+ editor so you can review or modify it before sending. Image blocks are stored in
279
+ private temporary files and inserted as `@` references. Escape cancels without
280
+ retrieving the prompt. The explicit `list` and `run` forms remain available in
281
+ every mode; `run` submits immediately and can pass an intentional empty value as
282
+ `name=`.
283
+
273
284
  Arguments use shell-style quoting. Positional values map to the prompt's declared
274
285
  argument order; `name=value` selects a declared argument explicitly. Quote or escape
275
286
  an equals sign in a positional value (for example, `"a=b"` or `a\=b`) to avoid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikuma.cloud/pix-mcp",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -28,11 +28,13 @@
28
28
  "peerDependencies": {
29
29
  "@earendil-works/pi-ai": "*",
30
30
  "@earendil-works/pi-coding-agent": "*",
31
+ "@earendil-works/pi-tui": "*",
31
32
  "typebox": "*"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@earendil-works/pi-ai": "0.87.1",
35
36
  "@earendil-works/pi-coding-agent": "0.87.1",
37
+ "@earendil-works/pi-tui": "0.87.1",
36
38
  "typebox": "1.3.27"
37
39
  }
38
40
  }
package/src/catalog.ts CHANGED
@@ -78,7 +78,7 @@ export function summary(item: Entry) {
78
78
 
79
79
  export function compact(value: string, limit: number): string {
80
80
  const text = value
81
- .replace(/\p{Cc}/gu, " ")
81
+ .replace(/[\p{C}\p{Zl}\p{Zp}]/gu, " ")
82
82
  .replace(/\s+/g, " ")
83
83
  .trim();
84
84
  return text.length <= limit ? text : `${text.slice(0, limit)}…`;
package/src/index.ts CHANGED
@@ -2,13 +2,19 @@ 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
8
  import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js";
8
9
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
9
10
  import { Connection } from "./client.ts";
10
11
  import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
11
- import { formatResult } from "./output.ts";
12
+ import {
13
+ formatEditableContent,
14
+ formatResult,
15
+ removeEditablePromptGuard,
16
+ } from "./output.ts";
17
+ import { pickPrompt } from "./prompt-picker.ts";
12
18
  import {
13
19
  formatPromptList,
14
20
  formatPromptResult,
@@ -16,6 +22,8 @@ import {
16
22
  promptCompletions,
17
23
  promptKey,
18
24
  resolvePromptArguments,
25
+ type PromptArgumentToken,
26
+ type PromptCommand,
19
27
  type PromptEntry,
20
28
  } from "./prompts.ts";
21
29
  import { prepareSchema } from "./schema.ts";
@@ -31,6 +39,11 @@ interface Rejections {
31
39
  }[];
32
40
  }
33
41
 
42
+ interface PromptSelection {
43
+ command: PromptCommand;
44
+ item: PromptEntry;
45
+ }
46
+
34
47
  interface State {
35
48
  alive: boolean;
36
49
  status: string;
@@ -40,6 +53,8 @@ interface State {
40
53
  connections: Map<string, Connection>;
41
54
  rejected: Map<string, Rejections>;
42
55
  configIssues: ConfigIssue[];
56
+ promptDraftCleanups: Set<() => Promise<void>>;
57
+ promptDraftGuards: Set<string>;
43
58
  }
44
59
 
45
60
  const discoveryParameters = Type.Object(
@@ -73,7 +88,7 @@ export default function mcp(pi: ExtensionAPI) {
73
88
  "Read this MCP config file for this session (default: .mcp.json).",
74
89
  });
75
90
  pi.registerCommand("mcp-prompt", {
76
- description: "List or run a user-selected MCP prompt",
91
+ description: "Select, list, or run a user-controlled MCP prompt",
77
92
  getArgumentCompletions: (prefix) =>
78
93
  promptCompletions(prefix, [...(state?.prompts.values() ?? [])]),
79
94
  async handler(input, ctx) {
@@ -81,7 +96,14 @@ export default function mcp(pi: ExtensionAPI) {
81
96
  const owner = state;
82
97
  if (!owner) throw new Error("MCP session has not started.");
83
98
  current(owner);
84
- const command = parsePromptCommand(input);
99
+ const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
100
+ const selection = editBeforeSending
101
+ ? await choosePrompt(owner, ctx)
102
+ : undefined;
103
+ const command =
104
+ selection?.command ??
105
+ (editBeforeSending ? undefined : parsePromptCommand(input));
106
+ if (!command) return;
85
107
  if (command.action === "list") {
86
108
  if (
87
109
  command.server &&
@@ -113,34 +135,64 @@ export default function mcp(pi: ExtensionAPI) {
113
135
  ctx.ui.notify(text, "info");
114
136
  return;
115
137
  }
116
- const item = owner.prompts.get(promptKey(command.server, command.name));
138
+ const key = promptKey(command.server, command.name);
139
+ const item = selection?.item ?? owner.prompts.get(key);
117
140
  if (!item)
118
141
  throw new Error(
119
142
  "Unknown or unavailable MCP prompt. List prompts again.",
120
143
  );
144
+ const ensureSelectedPromptCurrent = () => {
145
+ if (selection && owner.prompts.get(key) !== item)
146
+ throw new Error(
147
+ "MCP prompt catalog changed. Open the picker again.",
148
+ );
149
+ };
150
+ ensureSelectedPromptCurrent();
121
151
  const connection = owner.connections.get(command.server);
122
152
  if (!connection) throw new Error("MCP connection unavailable.");
123
153
  const args = resolvePromptArguments(
124
154
  item.prompt,
125
155
  command.argumentTokens,
126
156
  );
127
- const result = await connection.getPrompt(
128
- item.prompt.name,
129
- args,
130
- ctx.signal,
131
- );
132
- current(owner);
133
- const formatted = await formatPromptResult(
134
- result,
135
- item.server,
136
- item.prompt.name,
137
- );
138
- current(owner);
139
- ctx.signal?.throwIfAborted();
140
- pi.sendUserMessage(
141
- formatted.content,
142
- ctx.isIdle() ? undefined : { deliverAs: "followUp" },
143
- );
157
+ if (ctx.mode === "tui")
158
+ ctx.ui.setStatus(
159
+ "mcp-prompt",
160
+ `Loading ${item.server} / ${item.prompt.name}…`,
161
+ );
162
+ try {
163
+ const result = await connection.getPrompt(
164
+ item.prompt.name,
165
+ args,
166
+ ctx.signal,
167
+ );
168
+ current(owner);
169
+ ensureSelectedPromptCurrent();
170
+ const formatted = await formatPromptResult(result);
171
+ current(owner);
172
+ ensureSelectedPromptCurrent();
173
+ ctx.signal?.throwIfAborted();
174
+ if (editBeforeSending) {
175
+ const draft = await formatEditableContent(formatted.content);
176
+ try {
177
+ current(owner);
178
+ ensureSelectedPromptCurrent();
179
+ ctx.signal?.throwIfAborted();
180
+ ctx.ui.pasteToEditor(draft.text);
181
+ owner.promptDraftCleanups.add(draft.cleanup);
182
+ if (draft.guard) owner.promptDraftGuards.add(draft.guard);
183
+ } catch (error) {
184
+ await draft.cleanup();
185
+ throw error;
186
+ }
187
+ } else {
188
+ pi.sendUserMessage(
189
+ formatted.content,
190
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" },
191
+ );
192
+ }
193
+ } finally {
194
+ if (ctx.mode === "tui") ctx.ui.setStatus("mcp-prompt", undefined);
195
+ }
144
196
  } catch (error) {
145
197
  const message =
146
198
  error instanceof Error ? error.message : "MCP prompt failed.";
@@ -150,6 +202,61 @@ export default function mcp(pi: ExtensionAPI) {
150
202
  },
151
203
  });
152
204
 
205
+ async function choosePrompt(
206
+ owner: State,
207
+ ctx: ExtensionCommandContext,
208
+ ): Promise<PromptSelection | undefined> {
209
+ const entries = [...owner.prompts.values()].sort(
210
+ (a, b) =>
211
+ a.server.localeCompare(b.server, "en") ||
212
+ a.prompt.name.localeCompare(b.prompt.name, "en"),
213
+ );
214
+ if (entries.length === 0) {
215
+ ctx.ui.notify("No MCP prompts are available.", "warning");
216
+ return;
217
+ }
218
+
219
+ const item = await pickPrompt(entries, ctx);
220
+ if (!item) return;
221
+ ctx.signal?.throwIfAborted();
222
+
223
+ const key = promptKey(item.server, item.prompt.name);
224
+ const ensureCurrent = () => {
225
+ current(owner);
226
+ if (owner.prompts.get(key) !== item)
227
+ throw new Error("MCP prompt catalog changed. Open the picker again.");
228
+ };
229
+ ensureCurrent();
230
+
231
+ const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
232
+ const argumentTokens: PromptArgumentToken[] = [];
233
+ for (const argument of item.prompt.arguments ?? []) {
234
+ const value = await ctx.ui.input(
235
+ `${argument.name} (${argument.required ? "required" : "optional"})`,
236
+ argument.required ? "Enter a value" : "Leave empty to omit",
237
+ dialogOptions,
238
+ );
239
+ if (value === undefined) return;
240
+ ctx.signal?.throwIfAborted();
241
+ ensureCurrent();
242
+ if (argument.required || value !== "")
243
+ argumentTokens.push({
244
+ value: `${argument.name}=${value}`,
245
+ separator: argument.name.length,
246
+ });
247
+ }
248
+
249
+ return {
250
+ command: {
251
+ action: "run",
252
+ server: item.server,
253
+ name: item.prompt.name,
254
+ argumentTokens,
255
+ },
256
+ item,
257
+ };
258
+ }
259
+
153
260
  function deactivate(names: Iterable<string>) {
154
261
  const removed = new Set(names);
155
262
  pi.setActiveTools(pi.getActiveTools().filter((name) => !removed.has(name)));
@@ -444,9 +551,15 @@ export default function mcp(pi: ExtensionAPI) {
444
551
  owner.alive = false;
445
552
  state = undefined;
446
553
  deactivate(owned);
447
- await Promise.all(
448
- [...owner.connections.values()].map((connection) => connection.close()),
449
- );
554
+ const promptDraftCleanups = [...owner.promptDraftCleanups];
555
+ owner.promptDraftCleanups.clear();
556
+ owner.promptDraftGuards.clear();
557
+ await Promise.all([
558
+ ...[...owner.connections.values()].map((connection) =>
559
+ connection.close(),
560
+ ),
561
+ ...promptDraftCleanups.map((cleanup) => cleanup()),
562
+ ]);
450
563
  }
451
564
 
452
565
  async function start(ctx: ExtensionContext) {
@@ -460,6 +573,8 @@ export default function mcp(pi: ExtensionAPI) {
460
573
  connections: new Map(),
461
574
  rejected: new Map(),
462
575
  configIssues: [],
576
+ promptDraftCleanups: new Set(),
577
+ promptDraftGuards: new Set(),
463
578
  };
464
579
  state = owner;
465
580
  try {
@@ -514,6 +629,35 @@ export default function mcp(pi: ExtensionAPI) {
514
629
  }
515
630
  }
516
631
 
632
+ // Keep each invisible editor guard through Pi's command/template dispatch,
633
+ // then remove only guards created by this session before model context.
634
+ pi.on("message_end", (event) => {
635
+ const owner = state;
636
+ if (!owner || event.message.role !== "user") return;
637
+ const consumed = new Set<string>();
638
+ const removeKnownGuards = (text: string) => {
639
+ let next = text;
640
+ for (const guard of owner.promptDraftGuards) {
641
+ if (!next.includes(guard)) continue;
642
+ next = removeEditablePromptGuard(next, guard);
643
+ consumed.add(guard);
644
+ }
645
+ return next;
646
+ };
647
+ let content: typeof event.message.content;
648
+ if (typeof event.message.content === "string") {
649
+ content = removeKnownGuards(event.message.content);
650
+ } else {
651
+ content = event.message.content.map((block) =>
652
+ block.type === "text"
653
+ ? { ...block, text: removeKnownGuards(block.text) }
654
+ : block,
655
+ );
656
+ }
657
+ for (const guard of consumed) owner.promptDraftGuards.delete(guard);
658
+ if (consumed.size > 0) return { message: { ...event.message, content } };
659
+ });
660
+
517
661
  // Any extension's registerTool can reactivate allowlisted tools in Pi 0.87.
518
662
  // Independently track loaded fingerprints, enforce them in execute, and scrub
519
663
  // request-local declarations at the last provider-neutral context boundary.
package/src/output.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdtemp, writeFile } from "node:fs/promises";
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";
@@ -70,14 +71,16 @@ export async function formatContent(
70
71
  `Structured content:\n${JSON.stringify(options.structuredContent)}`,
71
72
  );
72
73
  }
73
- const fullText = text.join("\n\n") || "(No text output)";
74
+ const fullText =
75
+ text.join("\n\n") || (options.preserveOrder ? "" : "(No text output)");
74
76
  const bounded = preview(fullText);
75
77
  const structured = options.structuredContent;
76
78
  const largeDetails =
77
79
  structured !== undefined &&
78
80
  Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
81
+ const textTruncated = bounded !== fullText;
79
82
  const truncated =
80
- bounded !== fullText ||
83
+ textTruncated ||
81
84
  omitted ||
82
85
  largeDetails ||
83
86
  (options.preserveErrorImages === true && images.length > 0);
@@ -99,7 +102,7 @@ export async function formatContent(
99
102
  let textIndex = 0;
100
103
  for (const block of ordered) {
101
104
  if (block.type === "image") {
102
- content.push(block);
105
+ if (!textTruncated || offset < bounded.length) content.push(block);
103
106
  continue;
104
107
  }
105
108
  const value = `${textIndex++ > 0 ? "\n\n" : ""}${block.text}`;
@@ -112,7 +115,8 @@ export async function formatContent(
112
115
  if (previous?.type === "text") previous.text += visible;
113
116
  else content.push({ type: "text", text: visible });
114
117
  }
115
- if (textIndex === 0) content.unshift({ type: "text", text: bounded });
118
+ if (textIndex === 0 && bounded)
119
+ content.unshift({ type: "text", text: bounded });
116
120
  if (artifactNotice)
117
121
  content.push({ type: "text", text: `\n\n${artifactNotice}` });
118
122
  } else {
@@ -137,6 +141,83 @@ export async function formatContent(
137
141
  };
138
142
  }
139
143
 
144
+ function createEditablePromptGuard(): string {
145
+ const bytes = randomBytes(8);
146
+ let guard = "\u2060\u2063";
147
+ for (const byte of bytes)
148
+ for (let bit = 7; bit >= 0; bit--)
149
+ guard += byte & (1 << bit) ? "\u2063" : "\u2060";
150
+ return guard;
151
+ }
152
+
153
+ export function removeEditablePromptGuard(text: string, guard: string): string {
154
+ return text.replaceAll(guard, "");
155
+ }
156
+
157
+ function prepareEditablePrompt(text: string): {
158
+ text: string;
159
+ guard?: string;
160
+ } {
161
+ // 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.
163
+ const sanitized = text
164
+ .replace(/\r\n?/gu, "\n")
165
+ .replace(/\p{Cc}/gu, (character) =>
166
+ character === "\n" || character === "\t" ? character : "",
167
+ )
168
+ .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
169
+ if (!/^[!/]/u.test(sanitized.trimStart())) return { text: sanitized };
170
+ const guard = createEditablePromptGuard();
171
+ return { text: `${guard}${sanitized}`, guard };
172
+ }
173
+
174
+ const imageExtensions: Record<string, string> = {
175
+ "image/png": "png",
176
+ "image/jpeg": "jpg",
177
+ "image/gif": "gif",
178
+ "image/webp": "webp",
179
+ };
180
+
181
+ export async function formatEditableContent(
182
+ content: (TextContent | ImageContent)[],
183
+ ): Promise<{ text: string; guard?: string; cleanup: () => Promise<void> }> {
184
+ let text = "";
185
+ let imageDirectory: string | undefined;
186
+ let imageIndex = 0;
187
+ try {
188
+ for (const block of content) {
189
+ if (block.type === "text") {
190
+ text += block.text;
191
+ continue;
192
+ }
193
+ imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-prompt-"));
194
+ const extension = imageExtensions[block.mimeType] ?? "img";
195
+ const path = join(
196
+ imageDirectory,
197
+ `prompt-image-${++imageIndex}.${extension}`,
198
+ );
199
+ await writeFile(path, Buffer.from(block.data, "base64"), { mode: 0o600 });
200
+ const separator = text.endsWith("\n\n")
201
+ ? ""
202
+ : text.endsWith("\n")
203
+ ? "\n"
204
+ : "\n\n";
205
+ text += `${separator}@${JSON.stringify(path.replaceAll("\\", "/"))}`;
206
+ }
207
+ } catch (error) {
208
+ if (imageDirectory)
209
+ await rm(imageDirectory, { recursive: true, force: true });
210
+ throw error;
211
+ }
212
+ return {
213
+ ...prepareEditablePrompt(text),
214
+ cleanup: async () => {
215
+ if (imageDirectory)
216
+ await rm(imageDirectory, { recursive: true, force: true });
217
+ },
218
+ };
219
+ }
220
+
140
221
  export function formatResult(result: CallToolResult) {
141
222
  // Pi 0.87 turns thrown tool errors into text only. Preserve error images in
142
223
  // the full-result artifact before the native execution wrapper throws.
@@ -0,0 +1,120 @@
1
+ import {
2
+ DynamicBorder,
3
+ type ExtensionCommandContext,
4
+ type Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ Container,
8
+ SelectList,
9
+ Text,
10
+ type Component,
11
+ type SelectItem,
12
+ type TUI,
13
+ type TuiMouseEvent,
14
+ } from "@earendil-works/pi-tui";
15
+ import { compact } from "./catalog.ts";
16
+ import { promptKey, type PromptEntry } from "./prompts.ts";
17
+
18
+ function details(entry: PromptEntry, theme: Theme): string {
19
+ const title = compact(entry.prompt.title ?? "", 120);
20
+ const description = compact(entry.prompt.description ?? "", 320);
21
+ return [
22
+ ...(title ? [theme.fg("accent", theme.bold(title))] : []),
23
+ theme.fg(
24
+ description ? "muted" : "dim",
25
+ description || "No description provided.",
26
+ ),
27
+ ].join("\n");
28
+ }
29
+
30
+ export function createPromptPicker(
31
+ entries: PromptEntry[],
32
+ tui: TUI,
33
+ theme: Theme,
34
+ done: (result: string | undefined) => void,
35
+ signal?: AbortSignal,
36
+ ): Component & { dispose(): void } {
37
+ const items: SelectItem[] = entries.map((entry) => ({
38
+ value: promptKey(entry.server, entry.prompt.name),
39
+ label: `${entry.server} / ${entry.prompt.name}`,
40
+ }));
41
+ const entriesByKey = new Map(
42
+ entries.map((entry) => [promptKey(entry.server, entry.prompt.name), entry]),
43
+ );
44
+ const container = new Container();
45
+ container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
46
+ container.addChild(
47
+ new Text(theme.fg("accent", theme.bold("Select MCP prompt"))),
48
+ );
49
+
50
+ const list = new SelectList(items, Math.min(items.length, 10), {
51
+ selectedPrefix: (text) => theme.fg("accent", text),
52
+ selectedText: (text) => theme.fg("accent", text),
53
+ description: (text) => theme.fg("muted", text),
54
+ scrollInfo: (text) => theme.fg("dim", text),
55
+ noMatch: (text) => theme.fg("warning", text),
56
+ });
57
+ container.addChild(list);
58
+
59
+ const focusedDescription = new Text("", 1, 1);
60
+ const updateDescription = (item: SelectItem) => {
61
+ const entry = entriesByKey.get(item.value);
62
+ if (entry) focusedDescription.setText(details(entry, theme));
63
+ };
64
+ const initialItem = items[0];
65
+ if (initialItem) updateDescription(initialItem);
66
+ list.onSelectionChange = updateDescription;
67
+ container.addChild(focusedDescription);
68
+ container.addChild(
69
+ new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")),
70
+ );
71
+ container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
72
+
73
+ let finished = false;
74
+ const finish = (result: string | undefined) => {
75
+ if (finished) return;
76
+ finished = true;
77
+ done(result);
78
+ };
79
+ list.onSelect = (item) => finish(item.value);
80
+ list.onCancel = () => finish(undefined);
81
+ const onAbort = () => finish(undefined);
82
+ signal?.addEventListener("abort", onAbort, { once: true });
83
+ if (signal?.aborted) onAbort();
84
+
85
+ return {
86
+ render(width: number) {
87
+ return container.render(width);
88
+ },
89
+ invalidate() {
90
+ container.invalidate();
91
+ },
92
+ handleInput(data: string) {
93
+ list.handleInput(data);
94
+ tui.requestRender();
95
+ },
96
+ handleMouse(event: TuiMouseEvent) {
97
+ const result = container.handleMouse(event);
98
+ if (result?.render) tui.requestRender();
99
+ return result;
100
+ },
101
+ dispose() {
102
+ signal?.removeEventListener("abort", onAbort);
103
+ },
104
+ };
105
+ }
106
+
107
+ export async function pickPrompt(
108
+ entries: PromptEntry[],
109
+ ctx: ExtensionCommandContext,
110
+ ): Promise<PromptEntry | undefined> {
111
+ if (ctx.signal?.aborted) return;
112
+ const selected = await ctx.ui.custom<string | undefined>(
113
+ (tui, theme, _keybindings, done) =>
114
+ createPromptPicker(entries, tui, theme, done, ctx.signal),
115
+ );
116
+ if (selected === undefined) return;
117
+ return entries.find(
118
+ (entry) => promptKey(entry.server, entry.prompt.name) === selected,
119
+ );
120
+ }
package/src/prompts.ts CHANGED
@@ -322,21 +322,29 @@ function tokenize(input: string): PromptArgumentToken[] {
322
322
  return tokens;
323
323
  }
324
324
 
325
- export async function formatPromptResult(
326
- result: GetPromptResult,
327
- server: string,
328
- name: string,
329
- ) {
325
+ function hasUsablePromptContent(
326
+ content: GetPromptResult["messages"][number]["content"],
327
+ ): boolean {
328
+ if (content.type === "text") return content.text.trim().length > 0;
329
+ if (content.type === "resource")
330
+ return "text" in content.resource
331
+ ? content.resource.text.trim().length > 0
332
+ : content.resource.blob.length > 0;
333
+ if (content.type === "resource_link") return content.uri.trim().length > 0;
334
+ return content.data.length > 0;
335
+ }
336
+
337
+ export async function formatPromptResult(result: GetPromptResult) {
330
338
  if (result.messages.length === 0)
331
339
  throw new Error("MCP prompt returned no messages.");
332
- const blocks: ContentBlock[] = [
333
- { type: "text", text: `[MCP prompt from ${server}/${name}]` },
334
- ];
340
+ const blocks: ContentBlock[] = [];
341
+ let hasSourceContent = false;
335
342
  const showRoles =
336
343
  result.messages.length > 1 || result.messages[0]?.role !== "user";
337
344
  for (const message of result.messages) {
338
345
  if (showRoles) blocks.push({ type: "text", text: `[${message.role}]` });
339
346
  const content = message.content;
347
+ if (hasUsablePromptContent(content)) hasSourceContent = true;
340
348
  if (content.type === "resource") {
341
349
  if ("text" in content.resource) {
342
350
  blocks.push({
@@ -362,5 +370,13 @@ export async function formatPromptResult(
362
370
  });
363
371
  } else blocks.push(content);
364
372
  }
365
- return formatContent(blocks, { artifact: result, preserveOrder: true });
373
+ if (!hasSourceContent)
374
+ throw new Error("MCP prompt returned no usable content.");
375
+ const formatted = await formatContent(blocks, {
376
+ artifact: result,
377
+ preserveOrder: true,
378
+ });
379
+ if (formatted.content.length === 0)
380
+ throw new Error("MCP prompt returned no usable content.");
381
+ return formatted;
366
382
  }