@george43g/mcp-kit 1.0.0 → 3.0.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 +189 -2
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +9 -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 +111 -7
- package/dist/tool-registry.d.ts.map +1 -1
- package/dist/tool-registry.js +78 -17
- package/dist/tool-registry.js.map +1 -1
- package/package.json +16 -5
- package/src/dispatch.ts +24 -5
- package/src/index.ts +7 -1
- package/src/testing.ts +303 -0
- package/src/tool-registry.ts +177 -25
package/README.md
CHANGED
|
@@ -4,8 +4,95 @@ MCP server building blocks: a tool registry, a dispatcher with timeout / perf /
|
|
|
4
4
|
abort / error-wrapping baked in, stdio + Streamable HTTP transports, `sanitize()`
|
|
5
5
|
for untrusted content, and UUID-gated prompt-injection helpers.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Peers: `zod` ^4 and `@george43g/robustness`. Upgrading from 2.x? See
|
|
8
|
+
[Migrating from 2.x](#migrating-from-2x-to-300) — the wire schemas changed dialect.
|
|
9
|
+
|
|
10
|
+
## Schemas: one Zod definition, JSON Schema 2020-12 on the wire
|
|
11
|
+
|
|
12
|
+
Each tool declares a Zod `input` and `output`. `makeRegistry()` derives both
|
|
13
|
+
wire schemas from them with `toMcpSchema()`; there is no hand-written JSON
|
|
14
|
+
Schema path, so the schema a client sees cannot drift from the validator the
|
|
15
|
+
dispatcher runs.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { toMcpSchema } from "@george43g/mcp-kit";
|
|
19
|
+
|
|
20
|
+
toMcpSchema(NoopInputSchema, "input", "noop");
|
|
21
|
+
// → { type: "object", properties: { … }, required: [ … ] } (no "$schema")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **Dialect 2020-12, `$schema` stripped.** 2020-12 is MCP's default dialect,
|
|
25
|
+
and a schema with no label gives no client a dialect to refuse. mcp-kit 2.x
|
|
26
|
+
emitted draft-07 via `zod-to-json-schema`, and Claude Code 2.1.292 throws on
|
|
27
|
+
`"$schema": "http://json-schema.org/draft-07/schema#"` — every 2.x tool broke.
|
|
28
|
+
- **`io` is real.** `inputSchema` describes what a caller may send (a
|
|
29
|
+
`.default()` field is optional); `outputSchema` describes what parsing yields
|
|
30
|
+
(that field is required, and objects carry `additionalProperties: false`).
|
|
31
|
+
- **Unrepresentable types throw at `makeRegistry()`**, naming the tool:
|
|
32
|
+
`z.date()`, `z.bigint()`, an output-side `.transform()`. Use an ISO string, a
|
|
33
|
+
number, or move the transform out of the schema.
|
|
34
|
+
- **The top level must be a `z.object`.** MCP requires `type: "object"`.
|
|
35
|
+
- **Recursive schemas keep `$defs` / `$ref`** (or a root `$ref: "#"`): a cycle
|
|
36
|
+
cannot be inlined. That is valid 2020-12, and the conformance helper below
|
|
37
|
+
compiles it.
|
|
38
|
+
|
|
39
|
+
## Per-request tool filter (scope gating)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const registry = makeRegistry(tools, {
|
|
43
|
+
filter: (tool, ctx) =>
|
|
44
|
+
!tool.name.startsWith("gmail_send") || (ctx.authInfo?.scopes.includes("gmail.send") ?? false),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
server.setRequestHandler(ListToolsRequestSchema, async (_req, extra) => ({
|
|
48
|
+
tools: registry.toMcpTools(includeDevOnly, extra),
|
|
49
|
+
}));
|
|
50
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) =>
|
|
51
|
+
dispatch(req.params.name, req.params.arguments ?? {}, extra.signal, extra),
|
|
52
|
+
);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- The filter lives on the **registry**, so the listing and the dispatcher apply
|
|
56
|
+
the same predicate. A refused call answers **exactly like an unknown tool**.
|
|
57
|
+
Hiding a tool from `tools/list` while it still answers by name is the hole
|
|
58
|
+
`devOnly` fell into once.
|
|
59
|
+
- `ctx` is `{ authInfo?, sessionId?, requestInfo? }` — a subset of the SDK's
|
|
60
|
+
`RequestHandlerExtra`, so pass `extra` straight through. In-process calls (a
|
|
61
|
+
CLI, a test) pass nothing and the filter sees `{}`; decide what no `authInfo`
|
|
62
|
+
means for your tools.
|
|
63
|
+
- Synchronous, evaluated per request. **A throwing filter counts as `false`**.
|
|
64
|
+
|
|
65
|
+
## Testing: `@george43g/mcp-kit/testing`
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { assertMcpConformance, validateStructured } from "@george43g/mcp-kit/testing";
|
|
69
|
+
|
|
70
|
+
it("every tool is accepted by a 2020-12 client", () => {
|
|
71
|
+
assertMcpConformance(makeAppRegistry(), {
|
|
72
|
+
samples: { noop: { input: [{ input: "x" }], output: [{ echo: "x", engine: "ts", durationMicros: 1 }] } },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("noop's structuredContent matches its outputSchema", async () => {
|
|
77
|
+
const result = await callMcpTool("noop", { input: "x" });
|
|
78
|
+
expect(validateStructured(noopTool, result).errors).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `assertMcpConformance(registry | definitions | Tool[])` compiles every input
|
|
83
|
+
and output schema under **Ajv2020 in strict mode** with no draft-07
|
|
84
|
+
meta-schema loaded — so a draft-07 label fails here exactly as it failed in
|
|
85
|
+
Claude Code. It collects every problem and throws one `McpConformanceError`.
|
|
86
|
+
A registry is checked in full: devOnly and filtered tools too.
|
|
87
|
+
- `validateStructured(tool, result)` is the SDK client's own `tools/call` check:
|
|
88
|
+
structured content required unless `isError`, and validated against
|
|
89
|
+
`outputSchema` when present. It returns `{ valid, errors }`.
|
|
90
|
+
- `ajv` / `ajv-formats` are dependencies of this package, imported only by this
|
|
91
|
+
entry. `@modelcontextprotocol/sdk` already depends on both, so they add nothing
|
|
92
|
+
to an install, and a server that never imports `/testing` never loads them.
|
|
93
|
+
- `allowUnionTypes` is on: `type: ["string", "number"]` is valid 2020-12 (Zod
|
|
94
|
+
emits it for `z.union([z.string(), z.number()])`); Ajv's strict mode flags it
|
|
95
|
+
only as style.
|
|
9
96
|
|
|
10
97
|
## The dispatcher is the point
|
|
11
98
|
|
|
@@ -128,6 +215,60 @@ The marker matters more than it looks: a **silently** shortened document is
|
|
|
128
215
|
indistinguishable from a document that really ended there, and a model reading it
|
|
129
216
|
will answer confidently from the fragment it received.
|
|
130
217
|
|
|
218
|
+
## Migrating from 2.x to 3.0.0
|
|
219
|
+
|
|
220
|
+
**Why:** 2.x emitted draft-07 JSON Schema, and Claude Code 2.1.292+ (a
|
|
221
|
+
2020-12-only client) throws on it, so every tool from every 2.x server failed in
|
|
222
|
+
new sessions. 3.0.0 moves to Zod 4's native converter and 2020-12.
|
|
223
|
+
|
|
224
|
+
Breaking changes, each with its migration:
|
|
225
|
+
|
|
226
|
+
1. **`zod` is a peer, `^4`** (was a dependency, `^3.23`). Install `zod@^4` in
|
|
227
|
+
your app and drop `zod-to-json-schema`. Zod 4's `zod` root keeps `z.object`,
|
|
228
|
+
`.describe()`, `.default()`, `z.enum`, `z.infer`; see Zod's own v4 migration
|
|
229
|
+
guide for `.errors` → `.issues`, `z.string().email()` → `z.email()` (the old
|
|
230
|
+
form still works), and error-map changes.
|
|
231
|
+
2. **Wire schemas are JSON Schema 2020-12 with no `$schema`** (were draft-07,
|
|
232
|
+
labelled). Clients that pinned draft-07 behaviour see 2020-12 keywords:
|
|
233
|
+
`prefixItems` for tuples, `$defs` for recursion. Nothing to do unless you
|
|
234
|
+
post-process `inputSchema` — and if you stripped `$schema` yourself as a
|
|
235
|
+
hot-fix, delete that code.
|
|
236
|
+
3. **Input objects are open; output objects stay closed.** `inputSchema` is
|
|
237
|
+
the input side of the schema (`io: "input"`) and no longer carries
|
|
238
|
+
`additionalProperties: false`, so a client may send extra keys — the
|
|
239
|
+
dispatcher's Zod parse strips them, as before. `.default()` fields stay
|
|
240
|
+
optional on input (unchanged) and required on output. `outputSchema` keeps
|
|
241
|
+
`additionalProperties: false`, as in 2.x.
|
|
242
|
+
4. **`makeRegistry()` throws at construction for an unrepresentable schema**,
|
|
243
|
+
naming the tool: `z.date()`, `z.bigint()`, `z.map()`, `z.set()`,
|
|
244
|
+
`z.symbol()`, `z.undefined()`, an output-side `.transform()`. 2.x emitted a
|
|
245
|
+
best-effort schema silently — `z.date()` became `{type: "string", format:
|
|
246
|
+
"date-time"}`, `z.map()` an array of pairs. **The common case is a `z.date()`
|
|
247
|
+
output field:** return `date.toISOString()` and declare
|
|
248
|
+
`z.string().datetime()` (or `z.iso.datetime()`).
|
|
249
|
+
5. **`makeRegistry()` throws when a tool's input or output is not a
|
|
250
|
+
`z.object`** (MCP requires `type: "object"`). Wrap the value:
|
|
251
|
+
`z.object({ result: … })`.
|
|
252
|
+
6. **`ToolRegistry` has a new required member, `allows(def, ctx?)`**, and
|
|
253
|
+
`toMcpTools()` takes an optional second argument. Only a hand-written
|
|
254
|
+
`ToolRegistry` implementation breaks; build it with `makeRegistry()`.
|
|
255
|
+
7. **"Invalid arguments" text changes wording**: the per-field lines come from
|
|
256
|
+
Zod 4's messages (`Invalid input: expected string, received number`, was
|
|
257
|
+
`Expected string, received number`). Rendered output is not covered by
|
|
258
|
+
semver; listed because a test that snapshots the text will notice.
|
|
259
|
+
8. **The SDK dependency floor is unchanged (`^1.29.0`)** — 1.29 already accepts
|
|
260
|
+
`zod ^3.25 || ^4`. Listed so nobody hunts for it.
|
|
261
|
+
|
|
262
|
+
Additive, no action needed: `toMcpSchema()`, the registry `filter` option and
|
|
263
|
+
the `ctx` argument to `dispatch`, and the `@george43g/mcp-kit/testing` entry.
|
|
264
|
+
|
|
265
|
+
Add one test so this never regresses silently:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { assertMcpConformance } from "@george43g/mcp-kit/testing";
|
|
269
|
+
it("tools conform", () => void assertMcpConformance(makeAppRegistry()));
|
|
270
|
+
```
|
|
271
|
+
|
|
131
272
|
## Upgrading to 1.0.0
|
|
132
273
|
|
|
133
274
|
**One breaking change, and it affects you only if you register `devOnly` tools.**
|
|
@@ -145,6 +286,52 @@ buildDispatcher: 1 devOnly tool(s) registered with no devOnlyEnabled predicate:
|
|
|
145
286
|
**Migration is one line** — pass the predicate, as shown in *Dev-only tools*
|
|
146
287
|
above. If you have no `devOnly` tools, there is nothing to do.
|
|
147
288
|
|
|
289
|
+
### Upgrading to 2.0.0 — declare `@george43g/robustness` yourself
|
|
290
|
+
|
|
291
|
+
**`robustness` moved from `dependencies` to `peerDependencies`.** If your app
|
|
292
|
+
already depends on `@george43g/robustness` directly — as every known consumer
|
|
293
|
+
does, and as the generated template does — **there is nothing to do**. If it does
|
|
294
|
+
not, add it.
|
|
295
|
+
|
|
296
|
+
```jsonc
|
|
297
|
+
// your package.json
|
|
298
|
+
"dependencies": {
|
|
299
|
+
"@george43g/robustness": ">=0.13.0 <1" // or a caret; any 0.x satisfies the peer
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**What this fixes, and it is not cosmetic.** As a plain dependency with a
|
|
304
|
+
*floor*, mcp-kit could resolve its **own second copy** of robustness whenever the
|
|
305
|
+
app's range and mcp-kit's stopped overlapping. The logger keeps prefix and file
|
|
306
|
+
state at module scope, so two copies means **two independent loggers**:
|
|
307
|
+
`setLogFilePrefix` in your app cannot reach the instance mcp-kit's `perf()` spans
|
|
308
|
+
write through, and dispatch spans silently land in the shared `$TMPDIR/mcp/`
|
|
309
|
+
bucket no matter how correctly your app brands itself.
|
|
310
|
+
|
|
311
|
+
That was live in **1.0.0**, whose floor was `>=0.12.0 <1`: an app on `^0.11.0`
|
|
312
|
+
that bumped mcp-kit alone got exactly this split. **2.0.0 removes the failure
|
|
313
|
+
mode by construction** — a peer is supplied by the consumer, so there is only
|
|
314
|
+
ever one instance. The peer range is deliberately wide (`>=0.11.0 <1`, every
|
|
315
|
+
symbol mcp-kit imports exists at 0.11.0), so no consumer is forced to move.
|
|
316
|
+
|
|
317
|
+
`tui-kit` has always declared robustness this way; mcp-kit was the outlier.
|
|
318
|
+
|
|
319
|
+
Verify with the one line that settles it:
|
|
320
|
+
|
|
321
|
+
```sh
|
|
322
|
+
grep -oE "@george43g/robustness@[0-9.]+" pnpm-lock.yaml | sort -u
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
One line is correct. Two means something still resolves its own copy.
|
|
326
|
+
|
|
327
|
+
**The generalisable lesson:** a plain dependency with a *wide* range behaves like
|
|
328
|
+
a peer — right up until the ranges stop overlapping. Two individually safe
|
|
329
|
+
changes (raising a floor, cutting a major) were jointly hazardous. Found by
|
|
330
|
+
`up-bank-mcp`.
|
|
331
|
+
Found by `up-bank-mcp` as range arithmetic over the published manifests.
|
|
332
|
+
**Not yet observed in a resolved tree by anyone** — treat the ordering as cheap
|
|
333
|
+
insurance rather than a reproduction.
|
|
334
|
+
|
|
148
335
|
**Why a throw and not a flipped default.** Before 1.0.0, omitting the predicate
|
|
149
336
|
left dev-only tools *callable* — hidden from `tools/list` and answering by name.
|
|
150
337
|
Two consumers hit that independently, and one measured a dev-only log reader
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* The exported `buildDispatcher` returns a function whose shape matches the
|
|
18
18
|
* SDK's `CallToolRequest` handler.
|
|
19
19
|
*/
|
|
20
|
-
import type { ContentBlock, ToolRegistry } from "./tool-registry.js";
|
|
20
|
+
import type { ContentBlock, ToolFilterContext, ToolRegistry } from "./tool-registry.js";
|
|
21
21
|
export interface ToolResult {
|
|
22
22
|
content: ContentBlock[];
|
|
23
23
|
structuredContent?: unknown;
|
|
@@ -73,6 +73,14 @@ export interface BuildDispatcherOptions {
|
|
|
73
73
|
*/
|
|
74
74
|
devOnlyEnabled?: () => boolean;
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Call a tool by name.
|
|
78
|
+
*
|
|
79
|
+
* `ctx` is what the registry's `filter` (see `makeRegistry`) is evaluated
|
|
80
|
+
* against. From an MCP `tools/call` handler, pass the handler's `extra`:
|
|
81
|
+
* `dispatch(name, args, extra.signal, extra)`. An in-process caller omits it,
|
|
82
|
+
* and the filter sees `{}`.
|
|
83
|
+
*/
|
|
84
|
+
export type Dispatch = (name: string, args: unknown, signal?: AbortSignal, ctx?: ToolFilterContext) => Promise<ToolResult>;
|
|
77
85
|
export declare function buildDispatcher(opts: BuildDispatcherOptions): Dispatch;
|
|
78
86
|
//# sourceMappingURL=dispatch.d.ts.map
|
package/dist/dispatch.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAgCxF,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,YAAY,CAAC;IACvB,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,sEAAsE;IACtE,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,MAAM,CAAC;IAC3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;CAChC;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,QAAQ,GAAG,CACrB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,MAAM,CAAC,EAAE,WAAW,EACpB,GAAG,CAAC,EAAE,iBAAiB,KACpB,OAAO,CAAC,UAAU,CAAC,CAAC;AAYzB,wBAAgB,eAAe,CAAC,IAAI,EAAE,sBAAsB,GAAG,QAAQ,CAiHtE"}
|
package/dist/dispatch.js
CHANGED
|
@@ -55,7 +55,9 @@ function defaultTimeoutMs() {
|
|
|
55
55
|
return envNum("MCP_TOOL_TIMEOUT_DEFAULT_MS", 30_000);
|
|
56
56
|
}
|
|
57
57
|
function formatZodError(err) {
|
|
58
|
-
return err.
|
|
58
|
+
return err.issues
|
|
59
|
+
.map((e) => ` - ${e.path.map(String).join(".") || "(root)"}: ${e.message}`)
|
|
60
|
+
.join("\n");
|
|
59
61
|
}
|
|
60
62
|
export function buildDispatcher(opts) {
|
|
61
63
|
// Make the ambiguous state unrepresentable, at construction, before a single
|
|
@@ -72,7 +74,7 @@ export function buildDispatcher(opts) {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
const fallback = defaultTimeoutMs();
|
|
75
|
-
return async (name, args, signal) => {
|
|
77
|
+
return async (name, args, signal, ctx = {}) => {
|
|
76
78
|
noteActivity();
|
|
77
79
|
opts.onCall?.(name);
|
|
78
80
|
const def = opts.registry.get(name);
|
|
@@ -84,7 +86,11 @@ export function buildDispatcher(opts) {
|
|
|
84
86
|
// construction, which the readonly type discourages but does not prevent.
|
|
85
87
|
// If that ever happens the tool is gated, not exposed.
|
|
86
88
|
const devGated = def?.devOnly === true && !(opts.devOnlyEnabled?.() ?? false);
|
|
87
|
-
|
|
89
|
+
// The registry's per-request filter gates the CALL path with the same
|
|
90
|
+
// predicate that gates the listing. Hiding a tool from tools/list while it
|
|
91
|
+
// still answers by name is the hole `devOnly` already fell into once.
|
|
92
|
+
const filtered = def !== undefined && !opts.registry.allows(def, ctx);
|
|
93
|
+
if (!def || devGated || filtered) {
|
|
88
94
|
opts.onError?.(name, new Error("unknown_tool"));
|
|
89
95
|
return {
|
|
90
96
|
content: [
|
package/dist/dispatch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EACL,MAAM,EACN,KAAK,IAAI,QAAQ,EACjB,IAAI,IAAI,OAAO,EACf,YAAY,EACZ,IAAI,EACJ,gBAAgB,EAChB,WAAW,GACZ,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGzC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,SAAS,SAAS,CAAC,IAAmB;IACpC,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IACpC,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC;
|
|
1
|
+
{"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ export { type WrapInstructionsResult, wrapInstructions, wrapToolError, wrapUntru
|
|
|
4
4
|
export type { BuildResourcesHandlerOptions, ResourceContent, ResourceHandlers, ResourceListEntry, ResourcesProvider, ResourceTemplate, } from "./resources.js";
|
|
5
5
|
export { buildResourcesHandler } from "./resources.js";
|
|
6
6
|
export { CONTENT_BUDGET, sanitize, sanitizeContent } from "./sanitize.js";
|
|
7
|
-
export type { AnyToolDefinition, ContentBlock, ToolDefinition, ToolRegistry, } from "./tool-registry.js";
|
|
8
|
-
export { makeRegistry } from "./tool-registry.js";
|
|
7
|
+
export type { AnyToolDefinition, AnyZodSchema, ContentBlock, MakeRegistryOptions, McpJsonSchema, McpSchemaIo, ToolDefinition, ToolFilter, ToolFilterContext, ToolRegistry, } from "./tool-registry.js";
|
|
8
|
+
export { makeRegistry, toMcpSchema } from "./tool-registry.js";
|
|
9
9
|
export type { HttpServerHandle, HttpServerOptions } from "./transports/http.js";
|
|
10
10
|
export { startHttpServer } from "./transports/http.js";
|
|
11
11
|
export type { StartStdioOptions } from "./transports/stdio.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,sBAAsB,EACtB,QAAQ,EACR,kBAAkB,EAClB,UAAU,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EACL,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,4BAA4B,EAC5B,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC1E,YAAY,EACV,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,sBAAsB,EACtB,QAAQ,EACR,kBAAkB,EAClB,UAAU,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EACL,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,4BAA4B,EAC5B,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC1E,YAAY,EACV,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,mBAAmB,EACnB,aAAa,EACb,WAAW,EACX,cAAc,EACd,UAAU,EACV,iBAAiB,EACjB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC/D,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,YAAY,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { buildDispatcher } from "./dispatch.js";
|
|
|
2
2
|
export { wrapInstructions, wrapToolError, wrapUntrusted, } from "./prompt-injection.js";
|
|
3
3
|
export { buildResourcesHandler } from "./resources.js";
|
|
4
4
|
export { CONTENT_BUDGET, sanitize, sanitizeContent } from "./sanitize.js";
|
|
5
|
-
export { makeRegistry } from "./tool-registry.js";
|
|
5
|
+
export { makeRegistry, toMcpSchema } from "./tool-registry.js";
|
|
6
6
|
export { startHttpServer } from "./transports/http.js";
|
|
7
7
|
export { startStdio } from "./transports/stdio.js";
|
|
8
8
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAEL,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAS/B,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAEL,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAS/B,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAa1E,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE/D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { type AnyToolDefinition, type ToolRegistry } from "./tool-registry.js";
|
|
24
|
+
/** Anything `assertMcpConformance` can check: a registry, its definitions, or a wire `tools/list`. */
|
|
25
|
+
export type ConformanceSource = ToolRegistry | readonly AnyToolDefinition[] | readonly Tool[];
|
|
26
|
+
/** Known-good values for one tool. Each must validate against the tool's wire schema. */
|
|
27
|
+
export interface ConformanceSamples {
|
|
28
|
+
input?: readonly unknown[];
|
|
29
|
+
output?: readonly unknown[];
|
|
30
|
+
}
|
|
31
|
+
export interface ConformanceOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Known-good values per tool name. Input samples are validated against
|
|
34
|
+
* `inputSchema`, output samples against `outputSchema` — under Ajv, i.e. as
|
|
35
|
+
* a client sees them. Catches a schema that compiles but rejects real data.
|
|
36
|
+
* A sample for a tool name that is not in the source is itself a failure, so
|
|
37
|
+
* a renamed tool cannot silently stop being checked.
|
|
38
|
+
*/
|
|
39
|
+
samples?: Readonly<Record<string, ConformanceSamples>>;
|
|
40
|
+
}
|
|
41
|
+
export interface ConformanceReport {
|
|
42
|
+
/** Names of the tools checked, in source order. */
|
|
43
|
+
tools: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface StructuredValidation {
|
|
46
|
+
valid: boolean;
|
|
47
|
+
/** One line per problem; empty when `valid`. */
|
|
48
|
+
errors: string[];
|
|
49
|
+
}
|
|
50
|
+
/** Thrown by `assertMcpConformance`; `problems` holds one line per failure. */
|
|
51
|
+
export declare class McpConformanceError extends Error {
|
|
52
|
+
readonly problems: readonly string[];
|
|
53
|
+
constructor(problems: readonly string[]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Throw `McpConformanceError` unless every tool's schemas would be accepted by a
|
|
57
|
+
* JSON Schema 2020-12 client, and every sample validates.
|
|
58
|
+
*
|
|
59
|
+
* Collects every problem before throwing, so one run shows them all.
|
|
60
|
+
*
|
|
61
|
+
* A `ToolRegistry` is checked in full — devOnly tools included and its
|
|
62
|
+
* `filter` ignored — because a hidden tool still ships a schema somebody's
|
|
63
|
+
* client will compile.
|
|
64
|
+
*
|
|
65
|
+
* Pass the result of a real `tools/list` (`Tool[]`) to check the wire output
|
|
66
|
+
* itself; pass definitions or a registry to check what `toMcpSchema` would emit.
|
|
67
|
+
*/
|
|
68
|
+
export declare function assertMcpConformance(source: ConformanceSource, opts?: ConformanceOptions): ConformanceReport;
|
|
69
|
+
/**
|
|
70
|
+
* Check a `tools/call` result the way the SDK client does before handing it to
|
|
71
|
+
* the model:
|
|
72
|
+
*
|
|
73
|
+
* - a tool with an `outputSchema` must return `structuredContent` unless the
|
|
74
|
+
* result is an error;
|
|
75
|
+
* - `structuredContent`, when present, must validate against `outputSchema`
|
|
76
|
+
* (under Ajv2020, strict).
|
|
77
|
+
*
|
|
78
|
+
* A tool with no `outputSchema` has nothing to check and is always valid.
|
|
79
|
+
* Accepts the wire `Tool` (from `tools/list`) or a `ToolDefinition`.
|
|
80
|
+
*
|
|
81
|
+
* Returns rather than throws, so a test reads
|
|
82
|
+
* `expect(validateStructured(tool, result).errors).toEqual([])` and the diff
|
|
83
|
+
* shows the reason.
|
|
84
|
+
*/
|
|
85
|
+
export declare function validateStructured(tool: Tool | AnyToolDefinition, result: {
|
|
86
|
+
structuredContent?: unknown;
|
|
87
|
+
isError?: boolean | undefined;
|
|
88
|
+
} | Record<string, unknown>): StructuredValidation;
|
|
89
|
+
//# sourceMappingURL=testing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oCAAoC,CAAC;AAG/D,OAAO,EACL,KAAK,iBAAiB,EAEtB,KAAK,YAAY,EAElB,MAAM,oBAAoB,CAAC;AAU5B,sGAAsG;AACtG,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG,SAAS,iBAAiB,EAAE,GAAG,SAAS,IAAI,EAAE,CAAC;AAE9F,yFAAyF;AACzF,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IAC3B,MAAM,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,iBAAiB;IAChC,mDAAmD;IACnD,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,OAAO,CAAC;IACf,gDAAgD;IAChD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,+EAA+E;AAC/E,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;gBACzB,QAAQ,EAAE,SAAS,MAAM,EAAE;CAOxC;AAuFD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,iBAAiB,EACzB,IAAI,GAAE,kBAAuB,GAC5B,iBAAiB,CA4DnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,IAAI,GAAG,iBAAiB,EAG9B,MAAM,EAAE;IAAE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/F,oBAAoB,CAmCtB"}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
23
|
+
import addFormatsModule from "ajv-formats";
|
|
24
|
+
import { toMcpSchema, } from "./tool-registry.js";
|
|
25
|
+
// ajv-formats is CommonJS with `module.exports = plugin` and `.default = plugin`;
|
|
26
|
+
// under NodeNext the default import is the module object, so take `.default`
|
|
27
|
+
// when it is there.
|
|
28
|
+
const addFormats = (addFormatsModule.default ??
|
|
29
|
+
addFormatsModule);
|
|
30
|
+
const DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
|
|
31
|
+
/** Thrown by `assertMcpConformance`; `problems` holds one line per failure. */
|
|
32
|
+
export class McpConformanceError extends Error {
|
|
33
|
+
problems;
|
|
34
|
+
constructor(problems) {
|
|
35
|
+
super(`MCP conformance failed (${problems.length} problem(s)):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
36
|
+
this.name = "McpConformanceError";
|
|
37
|
+
this.problems = problems;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The Ajv a 2020-12-only client effectively runs: strict mode, 2020-12 as the
|
|
42
|
+
* only meta-schema (no draft-07 is added, so a draft-07 `$schema` fails here
|
|
43
|
+
* exactly as it failed in the client), standard formats known.
|
|
44
|
+
*
|
|
45
|
+
* `allowUnionTypes` is the one relaxation, and it is not a relaxation of the
|
|
46
|
+
* spec: `type: ["string", "number"]` is valid 2020-12 — it is what Zod emits for
|
|
47
|
+
* `z.union([z.string(), z.number()])` — and Ajv's `strictTypes` flags it only
|
|
48
|
+
* as a style preference.
|
|
49
|
+
*/
|
|
50
|
+
function makeAjv() {
|
|
51
|
+
const ajv = new Ajv2020({ strict: true, allowUnionTypes: true, allErrors: true });
|
|
52
|
+
addFormats(ajv);
|
|
53
|
+
return ajv;
|
|
54
|
+
}
|
|
55
|
+
function formatAjvErrors(errors) {
|
|
56
|
+
return (errors ?? [])
|
|
57
|
+
.map((e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim())
|
|
58
|
+
.join("; ");
|
|
59
|
+
}
|
|
60
|
+
function isRegistry(source) {
|
|
61
|
+
return !Array.isArray(source) && typeof source.toMcpTools === "function";
|
|
62
|
+
}
|
|
63
|
+
function isDefinition(entry) {
|
|
64
|
+
return "input" in entry && "output" in entry;
|
|
65
|
+
}
|
|
66
|
+
/** Convert definitions to wire shape, recording a conversion throw as a problem. */
|
|
67
|
+
function toWire(entry, problems) {
|
|
68
|
+
if (!isDefinition(entry))
|
|
69
|
+
return entry;
|
|
70
|
+
const wire = { name: entry.name };
|
|
71
|
+
for (const io of ["input", "output"]) {
|
|
72
|
+
try {
|
|
73
|
+
const schema = toMcpSchema(io === "input" ? entry.input : entry.output, io, entry.name);
|
|
74
|
+
if (io === "input")
|
|
75
|
+
wire.inputSchema = schema;
|
|
76
|
+
else
|
|
77
|
+
wire.outputSchema = schema;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
problems.push(err.message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return wire;
|
|
84
|
+
}
|
|
85
|
+
function compileWireSchema(ajv, tool, field, schema, problems) {
|
|
86
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
87
|
+
problems.push(`${tool}: ${field} is not a JSON Schema object.`);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const s = schema;
|
|
91
|
+
if (s.$schema !== undefined && s.$schema !== DIALECT_2020_12) {
|
|
92
|
+
problems.push(`${tool}: ${field} declares $schema ${JSON.stringify(s.$schema)}. A 2020-12-only client ` +
|
|
93
|
+
"(Claude Code 2.1.292+) refuses it. Emit 2020-12 with no $schema — mcp-kit's toMcpSchema() does.");
|
|
94
|
+
}
|
|
95
|
+
if (s.type !== "object") {
|
|
96
|
+
problems.push(`${tool}: ${field} has type ${JSON.stringify(s.type ?? null)}; MCP requires "object".`);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return ajv.compile(s);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
problems.push(`${tool}: ${field} does not compile under Ajv2020 strict: ${err.message}`);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Throw `McpConformanceError` unless every tool's schemas would be accepted by a
|
|
108
|
+
* JSON Schema 2020-12 client, and every sample validates.
|
|
109
|
+
*
|
|
110
|
+
* Collects every problem before throwing, so one run shows them all.
|
|
111
|
+
*
|
|
112
|
+
* A `ToolRegistry` is checked in full — devOnly tools included and its
|
|
113
|
+
* `filter` ignored — because a hidden tool still ships a schema somebody's
|
|
114
|
+
* client will compile.
|
|
115
|
+
*
|
|
116
|
+
* Pass the result of a real `tools/list` (`Tool[]`) to check the wire output
|
|
117
|
+
* itself; pass definitions or a registry to check what `toMcpSchema` would emit.
|
|
118
|
+
*/
|
|
119
|
+
export function assertMcpConformance(source, opts = {}) {
|
|
120
|
+
const problems = [];
|
|
121
|
+
const entries = isRegistry(source) ? source.tools : source;
|
|
122
|
+
const ajv = makeAjv();
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const names = [];
|
|
125
|
+
const validators = new Map();
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const wire = toWire(entry, problems);
|
|
128
|
+
const name = typeof wire.name === "string" ? wire.name : "";
|
|
129
|
+
if (name === "") {
|
|
130
|
+
problems.push("A tool has no name.");
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(name))
|
|
134
|
+
problems.push(`${name}: duplicate tool name.`);
|
|
135
|
+
seen.add(name);
|
|
136
|
+
names.push(name);
|
|
137
|
+
const v = {};
|
|
138
|
+
if (wire.inputSchema === undefined) {
|
|
139
|
+
if (!isDefinition(entry))
|
|
140
|
+
problems.push(`${name}: no inputSchema (MCP requires one).`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
v.input = compileWireSchema(ajv, name, "inputSchema", wire.inputSchema, problems);
|
|
144
|
+
}
|
|
145
|
+
if (wire.outputSchema !== undefined) {
|
|
146
|
+
v.output = compileWireSchema(ajv, name, "outputSchema", wire.outputSchema, problems);
|
|
147
|
+
}
|
|
148
|
+
validators.set(name, v);
|
|
149
|
+
}
|
|
150
|
+
for (const [tool, samples] of Object.entries(opts.samples ?? {})) {
|
|
151
|
+
const v = validators.get(tool);
|
|
152
|
+
if (!v) {
|
|
153
|
+
problems.push(`samples given for "${tool}", which is not in the source.`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const io of ["input", "output"]) {
|
|
157
|
+
const list = samples[io] ?? [];
|
|
158
|
+
if (list.length === 0)
|
|
159
|
+
continue;
|
|
160
|
+
const validate = v[io];
|
|
161
|
+
if (!validate) {
|
|
162
|
+
problems.push(`${tool}: ${io} samples given but there is no compiled ${io}Schema.`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
list.forEach((sample, i) => {
|
|
166
|
+
if (!validate(sample)) {
|
|
167
|
+
problems.push(`${tool}: ${io} sample #${i} rejected: ${formatAjvErrors(validate.errors)}`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (problems.length > 0)
|
|
173
|
+
throw new McpConformanceError(problems);
|
|
174
|
+
return { tools: names };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Check a `tools/call` result the way the SDK client does before handing it to
|
|
178
|
+
* the model:
|
|
179
|
+
*
|
|
180
|
+
* - a tool with an `outputSchema` must return `structuredContent` unless the
|
|
181
|
+
* result is an error;
|
|
182
|
+
* - `structuredContent`, when present, must validate against `outputSchema`
|
|
183
|
+
* (under Ajv2020, strict).
|
|
184
|
+
*
|
|
185
|
+
* A tool with no `outputSchema` has nothing to check and is always valid.
|
|
186
|
+
* Accepts the wire `Tool` (from `tools/list`) or a `ToolDefinition`.
|
|
187
|
+
*
|
|
188
|
+
* Returns rather than throws, so a test reads
|
|
189
|
+
* `expect(validateStructured(tool, result).errors).toEqual([])` and the diff
|
|
190
|
+
* shows the reason.
|
|
191
|
+
*/
|
|
192
|
+
export function validateStructured(tool,
|
|
193
|
+
// `Record<string, unknown>` admits the SDK client's `callTool()` return type,
|
|
194
|
+
// whose union includes the legacy `{ toolResult }` shape.
|
|
195
|
+
result) {
|
|
196
|
+
const { structuredContent, isError } = result;
|
|
197
|
+
let outputSchema;
|
|
198
|
+
if (isDefinition(tool)) {
|
|
199
|
+
try {
|
|
200
|
+
outputSchema = toMcpSchema(tool.output, "output", tool.name);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
return { valid: false, errors: [err.message] };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
outputSchema = tool.outputSchema;
|
|
208
|
+
}
|
|
209
|
+
if (outputSchema === undefined)
|
|
210
|
+
return { valid: true, errors: [] };
|
|
211
|
+
if (!structuredContent && !isError) {
|
|
212
|
+
return {
|
|
213
|
+
valid: false,
|
|
214
|
+
errors: [`Tool ${tool.name} has an output schema but did not return structured content.`],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (!structuredContent)
|
|
218
|
+
return { valid: true, errors: [] };
|
|
219
|
+
const problems = [];
|
|
220
|
+
const validate = compileWireSchema(makeAjv(), tool.name, "outputSchema", outputSchema, problems);
|
|
221
|
+
if (!validate)
|
|
222
|
+
return { valid: false, errors: problems };
|
|
223
|
+
if (validate(structuredContent))
|
|
224
|
+
return { valid: true, errors: [] };
|
|
225
|
+
return {
|
|
226
|
+
valid: false,
|
|
227
|
+
errors: (validate.errors ?? []).map((e) => `${tool.name}: structuredContent${e.instancePath} ${e.message ?? "is invalid"}`),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=testing.js.map
|