@george43g/mcp-kit 2.0.0 → 3.1.0
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 +215 -2
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +82 -3
- package/dist/dispatch.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +89 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +230 -0
- package/dist/testing.js.map +1 -0
- package/dist/tool-registry.d.ts +196 -7
- package/dist/tool-registry.d.ts.map +1 -1
- package/dist/tool-registry.js +103 -17
- package/dist/tool-registry.js.map +1 -1
- package/dist/unknown-args.d.ts +39 -0
- package/dist/unknown-args.d.ts.map +1 -0
- package/dist/unknown-args.js +96 -0
- package/dist/unknown-args.js.map +1 -0
- package/package.json +13 -5
- package/src/dispatch.ts +109 -5
- package/src/index.ts +11 -1
- package/src/testing.ts +303 -0
- package/src/tool-registry.ts +287 -25
- package/src/unknown-args.ts +102 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `strictArgs` support: which argument names a tool declares, which a call
|
|
3
|
+
* sent that it does not, and "did you mean …?" for each.
|
|
4
|
+
*
|
|
5
|
+
* Internal: nothing here is re-exported from the package. A general
|
|
6
|
+
* string-suggestion helper is not what mcp-kit is for, and every export is
|
|
7
|
+
* surface to keep stable.
|
|
8
|
+
*/
|
|
9
|
+
import type { McpJsonSchema, UnknownArgument } from "./tool-registry.js";
|
|
10
|
+
/**
|
|
11
|
+
* Optimal-string-alignment distance: Levenshtein plus adjacent transposition,
|
|
12
|
+
* so `chatIdentifer` → `chatIdentifier` is 1 and `limti` → `limit` is 1.
|
|
13
|
+
*
|
|
14
|
+
* Returns `max + 1` as soon as the distance is known to exceed `max`, so a
|
|
15
|
+
* long unrelated key costs one row, not a full matrix.
|
|
16
|
+
*/
|
|
17
|
+
export declare function editDistance(a: string, b: string, max?: number): number;
|
|
18
|
+
/**
|
|
19
|
+
* The closest candidate within 2 edits, or `undefined`.
|
|
20
|
+
*
|
|
21
|
+
* Two guards against noise: the distance must also be at most half the
|
|
22
|
+
* candidate's length (so `q` does not suggest `id`), and a tie goes to the
|
|
23
|
+
* candidate listed first, so the suggestion is deterministic.
|
|
24
|
+
*/
|
|
25
|
+
export declare function closestMatch(input: string, candidates: Iterable<string>): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* The top-level argument names an input wire schema declares, or `null` when
|
|
28
|
+
* the schema declares itself open (`additionalProperties` other than `false`,
|
|
29
|
+
* `patternProperties`, or `propertyNames`), in which case no name is unknown.
|
|
30
|
+
*
|
|
31
|
+
* Read from the wire schema rather than the Zod object so that "unknown" means
|
|
32
|
+
* exactly "not advertised to the client", for every Zod shape that converts to
|
|
33
|
+
* a top-level object: a refined or transformed `z.object`, and an intersection,
|
|
34
|
+
* which Zod 4 merges into one `properties` map.
|
|
35
|
+
*/
|
|
36
|
+
export declare function declaredArgs(inputSchema: McpJsonSchema): readonly string[] | null;
|
|
37
|
+
/** Keys of `args` not in `declared`, each with its nearest declared name. */
|
|
38
|
+
export declare function findUnknownArgs(declared: readonly string[] | null, args: unknown): UnknownArgument[];
|
|
39
|
+
//# sourceMappingURL=unknown-args.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unknown-args.d.ts","sourceRoot":"","sources":["../src/unknown-args.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAEzE;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,SAA2B,GAAG,MAAM,CAqBzF;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,SAAS,CAY5F;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,WAAW,EAAE,aAAa,GAAG,SAAS,MAAM,EAAE,GAAG,IAAI,CAWjF;AAED,6EAA6E;AAC7E,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,EAClC,IAAI,EAAE,OAAO,GACZ,eAAe,EAAE,CAYnB"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `strictArgs` support: which argument names a tool declares, which a call
|
|
3
|
+
* sent that it does not, and "did you mean …?" for each.
|
|
4
|
+
*
|
|
5
|
+
* Internal: nothing here is re-exported from the package. A general
|
|
6
|
+
* string-suggestion helper is not what mcp-kit is for, and every export is
|
|
7
|
+
* surface to keep stable.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Optimal-string-alignment distance: Levenshtein plus adjacent transposition,
|
|
11
|
+
* so `chatIdentifer` → `chatIdentifier` is 1 and `limti` → `limit` is 1.
|
|
12
|
+
*
|
|
13
|
+
* Returns `max + 1` as soon as the distance is known to exceed `max`, so a
|
|
14
|
+
* long unrelated key costs one row, not a full matrix.
|
|
15
|
+
*/
|
|
16
|
+
export function editDistance(a, b, max = Number.POSITIVE_INFINITY) {
|
|
17
|
+
if (Math.abs(a.length - b.length) > max)
|
|
18
|
+
return max + 1;
|
|
19
|
+
let prev2 = [];
|
|
20
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
21
|
+
for (let i = 1; i <= a.length; i++) {
|
|
22
|
+
const cur = [i];
|
|
23
|
+
let rowMin = i;
|
|
24
|
+
for (let j = 1; j <= b.length; j++) {
|
|
25
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
26
|
+
let d = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
|
|
27
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
28
|
+
d = Math.min(d, (prev2[j - 2] ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
cur[j] = d;
|
|
31
|
+
if (d < rowMin)
|
|
32
|
+
rowMin = d;
|
|
33
|
+
}
|
|
34
|
+
if (rowMin > max)
|
|
35
|
+
return max + 1;
|
|
36
|
+
prev2 = prev;
|
|
37
|
+
prev = cur;
|
|
38
|
+
}
|
|
39
|
+
return prev[b.length] ?? 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The closest candidate within 2 edits, or `undefined`.
|
|
43
|
+
*
|
|
44
|
+
* Two guards against noise: the distance must also be at most half the
|
|
45
|
+
* candidate's length (so `q` does not suggest `id`), and a tie goes to the
|
|
46
|
+
* candidate listed first, so the suggestion is deterministic.
|
|
47
|
+
*/
|
|
48
|
+
export function closestMatch(input, candidates) {
|
|
49
|
+
let best;
|
|
50
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
const limit = Math.min(2, Math.floor(candidate.length / 2));
|
|
53
|
+
const d = editDistance(input, candidate, limit);
|
|
54
|
+
if (d <= limit && d < bestDistance) {
|
|
55
|
+
best = candidate;
|
|
56
|
+
bestDistance = d;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The top-level argument names an input wire schema declares, or `null` when
|
|
63
|
+
* the schema declares itself open (`additionalProperties` other than `false`,
|
|
64
|
+
* `patternProperties`, or `propertyNames`), in which case no name is unknown.
|
|
65
|
+
*
|
|
66
|
+
* Read from the wire schema rather than the Zod object so that "unknown" means
|
|
67
|
+
* exactly "not advertised to the client", for every Zod shape that converts to
|
|
68
|
+
* a top-level object: a refined or transformed `z.object`, and an intersection,
|
|
69
|
+
* which Zod 4 merges into one `properties` map.
|
|
70
|
+
*/
|
|
71
|
+
export function declaredArgs(inputSchema) {
|
|
72
|
+
const extra = inputSchema.additionalProperties;
|
|
73
|
+
if ((extra !== undefined && extra !== false) ||
|
|
74
|
+
inputSchema.patternProperties !== undefined ||
|
|
75
|
+
inputSchema.propertyNames !== undefined) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const props = inputSchema.properties;
|
|
79
|
+
return props !== null && typeof props === "object" ? Object.keys(props) : [];
|
|
80
|
+
}
|
|
81
|
+
/** Keys of `args` not in `declared`, each with its nearest declared name. */
|
|
82
|
+
export function findUnknownArgs(declared, args) {
|
|
83
|
+
if (declared === null || args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const known = new Set(declared);
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const key of Object.keys(args)) {
|
|
89
|
+
if (known.has(key))
|
|
90
|
+
continue;
|
|
91
|
+
const suggestion = closestMatch(key, declared);
|
|
92
|
+
out.push(suggestion === undefined ? { key } : { key, suggestion });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=unknown-args.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unknown-args.js","sourceRoot":"","sources":["../src/unknown-args.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@george43g/mcp-kit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "MCP server building blocks: tool registry types, dispatcher with timeout/perf/abort/error-wrap baked in, stdio + Streamable HTTP transports, sanitize() for user content, UUID-gated prompt-injection helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,10 +33,16 @@
|
|
|
33
33
|
"import": "./dist/transports/stdio.js",
|
|
34
34
|
"default": "./dist/transports/stdio.js"
|
|
35
35
|
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"types": "./dist/testing.d.ts",
|
|
38
|
+
"import": "./dist/testing.js",
|
|
39
|
+
"default": "./dist/testing.js"
|
|
40
|
+
},
|
|
36
41
|
"./package.json": "./package.json"
|
|
37
42
|
},
|
|
38
43
|
"files": [
|
|
39
44
|
"dist",
|
|
45
|
+
"!dist/tsconfig.tsbuildinfo",
|
|
40
46
|
"src",
|
|
41
47
|
"!src/**/*.test.ts",
|
|
42
48
|
"README.md",
|
|
@@ -52,8 +58,8 @@
|
|
|
52
58
|
},
|
|
53
59
|
"dependencies": {
|
|
54
60
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
61
|
+
"ajv": "^8.17.1",
|
|
62
|
+
"ajv-formats": "^3.0.1"
|
|
57
63
|
},
|
|
58
64
|
"devDependencies": {
|
|
59
65
|
"@george43g/robustness": "workspace:*",
|
|
@@ -67,9 +73,11 @@
|
|
|
67
73
|
"semantic-release": "^24.2.0",
|
|
68
74
|
"semantic-release-monorepo": "^8.0.2",
|
|
69
75
|
"typescript": "^5.7.0",
|
|
70
|
-
"vitest": "^3.2.7"
|
|
76
|
+
"vitest": "^3.2.7",
|
|
77
|
+
"zod": "^4.0.0"
|
|
71
78
|
},
|
|
72
79
|
"peerDependencies": {
|
|
73
|
-
"@george43g/robustness": ">=0.11.0 <1"
|
|
80
|
+
"@george43g/robustness": ">=0.11.0 <1",
|
|
81
|
+
"zod": "^4.0.0"
|
|
74
82
|
}
|
|
75
83
|
}
|
package/src/dispatch.ts
CHANGED
|
@@ -31,7 +31,16 @@ import {
|
|
|
31
31
|
import type { ZodError } from "zod";
|
|
32
32
|
import { wrapToolError } from "./prompt-injection.js";
|
|
33
33
|
import { sanitize } from "./sanitize.js";
|
|
34
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
type AnyToolDefinition,
|
|
36
|
+
type ContentBlock,
|
|
37
|
+
type ToolFilterContext,
|
|
38
|
+
type ToolFilterDecision,
|
|
39
|
+
type ToolRegistry,
|
|
40
|
+
toMcpSchema,
|
|
41
|
+
type UnknownArgument,
|
|
42
|
+
} from "./tool-registry.js";
|
|
43
|
+
import { declaredArgs, findUnknownArgs } from "./unknown-args.js";
|
|
35
44
|
|
|
36
45
|
/**
|
|
37
46
|
* Replace the absolute home-directory prefix with `~` in anything headed for a
|
|
@@ -121,14 +130,63 @@ export interface BuildDispatcherOptions {
|
|
|
121
130
|
devOnlyEnabled?: () => boolean;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Call a tool by name.
|
|
135
|
+
*
|
|
136
|
+
* `ctx` is what the registry's `filter` (see `makeRegistry`) is evaluated
|
|
137
|
+
* against. From an MCP `tools/call` handler, pass the handler's `extra`:
|
|
138
|
+
* `dispatch(name, args, extra.signal, extra)`. An in-process caller omits it,
|
|
139
|
+
* and the filter sees `{}`.
|
|
140
|
+
*/
|
|
141
|
+
export type Dispatch = (
|
|
142
|
+
name: string,
|
|
143
|
+
args: unknown,
|
|
144
|
+
signal?: AbortSignal,
|
|
145
|
+
ctx?: ToolFilterContext,
|
|
146
|
+
) => Promise<ToolResult>;
|
|
125
147
|
|
|
126
148
|
function defaultTimeoutMs(): number {
|
|
127
149
|
return envNum("MCP_TOOL_TIMEOUT_DEFAULT_MS", 30_000);
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
function formatZodError(err: ZodError): string {
|
|
131
|
-
return err.
|
|
153
|
+
return err.issues
|
|
154
|
+
.map((e) => ` - ${e.path.map(String).join(".") || "(root)"}: ${e.message}`)
|
|
155
|
+
.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** At most this many unknown keys are named in one error; the rest are counted. */
|
|
159
|
+
const MAX_UNKNOWN_REPORTED = 20;
|
|
160
|
+
|
|
161
|
+
function formatUnknownArgs(unknown: UnknownArgument[]): string {
|
|
162
|
+
const lines = unknown.slice(0, MAX_UNKNOWN_REPORTED).map(({ key, suggestion }) => {
|
|
163
|
+
// The key is caller-supplied and echoed back: neutralise control characters,
|
|
164
|
+
// including the newlines sanitize() keeps, so a key cannot forge a line.
|
|
165
|
+
const shown = (sanitize(key, 128) ?? "").replace(/[\t\n\r]/g, "\uFFFD");
|
|
166
|
+
return suggestion === undefined
|
|
167
|
+
? `unknown argument ${shown}`
|
|
168
|
+
: `unknown argument ${shown}; did you mean ${suggestion}?`;
|
|
169
|
+
});
|
|
170
|
+
const rest = unknown.length - MAX_UNKNOWN_REPORTED;
|
|
171
|
+
if (rest > 0) lines.push(`and ${rest} more unknown argument(s)`);
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Declared top-level argument names per definition, derived from the wire
|
|
176
|
+
// input schema on first need. Weak so a definition can still be collected.
|
|
177
|
+
const declaredCache = new WeakMap<AnyToolDefinition, readonly string[] | null>();
|
|
178
|
+
function declaredFor(def: AnyToolDefinition): readonly string[] | null {
|
|
179
|
+
if (!declaredCache.has(def)) {
|
|
180
|
+
declaredCache.set(def, declaredArgs(toMcpSchema(def.input, "input", def.name)));
|
|
181
|
+
}
|
|
182
|
+
return declaredCache.get(def) ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function unknownArgsHint(def: AnyToolDefinition): string {
|
|
186
|
+
const declared = declaredFor(def) ?? [];
|
|
187
|
+
return declared.length === 0
|
|
188
|
+
? "This tool takes no arguments."
|
|
189
|
+
: `Valid arguments: ${declared.join(", ")}.`;
|
|
132
190
|
}
|
|
133
191
|
|
|
134
192
|
export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
|
|
@@ -149,7 +207,21 @@ export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
|
|
|
149
207
|
}
|
|
150
208
|
|
|
151
209
|
const fallback = defaultTimeoutMs();
|
|
152
|
-
|
|
210
|
+
const { registry } = opts;
|
|
211
|
+
// A registry built by `makeRegistry()` has both methods; a hand-written one
|
|
212
|
+
// from 3.0 may not, and still gets the boolean gate and per-tool strictArgs.
|
|
213
|
+
const evaluate = (def: AnyToolDefinition, ctx: ToolFilterContext): ToolFilterDecision =>
|
|
214
|
+
registry.evaluate
|
|
215
|
+
? registry.evaluate(def, ctx)
|
|
216
|
+
: registry.allows(def, ctx)
|
|
217
|
+
? { allowed: true }
|
|
218
|
+
: { allowed: false };
|
|
219
|
+
const unknownArgs = (def: AnyToolDefinition, args: unknown): UnknownArgument[] => {
|
|
220
|
+
if (registry.unknownArgs) return registry.unknownArgs(def, args);
|
|
221
|
+
if (def.strictArgs !== true) return [];
|
|
222
|
+
return findUnknownArgs(declaredFor(def), args);
|
|
223
|
+
};
|
|
224
|
+
return async (name, args, signal, ctx = {}) => {
|
|
153
225
|
noteActivity();
|
|
154
226
|
opts.onCall?.(name);
|
|
155
227
|
const def = opts.registry.get(name);
|
|
@@ -162,8 +234,24 @@ export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
|
|
|
162
234
|
// construction, which the readonly type discourages but does not prevent.
|
|
163
235
|
// If that ever happens the tool is gated, not exposed.
|
|
164
236
|
const devGated = def?.devOnly === true && !(opts.devOnlyEnabled?.() ?? false);
|
|
237
|
+
// The registry's per-request filter gates the CALL path with the same
|
|
238
|
+
// predicate that gates the listing. Hiding a tool from tools/list while it
|
|
239
|
+
// still answers by name is the hole `devOnly` already fell into once.
|
|
240
|
+
// Evaluated only for a tool that exists and is not dev-gated, so a deny
|
|
241
|
+
// reason can never reveal a dev-only tool.
|
|
242
|
+
const decision: ToolFilterDecision =
|
|
243
|
+
def !== undefined && !devGated ? evaluate(def, ctx) : { allowed: false };
|
|
244
|
+
|
|
245
|
+
// `{ deny }`: the filter chose to tell the caller why, so it can recover.
|
|
246
|
+
if (!decision.allowed && decision.deny !== undefined) {
|
|
247
|
+
opts.onError?.(name, new Error("tool_denied"));
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: "text", text: wrapToolError(name, decision.deny) }],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
165
253
|
|
|
166
|
-
if (!def ||
|
|
254
|
+
if (!def || !decision.allowed) {
|
|
167
255
|
opts.onError?.(name, new Error("unknown_tool"));
|
|
168
256
|
return {
|
|
169
257
|
content: [
|
|
@@ -176,6 +264,22 @@ export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
|
|
|
176
264
|
};
|
|
177
265
|
}
|
|
178
266
|
|
|
267
|
+
// strictArgs: refuse an undeclared argument name before parsing, because
|
|
268
|
+
// parsing would strip it silently and run the handler without it.
|
|
269
|
+
const unknown = unknownArgs(def, args ?? {});
|
|
270
|
+
if (unknown.length > 0) {
|
|
271
|
+
opts.onError?.(name, new Error("unknown_arguments"));
|
|
272
|
+
return {
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: "text",
|
|
276
|
+
text: wrapToolError(name, formatUnknownArgs(unknown), unknownArgsHint(def)),
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
isError: true,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
179
283
|
const parsed = def.input.safeParse(args ?? {});
|
|
180
284
|
if (!parsed.success) {
|
|
181
285
|
opts.onError?.(name, parsed.error);
|
package/src/index.ts
CHANGED
|
@@ -23,11 +23,21 @@ export { buildResourcesHandler } from "./resources.js";
|
|
|
23
23
|
export { CONTENT_BUDGET, sanitize, sanitizeContent } from "./sanitize.js";
|
|
24
24
|
export type {
|
|
25
25
|
AnyToolDefinition,
|
|
26
|
+
AnyZodSchema,
|
|
26
27
|
ContentBlock,
|
|
28
|
+
MakeRegistryOptions,
|
|
29
|
+
McpJsonSchema,
|
|
30
|
+
McpSchemaIo,
|
|
27
31
|
ToolDefinition,
|
|
32
|
+
ToolDeny,
|
|
33
|
+
ToolFilter,
|
|
34
|
+
ToolFilterContext,
|
|
35
|
+
ToolFilterDecision,
|
|
36
|
+
ToolFilterResult,
|
|
28
37
|
ToolRegistry,
|
|
38
|
+
UnknownArgument,
|
|
29
39
|
} from "./tool-registry.js";
|
|
30
|
-
export { makeRegistry } from "./tool-registry.js";
|
|
40
|
+
export { makeRegistry, toMcpSchema } from "./tool-registry.js";
|
|
31
41
|
export type { HttpServerHandle, HttpServerOptions } from "./transports/http.js";
|
|
32
42
|
export { startHttpServer } from "./transports/http.js";
|
|
33
43
|
export type { StartStdioOptions } from "./transports/stdio.js";
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@george43g/mcp-kit/testing` — MCP wire-conformance checks for a consumer's
|
|
3
|
+
* test suite.
|
|
4
|
+
*
|
|
5
|
+
* Two questions, each one a client has actually failed on:
|
|
6
|
+
*
|
|
7
|
+
* 1. Does every tool's `inputSchema` / `outputSchema` compile in a client that
|
|
8
|
+
* only speaks JSON Schema 2020-12? (`assertMcpConformance`) Claude Code
|
|
9
|
+
* 2.1.292 refused every mcp-kit 2.x tool because the schemas were labelled
|
|
10
|
+
* draft-07.
|
|
11
|
+
* 2. Does a tool's `structuredContent` satisfy its own `outputSchema`?
|
|
12
|
+
* (`validateStructured`) The SDK client runs exactly this check on every
|
|
13
|
+
* `tools/call` and turns a mismatch into an error.
|
|
14
|
+
*
|
|
15
|
+
* A separate entry so Ajv is never loaded by a running server: nothing under
|
|
16
|
+
* the main entry imports this file. `ajv` and `ajv-formats` are regular
|
|
17
|
+
* dependencies of the package rather than optional peers because
|
|
18
|
+
* `@modelcontextprotocol/sdk` already depends on both (^8.17.1 / ^3.0.1) — they
|
|
19
|
+
* are in every consumer's install already, so declaring them adds no package,
|
|
20
|
+
* and a consumer's vitest needs no extra devDependency to use this entry.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
24
|
+
import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js";
|
|
25
|
+
import addFormatsModule from "ajv-formats";
|
|
26
|
+
import {
|
|
27
|
+
type AnyToolDefinition,
|
|
28
|
+
type McpJsonSchema,
|
|
29
|
+
type ToolRegistry,
|
|
30
|
+
toMcpSchema,
|
|
31
|
+
} from "./tool-registry.js";
|
|
32
|
+
|
|
33
|
+
// ajv-formats is CommonJS with `module.exports = plugin` and `.default = plugin`;
|
|
34
|
+
// under NodeNext the default import is the module object, so take `.default`
|
|
35
|
+
// when it is there.
|
|
36
|
+
const addFormats = ((addFormatsModule as unknown as { default?: unknown }).default ??
|
|
37
|
+
addFormatsModule) as (ajv: Ajv2020) => Ajv2020;
|
|
38
|
+
|
|
39
|
+
const DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
|
|
40
|
+
|
|
41
|
+
/** Anything `assertMcpConformance` can check: a registry, its definitions, or a wire `tools/list`. */
|
|
42
|
+
export type ConformanceSource = ToolRegistry | readonly AnyToolDefinition[] | readonly Tool[];
|
|
43
|
+
|
|
44
|
+
/** Known-good values for one tool. Each must validate against the tool's wire schema. */
|
|
45
|
+
export interface ConformanceSamples {
|
|
46
|
+
input?: readonly unknown[];
|
|
47
|
+
output?: readonly unknown[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConformanceOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Known-good values per tool name. Input samples are validated against
|
|
53
|
+
* `inputSchema`, output samples against `outputSchema` — under Ajv, i.e. as
|
|
54
|
+
* a client sees them. Catches a schema that compiles but rejects real data.
|
|
55
|
+
* A sample for a tool name that is not in the source is itself a failure, so
|
|
56
|
+
* a renamed tool cannot silently stop being checked.
|
|
57
|
+
*/
|
|
58
|
+
samples?: Readonly<Record<string, ConformanceSamples>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ConformanceReport {
|
|
62
|
+
/** Names of the tools checked, in source order. */
|
|
63
|
+
tools: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface StructuredValidation {
|
|
67
|
+
valid: boolean;
|
|
68
|
+
/** One line per problem; empty when `valid`. */
|
|
69
|
+
errors: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Thrown by `assertMcpConformance`; `problems` holds one line per failure. */
|
|
73
|
+
export class McpConformanceError extends Error {
|
|
74
|
+
readonly problems: readonly string[];
|
|
75
|
+
constructor(problems: readonly string[]) {
|
|
76
|
+
super(
|
|
77
|
+
`MCP conformance failed (${problems.length} problem(s)):\n${problems.map((p) => ` - ${p}`).join("\n")}`,
|
|
78
|
+
);
|
|
79
|
+
this.name = "McpConformanceError";
|
|
80
|
+
this.problems = problems;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The Ajv a 2020-12-only client effectively runs: strict mode, 2020-12 as the
|
|
86
|
+
* only meta-schema (no draft-07 is added, so a draft-07 `$schema` fails here
|
|
87
|
+
* exactly as it failed in the client), standard formats known.
|
|
88
|
+
*
|
|
89
|
+
* `allowUnionTypes` is the one relaxation, and it is not a relaxation of the
|
|
90
|
+
* spec: `type: ["string", "number"]` is valid 2020-12 — it is what Zod emits for
|
|
91
|
+
* `z.union([z.string(), z.number()])` — and Ajv's `strictTypes` flags it only
|
|
92
|
+
* as a style preference.
|
|
93
|
+
*/
|
|
94
|
+
function makeAjv(): Ajv2020 {
|
|
95
|
+
const ajv = new Ajv2020({ strict: true, allowUnionTypes: true, allErrors: true });
|
|
96
|
+
addFormats(ajv);
|
|
97
|
+
return ajv;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
|
|
101
|
+
return (errors ?? [])
|
|
102
|
+
.map((e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim())
|
|
103
|
+
.join("; ");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface WireTool {
|
|
107
|
+
name: string;
|
|
108
|
+
inputSchema?: unknown;
|
|
109
|
+
outputSchema?: unknown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRegistry(source: ConformanceSource): source is ToolRegistry {
|
|
113
|
+
return !Array.isArray(source) && typeof (source as ToolRegistry).toMcpTools === "function";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isDefinition(entry: AnyToolDefinition | Tool): entry is AnyToolDefinition {
|
|
117
|
+
return "input" in entry && "output" in entry;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Convert definitions to wire shape, recording a conversion throw as a problem. */
|
|
121
|
+
function toWire(entry: AnyToolDefinition | Tool, problems: string[]): WireTool {
|
|
122
|
+
if (!isDefinition(entry)) return entry as WireTool;
|
|
123
|
+
const wire: WireTool = { name: entry.name };
|
|
124
|
+
for (const io of ["input", "output"] as const) {
|
|
125
|
+
try {
|
|
126
|
+
const schema = toMcpSchema(io === "input" ? entry.input : entry.output, io, entry.name);
|
|
127
|
+
if (io === "input") wire.inputSchema = schema;
|
|
128
|
+
else wire.outputSchema = schema;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
problems.push((err as Error).message);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return wire;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function compileWireSchema(
|
|
137
|
+
ajv: Ajv2020,
|
|
138
|
+
tool: string,
|
|
139
|
+
field: "inputSchema" | "outputSchema",
|
|
140
|
+
schema: unknown,
|
|
141
|
+
problems: string[],
|
|
142
|
+
): ValidateFunction | undefined {
|
|
143
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
144
|
+
problems.push(`${tool}: ${field} is not a JSON Schema object.`);
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const s = schema as Record<string, unknown>;
|
|
148
|
+
if (s.$schema !== undefined && s.$schema !== DIALECT_2020_12) {
|
|
149
|
+
problems.push(
|
|
150
|
+
`${tool}: ${field} declares $schema ${JSON.stringify(s.$schema)}. A 2020-12-only client ` +
|
|
151
|
+
"(Claude Code 2.1.292+) refuses it. Emit 2020-12 with no $schema — mcp-kit's toMcpSchema() does.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (s.type !== "object") {
|
|
155
|
+
problems.push(
|
|
156
|
+
`${tool}: ${field} has type ${JSON.stringify(s.type ?? null)}; MCP requires "object".`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return ajv.compile(s);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
problems.push(
|
|
163
|
+
`${tool}: ${field} does not compile under Ajv2020 strict: ${(err as Error).message}`,
|
|
164
|
+
);
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Throw `McpConformanceError` unless every tool's schemas would be accepted by a
|
|
171
|
+
* JSON Schema 2020-12 client, and every sample validates.
|
|
172
|
+
*
|
|
173
|
+
* Collects every problem before throwing, so one run shows them all.
|
|
174
|
+
*
|
|
175
|
+
* A `ToolRegistry` is checked in full — devOnly tools included and its
|
|
176
|
+
* `filter` ignored — because a hidden tool still ships a schema somebody's
|
|
177
|
+
* client will compile.
|
|
178
|
+
*
|
|
179
|
+
* Pass the result of a real `tools/list` (`Tool[]`) to check the wire output
|
|
180
|
+
* itself; pass definitions or a registry to check what `toMcpSchema` would emit.
|
|
181
|
+
*/
|
|
182
|
+
export function assertMcpConformance(
|
|
183
|
+
source: ConformanceSource,
|
|
184
|
+
opts: ConformanceOptions = {},
|
|
185
|
+
): ConformanceReport {
|
|
186
|
+
const problems: string[] = [];
|
|
187
|
+
const entries: readonly (AnyToolDefinition | Tool)[] = isRegistry(source) ? source.tools : source;
|
|
188
|
+
const ajv = makeAjv();
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
const names: string[] = [];
|
|
191
|
+
const validators = new Map<
|
|
192
|
+
string,
|
|
193
|
+
{ input?: ValidateFunction | undefined; output?: ValidateFunction | undefined }
|
|
194
|
+
>();
|
|
195
|
+
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const wire = toWire(entry, problems);
|
|
198
|
+
const name = typeof wire.name === "string" ? wire.name : "";
|
|
199
|
+
if (name === "") {
|
|
200
|
+
problems.push("A tool has no name.");
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (seen.has(name)) problems.push(`${name}: duplicate tool name.`);
|
|
204
|
+
seen.add(name);
|
|
205
|
+
names.push(name);
|
|
206
|
+
|
|
207
|
+
const v: { input?: ValidateFunction | undefined; output?: ValidateFunction | undefined } = {};
|
|
208
|
+
if (wire.inputSchema === undefined) {
|
|
209
|
+
if (!isDefinition(entry)) problems.push(`${name}: no inputSchema (MCP requires one).`);
|
|
210
|
+
} else {
|
|
211
|
+
v.input = compileWireSchema(ajv, name, "inputSchema", wire.inputSchema, problems);
|
|
212
|
+
}
|
|
213
|
+
if (wire.outputSchema !== undefined) {
|
|
214
|
+
v.output = compileWireSchema(ajv, name, "outputSchema", wire.outputSchema, problems);
|
|
215
|
+
}
|
|
216
|
+
validators.set(name, v);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const [tool, samples] of Object.entries(opts.samples ?? {})) {
|
|
220
|
+
const v = validators.get(tool);
|
|
221
|
+
if (!v) {
|
|
222
|
+
problems.push(`samples given for "${tool}", which is not in the source.`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const io of ["input", "output"] as const) {
|
|
226
|
+
const list = samples[io] ?? [];
|
|
227
|
+
if (list.length === 0) continue;
|
|
228
|
+
const validate = v[io];
|
|
229
|
+
if (!validate) {
|
|
230
|
+
problems.push(`${tool}: ${io} samples given but there is no compiled ${io}Schema.`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
list.forEach((sample, i) => {
|
|
234
|
+
if (!validate(sample)) {
|
|
235
|
+
problems.push(
|
|
236
|
+
`${tool}: ${io} sample #${i} rejected: ${formatAjvErrors(validate.errors)}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (problems.length > 0) throw new McpConformanceError(problems);
|
|
244
|
+
return { tools: names };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Check a `tools/call` result the way the SDK client does before handing it to
|
|
249
|
+
* the model:
|
|
250
|
+
*
|
|
251
|
+
* - a tool with an `outputSchema` must return `structuredContent` unless the
|
|
252
|
+
* result is an error;
|
|
253
|
+
* - `structuredContent`, when present, must validate against `outputSchema`
|
|
254
|
+
* (under Ajv2020, strict).
|
|
255
|
+
*
|
|
256
|
+
* A tool with no `outputSchema` has nothing to check and is always valid.
|
|
257
|
+
* Accepts the wire `Tool` (from `tools/list`) or a `ToolDefinition`.
|
|
258
|
+
*
|
|
259
|
+
* Returns rather than throws, so a test reads
|
|
260
|
+
* `expect(validateStructured(tool, result).errors).toEqual([])` and the diff
|
|
261
|
+
* shows the reason.
|
|
262
|
+
*/
|
|
263
|
+
export function validateStructured(
|
|
264
|
+
tool: Tool | AnyToolDefinition,
|
|
265
|
+
// `Record<string, unknown>` admits the SDK client's `callTool()` return type,
|
|
266
|
+
// whose union includes the legacy `{ toolResult }` shape.
|
|
267
|
+
result: { structuredContent?: unknown; isError?: boolean | undefined } | Record<string, unknown>,
|
|
268
|
+
): StructuredValidation {
|
|
269
|
+
const { structuredContent, isError } = result as {
|
|
270
|
+
structuredContent?: unknown;
|
|
271
|
+
isError?: boolean;
|
|
272
|
+
};
|
|
273
|
+
let outputSchema: unknown;
|
|
274
|
+
if (isDefinition(tool)) {
|
|
275
|
+
try {
|
|
276
|
+
outputSchema = toMcpSchema(tool.output, "output", tool.name) satisfies McpJsonSchema;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return { valid: false, errors: [(err as Error).message] };
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
outputSchema = tool.outputSchema;
|
|
282
|
+
}
|
|
283
|
+
if (outputSchema === undefined) return { valid: true, errors: [] };
|
|
284
|
+
|
|
285
|
+
if (!structuredContent && !isError) {
|
|
286
|
+
return {
|
|
287
|
+
valid: false,
|
|
288
|
+
errors: [`Tool ${tool.name} has an output schema but did not return structured content.`],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (!structuredContent) return { valid: true, errors: [] };
|
|
292
|
+
|
|
293
|
+
const problems: string[] = [];
|
|
294
|
+
const validate = compileWireSchema(makeAjv(), tool.name, "outputSchema", outputSchema, problems);
|
|
295
|
+
if (!validate) return { valid: false, errors: problems };
|
|
296
|
+
if (validate(structuredContent)) return { valid: true, errors: [] };
|
|
297
|
+
return {
|
|
298
|
+
valid: false,
|
|
299
|
+
errors: (validate.errors ?? []).map(
|
|
300
|
+
(e) => `${tool.name}: structuredContent${e.instancePath} ${e.message ?? "is invalid"}`,
|
|
301
|
+
),
|
|
302
|
+
};
|
|
303
|
+
}
|