@ikuma.cloud/pix-mcp 0.0.8 → 0.0.10
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 +154 -63
- package/mcp.schema.json +1 -1
- package/package.json +4 -2
- package/src/client.ts +335 -9
- package/src/command.ts +69 -0
- package/src/config.ts +51 -6
- package/src/index.ts +323 -25
- package/src/output.ts +9 -10
- package/src/prompts.ts +18 -70
- package/src/resource-picker.ts +157 -0
- package/src/resources.ts +769 -0
package/src/resources.ts
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { mediaTypeEssence } from "@modelcontextprotocol/sdk/shared/mediaType.js";
|
|
2
|
+
import { StdUriTemplate } from "@std-uritemplate/std-uritemplate";
|
|
3
|
+
import fastUri from "fast-uri";
|
|
4
|
+
import type {
|
|
5
|
+
ContentBlock,
|
|
6
|
+
ReadResourceResult,
|
|
7
|
+
Resource,
|
|
8
|
+
ResourceTemplate,
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { compact } from "./catalog.ts";
|
|
11
|
+
import {
|
|
12
|
+
quoteCommandArgument,
|
|
13
|
+
renderCommandToken,
|
|
14
|
+
tokenizeCommand,
|
|
15
|
+
type CommandToken,
|
|
16
|
+
} from "./command.ts";
|
|
17
|
+
import { formatContent } from "./output.ts";
|
|
18
|
+
|
|
19
|
+
export interface DirectResourceEntry {
|
|
20
|
+
kind: "resource";
|
|
21
|
+
server: string;
|
|
22
|
+
resource: Resource;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TemplateResourceEntry {
|
|
26
|
+
kind: "template";
|
|
27
|
+
server: string;
|
|
28
|
+
template: ResourceTemplate;
|
|
29
|
+
variables: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ResourceEntry = DirectResourceEntry | TemplateResourceEntry;
|
|
33
|
+
export type ResourceArgumentToken = CommandToken;
|
|
34
|
+
|
|
35
|
+
export type ResourceCommand =
|
|
36
|
+
| { action: "list"; server?: string }
|
|
37
|
+
| {
|
|
38
|
+
action: "read";
|
|
39
|
+
server: string;
|
|
40
|
+
target: string;
|
|
41
|
+
argumentTokens: ResourceArgumentToken[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const MAX_IDENTIFIER_BYTES = 16 * 1024;
|
|
45
|
+
const MAX_METADATA_BYTES = 16 * 1024;
|
|
46
|
+
const MAX_MIME_TYPE_BYTES = 256;
|
|
47
|
+
const MAX_VARIABLES = 100;
|
|
48
|
+
const MAX_ANNOTATION_AUDIENCE = 2;
|
|
49
|
+
const MAX_EXPANSION_WORK_BYTES = 1024 * 1024;
|
|
50
|
+
const templateLiteralCharacters = new Set(
|
|
51
|
+
"!#$&()*+,-./0123456789:;=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]_abcdefghijklmnopqrstuvwxyz~",
|
|
52
|
+
);
|
|
53
|
+
const unsafeIdentifier = /[\p{C}\p{Zl}\p{Zp}]/u;
|
|
54
|
+
const unsafeTemplateCharacter = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
|
|
55
|
+
const variableName =
|
|
56
|
+
/^(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})+(?:\.(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})+)*$/u;
|
|
57
|
+
|
|
58
|
+
function validIdentifier(value: string, maximum = MAX_IDENTIFIER_BYTES) {
|
|
59
|
+
const bytes = Buffer.byteLength(value);
|
|
60
|
+
return bytes > 0 && bytes <= maximum && !unsafeIdentifier.test(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateCommonMetadata(
|
|
64
|
+
item: Pick<
|
|
65
|
+
Resource | ResourceTemplate,
|
|
66
|
+
"name" | "title" | "description" | "mimeType" | "annotations"
|
|
67
|
+
>,
|
|
68
|
+
) {
|
|
69
|
+
if (
|
|
70
|
+
!validIdentifier(item.name) ||
|
|
71
|
+
Buffer.byteLength(item.title ?? "") > MAX_METADATA_BYTES ||
|
|
72
|
+
Buffer.byteLength(item.description ?? "") > MAX_METADATA_BYTES ||
|
|
73
|
+
(item.mimeType !== undefined &&
|
|
74
|
+
!validIdentifier(item.mimeType, MAX_MIME_TYPE_BYTES)) ||
|
|
75
|
+
(item.annotations?.audience?.length ?? 0) > MAX_ANNOTATION_AUDIENCE
|
|
76
|
+
)
|
|
77
|
+
throw new Error("Invalid resource metadata");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function validateResourceUri(uri: string): void {
|
|
81
|
+
if (
|
|
82
|
+
!validIdentifier(uri) ||
|
|
83
|
+
!/^[\x21-\x7e]+$/u.test(uri) ||
|
|
84
|
+
/[^A-Za-z0-9._~:/?#\u005b\u005d@!$&'()*+,;=%-]/u.test(uri) ||
|
|
85
|
+
/%(?![0-9A-Fa-f]{2})/u.test(uri) ||
|
|
86
|
+
(uri.match(/#/gu)?.length ?? 0) > 1
|
|
87
|
+
)
|
|
88
|
+
throw new Error("Invalid MCP resource URI.");
|
|
89
|
+
const parsed = fastUri.parse(uri);
|
|
90
|
+
const afterScheme = uri.slice(uri.indexOf(":") + 1);
|
|
91
|
+
const authorityEnd = afterScheme.startsWith("//")
|
|
92
|
+
? afterScheme.slice(2).search(/[/?#]/u)
|
|
93
|
+
: -1;
|
|
94
|
+
const outsideAuthority = afterScheme.startsWith("//")
|
|
95
|
+
? authorityEnd < 0
|
|
96
|
+
? ""
|
|
97
|
+
: afterScheme.slice(authorityEnd + 2)
|
|
98
|
+
: afterScheme;
|
|
99
|
+
if (
|
|
100
|
+
parsed.error ||
|
|
101
|
+
parsed.scheme === undefined ||
|
|
102
|
+
parsed.userinfo !== undefined ||
|
|
103
|
+
outsideAuthority.includes("[") ||
|
|
104
|
+
outsideAuthority.includes("]")
|
|
105
|
+
)
|
|
106
|
+
throw new Error("Invalid MCP resource URI.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validTemplateUnicode(codePoint: number): boolean {
|
|
110
|
+
return (
|
|
111
|
+
(codePoint >= 0xa0 && codePoint <= 0xd7ff) ||
|
|
112
|
+
(codePoint >= 0xf900 && codePoint <= 0xfdcf) ||
|
|
113
|
+
(codePoint >= 0xfdf0 && codePoint <= 0xffef) ||
|
|
114
|
+
(codePoint >= 0x1_0000 && codePoint <= 0x1_fffd) ||
|
|
115
|
+
(codePoint >= 0x2_0000 && codePoint <= 0x2_fffd) ||
|
|
116
|
+
(codePoint >= 0x3_0000 && codePoint <= 0x3_fffd) ||
|
|
117
|
+
(codePoint >= 0x4_0000 && codePoint <= 0x4_fffd) ||
|
|
118
|
+
(codePoint >= 0x5_0000 && codePoint <= 0x5_fffd) ||
|
|
119
|
+
(codePoint >= 0x6_0000 && codePoint <= 0x6_fffd) ||
|
|
120
|
+
(codePoint >= 0x7_0000 && codePoint <= 0x7_fffd) ||
|
|
121
|
+
(codePoint >= 0x8_0000 && codePoint <= 0x8_fffd) ||
|
|
122
|
+
(codePoint >= 0x9_0000 && codePoint <= 0x9_fffd) ||
|
|
123
|
+
(codePoint >= 0xa_0000 && codePoint <= 0xa_fffd) ||
|
|
124
|
+
(codePoint >= 0xb_0000 && codePoint <= 0xb_fffd) ||
|
|
125
|
+
(codePoint >= 0xc_0000 && codePoint <= 0xc_fffd) ||
|
|
126
|
+
(codePoint >= 0xd_0000 && codePoint <= 0xd_fffd) ||
|
|
127
|
+
(codePoint >= 0xe_1000 && codePoint <= 0xe_fffd) ||
|
|
128
|
+
(codePoint >= 0xe000 && codePoint <= 0xf8ff) ||
|
|
129
|
+
(codePoint >= 0xf_0000 && codePoint <= 0xf_fffd) ||
|
|
130
|
+
(codePoint >= 0x10_0000 && codePoint <= 0x10_fffd)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateTemplateLiteral(literal: string): void {
|
|
135
|
+
for (let index = 0; index < literal.length;) {
|
|
136
|
+
const codePoint = literal.codePointAt(index);
|
|
137
|
+
const character =
|
|
138
|
+
codePoint === undefined ? "" : String.fromCodePoint(codePoint);
|
|
139
|
+
if (character === "%") {
|
|
140
|
+
if (!/^[0-9A-Fa-f]{2}$/u.test(literal.slice(index + 1, index + 3)))
|
|
141
|
+
throw new Error("Invalid resource URI template");
|
|
142
|
+
index += 3;
|
|
143
|
+
} else {
|
|
144
|
+
if (
|
|
145
|
+
!character ||
|
|
146
|
+
(!templateLiteralCharacters.has(character) &&
|
|
147
|
+
!validTemplateUnicode(codePoint ?? -1))
|
|
148
|
+
)
|
|
149
|
+
throw new Error("Invalid resource URI template");
|
|
150
|
+
index += character.length;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function uriTemplatePrefix(value: string, maximum: number): string {
|
|
156
|
+
let end = 0;
|
|
157
|
+
let count = 0;
|
|
158
|
+
while (end < value.length && count < maximum) {
|
|
159
|
+
if (
|
|
160
|
+
value[end] === "%" &&
|
|
161
|
+
/^[0-9A-Fa-f]{2}$/u.test(value.slice(end + 1, end + 3))
|
|
162
|
+
)
|
|
163
|
+
end += 3;
|
|
164
|
+
else {
|
|
165
|
+
const codePoint = value.codePointAt(end);
|
|
166
|
+
if (codePoint === undefined) break;
|
|
167
|
+
end += String.fromCodePoint(codePoint).length;
|
|
168
|
+
}
|
|
169
|
+
count++;
|
|
170
|
+
}
|
|
171
|
+
return value.slice(0, end);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function encodeTemplateValue(value: string, allowReserved: boolean): string {
|
|
175
|
+
let encoded = "";
|
|
176
|
+
for (let index = 0; index < value.length;) {
|
|
177
|
+
if (
|
|
178
|
+
allowReserved &&
|
|
179
|
+
value[index] === "%" &&
|
|
180
|
+
/^[0-9A-Fa-f]{2}$/u.test(value.slice(index + 1, index + 3))
|
|
181
|
+
) {
|
|
182
|
+
encoded += value.slice(index, index + 3);
|
|
183
|
+
index += 3;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const codePoint = value.codePointAt(index);
|
|
187
|
+
if (codePoint === undefined) break;
|
|
188
|
+
const character = String.fromCodePoint(codePoint);
|
|
189
|
+
if (
|
|
190
|
+
/^[A-Za-z0-9._~-]$/u.test(character) ||
|
|
191
|
+
(allowReserved && ":/?#[]@!$&'()*+,;=".includes(character))
|
|
192
|
+
)
|
|
193
|
+
encoded += character;
|
|
194
|
+
else
|
|
195
|
+
encoded += encodeURIComponent(character).replace(
|
|
196
|
+
/[!'()*]/gu,
|
|
197
|
+
(reserved) => `%${reserved.codePointAt(0)?.toString(16).toUpperCase()}`,
|
|
198
|
+
);
|
|
199
|
+
index += character.length;
|
|
200
|
+
}
|
|
201
|
+
return encoded;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function expandPrefixedExpression(
|
|
205
|
+
expression: string,
|
|
206
|
+
substitutions: Record<string, string | undefined>,
|
|
207
|
+
): string {
|
|
208
|
+
const candidate = expression[1] ?? "";
|
|
209
|
+
const operator = "+#./;?&".includes(candidate) ? candidate : "";
|
|
210
|
+
const body = expression.slice(operator ? 2 : 1, -1);
|
|
211
|
+
const expanded: string[] = [];
|
|
212
|
+
for (let specification of body.split(",")) {
|
|
213
|
+
if (specification.endsWith("*")) specification = specification.slice(0, -1);
|
|
214
|
+
const prefix = specification.match(/:([1-9][0-9]{0,3})$/u);
|
|
215
|
+
const name = prefix
|
|
216
|
+
? specification.slice(0, -prefix[0].length)
|
|
217
|
+
: specification;
|
|
218
|
+
const original = substitutions[name];
|
|
219
|
+
if (original === undefined) continue;
|
|
220
|
+
const value = prefix?.[1]
|
|
221
|
+
? uriTemplatePrefix(original, Number(prefix[1]))
|
|
222
|
+
: original;
|
|
223
|
+
const encoded = encodeTemplateValue(
|
|
224
|
+
value,
|
|
225
|
+
operator === "+" || operator === "#",
|
|
226
|
+
);
|
|
227
|
+
if (operator === ";") expanded.push(encoded ? `${name}=${encoded}` : name);
|
|
228
|
+
else if (operator === "?" || operator === "&")
|
|
229
|
+
expanded.push(`${name}=${encoded}`);
|
|
230
|
+
else expanded.push(encoded);
|
|
231
|
+
}
|
|
232
|
+
if (expanded.length === 0) return "";
|
|
233
|
+
const first =
|
|
234
|
+
operator === "#" ||
|
|
235
|
+
operator === "." ||
|
|
236
|
+
operator === "/" ||
|
|
237
|
+
operator === ";" ||
|
|
238
|
+
operator === "?" ||
|
|
239
|
+
operator === "&"
|
|
240
|
+
? operator
|
|
241
|
+
: "";
|
|
242
|
+
const separator =
|
|
243
|
+
operator === "." || operator === "/" || operator === ";"
|
|
244
|
+
? operator
|
|
245
|
+
: operator === "?" || operator === "&"
|
|
246
|
+
? "&"
|
|
247
|
+
: ",";
|
|
248
|
+
return `${first}${expanded.join(separator)}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function expandUriTemplate(
|
|
252
|
+
template: string,
|
|
253
|
+
substitutions: Record<string, string | undefined>,
|
|
254
|
+
): string {
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
let cursor = 0;
|
|
257
|
+
while (cursor < template.length) {
|
|
258
|
+
const start = template.indexOf("{", cursor);
|
|
259
|
+
if (start < 0) {
|
|
260
|
+
parts.push(
|
|
261
|
+
StdUriTemplate.expand(template.slice(cursor), Object.create(null)),
|
|
262
|
+
);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
parts.push(
|
|
266
|
+
StdUriTemplate.expand(template.slice(cursor, start), Object.create(null)),
|
|
267
|
+
);
|
|
268
|
+
const end = template.indexOf("}", start + 1);
|
|
269
|
+
if (end < 0) throw new Error("Invalid resource URI template");
|
|
270
|
+
const expression = template.slice(start, end + 1);
|
|
271
|
+
const operator = expression[1];
|
|
272
|
+
let expanded = /:[1-9][0-9]{0,3}(?=[,}])/u.test(expression)
|
|
273
|
+
? expandPrefixedExpression(expression, substitutions)
|
|
274
|
+
: StdUriTemplate.expand(expression, substitutions);
|
|
275
|
+
if (
|
|
276
|
+
operator !== "+" &&
|
|
277
|
+
operator !== "#" &&
|
|
278
|
+
!/:[1-9][0-9]{0,3}(?=[,}])/u.test(expression)
|
|
279
|
+
) {
|
|
280
|
+
// encodeURIComponent intentionally leaves these reserved characters raw.
|
|
281
|
+
expanded = expanded.replace(
|
|
282
|
+
/[!'()*]/gu,
|
|
283
|
+
(character) =>
|
|
284
|
+
`%${character.codePointAt(0)?.toString(16).toUpperCase()}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
parts.push(expanded);
|
|
288
|
+
cursor = end + 1;
|
|
289
|
+
}
|
|
290
|
+
return parts.join("");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function validateResource(resource: Resource): void {
|
|
294
|
+
validateCommonMetadata(resource);
|
|
295
|
+
try {
|
|
296
|
+
validateResourceUri(resource.uri);
|
|
297
|
+
} catch {
|
|
298
|
+
throw new Error("Invalid resource metadata");
|
|
299
|
+
}
|
|
300
|
+
if (
|
|
301
|
+
resource.size !== undefined &&
|
|
302
|
+
(!Number.isSafeInteger(resource.size) || resource.size < 0)
|
|
303
|
+
)
|
|
304
|
+
throw new Error("Invalid resource metadata");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function resourceTemplateVariables(template: string): string[] {
|
|
308
|
+
const templateBytes = Buffer.byteLength(template);
|
|
309
|
+
if (
|
|
310
|
+
templateBytes === 0 ||
|
|
311
|
+
templateBytes > MAX_IDENTIFIER_BYTES ||
|
|
312
|
+
unsafeTemplateCharacter.test(template)
|
|
313
|
+
)
|
|
314
|
+
throw new Error("Invalid resource template metadata");
|
|
315
|
+
const variables: string[] = [];
|
|
316
|
+
const seen = new Set<string>();
|
|
317
|
+
let cursor = 0;
|
|
318
|
+
while (cursor < template.length) {
|
|
319
|
+
const start = template.indexOf("{", cursor);
|
|
320
|
+
const strayClose = template.indexOf("}", cursor);
|
|
321
|
+
if (start < 0) {
|
|
322
|
+
if (strayClose >= 0) throw new Error("Invalid resource URI template");
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
if (strayClose >= 0 && strayClose < start)
|
|
326
|
+
throw new Error("Invalid resource URI template");
|
|
327
|
+
validateTemplateLiteral(template.slice(cursor, start));
|
|
328
|
+
const end = template.indexOf("}", start + 1);
|
|
329
|
+
if (end < 0 || template.slice(start + 1, end).includes("{"))
|
|
330
|
+
throw new Error("Invalid resource URI template");
|
|
331
|
+
let expression = template.slice(start + 1, end);
|
|
332
|
+
if (/^[+#./;?&]/u.test(expression)) expression = expression.slice(1);
|
|
333
|
+
const specifications = expression.split(",");
|
|
334
|
+
if (specifications.some((specification) => specification.length === 0))
|
|
335
|
+
throw new Error("Invalid resource URI template");
|
|
336
|
+
for (let specification of specifications) {
|
|
337
|
+
if (specification.endsWith("*"))
|
|
338
|
+
specification = specification.slice(0, -1);
|
|
339
|
+
else {
|
|
340
|
+
const prefix = specification.match(/:([1-9][0-9]{0,3})$/u);
|
|
341
|
+
if (prefix) specification = specification.slice(0, -prefix[0].length);
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
!variableName.test(specification) ||
|
|
345
|
+
Buffer.byteLength(specification) > 256
|
|
346
|
+
)
|
|
347
|
+
throw new Error("Invalid resource URI template");
|
|
348
|
+
if (!seen.has(specification)) {
|
|
349
|
+
if (variables.length >= MAX_VARIABLES)
|
|
350
|
+
throw new Error("Resource URI template has too many variables");
|
|
351
|
+
seen.add(specification);
|
|
352
|
+
variables.push(specification);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
cursor = end + 1;
|
|
356
|
+
}
|
|
357
|
+
validateTemplateLiteral(template.slice(cursor));
|
|
358
|
+
try {
|
|
359
|
+
expandUriTemplate(template, Object.create(null));
|
|
360
|
+
} catch {
|
|
361
|
+
throw new Error("Invalid resource URI template");
|
|
362
|
+
}
|
|
363
|
+
return variables;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function validateResourceTemplate(template: ResourceTemplate): string[] {
|
|
367
|
+
validateCommonMetadata(template);
|
|
368
|
+
return resourceTemplateVariables(template.uriTemplate);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function resourceKey(
|
|
372
|
+
server: string,
|
|
373
|
+
kind: ResourceEntry["kind"],
|
|
374
|
+
identifier: string,
|
|
375
|
+
): string {
|
|
376
|
+
return JSON.stringify([server, kind, identifier]);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function resourceEntryKey(entry: ResourceEntry): string {
|
|
380
|
+
return resourceKey(
|
|
381
|
+
entry.server,
|
|
382
|
+
entry.kind,
|
|
383
|
+
entry.kind === "resource" ? entry.resource.uri : entry.template.uriTemplate,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function parseResourceCommand(input: string): ResourceCommand {
|
|
388
|
+
const tokens = tokenizeCommand(input, "resource");
|
|
389
|
+
const action = tokens.shift()?.value;
|
|
390
|
+
if (action === "list") {
|
|
391
|
+
if (tokens.length > 1)
|
|
392
|
+
throw new Error("Usage: /mcp-resource list [server]");
|
|
393
|
+
const server = tokens[0]?.value;
|
|
394
|
+
return { action, ...(server ? { server } : {}) };
|
|
395
|
+
}
|
|
396
|
+
if (action === "read") {
|
|
397
|
+
const server = tokens.shift()?.value;
|
|
398
|
+
const target = tokens.shift()?.value;
|
|
399
|
+
if (!server || !target)
|
|
400
|
+
throw new Error(
|
|
401
|
+
"Usage: /mcp-resource read <server> <uri-or-template> [name=value ...]",
|
|
402
|
+
);
|
|
403
|
+
return { action, server, target, argumentTokens: tokens };
|
|
404
|
+
}
|
|
405
|
+
throw new Error(
|
|
406
|
+
"Usage: /mcp-resource list [server] | /mcp-resource read <server> <uri-or-template> [name=value ...]",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function expandedUriUpperBound(
|
|
411
|
+
template: string,
|
|
412
|
+
values: Record<string, string>,
|
|
413
|
+
): number {
|
|
414
|
+
let bytes = 0;
|
|
415
|
+
let offset = 0;
|
|
416
|
+
for (const match of template.matchAll(/\{[+#./;?&]?([^{}]+)\}/gu)) {
|
|
417
|
+
bytes += Buffer.byteLength(template.slice(offset, match.index));
|
|
418
|
+
offset = match.index + match[0].length;
|
|
419
|
+
for (let specification of match[1]?.split(",") ?? []) {
|
|
420
|
+
let prefixLength: number | undefined;
|
|
421
|
+
if (specification.endsWith("*"))
|
|
422
|
+
specification = specification.slice(0, -1);
|
|
423
|
+
else {
|
|
424
|
+
const prefix = specification.match(/:([1-9][0-9]{0,3})$/u);
|
|
425
|
+
if (prefix?.[1]) {
|
|
426
|
+
prefixLength = Number(prefix[1]);
|
|
427
|
+
specification = specification.slice(0, -prefix[0].length);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const value = values[specification];
|
|
431
|
+
if (value === undefined) continue;
|
|
432
|
+
const boundedValue =
|
|
433
|
+
prefixLength === undefined
|
|
434
|
+
? value
|
|
435
|
+
: uriTemplatePrefix(value, prefixLength);
|
|
436
|
+
// encodeURIComponent leaves five RFC 3986 reserved characters unchanged;
|
|
437
|
+
// count them as percent-encoded to bound every RFC 6570 operator.
|
|
438
|
+
const encoded = encodeURIComponent(boundedValue).replace(
|
|
439
|
+
/[!'()*]/gu,
|
|
440
|
+
"xxx",
|
|
441
|
+
);
|
|
442
|
+
bytes += encoded.length + Buffer.byteLength(specification) * 3 + 16;
|
|
443
|
+
if (bytes > MAX_EXPANSION_WORK_BYTES) return bytes;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return bytes + Buffer.byteLength(template.slice(offset));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function resolveResourceUri(
|
|
450
|
+
target: string,
|
|
451
|
+
template: TemplateResourceEntry | undefined,
|
|
452
|
+
tokens: (ResourceArgumentToken | string)[],
|
|
453
|
+
): string {
|
|
454
|
+
if (!template) {
|
|
455
|
+
if (tokens.length > 0)
|
|
456
|
+
throw new Error(
|
|
457
|
+
"Resource arguments require an exact URI template from the catalog.",
|
|
458
|
+
);
|
|
459
|
+
validateResourceUri(target);
|
|
460
|
+
return target;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const declared = new Set(template.variables);
|
|
464
|
+
const named = new Map<string, string>();
|
|
465
|
+
const positional: string[] = [];
|
|
466
|
+
for (const token of tokens) {
|
|
467
|
+
const value = typeof token === "string" ? token : token.value;
|
|
468
|
+
const separator =
|
|
469
|
+
typeof token === "string" ? token.indexOf("=") : token.separator;
|
|
470
|
+
const candidate = value.slice(0, separator);
|
|
471
|
+
if (separator !== undefined && separator > 0 && declared.has(candidate)) {
|
|
472
|
+
if (named.has(candidate))
|
|
473
|
+
throw new Error(
|
|
474
|
+
`Resource template variable ${candidate} was provided more than once.`,
|
|
475
|
+
);
|
|
476
|
+
named.set(candidate, value.slice(separator + 1));
|
|
477
|
+
} else positional.push(value);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const values = Object.create(null) as Record<string, string>;
|
|
481
|
+
let position = 0;
|
|
482
|
+
for (const name of template.variables) {
|
|
483
|
+
const namedValue = named.get(name);
|
|
484
|
+
const value = namedValue ?? positional[position];
|
|
485
|
+
if (namedValue === undefined && value !== undefined) position++;
|
|
486
|
+
if (value !== undefined) values[name] = value;
|
|
487
|
+
named.delete(name);
|
|
488
|
+
}
|
|
489
|
+
if (position < positional.length)
|
|
490
|
+
throw new Error("Too many positional resource arguments were provided.");
|
|
491
|
+
if (Buffer.byteLength(JSON.stringify(values)) > 256 * 1024)
|
|
492
|
+
throw new Error("MCP resource template arguments exceed 256 KiB.");
|
|
493
|
+
try {
|
|
494
|
+
// This loose preflight bounds allocation amplification from repeated
|
|
495
|
+
// variables; the exact 16 KiB result limit is enforced after expansion.
|
|
496
|
+
if (
|
|
497
|
+
expandedUriUpperBound(template.template.uriTemplate, values) >
|
|
498
|
+
MAX_EXPANSION_WORK_BYTES
|
|
499
|
+
)
|
|
500
|
+
throw new Error();
|
|
501
|
+
} catch {
|
|
502
|
+
throw new Error("Expanded MCP resource URI exceeds 16 KiB.");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let uri: string;
|
|
506
|
+
try {
|
|
507
|
+
uri = expandUriTemplate(template.template.uriTemplate, values);
|
|
508
|
+
} catch {
|
|
509
|
+
throw new Error("Could not expand the MCP resource URI template.");
|
|
510
|
+
}
|
|
511
|
+
if (Buffer.byteLength(uri) > MAX_IDENTIFIER_BYTES)
|
|
512
|
+
throw new Error("Expanded MCP resource URI exceeds 16 KiB.");
|
|
513
|
+
try {
|
|
514
|
+
validateResourceUri(uri);
|
|
515
|
+
} catch {
|
|
516
|
+
throw new Error("Expanded MCP resource URI is invalid.");
|
|
517
|
+
}
|
|
518
|
+
return uri;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function entryTarget(entry: ResourceEntry): string {
|
|
522
|
+
return entry.kind === "resource"
|
|
523
|
+
? entry.resource.uri
|
|
524
|
+
: entry.template.uriTemplate;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function entryMetadata(entry: ResourceEntry) {
|
|
528
|
+
return entry.kind === "resource" ? entry.resource : entry.template;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function formatResourceList(
|
|
532
|
+
entries: ResourceEntry[],
|
|
533
|
+
server?: string,
|
|
534
|
+
): string {
|
|
535
|
+
const selected = entries
|
|
536
|
+
.filter((entry) => !server || entry.server === server)
|
|
537
|
+
.sort(
|
|
538
|
+
(a, b) =>
|
|
539
|
+
a.server.localeCompare(b.server, "en") ||
|
|
540
|
+
entryTarget(a).localeCompare(entryTarget(b), "en") ||
|
|
541
|
+
a.kind.localeCompare(b.kind, "en"),
|
|
542
|
+
);
|
|
543
|
+
if (selected.length === 0)
|
|
544
|
+
return server
|
|
545
|
+
? `No MCP resources are available from ${server}.`
|
|
546
|
+
: "No MCP resources are available.";
|
|
547
|
+
const lines = ["Available MCP resources:"];
|
|
548
|
+
let shown = 0;
|
|
549
|
+
for (const entry of selected) {
|
|
550
|
+
const metadata = entryMetadata(entry);
|
|
551
|
+
const description = compact(
|
|
552
|
+
metadata.description ?? metadata.title ?? metadata.name,
|
|
553
|
+
160,
|
|
554
|
+
);
|
|
555
|
+
const variables =
|
|
556
|
+
entry.kind === "template" && entry.variables.length > 0
|
|
557
|
+
? ` ${entry.variables.map((name) => `[${quoteCommandArgument(name)}]`).join(" ")}`
|
|
558
|
+
: "";
|
|
559
|
+
const line = `${entry.server} ${entry.kind} ${quoteCommandArgument(entryTarget(entry))}${variables}${description ? ` — ${description}` : ""}`;
|
|
560
|
+
if (shown >= 100 || lines.join("\n").length + line.length > 15_000) {
|
|
561
|
+
lines.push(`${selected.length - shown} additional resources omitted.`);
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
lines.push(line);
|
|
565
|
+
shown++;
|
|
566
|
+
}
|
|
567
|
+
lines.push(
|
|
568
|
+
"Read one with /mcp-resource read <server> <uri-or-template> [name=value ...].",
|
|
569
|
+
);
|
|
570
|
+
return lines.join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function completionItems(values: string[], prefix: string) {
|
|
574
|
+
return values.map((value) => ({
|
|
575
|
+
value: `${prefix}${quoteCommandArgument(value)}`,
|
|
576
|
+
label: value,
|
|
577
|
+
}));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function resourceCompletions(
|
|
581
|
+
prefix: string,
|
|
582
|
+
entries: ResourceEntry[],
|
|
583
|
+
configuredServers: Iterable<string> = [],
|
|
584
|
+
): { value: string; label: string; description?: string }[] | null {
|
|
585
|
+
const trailingSpace = /\s$/u.test(prefix);
|
|
586
|
+
let tokens: ResourceArgumentToken[];
|
|
587
|
+
try {
|
|
588
|
+
tokens = tokenizeCommand(prefix);
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
if (tokens.length === 0)
|
|
593
|
+
return [
|
|
594
|
+
{ value: "list", label: "list", description: "List MCP resources" },
|
|
595
|
+
{ value: "read", label: "read", description: "Read an MCP resource" },
|
|
596
|
+
];
|
|
597
|
+
if (tokens.length === 1 && !trailingSpace)
|
|
598
|
+
return ["list", "read"]
|
|
599
|
+
.filter((action) => action.startsWith(tokens[0]?.value ?? ""))
|
|
600
|
+
.map((action) => ({ value: action, label: action }));
|
|
601
|
+
const action = tokens[0]?.value;
|
|
602
|
+
if (action !== "list" && action !== "read") return null;
|
|
603
|
+
const servers = [
|
|
604
|
+
...new Set([...configuredServers, ...entries.map((entry) => entry.server)]),
|
|
605
|
+
].sort();
|
|
606
|
+
if (tokens.length === 1 || (tokens.length === 2 && !trailingSpace)) {
|
|
607
|
+
const current = trailingSpace ? "" : (tokens[1]?.value ?? "");
|
|
608
|
+
return completionItems(
|
|
609
|
+
servers.filter((server) => server.startsWith(current)),
|
|
610
|
+
`${action} `,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
if (action === "list") return null;
|
|
614
|
+
const server = tokens[1]?.value;
|
|
615
|
+
if (!server || !servers.includes(server)) return null;
|
|
616
|
+
if (
|
|
617
|
+
(tokens.length === 2 && trailingSpace) ||
|
|
618
|
+
(tokens.length === 3 && !trailingSpace)
|
|
619
|
+
) {
|
|
620
|
+
const current = trailingSpace ? "" : (tokens[2]?.value ?? "");
|
|
621
|
+
return entries
|
|
622
|
+
.filter(
|
|
623
|
+
(entry) =>
|
|
624
|
+
entry.server === server && entryTarget(entry).startsWith(current),
|
|
625
|
+
)
|
|
626
|
+
.sort((a, b) => entryTarget(a).localeCompare(entryTarget(b), "en"))
|
|
627
|
+
.map((entry) => {
|
|
628
|
+
const metadata = entryMetadata(entry);
|
|
629
|
+
return {
|
|
630
|
+
value: `read ${quoteCommandArgument(server)} ${quoteCommandArgument(entryTarget(entry))}`,
|
|
631
|
+
label: compact(entryTarget(entry), 180),
|
|
632
|
+
...(metadata.description || metadata.title
|
|
633
|
+
? {
|
|
634
|
+
description: compact(
|
|
635
|
+
metadata.description ?? metadata.title ?? "",
|
|
636
|
+
100,
|
|
637
|
+
),
|
|
638
|
+
}
|
|
639
|
+
: {}),
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const target = tokens[2]?.value;
|
|
644
|
+
if (!target) return null;
|
|
645
|
+
const template = entries.find(
|
|
646
|
+
(entry): entry is TemplateResourceEntry =>
|
|
647
|
+
entry.kind === "template" &&
|
|
648
|
+
entry.server === server &&
|
|
649
|
+
entry.template.uriTemplate === target,
|
|
650
|
+
);
|
|
651
|
+
if (!template) return null;
|
|
652
|
+
const supplied = tokens.slice(3);
|
|
653
|
+
const currentToken = trailingSpace ? undefined : supplied.pop();
|
|
654
|
+
const current = currentToken?.value ?? "";
|
|
655
|
+
if (currentToken?.separator !== undefined) return null;
|
|
656
|
+
const named = new Set<string>();
|
|
657
|
+
let positional = 0;
|
|
658
|
+
for (const token of supplied) {
|
|
659
|
+
const candidate = token.value.slice(0, token.separator);
|
|
660
|
+
if (
|
|
661
|
+
token.separator !== undefined &&
|
|
662
|
+
token.separator > 0 &&
|
|
663
|
+
template.variables.includes(candidate)
|
|
664
|
+
)
|
|
665
|
+
named.add(candidate);
|
|
666
|
+
else positional++;
|
|
667
|
+
}
|
|
668
|
+
const satisfied = new Set<string>();
|
|
669
|
+
let position = 0;
|
|
670
|
+
for (const name of template.variables) {
|
|
671
|
+
if (named.has(name)) satisfied.add(name);
|
|
672
|
+
else if (position < positional) {
|
|
673
|
+
satisfied.add(name);
|
|
674
|
+
position++;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const base = [
|
|
678
|
+
"read",
|
|
679
|
+
quoteCommandArgument(server),
|
|
680
|
+
quoteCommandArgument(target),
|
|
681
|
+
...supplied.map(renderCommandToken),
|
|
682
|
+
].join(" ");
|
|
683
|
+
const variables = template.variables.filter(
|
|
684
|
+
(name) => !satisfied.has(name) && name.startsWith(current),
|
|
685
|
+
);
|
|
686
|
+
return variables.length > 0
|
|
687
|
+
? variables.map((name) => ({
|
|
688
|
+
value: `${base} ${quoteCommandArgument(name)}=`,
|
|
689
|
+
label: `${name}=`,
|
|
690
|
+
}))
|
|
691
|
+
: null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function sanitizeResourceText(text: string): string {
|
|
695
|
+
return text
|
|
696
|
+
.replace(/\r\n?/gu, "\n")
|
|
697
|
+
.replace(/\p{Cc}/gu, (character) =>
|
|
698
|
+
character === "\n" || character === "\t" ? character : "",
|
|
699
|
+
)
|
|
700
|
+
.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function resourceArtifact(result: ReadResourceResult): ReadResourceResult {
|
|
704
|
+
const artifact = {
|
|
705
|
+
...result,
|
|
706
|
+
contents: result.contents.map((content) => ({ ...content })),
|
|
707
|
+
};
|
|
708
|
+
delete artifact._meta;
|
|
709
|
+
for (const content of artifact.contents) delete content._meta;
|
|
710
|
+
return artifact;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export async function formatResourceResult(
|
|
714
|
+
server: string,
|
|
715
|
+
result: ReadResourceResult,
|
|
716
|
+
) {
|
|
717
|
+
if (result.contents.length === 0)
|
|
718
|
+
throw new Error("MCP resource returned no content.");
|
|
719
|
+
if (result.contents.length > 100)
|
|
720
|
+
throw new Error("MCP resource returned too many content items.");
|
|
721
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 16 * 1024 * 1024)
|
|
722
|
+
throw new Error("MCP resource result exceeds 16 MiB.");
|
|
723
|
+
|
|
724
|
+
const artifact = resourceArtifact(result);
|
|
725
|
+
const blocks: ContentBlock[] = [];
|
|
726
|
+
let sanitized = false;
|
|
727
|
+
for (const content of artifact.contents) {
|
|
728
|
+
try {
|
|
729
|
+
validateResourceUri(content.uri);
|
|
730
|
+
} catch {
|
|
731
|
+
throw new Error("MCP resource returned invalid metadata.");
|
|
732
|
+
}
|
|
733
|
+
if (
|
|
734
|
+
content.mimeType !== undefined &&
|
|
735
|
+
!validIdentifier(content.mimeType, MAX_MIME_TYPE_BYTES)
|
|
736
|
+
)
|
|
737
|
+
throw new Error("MCP resource returned invalid metadata.");
|
|
738
|
+
const marker = `[MCP resource ${JSON.stringify({
|
|
739
|
+
server,
|
|
740
|
+
uri: content.uri,
|
|
741
|
+
...(content.mimeType ? { mimeType: content.mimeType } : {}),
|
|
742
|
+
})}]`;
|
|
743
|
+
blocks.push({ type: "text", text: marker });
|
|
744
|
+
if ("text" in content) {
|
|
745
|
+
const text = sanitizeResourceText(content.text);
|
|
746
|
+
if (text !== content.text) sanitized = true;
|
|
747
|
+
blocks.push({ type: "text", text });
|
|
748
|
+
} else {
|
|
749
|
+
const mimeType = mediaTypeEssence(content.mimeType);
|
|
750
|
+
if (
|
|
751
|
+
mimeType &&
|
|
752
|
+
["image/png", "image/jpeg", "image/gif", "image/webp"].includes(
|
|
753
|
+
mimeType,
|
|
754
|
+
)
|
|
755
|
+
) {
|
|
756
|
+
blocks.push({
|
|
757
|
+
type: "image",
|
|
758
|
+
data: content.blob,
|
|
759
|
+
mimeType,
|
|
760
|
+
});
|
|
761
|
+
} else blocks.push({ type: "resource", resource: content });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return formatContent(blocks, {
|
|
765
|
+
artifact,
|
|
766
|
+
preserveOrder: true,
|
|
767
|
+
artifactRequired: sanitized,
|
|
768
|
+
});
|
|
769
|
+
}
|