@ikuma.cloud/pix-mcp 0.0.5 → 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,33 +12,35 @@ 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 trust requirements 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
- ## Configuration and trust
20
+ ## Configuration
20
21
 
21
22
  By default, read only `.mcp.json` in Pi's working directory. There is no ancestor
22
23
  search, global config merge, automatic import, or persistent metadata cache.
23
24
  Relative `--mcp-config` paths resolve from that working directory; a stdio
24
25
  server's `cwd` resolves from the config directory and defaults to that directory.
25
26
 
26
- A bare `.mcp.json` is not protected by Pi's project-trust mechanism. This adapter
27
- asks before using the default file. In a headless session, it remains disabled
28
- unless explicitly trusted. Either of these authorizes the file for one session:
27
+ The adapter automatically loads the selected file and starts its enabled servers
28
+ in all modes, without a confirmation prompt. Use `--mcp-config <path>` to select
29
+ a different file.
29
30
 
30
- - `--mcp-config <path>`: select **and trust** a file.
31
- - `--mcp-trust-config`: trust the default `.mcp.json`.
31
+ **Breaking change:** `--mcp-trust-config` has been removed. Remove it from existing
32
+ launch commands; print and JSON sessions also load the default file automatically.
32
33
 
33
- Review the file first: trusting it can launch arbitrary local programs and
34
- contact remote services. Configuration trust is not an OS sandbox.
34
+ Review the file before starting Pi: it can launch arbitrary local programs and
35
+ contact remote services. A bare `.mcp.json` is not protected by Pi's project-trust
36
+ mechanism, and the adapter is not an OS sandbox. Set a server's `disabled` field
37
+ to `true` to prevent it from starting.
35
38
 
36
39
  At session startup, interactive and RPC sessions receive an `MCP config: <path>`
37
- notice with the resolved absolute path after the file is read and trusted, even
38
- when a flag skips the trust prompt. The notice identifies the configuration file,
39
- not whether every server connected; it never includes configuration contents.
40
- Missing, unreadable, or malformed files and declined trust produce no notice.
41
- Print and JSON sessions remain silent.
40
+ notice with the resolved absolute path after the file is read. The notice
41
+ identifies the configuration file, not whether every server connected; it never
42
+ includes configuration contents. Missing, unreadable, or malformed files produce
43
+ no notice. Print and JSON sessions remain silent.
42
44
 
43
45
  ```json
44
46
  {
@@ -81,7 +83,7 @@ URLs, and HTTP headers.
81
83
  | `type` | `stdio`, `http`, or `streamable-http` (alias of `http`); only stdio is inferred, when `command` is present |
82
84
  | `command`, `args`, `env` | Stdio only; executable and argument array, not a shell command |
83
85
  | `url`, `headers` | Streamable HTTP only; explicit `type` required; no legacy SSE fallback or redirect following |
84
- | `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 |
85
87
  | `cwd` | Stdio extension: working directory relative to the config directory |
86
88
  | `description` | Discovery extension: optional summary, truncated to 500 characters |
87
89
  | `startupTimeoutMs` | pix extension: complete connection/initialization handshake deadline; default 30000 |
@@ -111,17 +113,14 @@ count limits remain fatal for the whole file. An invalid-only configuration
111
113
  reports that no valid servers remain. Disabled entries are omitted, not reported
112
114
  as failed connections. Correct the file and reload Pi to retry.
113
115
 
114
- Configuration trust still applies before any valid server is started or its
115
- metadata exposed.
116
-
117
116
  ### Deadlines and migration
118
117
 
119
118
  All three deadline fields accept integers from 1 through 2,147,483,647 milliseconds
120
119
  (Node's timer-safe maximum). Each defaults independently to 30 seconds. Setting
121
- `"timeout": 960000` permits a 16-minute tool call without lengthening startup or
122
- discovery. The call clock starts after connection startup;
123
- progress does not reset it. Catalog deadlines cover all pages of one snapshot;
124
- 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.
125
124
 
126
125
  HTTP deadlines cover response headers and bodies, including JSON and SSE. MCP
127
126
  requests borrow the host's HTTP/proxy routing but override its header/body idle
@@ -147,7 +146,7 @@ permission prompts in both interactive and headless sessions. Native calls pass
147
146
  through Pi's normal `tool_call` and `tool_result` hooks. For approvals or access
148
147
  policies, install a Pi extension that handles `tool_call` so it can manage MCP
149
148
  and other tools together. Server annotations do not bypass those hooks.
150
- Configuration trust above is separate: it authorizes startup, not individual calls.
149
+ These hooks govern tool calls, not automatic server startup.
151
150
 
152
151
  **Migration from 0.0.2:** Remove the server-level `approve` field. Entries that
153
152
  still contain it are rejected with migration guidance rather than silently
@@ -159,9 +158,10 @@ credentials; debug a failing server separately in a trusted environment.
159
158
 
160
159
  ## Discovery and execution
161
160
 
162
- At session startup, the adapter connects to trusted servers and fetches their
163
- paginated tool catalogs. A server's configuration, connection, or discovery
164
- failure does not hide tools from other servers.
161
+ At session startup, the adapter connects to enabled servers and fetches their
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.
165
165
  Full schemas stay out of model context until selected. **Schema exposure is lazy;
166
166
  initial connections and metadata discovery are not.**
167
167
 
@@ -196,11 +196,11 @@ For example, `draft07-reference` identifies unsupported draft-07 references;
196
196
  returned by list, search, and load, and replaced on each catalog refresh. Raw
197
197
  exceptions, schema contents, invalid tool names, and dialect URLs are not exposed.
198
198
 
199
- 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
200
200
  producing a per-tool rejection. Losing an established HTTP notification stream
201
- also withdraws tools rather than silently keeping a stale catalog; servers that
202
- decline the optional stream with HTTP 405 remain usable. Reload Pi to reconnect
203
- 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.
204
204
 
205
205
  Pi handles provider compatibility. Some providers support transcript-anchored
206
206
  schema additions; others rebuild the tool set and may invalidate prompt caches.
@@ -259,6 +259,41 @@ complete draft-07 converter. For unsupported dialect constructs, the server must
259
259
  supply an equivalent supported schema—not merely remove or change `$schema`.
260
260
  Embedded draft-07 declarations inside a 2020-12 document also remain unsupported.
261
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
+
262
297
  ## Output and limits
263
298
 
264
299
  Text, supported images, and structured content are retained. Long text gets a
@@ -268,17 +303,22 @@ content is explicitly omitted from the preview, not silently discarded. Pi's
268
303
  error-result path is text-only, so images in MCP errors are preserved in a
269
304
  full-result artifact rather than displayed inline.
270
305
 
271
- When necessary, the full MCP result is written to `pix-mcp-*/result.json` under
272
- the system temp directory (directory mode 0700, file mode 0600). Pi can inspect it
273
- with `read`. These artifacts may contain sensitive data and are **not deleted at
274
- session shutdown**; remove them when no longer needed. Output limits are not a
275
- 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.
276
311
 
277
312
  Configuration is limited to 256 KiB and 32 servers. Startup connects at most four
278
- servers concurrently. Each catalog is limited to 1000 tools, 100 pagination
279
- cursors, and 2 MiB of metadata; individual input/output schemas are limited to
280
- 64 KiB. Tool names must use 1–128 ASCII letters, digits, underscores, hyphens, or
281
- 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.
282
322
  Schemas are syntax-checked before compilation; see [schema compatibility](#schema-compatibility)
283
323
  for supported dialects and the draft-07 subset. Schema nesting is limited to 64
284
324
  levels, including literal data. External schema references are unsupported, but
@@ -295,9 +335,10 @@ roll back effects.
295
335
 
296
336
  ## Deliberately out of scope
297
337
 
298
- OAuth, legacy SSE transport, MCP prompts/resources APIs, sampling, elicitation,
299
- MCP apps, task execution, semantic search, scripting, config UI, and persistent
300
- 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.
301
342
 
302
343
  ## Contributing
303
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.5",
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>;
@@ -60,13 +70,84 @@ export default function mcp(pi: ExtensionAPI) {
60
70
  pi.registerFlag("mcp-config", {
61
71
  type: "string",
62
72
  description:
63
- "Read and trust this MCP config file for this session (default: .mcp.json, with confirmation).",
73
+ "Read this MCP config file for this session (default: .mcp.json).",
64
74
  });
65
- pi.registerFlag("mcp-trust-config", {
66
- type: "boolean",
67
- default: false,
68
- description:
69
- "Trust the default .mcp.json for this session; it can launch processes and contact servers.",
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
+ },
70
151
  });
71
152
 
72
153
  function deactivate(names: Iterable<string>) {
@@ -236,6 +317,23 @@ export default function mcp(pi: ExtensionAPI) {
236
317
  if (active) pi.setActiveTools(active);
237
318
  }
238
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
+
239
337
  function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
240
338
  if (!owner.alive || state !== owner) return;
241
339
  const previous = new Map(
@@ -357,6 +455,7 @@ export default function mcp(pi: ExtensionAPI) {
357
455
  alive: true,
358
456
  status: "No MCP servers configured.",
359
457
  entries: new Map(),
458
+ prompts: new Map(),
360
459
  loaded: new Map(),
361
460
  connections: new Map(),
362
461
  rejected: new Map(),
@@ -375,20 +474,6 @@ export default function mcp(pi: ExtensionAPI) {
375
474
  "The explicitly selected MCP configuration does not exist.";
376
475
  return;
377
476
  }
378
- // A bare .mcp.json is not among Pi's project-trust resources. Require our
379
- // own decision rather than assuming Pi has approved launching its commands.
380
- let trusted = explicit || pi.getFlag("mcp-trust-config") === true;
381
- if (!trusted && ctx.hasUI)
382
- trusted = await ctx.ui.confirm(
383
- "Trust MCP configuration for this session?",
384
- `${path}\nThis file can launch local processes and contact remote servers. Review it before approving.`,
385
- );
386
- current(owner);
387
- if (!trusted) {
388
- owner.status =
389
- "MCP configuration not trusted. Review it, then restart with --mcp-trust-config or --mcp-config <path>.";
390
- return;
391
- }
392
477
  if (ctx.hasUI) ctx.ui.notify(`MCP config: ${path}`, "info");
393
478
  owner.configIssues = config.issues;
394
479
  owner.status =
@@ -402,7 +487,11 @@ export default function mcp(pi: ExtensionAPI) {
402
487
  for (const server of config.servers) {
403
488
  owner.connections.set(
404
489
  server.name,
405
- 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
+ ),
406
495
  );
407
496
  }
408
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
+ }