@ikuma.cloud/pix-mcp 0.0.6 → 0.0.7

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
@@ -1,8 +1,8 @@
1
1
  # @ikuma.cloud/pix-mcp
2
2
 
3
- A small Pi MCP adapter: discover tools, load their schemas on demand, then call
4
- those tools natively. No scripting engine or model-provider-specific API is
5
- required.
3
+ A small Pi MCP adapter: discover tools, load their schemas on demand, call
4
+ those tools natively, and run user-selected MCP prompts. No scripting engine or
5
+ model-provider-specific API is required.
6
6
 
7
7
  ## Usage
8
8
 
@@ -12,9 +12,10 @@ To load this package in an existing Pi installation:
12
12
  pi -e /absolute/path/to/pix/packages/pix-mcp
13
13
  ```
14
14
 
15
- Disable any other MCP adapter that would collide with the `mcp` tool or flags,
16
- but keep any permission-control extensions enabled. Review the configuration
17
- and automatic startup behavior below before connecting servers.
15
+ Disable any other MCP adapter that would collide with the `mcp` tool,
16
+ `/mcp-prompt` command, or flags, but keep any permission-control extensions
17
+ enabled. Review the configuration and automatic startup behavior below before
18
+ connecting servers.
18
19
 
19
20
  ## Configuration
20
21
 
@@ -82,7 +83,7 @@ URLs, and HTTP headers.
82
83
  | `type` | `stdio`, `http`, or `streamable-http` (alias of `http`); only stdio is inferred, when `command` is present |
83
84
  | `command`, `args`, `env` | Stdio only; executable and argument array, not a shell command |
84
85
  | `url`, `headers` | Streamable HTTP only; explicit `type` required; no legacy SSE fallback or redirect following |
85
- | `timeout` | Hard deadline for each tool invocation, in milliseconds; default 30000 |
86
+ | `timeout` | Hard deadline for each tool invocation or prompt retrieval, in milliseconds; default 30000 |
86
87
  | `cwd` | Stdio extension: working directory relative to the config directory |
87
88
  | `description` | Discovery extension: optional summary, truncated to 500 characters |
88
89
  | `startupTimeoutMs` | pix extension: complete connection/initialization handshake deadline; default 30000 |
@@ -116,10 +117,10 @@ as failed connections. Correct the file and reload Pi to retry.
116
117
 
117
118
  All three deadline fields accept integers from 1 through 2,147,483,647 milliseconds
118
119
  (Node's timer-safe maximum). Each defaults independently to 30 seconds. Setting
119
- `"timeout": 960000` permits a 16-minute tool call without lengthening startup or
120
- discovery. The call clock starts after connection startup;
121
- progress does not reset it. Catalog deadlines cover all pages of one snapshot;
122
- a subsequent list-change refresh starts a new deadline.
120
+ `"timeout": 960000` permits a 16-minute tool call or prompt retrieval without
121
+ lengthening startup or discovery. The invocation clock starts after connection
122
+ startup; progress does not reset it. Catalog deadlines cover all pages of one
123
+ snapshot; a subsequent list-change refresh starts a new deadline.
123
124
 
124
125
  HTTP deadlines cover response headers and bodies, including JSON and SSE. MCP
125
126
  requests borrow the host's HTTP/proxy routing but override its header/body idle
@@ -158,8 +159,9 @@ credentials; debug a failing server separately in a trusted environment.
158
159
  ## Discovery and execution
159
160
 
160
161
  At session startup, the adapter connects to enabled servers and fetches their
161
- paginated tool catalogs. A server's configuration, connection, or discovery
162
- failure does not hide tools from other servers.
162
+ advertised paginated tool and prompt catalogs. A server's configuration,
163
+ connection, or discovery failure does not hide healthy features from other
164
+ servers.
163
165
  Full schemas stay out of model context until selected. **Schema exposure is lazy;
164
166
  initial connections and metadata discovery are not.**
165
167
 
@@ -194,11 +196,11 @@ For example, `draft07-reference` identifies unsupported draft-07 references;
194
196
  returned by list, search, and load, and replaced on each catalog refresh. Raw
195
197
  exceptions, schema contents, invalid tool names, and dialect URLs are not exposed.
196
198
 
197
- An unsupported output schema still fails that server's discovery rather than
199
+ An unsupported output schema still fails that server's tool discovery rather than
198
200
  producing a per-tool rejection. Losing an established HTTP notification stream
199
- also withdraws tools rather than silently keeping a stale catalog; servers that
200
- decline the optional stream with HTTP 405 remain usable. Reload Pi to reconnect
201
- a failed server or reread configuration.
201
+ withdraws tool and prompt catalogs rather than silently keeping stale metadata;
202
+ servers that decline the optional stream with HTTP 405 remain usable. Reload Pi
203
+ to reconnect a failed server or reread configuration.
202
204
 
203
205
  Pi handles provider compatibility. Some providers support transcript-anchored
204
206
  schema additions; others rebuild the tool set and may invalidate prompt caches.
@@ -257,6 +259,41 @@ complete draft-07 converter. For unsupported dialect constructs, the server must
257
259
  supply an equivalent supported schema—not merely remove or change `$schema`.
258
260
  Embedded draft-07 declarations inside a 2020-12 document also remain unsupported.
259
261
 
262
+ ## Prompts
263
+
264
+ MCP prompts are user-controlled and are not exposed as model-callable tools. Use
265
+ the stable `/mcp-prompt` command so catalog changes do not leave stale slash
266
+ commands behind:
267
+
268
+ ```text
269
+ /mcp-prompt list [server]
270
+ /mcp-prompt run <server> <prompt> [name=value ...]
271
+ ```
272
+
273
+ Arguments use shell-style quoting. Positional values map to the prompt's declared
274
+ argument order; `name=value` selects a declared argument explicitly. Quote or escape
275
+ an equals sign in a positional value (for example, `"a=b"` or `a\=b`) to avoid
276
+ assignment parsing. Argument names containing `=` work when the name is quoted,
277
+ as in `"x=y"=value`. The adapter checks required arguments before sending
278
+ `prompts/get`. Prompt retrieval uses the
279
+ server's `timeout`; prompt discovery uses `catalogTimeoutMs` and follows pagination.
280
+ Prompt list-change notifications atomically replace that server's prompt catalog.
281
+ Duplicate or invalid prompt metadata fails only that server's prompt catalog. A
282
+ prompt discovery failure does not hide healthy tools, and a tool discovery failure
283
+ does not hide healthy prompts.
284
+
285
+ Pi cannot insert an arbitrary MCP message sequence with its original roles. A
286
+ single user message is passed through; multi-message prompts are flattened with
287
+ explicit `[user]` and `[assistant]` markers. Text, supported images, and embedded
288
+ text or supported-image resources are retained. Resource links become textual
289
+ references. Audio and other binary content are omitted from the preview and
290
+ preserved in a private full-result artifact. MCP argument completion requests are
291
+ not supported.
292
+
293
+ Prompt metadata and bodies are untrusted server content. Catalog metadata stays
294
+ in command UI; a prompt body enters model context only after the user explicitly
295
+ runs it. Review configured servers and selected prompts accordingly.
296
+
260
297
  ## Output and limits
261
298
 
262
299
  Text, supported images, and structured content are retained. Long text gets a
@@ -266,17 +303,22 @@ content is explicitly omitted from the preview, not silently discarded. Pi's
266
303
  error-result path is text-only, so images in MCP errors are preserved in a
267
304
  full-result artifact rather than displayed inline.
268
305
 
269
- When necessary, the full MCP result is written to `pix-mcp-*/result.json` under
270
- the system temp directory (directory mode 0700, file mode 0600). Pi can inspect it
271
- with `read`. These artifacts may contain sensitive data and are **not deleted at
272
- session shutdown**; remove them when no longer needed. Output limits are not a
273
- complete memory or security sandbox.
306
+ When necessary, the full MCP tool or prompt result is written to
307
+ `pix-mcp-*/result.json` under the system temp directory (directory mode 0700,
308
+ file mode 0600). Pi can inspect it with `read`. These artifacts may contain
309
+ sensitive data and are **not deleted at session shutdown**; remove them when no
310
+ longer needed. Output limits are not a complete memory or security sandbox.
274
311
 
275
312
  Configuration is limited to 256 KiB and 32 servers. Startup connects at most four
276
- servers concurrently. Each catalog is limited to 1000 tools, 100 pagination
277
- cursors, and 2 MiB of metadata; individual input/output schemas are limited to
278
- 64 KiB. Tool names must use 1–128 ASCII letters, digits, underscores, hyphens, or
279
- periods; descriptions are limited to 16 KiB. Stdio messages are limited to 16 MiB.
313
+ servers concurrently. Each tool or prompt catalog is limited to 1000 entries,
314
+ 100 pagination cursors, and 2 MiB of metadata; individual input/output schemas are
315
+ limited to 64 KiB. Prompt arguments are limited to 256 KiB per retrieval;
316
+ prompt and prompt-argument names are limited to 256 bytes, cannot contain Unicode
317
+ control, format, or line-separator characters, and each prompt can declare at most
318
+ 100 arguments. Tool names must use 1–128 ASCII letters, digits,
319
+ underscores, hyphens, or periods. Tool and prompt descriptions, prompt-argument
320
+ descriptions, and prompt titles are limited to 16 KiB. Stdio messages are limited
321
+ to 16 MiB.
280
322
  Schemas are syntax-checked before compilation; see [schema compatibility](#schema-compatibility)
281
323
  for supported dialects and the draft-07 subset. Schema nesting is limited to 64
282
324
  levels, including literal data. External schema references are unsupported, but
@@ -293,9 +335,10 @@ roll back effects.
293
335
 
294
336
  ## Deliberately out of scope
295
337
 
296
- OAuth, legacy SSE transport, MCP prompts/resources APIs, sampling, elicitation,
297
- MCP apps, task execution, semantic search, scripting, config UI, and persistent
298
- catalog caching. Use a fuller adapter when those capabilities are required.
338
+ OAuth, legacy SSE transport, MCP resources APIs, prompt argument completion,
339
+ sampling, elicitation, MCP apps, task execution, semantic search, scripting,
340
+ config UI, and persistent catalog caching. Use a fuller adapter when those
341
+ capabilities are required.
299
342
 
300
343
  ## Contributing
301
344
 
package/mcp.schema.json CHANGED
@@ -33,7 +33,7 @@
33
33
  "description": { "$ref": "#/$defs/text" },
34
34
  "timeout": {
35
35
  "$ref": "#/$defs/timeout",
36
- "description": "Hard deadline for one tool invocation."
36
+ "description": "Hard deadline for one tool invocation or prompt retrieval."
37
37
  },
38
38
  "startupTimeoutMs": {
39
39
  "$ref": "#/$defs/timeout",
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ikuma.cloud/pix-mcp",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "type": "module",
8
- "description": "MCP discovery and lazily activated native tools for Pi Coding Agent",
8
+ "description": "MCP prompts and lazily activated native tools for Pi Coding Agent",
9
9
  "keywords": [
10
10
  "pi-package"
11
11
  ],
package/src/client.ts CHANGED
@@ -7,9 +7,13 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
7
7
  import { mediaTypeEssence } from "@modelcontextprotocol/sdk/shared/mediaType.js";
8
8
  import {
9
9
  CallToolResultSchema,
10
+ GetPromptResultSchema,
11
+ ListPromptsResultSchema,
10
12
  ListToolsResultSchema,
13
+ PromptListChangedNotificationSchema,
11
14
  ToolSchema,
12
15
  ToolListChangedNotificationSchema,
16
+ type Prompt,
13
17
  type Tool,
14
18
  } from "@modelcontextprotocol/sdk/types.js";
15
19
  import type { ServerConfig } from "./config.ts";
@@ -28,25 +32,46 @@ const catalogResultSchema = ListToolsResultSchema.extend({
28
32
  }).array(),
29
33
  });
30
34
 
35
+ // Prompt identifiers are rendered in command UI and diagnostics, so bound them
36
+ // and reject control, format, and line-separator characters before they enter state.
37
+ const promptIdentifier = (value: string) =>
38
+ Buffer.byteLength(value) > 0 &&
39
+ Buffer.byteLength(value) <= 256 &&
40
+ !/[\p{C}\p{Zl}\p{Zp}]/u.test(value);
41
+
31
42
  export class Connection {
32
43
  readonly client: Client;
33
44
  readonly config: ServerConfig;
34
45
  status = "Not connected";
46
+ toolStatus = "Not discovered";
47
+ promptStatus = "Not discovered";
35
48
  instructions = "";
36
49
  #transport: StdioClientTransport | StreamableHTTPClientTransport;
37
50
  #lifetime = new AbortController();
38
51
  #initializing: Promise<void> | undefined;
39
52
  #refreshing: Promise<void> | undefined;
53
+ #promptRefreshing: Promise<void> | undefined;
40
54
  #closing: Promise<void> | undefined;
41
55
  #dirty = false;
56
+ #promptDirty = false;
42
57
  #stopped = false;
58
+ #connected = false;
59
+ #toolsReady = false;
60
+ #promptsReady = false;
43
61
  #changed: (tools: Tool[]) => void;
62
+ #promptsChanged: (prompts: Prompt[]) => void;
63
+ #promptNames = new Set<string>();
44
64
  #outputValidators = new Map<string, ReturnType<typeof compileSchema>>();
45
65
  #operation = new AsyncLocalStorage<AbortSignal>();
46
66
 
47
- constructor(config: ServerConfig, changed: (tools: Tool[]) => void) {
67
+ constructor(
68
+ config: ServerConfig,
69
+ changed: (tools: Tool[]) => void,
70
+ promptsChanged: (prompts: Prompt[]) => void = () => {},
71
+ ) {
48
72
  this.config = config;
49
73
  this.#changed = changed;
74
+ this.#promptsChanged = promptsChanged;
50
75
  this.client = new Client(
51
76
  { name: "pix-mcp", version: "0.0.0" },
52
77
  { capabilities: {}, jsonSchemaValidator: schemaValidator },
@@ -86,19 +111,47 @@ export class Connection {
86
111
  }
87
112
  },
88
113
  );
114
+ this.client.setNotificationHandler(
115
+ PromptListChangedNotificationSchema,
116
+ async () => {
117
+ if (this.#stopped) return;
118
+ try {
119
+ await this.refreshPrompts();
120
+ } catch {
121
+ /* refresh already clears the stale catalog */
122
+ }
123
+ },
124
+ );
89
125
  }
90
126
 
91
127
  #disconnect() {
92
128
  if (this.#stopped || this.#lifetime.signal.aborted) return;
93
129
  this.status = "Disconnected; reload Pi to reconnect";
130
+ this.toolStatus = "Unavailable";
131
+ this.promptStatus = "Unavailable";
132
+ this.#connected = false;
133
+ this.#toolsReady = false;
134
+ this.#promptsReady = false;
94
135
  this.#lifetime.abort(
95
136
  new Error("MCP connection is unavailable; reload Pi to reconnect."),
96
137
  );
97
138
  this.#outputValidators.clear();
139
+ this.#promptNames.clear();
98
140
  this.#changed([]);
141
+ this.#promptsChanged([]);
99
142
  void this.client.close().catch(() => {});
100
143
  }
101
144
 
145
+ #updateStatus() {
146
+ if (!this.#connected || this.#stopped) return;
147
+ if (this.#toolsReady && this.#promptsReady) this.status = "Connected";
148
+ else if (this.#toolsReady)
149
+ this.status = "Connected; prompt discovery failed";
150
+ else if (this.#promptsReady)
151
+ this.status = "Connected; tool discovery failed";
152
+ else this.status = "Discovery failed; reload Pi to retry";
153
+ }
154
+
102
155
  async #deadline<T>(
103
156
  timeout: number,
104
157
  signal: AbortSignal | undefined,
@@ -250,29 +303,51 @@ export class Connection {
250
303
  clearTimeout(handshakeTimer);
251
304
  }
252
305
  this.#lifetime.signal.throwIfAborted();
306
+ this.#connected = true;
307
+ this.status = "Discovering";
253
308
  this.instructions = this.client.getInstructions() ?? "";
254
- await this.refresh();
255
309
  } catch {
256
310
  if (!this.#stopped) {
257
311
  this.status =
258
- "Connection or discovery failed; check configuration/authentication and reload Pi";
312
+ "Connection failed; check configuration/authentication and reload Pi";
313
+ this.toolStatus = "Unavailable";
314
+ this.promptStatus = "Unavailable";
259
315
  this.#changed([]);
316
+ this.#promptsChanged([]);
260
317
  }
261
318
  await this.client.close().catch(() => {});
262
- throw new Error(
263
- `MCP server ${this.config.name}: connection or discovery failed.`,
264
- );
319
+ throw new Error(`MCP server ${this.config.name}: connection failed.`);
265
320
  }
321
+ const results = await Promise.allSettled([
322
+ this.refresh(),
323
+ this.refreshPrompts(),
324
+ ]);
325
+ if (results.some((result) => result.status === "rejected"))
326
+ throw new Error(`MCP server ${this.config.name}: discovery failed.`);
266
327
  }
267
328
 
268
329
  refresh(): Promise<void> {
269
330
  this.#dirty = true;
270
- this.#refreshing ??= this.#refresh().finally(() => {
271
- this.#refreshing = undefined;
272
- });
331
+ this.#refreshing ??= this.#drainToolRefreshes();
273
332
  return this.#refreshing;
274
333
  }
275
334
 
335
+ async #drainToolRefreshes(): Promise<void> {
336
+ let failed = false;
337
+ let failure: unknown;
338
+ try {
339
+ await this.#refresh();
340
+ } catch (error) {
341
+ failed = true;
342
+ failure = error;
343
+ }
344
+ // Clear ownership and inspect dirty state without an await between them. A
345
+ // notification queued as the prior refresh settles must start another pass.
346
+ this.#refreshing = undefined;
347
+ if (this.#dirty && !this.#stopped) return this.refresh();
348
+ if (failed) throw failure;
349
+ }
350
+
276
351
  async #refresh(): Promise<void> {
277
352
  try {
278
353
  while (this.#dirty && !this.#stopped) {
@@ -337,18 +412,187 @@ export class Connection {
337
412
  signal.throwIfAborted();
338
413
  if (!this.#stopped) {
339
414
  this.#outputValidators = validators;
340
- this.status = "Connected";
415
+ this.#toolsReady = true;
416
+ this.toolStatus = "Available";
341
417
  this.#changed(tools);
418
+ this.#updateStatus();
342
419
  }
343
420
  },
344
421
  );
345
422
  }
346
423
  } catch {
347
424
  if (!this.#stopped) {
348
- this.status = "Discovery failed; reload Pi to retry";
425
+ this.#toolsReady = false;
426
+ this.toolStatus = "Discovery failed; reload Pi to retry";
427
+ this.#outputValidators.clear();
349
428
  this.#changed([]);
429
+ this.#updateStatus();
350
430
  }
351
- throw new Error(`MCP server ${this.config.name}: discovery failed.`);
431
+ throw new Error(`MCP server ${this.config.name}: tool discovery failed.`);
432
+ }
433
+ }
434
+
435
+ refreshPrompts(): Promise<void> {
436
+ this.#promptDirty = true;
437
+ this.#promptRefreshing ??= this.#drainPromptRefreshes();
438
+ return this.#promptRefreshing;
439
+ }
440
+
441
+ async #drainPromptRefreshes(): Promise<void> {
442
+ let failed = false;
443
+ let failure: unknown;
444
+ try {
445
+ await this.#refreshPrompts();
446
+ } catch (error) {
447
+ failed = true;
448
+ failure = error;
449
+ }
450
+ this.#promptRefreshing = undefined;
451
+ if (this.#promptDirty && !this.#stopped) return this.refreshPrompts();
452
+ if (failed) throw failure;
453
+ }
454
+
455
+ async #refreshPrompts(): Promise<void> {
456
+ try {
457
+ while (this.#promptDirty && !this.#stopped) {
458
+ this.#promptDirty = false;
459
+ const prompts: Prompt[] = [];
460
+ const cursors = new Set<string>();
461
+ const names = new Set<string>();
462
+ const supportsPrompts = Boolean(
463
+ this.client.getServerCapabilities()?.prompts,
464
+ );
465
+ let cursor: string | undefined;
466
+ let bytes = 0;
467
+ await this.#deadline(
468
+ this.config.catalogTimeoutMs,
469
+ undefined,
470
+ async (signal) => {
471
+ if (supportsPrompts) {
472
+ do {
473
+ const page = await this.#deadline(
474
+ this.config.catalogTimeoutMs,
475
+ signal,
476
+ (requestSignal) =>
477
+ this.client.request(
478
+ {
479
+ method: "prompts/list",
480
+ params: cursor === undefined ? {} : { cursor },
481
+ },
482
+ ListPromptsResultSchema,
483
+ {
484
+ signal: requestSignal,
485
+ timeout: this.config.catalogTimeoutMs,
486
+ },
487
+ ),
488
+ );
489
+ bytes += Buffer.byteLength(JSON.stringify(page));
490
+ if (
491
+ bytes > 2 * 1024 * 1024 ||
492
+ prompts.length + page.prompts.length > 1000
493
+ )
494
+ throw new Error("Catalog limit");
495
+ for (const prompt of page.prompts) {
496
+ if (
497
+ !promptIdentifier(prompt.name) ||
498
+ Buffer.byteLength(prompt.title ?? "") > 16 * 1024 ||
499
+ Buffer.byteLength(prompt.description ?? "") > 16 * 1024 ||
500
+ (prompt.arguments?.length ?? 0) > 100
501
+ )
502
+ throw new Error("Invalid prompt metadata");
503
+ if (names.has(prompt.name))
504
+ throw new Error("Duplicate prompt name");
505
+ const argumentNames = new Set<string>();
506
+ for (const argument of prompt.arguments ?? []) {
507
+ if (
508
+ !promptIdentifier(argument.name) ||
509
+ Buffer.byteLength(argument.description ?? "") > 16 * 1024
510
+ )
511
+ throw new Error("Invalid prompt argument metadata");
512
+ if (argumentNames.has(argument.name))
513
+ throw new Error("Duplicate prompt argument name");
514
+ argumentNames.add(argument.name);
515
+ }
516
+ names.add(prompt.name);
517
+ prompts.push(prompt);
518
+ }
519
+ cursor = page.nextCursor;
520
+ if (cursor !== undefined) {
521
+ if (cursors.has(cursor) || cursors.size >= 100)
522
+ throw new Error("Invalid pagination");
523
+ cursors.add(cursor);
524
+ }
525
+ } while (cursor !== undefined);
526
+ }
527
+ signal.throwIfAborted();
528
+ if (!this.#stopped) {
529
+ this.#promptNames = names;
530
+ this.#promptsReady = true;
531
+ this.promptStatus = supportsPrompts
532
+ ? "Available"
533
+ : "Not supported";
534
+ this.#promptsChanged(prompts);
535
+ this.#updateStatus();
536
+ }
537
+ },
538
+ );
539
+ }
540
+ } catch {
541
+ if (!this.#stopped) {
542
+ this.#promptsReady = false;
543
+ this.promptStatus = "Discovery failed; reload Pi to retry";
544
+ this.#promptNames.clear();
545
+ this.#promptsChanged([]);
546
+ this.#updateStatus();
547
+ }
548
+ throw new Error(
549
+ `MCP server ${this.config.name}: prompt discovery failed.`,
550
+ );
551
+ }
552
+ }
553
+
554
+ async getPrompt(
555
+ name: string,
556
+ args: Record<string, string> | undefined,
557
+ signal?: AbortSignal,
558
+ ) {
559
+ await this.start().catch(() => {});
560
+ const combined = signal
561
+ ? AbortSignal.any([signal, this.#lifetime.signal])
562
+ : this.#lifetime.signal;
563
+ combined.throwIfAborted();
564
+ if (!this.#promptsReady || !this.#promptNames.has(name))
565
+ throw new Error("MCP prompt is unavailable; list prompts again.");
566
+ if (args && Buffer.byteLength(JSON.stringify(args)) > 256 * 1024)
567
+ throw new Error("MCP prompt arguments exceed 256 KiB.");
568
+ try {
569
+ return await this.#deadline(
570
+ this.config.timeout,
571
+ combined,
572
+ (requestSignal) =>
573
+ this.client.request(
574
+ {
575
+ method: "prompts/get",
576
+ params: {
577
+ name,
578
+ ...(args && Object.keys(args).length > 0
579
+ ? { arguments: args }
580
+ : {}),
581
+ },
582
+ },
583
+ GetPromptResultSchema,
584
+ {
585
+ signal: requestSignal,
586
+ timeout: this.config.timeout,
587
+ resetTimeoutOnProgress: false,
588
+ },
589
+ ),
590
+ );
591
+ } catch {
592
+ combined.throwIfAborted();
593
+ throw new Error(
594
+ `MCP server ${this.config.name}: prompt request failed or timed out.`,
595
+ );
352
596
  }
353
597
  }
354
598
 
@@ -357,11 +601,13 @@ export class Connection {
357
601
  args: Record<string, unknown>,
358
602
  signal?: AbortSignal,
359
603
  ) {
360
- await this.start();
604
+ await this.start().catch(() => {});
361
605
  const combined = signal
362
606
  ? AbortSignal.any([signal, this.#lifetime.signal])
363
607
  : this.#lifetime.signal;
364
608
  combined.throwIfAborted();
609
+ if (!this.#toolsReady)
610
+ throw new Error("MCP tool catalog is unavailable; reload Pi to retry.");
365
611
  const outputValidator = this.#outputValidators.get(name);
366
612
  try {
367
613
  // SDK callTool consults a mutable per-page validator after the response and
@@ -408,6 +654,14 @@ export class Connection {
408
654
  this.#stopped = true;
409
655
  this.#lifetime.abort();
410
656
  this.status = "Closed";
657
+ this.toolStatus = "Closed";
658
+ this.promptStatus = "Closed";
659
+ this.#toolsReady = false;
660
+ this.#promptsReady = false;
661
+ this.#outputValidators.clear();
662
+ this.#promptNames.clear();
663
+ this.#changed([]);
664
+ this.#promptsChanged([]);
411
665
  if (
412
666
  this.#transport instanceof StreamableHTTPClientTransport &&
413
667
  this.#transport.sessionId
@@ -417,5 +671,6 @@ export class Connection {
417
671
  await this.client.close().catch(() => {});
418
672
  await this.#initializing?.catch(() => {});
419
673
  await this.#refreshing?.catch(() => {});
674
+ await this.#promptRefreshing?.catch(() => {});
420
675
  }
421
676
  }
package/src/index.ts CHANGED
@@ -4,11 +4,20 @@ import type {
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
7
+ import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
9
9
  import { Connection } from "./client.ts";
10
10
  import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
11
11
  import { formatResult } from "./output.ts";
12
+ import {
13
+ formatPromptList,
14
+ formatPromptResult,
15
+ parsePromptCommand,
16
+ promptCompletions,
17
+ promptKey,
18
+ resolvePromptArguments,
19
+ type PromptEntry,
20
+ } from "./prompts.ts";
12
21
  import { prepareSchema } from "./schema.ts";
13
22
  import { ToolRejectionError, type RejectionCode } from "./rejection.ts";
14
23
 
@@ -26,6 +35,7 @@ interface State {
26
35
  alive: boolean;
27
36
  status: string;
28
37
  entries: Map<string, Entry>;
38
+ prompts: Map<string, PromptEntry>;
29
39
  loaded: Map<string, string>;
30
40
  connections: Map<string, Connection>;
31
41
  rejected: Map<string, Rejections>;
@@ -62,6 +72,83 @@ export default function mcp(pi: ExtensionAPI) {
62
72
  description:
63
73
  "Read this MCP config file for this session (default: .mcp.json).",
64
74
  });
75
+ pi.registerCommand("mcp-prompt", {
76
+ description: "List or run a user-selected MCP prompt",
77
+ getArgumentCompletions: (prefix) =>
78
+ promptCompletions(prefix, [...(state?.prompts.values() ?? [])]),
79
+ async handler(input, ctx) {
80
+ try {
81
+ const owner = state;
82
+ if (!owner) throw new Error("MCP session has not started.");
83
+ current(owner);
84
+ const command = parsePromptCommand(input);
85
+ if (command.action === "list") {
86
+ if (
87
+ command.server &&
88
+ !owner.connections.has(command.server) &&
89
+ !owner.configIssues.some((issue) => issue.name === command.server)
90
+ )
91
+ throw new Error("Unknown MCP server.");
92
+ const promptStatus = [
93
+ ...owner.configIssues
94
+ .filter(
95
+ (issue) => !command.server || issue.name === command.server,
96
+ )
97
+ .map((issue) => `${issue.name}: ${issue.message}`),
98
+ ...[...owner.connections.values()]
99
+ .filter(
100
+ (connection) =>
101
+ !command.server || connection.config.name === command.server,
102
+ )
103
+ .map(
104
+ (connection) =>
105
+ `${connection.config.name}: ${connection.promptStatus}`,
106
+ ),
107
+ ].join("\n");
108
+ const text = `${promptStatus}${promptStatus ? "\n\n" : ""}${formatPromptList(
109
+ [...owner.prompts.values()],
110
+ command.server,
111
+ )}`;
112
+ if (!ctx.hasUI) throw new Error(text);
113
+ ctx.ui.notify(text, "info");
114
+ return;
115
+ }
116
+ const item = owner.prompts.get(promptKey(command.server, command.name));
117
+ if (!item)
118
+ throw new Error(
119
+ "Unknown or unavailable MCP prompt. List prompts again.",
120
+ );
121
+ const connection = owner.connections.get(command.server);
122
+ if (!connection) throw new Error("MCP connection unavailable.");
123
+ const args = resolvePromptArguments(
124
+ item.prompt,
125
+ command.argumentTokens,
126
+ );
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
+ );
144
+ } catch (error) {
145
+ const message =
146
+ error instanceof Error ? error.message : "MCP prompt failed.";
147
+ if (!ctx.hasUI) throw new Error(message);
148
+ ctx.ui.notify(message, "error");
149
+ }
150
+ },
151
+ });
65
152
 
66
153
  function deactivate(names: Iterable<string>) {
67
154
  const removed = new Set(names);
@@ -230,6 +317,23 @@ export default function mcp(pi: ExtensionAPI) {
230
317
  if (active) pi.setActiveTools(active);
231
318
  }
232
319
 
320
+ function synchronizePrompts(
321
+ owner: State,
322
+ config: ServerConfig,
323
+ prompts: Prompt[],
324
+ ) {
325
+ if (!owner.alive || state !== owner) return;
326
+ for (const [key, item] of owner.prompts) {
327
+ if (item.server === config.name) owner.prompts.delete(key);
328
+ }
329
+ for (const prompt of prompts) {
330
+ owner.prompts.set(promptKey(config.name, prompt.name), {
331
+ server: config.name,
332
+ prompt,
333
+ });
334
+ }
335
+ }
336
+
233
337
  function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
234
338
  if (!owner.alive || state !== owner) return;
235
339
  const previous = new Map(
@@ -351,6 +455,7 @@ export default function mcp(pi: ExtensionAPI) {
351
455
  alive: true,
352
456
  status: "No MCP servers configured.",
353
457
  entries: new Map(),
458
+ prompts: new Map(),
354
459
  loaded: new Map(),
355
460
  connections: new Map(),
356
461
  rejected: new Map(),
@@ -382,7 +487,11 @@ export default function mcp(pi: ExtensionAPI) {
382
487
  for (const server of config.servers) {
383
488
  owner.connections.set(
384
489
  server.name,
385
- new Connection(server, (tools) => synchronize(owner, server, tools)),
490
+ new Connection(
491
+ server,
492
+ (tools) => synchronize(owner, server, tools),
493
+ (prompts) => synchronizePrompts(owner, server, prompts),
494
+ ),
386
495
  );
387
496
  }
388
497
  // Bound startup concurrency without letting one broken server hide healthy ones.
package/src/output.ts CHANGED
@@ -2,7 +2,10 @@ import { mkdtemp, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
5
- import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5
+ import type {
6
+ CallToolResult,
7
+ ContentBlock,
8
+ } from "@modelcontextprotocol/sdk/types.js";
6
9
 
7
10
  export const MAX_TEXT_BYTES = 24 * 1024;
8
11
  const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
@@ -20,12 +23,27 @@ function preview(text: string): string {
20
23
  .join("\n");
21
24
  }
22
25
 
23
- export async function formatResult(result: CallToolResult) {
26
+ interface ContentOptions {
27
+ artifact: unknown;
28
+ structuredContent?: unknown;
29
+ preserveErrorImages?: boolean;
30
+ preserveOrder?: boolean;
31
+ }
32
+
33
+ export async function formatContent(
34
+ blocks: ContentBlock[],
35
+ options: ContentOptions,
36
+ ) {
24
37
  const text: string[] = [];
25
38
  const images: ImageContent[] = [];
39
+ const ordered: (TextContent | ImageContent)[] = [];
40
+ const addText = (value: string) => {
41
+ text.push(value);
42
+ ordered.push({ type: "text", text: value });
43
+ };
26
44
  let omitted = false;
27
- for (const block of result.content) {
28
- if (block.type === "text") text.push(block.text);
45
+ for (const block of blocks) {
46
+ if (block.type === "text") addText(block.text);
29
47
  else if (
30
48
  block.type === "image" &&
31
49
  images.length < MAX_IMAGES &&
@@ -35,53 +53,77 @@ export async function formatResult(result: CallToolResult) {
35
53
  // Padding can give three decoded sizes the same encoded length.
36
54
  Buffer.byteLength(block.data, "base64") <= MAX_IMAGE_BYTES
37
55
  ) {
38
- images.push({
56
+ const image: ImageContent = {
39
57
  type: "image",
40
58
  data: block.data,
41
59
  mimeType: block.mimeType,
42
- });
60
+ };
61
+ images.push(image);
62
+ ordered.push(image);
43
63
  } else {
44
- text.push(`[MCP ${block.type} content omitted; see full result.]`);
64
+ addText(`[MCP ${block.type} content omitted; see full result.]`);
45
65
  omitted = true;
46
66
  }
47
67
  }
48
- if (result.structuredContent !== undefined) {
49
- text.push(
50
- `Structured content:\n${JSON.stringify(result.structuredContent)}`,
68
+ if (options.structuredContent !== undefined) {
69
+ addText(
70
+ `Structured content:\n${JSON.stringify(options.structuredContent)}`,
51
71
  );
52
72
  }
53
73
  const fullText = text.join("\n\n") || "(No text output)";
54
74
  const bounded = preview(fullText);
55
- const structured = result.structuredContent;
75
+ const structured = options.structuredContent;
56
76
  const largeDetails =
57
77
  structured !== undefined &&
58
78
  Buffer.byteLength(JSON.stringify(structured)) > MAX_DETAILS_BYTES;
59
- // Pi 0.87 turns thrown tool errors into text only. Preserve error images in
60
- // the full-result artifact before the native execution wrapper throws.
61
79
  const truncated =
62
80
  bounded !== fullText ||
63
81
  omitted ||
64
82
  largeDetails ||
65
- (result.isError === true && images.length > 0);
83
+ (options.preserveErrorImages === true && images.length > 0);
66
84
  let fullOutputPath: string | undefined;
67
85
  if (truncated) {
68
86
  const directory = await mkdtemp(join(tmpdir(), "pix-mcp-"));
69
87
  fullOutputPath = join(directory, "result.json");
70
- await writeFile(fullOutputPath, JSON.stringify(result, null, 2), {
88
+ await writeFile(fullOutputPath, JSON.stringify(options.artifact, null, 2), {
71
89
  mode: 0o600,
72
90
  });
73
91
  }
74
- const content: (TextContent | ImageContent)[] = [
75
- {
76
- type: "text",
77
- text:
78
- bounded +
79
- (fullOutputPath
80
- ? `\n\nFull MCP result: ${fullOutputPath}\nUse read with offset/limit to inspect it.`
81
- : ""),
82
- },
83
- ...images,
84
- ];
92
+ const artifactNotice = fullOutputPath
93
+ ? `Full MCP result: ${fullOutputPath}\nUse read with offset/limit to inspect it.`
94
+ : undefined;
95
+ let content: (TextContent | ImageContent)[];
96
+ if (options.preserveOrder) {
97
+ content = [];
98
+ let offset = 0;
99
+ let textIndex = 0;
100
+ for (const block of ordered) {
101
+ if (block.type === "image") {
102
+ content.push(block);
103
+ continue;
104
+ }
105
+ const value = `${textIndex++ > 0 ? "\n\n" : ""}${block.text}`;
106
+ // Slice the already byte/line-bounded preview itself; value supplies only
107
+ // the original block boundary needed to preserve image ordering.
108
+ const visible = bounded.slice(offset, offset + value.length);
109
+ offset += visible.length;
110
+ if (!visible) continue;
111
+ const previous = content.at(-1);
112
+ if (previous?.type === "text") previous.text += visible;
113
+ else content.push({ type: "text", text: visible });
114
+ }
115
+ if (textIndex === 0) content.unshift({ type: "text", text: bounded });
116
+ if (artifactNotice)
117
+ content.push({ type: "text", text: `\n\n${artifactNotice}` });
118
+ } else {
119
+ content = [
120
+ {
121
+ type: "text",
122
+ text: bounded + (artifactNotice ? `\n\n${artifactNotice}` : ""),
123
+ },
124
+ ...images,
125
+ ];
126
+ }
85
127
  return {
86
128
  content,
87
129
  details: {
@@ -94,3 +136,13 @@ export async function formatResult(result: CallToolResult) {
94
136
  },
95
137
  };
96
138
  }
139
+
140
+ export function formatResult(result: CallToolResult) {
141
+ // Pi 0.87 turns thrown tool errors into text only. Preserve error images in
142
+ // the full-result artifact before the native execution wrapper throws.
143
+ return formatContent(result.content, {
144
+ artifact: result,
145
+ structuredContent: result.structuredContent,
146
+ preserveErrorImages: result.isError === true,
147
+ });
148
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,366 @@
1
+ import type {
2
+ ContentBlock,
3
+ GetPromptResult,
4
+ Prompt,
5
+ } from "@modelcontextprotocol/sdk/types.js";
6
+ import { compact } from "./catalog.ts";
7
+ import { formatContent } from "./output.ts";
8
+
9
+ export interface PromptEntry {
10
+ server: string;
11
+ prompt: Prompt;
12
+ }
13
+
14
+ export interface PromptArgumentToken {
15
+ value: string;
16
+ separator?: number;
17
+ }
18
+
19
+ export type PromptCommand =
20
+ | { action: "list"; server?: string }
21
+ | {
22
+ action: "run";
23
+ server: string;
24
+ name: string;
25
+ argumentTokens: PromptArgumentToken[];
26
+ };
27
+
28
+ export function promptKey(server: string, name: string): string {
29
+ return JSON.stringify([server, name]);
30
+ }
31
+
32
+ export function parsePromptCommand(input: string): PromptCommand {
33
+ const tokens = tokenize(input);
34
+ const action = tokens.shift()?.value;
35
+ if (action === "list") {
36
+ if (tokens.length > 1) throw new Error("Usage: /mcp-prompt list [server]");
37
+ const server = tokens[0]?.value;
38
+ return { action, ...(server ? { server } : {}) };
39
+ }
40
+ if (action === "run") {
41
+ const server = tokens.shift()?.value;
42
+ const name = tokens.shift()?.value;
43
+ if (!server || !name)
44
+ throw new Error(
45
+ "Usage: /mcp-prompt run <server> <prompt> [name=value ...]",
46
+ );
47
+ return { action, server, name, argumentTokens: tokens };
48
+ }
49
+ throw new Error(
50
+ "Usage: /mcp-prompt list [server] | /mcp-prompt run <server> <prompt> [name=value ...]",
51
+ );
52
+ }
53
+
54
+ export function resolvePromptArguments(
55
+ prompt: Prompt,
56
+ tokens: (PromptArgumentToken | string)[],
57
+ ): Record<string, string> | undefined {
58
+ const declared = new Set(
59
+ (prompt.arguments ?? []).map((argument) => argument.name),
60
+ );
61
+ const named = new Map<string, string>();
62
+ const positional: string[] = [];
63
+ for (const token of tokens) {
64
+ const value = typeof token === "string" ? token : token.value;
65
+ const separator =
66
+ typeof token === "string" ? token.indexOf("=") : token.separator;
67
+ const candidate = value.slice(0, separator);
68
+ if (separator !== undefined && separator > 0 && declared.has(candidate)) {
69
+ const name = candidate;
70
+ if (named.has(name))
71
+ throw new Error(`Prompt argument ${name} was provided more than once.`);
72
+ named.set(name, value.slice(separator + 1));
73
+ } else positional.push(value);
74
+ }
75
+
76
+ const result = Object.create(null) as Record<string, string>;
77
+ let position = 0;
78
+ for (const argument of prompt.arguments ?? []) {
79
+ const namedValue = named.get(argument.name);
80
+ const value = namedValue ?? positional[position];
81
+ if (namedValue === undefined && value !== undefined) position++;
82
+ if (value !== undefined) result[argument.name] = value;
83
+ named.delete(argument.name);
84
+ }
85
+ if (position < positional.length)
86
+ throw new Error("Too many positional prompt arguments were provided.");
87
+ for (const [name, value] of named) result[name] = value;
88
+
89
+ const missing = (prompt.arguments ?? [])
90
+ .filter(
91
+ (argument) =>
92
+ argument.required === true && !Object.hasOwn(result, argument.name),
93
+ )
94
+ .map((argument) => argument.name);
95
+ if (missing.length > 0)
96
+ throw new Error(
97
+ `Missing required prompt arguments: ${missing.join(", ")}.`,
98
+ );
99
+ return Object.keys(result).length > 0 ? result : undefined;
100
+ }
101
+
102
+ export function formatPromptList(
103
+ entries: PromptEntry[],
104
+ server?: string,
105
+ ): string {
106
+ const selected = entries
107
+ .filter((entry) => !server || entry.server === server)
108
+ .sort(
109
+ (a, b) =>
110
+ a.server.localeCompare(b.server, "en") ||
111
+ a.prompt.name.localeCompare(b.prompt.name, "en"),
112
+ );
113
+ if (selected.length === 0)
114
+ return server
115
+ ? `No MCP prompts are available from ${server}.`
116
+ : "No MCP prompts are available.";
117
+ const lines = ["Available MCP prompts:"];
118
+ let shown = 0;
119
+ for (const entry of selected) {
120
+ const argumentsHint = (entry.prompt.arguments ?? [])
121
+ .map((argument) =>
122
+ argument.required
123
+ ? `<${quote(argument.name)}>`
124
+ : `[${quote(argument.name)}]`,
125
+ )
126
+ .join(" ");
127
+ const description = compact(
128
+ entry.prompt.description ?? entry.prompt.title ?? "",
129
+ 160,
130
+ );
131
+ const line = `${entry.server} ${quote(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
132
+ if (shown >= 100 || lines.join("\n").length + line.length > 15_000) {
133
+ lines.push(`${selected.length - shown} additional prompts omitted.`);
134
+ break;
135
+ }
136
+ lines.push(line);
137
+ shown++;
138
+ }
139
+ lines.push(
140
+ "Run one with /mcp-prompt run <server> <prompt> [name=value ...].",
141
+ );
142
+ return lines.join("\n");
143
+ }
144
+
145
+ export function promptCompletions(
146
+ prefix: string,
147
+ entries: PromptEntry[],
148
+ ): { value: string; label: string; description?: string }[] | null {
149
+ const trailingSpace = /\s$/.test(prefix);
150
+ let tokens: PromptArgumentToken[];
151
+ try {
152
+ tokens = tokenize(prefix);
153
+ } catch {
154
+ return null;
155
+ }
156
+ if (tokens.length === 0)
157
+ return [
158
+ { value: "list", label: "list", description: "List MCP prompts" },
159
+ { value: "run", label: "run", description: "Run an MCP prompt" },
160
+ ];
161
+ if (tokens.length === 1 && !trailingSpace) {
162
+ return ["list", "run"]
163
+ .filter((action) => action.startsWith(tokens[0]?.value ?? ""))
164
+ .map((action) => ({ value: action, label: action }));
165
+ }
166
+ const action = tokens[0]?.value;
167
+ if (action !== "list" && action !== "run") return null;
168
+ const servers = [...new Set(entries.map((entry) => entry.server))].sort();
169
+ const completingServer =
170
+ tokens.length === 1 || (tokens.length === 2 && !trailingSpace);
171
+ if (completingServer) {
172
+ const current = trailingSpace ? "" : (tokens[1]?.value ?? "");
173
+ return completionItems(
174
+ servers.filter((server) => server.startsWith(current)),
175
+ `${action} `,
176
+ (server) => server,
177
+ );
178
+ }
179
+ if (action === "list") return null;
180
+ const server = tokens[1]?.value;
181
+ if (!server || !servers.includes(server)) return null;
182
+ if (
183
+ (tokens.length === 2 && trailingSpace) ||
184
+ (tokens.length === 3 && !trailingSpace)
185
+ ) {
186
+ const current = trailingSpace ? "" : (tokens[2]?.value ?? "");
187
+ const prompts = entries
188
+ .filter(
189
+ (entry) =>
190
+ entry.server === server && entry.prompt.name.startsWith(current),
191
+ )
192
+ .sort((a, b) => a.prompt.name.localeCompare(b.prompt.name, "en"));
193
+ return prompts.map((entry) => ({
194
+ value: `run ${quote(server)} ${quote(entry.prompt.name)}`,
195
+ label: entry.prompt.name,
196
+ ...(entry.prompt.description || entry.prompt.title
197
+ ? {
198
+ description: compact(
199
+ entry.prompt.description ?? entry.prompt.title ?? "",
200
+ 100,
201
+ ),
202
+ }
203
+ : {}),
204
+ }));
205
+ }
206
+ const name = tokens[2]?.value;
207
+ const selected = entries.find(
208
+ (entry) => entry.server === server && entry.prompt.name === name,
209
+ );
210
+ if (!selected) return null;
211
+ const supplied = tokens.slice(3);
212
+ const currentToken = trailingSpace ? undefined : supplied.pop();
213
+ const current = currentToken?.value ?? "";
214
+ if (currentToken?.separator !== undefined) return null;
215
+ const definitions = selected.prompt.arguments ?? [];
216
+ const declared = new Set(definitions.map((argument) => argument.name));
217
+ const named = new Set<string>();
218
+ let positional = 0;
219
+ for (const token of supplied) {
220
+ const candidate = token.value.slice(0, token.separator);
221
+ if (
222
+ token.separator !== undefined &&
223
+ token.separator > 0 &&
224
+ declared.has(candidate)
225
+ )
226
+ named.add(candidate);
227
+ else positional++;
228
+ }
229
+ const satisfied = new Set<string>();
230
+ let position = 0;
231
+ for (const argument of definitions) {
232
+ if (named.has(argument.name)) satisfied.add(argument.name);
233
+ else if (position < positional) {
234
+ satisfied.add(argument.name);
235
+ position++;
236
+ }
237
+ }
238
+ const base = [
239
+ "run",
240
+ quote(server),
241
+ quote(selected.prompt.name),
242
+ ...supplied.map((token) => quote(token.value)),
243
+ ].join(" ");
244
+ const arguments_ = definitions.filter(
245
+ (argument) =>
246
+ !satisfied.has(argument.name) && argument.name.startsWith(current),
247
+ );
248
+ return arguments_.length > 0
249
+ ? arguments_.map((argument) => ({
250
+ value: `${base} ${quote(argument.name)}=`,
251
+ label: `${argument.name}=`,
252
+ ...(argument.description
253
+ ? { description: compact(argument.description, 100) }
254
+ : {}),
255
+ }))
256
+ : null;
257
+ }
258
+
259
+ function completionItems(
260
+ values: string[],
261
+ prefix: string,
262
+ label: (value: string) => string,
263
+ ) {
264
+ return values.map((value) => ({
265
+ value: `${prefix}${quote(value)}`,
266
+ label: label(value),
267
+ }));
268
+ }
269
+
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
+ export async function formatPromptResult(
326
+ result: GetPromptResult,
327
+ server: string,
328
+ name: string,
329
+ ) {
330
+ if (result.messages.length === 0)
331
+ throw new Error("MCP prompt returned no messages.");
332
+ const blocks: ContentBlock[] = [
333
+ { type: "text", text: `[MCP prompt from ${server}/${name}]` },
334
+ ];
335
+ const showRoles =
336
+ result.messages.length > 1 || result.messages[0]?.role !== "user";
337
+ for (const message of result.messages) {
338
+ if (showRoles) blocks.push({ type: "text", text: `[${message.role}]` });
339
+ const content = message.content;
340
+ if (content.type === "resource") {
341
+ if ("text" in content.resource) {
342
+ blocks.push({
343
+ type: "text",
344
+ text: `[MCP embedded resource ${content.resource.uri}]\n${content.resource.text}`,
345
+ });
346
+ } else if (
347
+ content.resource.mimeType &&
348
+ ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(
349
+ content.resource.mimeType,
350
+ )
351
+ ) {
352
+ blocks.push({
353
+ type: "image",
354
+ data: content.resource.blob,
355
+ mimeType: content.resource.mimeType,
356
+ });
357
+ } else blocks.push(content);
358
+ } else if (content.type === "resource_link") {
359
+ blocks.push({
360
+ type: "text",
361
+ text: `[MCP resource link ${content.name}: ${content.uri}]`,
362
+ });
363
+ } else blocks.push(content);
364
+ }
365
+ return formatContent(blocks, { artifact: result, preserveOrder: true });
366
+ }