@ikuma.cloud/pix-mcp 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -29
- package/mcp.schema.json +1 -1
- package/package.json +4 -2
- package/src/catalog.ts +1 -1
- package/src/client.ts +268 -13
- package/src/index.ts +259 -6
- package/src/output.ts +162 -29
- package/src/prompt-picker.ts +120 -0
- package/src/prompts.ts +382 -0
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
function hasUsablePromptContent(
|
|
326
|
+
content: GetPromptResult["messages"][number]["content"],
|
|
327
|
+
): boolean {
|
|
328
|
+
if (content.type === "text") return content.text.trim().length > 0;
|
|
329
|
+
if (content.type === "resource")
|
|
330
|
+
return "text" in content.resource
|
|
331
|
+
? content.resource.text.trim().length > 0
|
|
332
|
+
: content.resource.blob.length > 0;
|
|
333
|
+
if (content.type === "resource_link") return content.uri.trim().length > 0;
|
|
334
|
+
return content.data.length > 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function formatPromptResult(result: GetPromptResult) {
|
|
338
|
+
if (result.messages.length === 0)
|
|
339
|
+
throw new Error("MCP prompt returned no messages.");
|
|
340
|
+
const blocks: ContentBlock[] = [];
|
|
341
|
+
let hasSourceContent = false;
|
|
342
|
+
const showRoles =
|
|
343
|
+
result.messages.length > 1 || result.messages[0]?.role !== "user";
|
|
344
|
+
for (const message of result.messages) {
|
|
345
|
+
if (showRoles) blocks.push({ type: "text", text: `[${message.role}]` });
|
|
346
|
+
const content = message.content;
|
|
347
|
+
if (hasUsablePromptContent(content)) hasSourceContent = true;
|
|
348
|
+
if (content.type === "resource") {
|
|
349
|
+
if ("text" in content.resource) {
|
|
350
|
+
blocks.push({
|
|
351
|
+
type: "text",
|
|
352
|
+
text: `[MCP embedded resource ${content.resource.uri}]\n${content.resource.text}`,
|
|
353
|
+
});
|
|
354
|
+
} else if (
|
|
355
|
+
content.resource.mimeType &&
|
|
356
|
+
["image/png", "image/jpeg", "image/gif", "image/webp"].includes(
|
|
357
|
+
content.resource.mimeType,
|
|
358
|
+
)
|
|
359
|
+
) {
|
|
360
|
+
blocks.push({
|
|
361
|
+
type: "image",
|
|
362
|
+
data: content.resource.blob,
|
|
363
|
+
mimeType: content.resource.mimeType,
|
|
364
|
+
});
|
|
365
|
+
} else blocks.push(content);
|
|
366
|
+
} else if (content.type === "resource_link") {
|
|
367
|
+
blocks.push({
|
|
368
|
+
type: "text",
|
|
369
|
+
text: `[MCP resource link ${content.name}: ${content.uri}]`,
|
|
370
|
+
});
|
|
371
|
+
} else blocks.push(content);
|
|
372
|
+
}
|
|
373
|
+
if (!hasSourceContent)
|
|
374
|
+
throw new Error("MCP prompt returned no usable content.");
|
|
375
|
+
const formatted = await formatContent(blocks, {
|
|
376
|
+
artifact: result,
|
|
377
|
+
preserveOrder: true,
|
|
378
|
+
});
|
|
379
|
+
if (formatted.content.length === 0)
|
|
380
|
+
throw new Error("MCP prompt returned no usable content.");
|
|
381
|
+
return formatted;
|
|
382
|
+
}
|