@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 +72 -29
- package/mcp.schema.json +1 -1
- package/package.json +2 -2
- package/src/client.ts +268 -13
- package/src/index.ts +111 -2
- package/src/output.ts +78 -26
- package/src/prompts.ts +366 -0
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,
|
|
4
|
-
those tools natively. No scripting engine or
|
|
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
|
|
16
|
-
but keep any permission-control extensions
|
|
17
|
-
and automatic startup behavior below before
|
|
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
|
|
120
|
-
discovery. The
|
|
121
|
-
progress does not reset it. Catalog deadlines cover all pages of one
|
|
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,
|
|
162
|
-
failure does not hide
|
|
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
|
-
|
|
200
|
-
decline the optional stream with HTTP 405 remain usable. Reload Pi
|
|
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
|
|
270
|
-
the system temp directory (directory mode 0700,
|
|
271
|
-
with `read`. These artifacts may contain
|
|
272
|
-
session shutdown**; remove them when no
|
|
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
|
|
277
|
-
cursors, and 2 MiB of metadata; individual input/output schemas are
|
|
278
|
-
64 KiB.
|
|
279
|
-
|
|
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
|
|
297
|
-
MCP apps, task execution, semantic search, scripting,
|
|
298
|
-
catalog caching. Use a fuller adapter when those
|
|
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.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"description": "MCP
|
|
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(
|
|
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
|
|
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.#
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
28
|
-
if (block.type === "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
|
-
|
|
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
|
-
|
|
64
|
+
addText(`[MCP ${block.type} content omitted; see full result.]`);
|
|
45
65
|
omitted = true;
|
|
46
66
|
}
|
|
47
67
|
}
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
`Structured content:\n${JSON.stringify(
|
|
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 =
|
|
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
|
-
(
|
|
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(
|
|
88
|
+
await writeFile(fullOutputPath, JSON.stringify(options.artifact, null, 2), {
|
|
71
89
|
mode: 0o600,
|
|
72
90
|
});
|
|
73
91
|
}
|
|
74
|
-
const
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|