Repository navigation
fix: advertise and validate JSON Schema tools via fromJsonSchema (T3) - #38
Conversation
JSON Schema inputSchema values registered as an open object, so clients saw no parameters and arguments were not validated. They now go through the SDK v2 fromJsonSchema(), with one validator per DynamicToolManager so a client's schemas (and $id values) never cross to another client. Schemas that do not compile, or are not a Zod 4 shape or a JSON Schema object, fail the toolset's enable with E_VALIDATION. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmUXGTprrczjscL9XyWxYz
TS review:
|
| # | Sev | Location | Finding |
|---|---|---|---|
| 1 | MEDIUM | src/core/DynamicToolManager.ts:268 |
The SDK's getValidator reuses engine.getSchema($id) before compiling. Two tools in one manager sharing a $id with different schemas get the first tool's validator while tools/list advertises the second schema. A loader whose schema changes on re-enable with a stable $id keeps a stale validator. |
| 2 | MEDIUM | src/core/AGENTS.md:23, DynamicToolManager.ts:39 |
"No cache growth" is overstated: Ajv's _cache is never evicted on disable and loaders yield fresh schema objects per enable. Growth is bounded by the client's lifetime, not removed. |
| 3 | LOW | DynamicToolManager.ts:39, 265-269, 465 |
Lazy optional field plus getValidator thunk is unnecessary: the AjvJsonSchemaValidator constructor is cheap (engines are lazy). |
| 4 | LOW | DynamicToolManager.ts:39 |
Field jsonSchemaValidator has the same name as the imported type. |
| 5 | LOW | DynamicToolManager.ts:452 |
TSDoc says "missing schema"; code opens any non-object value. |
| 6 | LOW | CHANGELOG.md:11, src/core/AGENTS.md |
"instead of failing at tools/list" is inaccurate (such schemas never failed before) and garbled in AGENTS.md. |
| 7 | LOW | tests/e2e/jsonSchemaTools.e2e.test.ts |
No end-to-end test of two concurrent mcp-client-id clients with independent enable/disable state and a JSON Schema tool. |
| 8 | LOW | tests/dynamicToolManager.test.ts |
Loose `/invalid |
{
"recommendation": "COMMENT",
"summary": "Correct and well-scoped; the per-client validator isolation holds, but same-$id collisions and cache growth within one client remain, the lazy-validator thunk is unnecessary, and the concurrent-client requirement lacks an e2e test.",
"findings": [
{ "severity": "MEDIUM", "file": "src/core/DynamicToolManager.ts", "line": 268, "issue": "Same-$id tools in one manager (or a re-enabled loader tool with changed schema) reuse the first compiled validator while tools/list advertises the new schema." },
{ "severity": "MEDIUM", "file": "src/core/AGENTS.md", "line": 23, "issue": "Claim that per-manager validators avoid cache growth is only partly true; growth is bounded by client lifetime." },
{ "severity": "LOW", "file": "src/core/DynamicToolManager.ts", "line": 39, "issue": "Lazy optional field plus getValidator thunk is unnecessary." },
{ "severity": "LOW", "file": "src/core/DynamicToolManager.ts", "line": 39, "issue": "Field name shadows the imported type name." },
{ "severity": "LOW", "file": "src/core/DynamicToolManager.ts", "line": 452, "issue": "TSDoc 'missing schema' should be 'missing or non-object'." },
{ "severity": "LOW", "file": "CHANGELOG.md", "line": 11, "issue": "'instead of failing at tools/list' is inaccurate; garbled in AGENTS.md." },
{ "severity": "LOW", "file": "tests/e2e/jsonSchemaTools.e2e.test.ts", "line": null, "issue": "No e2e test for two concurrent clients with a JSON Schema tool." },
{ "severity": "LOW", "file": "tests/dynamicToolManager.test.ts", "line": null, "issue": "Loose assertion, single-tool rollback test, stray trailing comma." }
]
}Generated by Claude Code
Drop the root $id before compiling so tools sharing one $id keep their own validator, make the per-manager validator an eager field, pin test assertions, and add rollback, same-$id and concurrent-client tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmUXGTprrczjscL9XyWxYz
Fix mapping for TS review round 1Fixes are in
Checks: Generated by Claude Code |
Copy the schema only when a root $id must be dropped so Ajv's identity cache still hits, pin the per-manager validator with a constructor spy, tighten docs and assertions, and cover absolute $refs to a nested $id. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmUXGTprrczjscL9XyWxYz
TS review round 2:
|
| # | Sev | Location | Finding |
|---|---|---|---|
| 1 | MEDIUM | DynamicToolManager.ts:495, src/core/AGENTS.md:23 |
The shallow copy { ...schema } is made for every JSON Schema tool, so Ajv's identity cache never hits on re-enable, and invariant 9 understates which toolsets grow the cache. Copy only when a root $id exists. |
| 2 | LOW | tests/dynamicToolManager.test.ts:536, tests/e2e/jsonSchemaTools.e2e.test.ts:148 |
The cross-manager and cross-client tests are valid behaviour tests but do not pin the per-manager validator they are named after. Rename, or add a constructor spy. |
| 3 | LOW | DynamicToolManager.ts:489, CHANGELOG.md:20, AGENTS.md |
Docs say a Zod/non-Zod mixed shape always fails; one with type: "object" goes to Ajv. type: ["object"] arrays are now refused and used to work. |
| 4 | LOW | DynamicToolManager.ts:452, CHANGELOG.md:22 |
Docs warn only about refs to the root $id URL; nested relative or absolute $id refs may also be affected (unverified). |
| 5 | LOW | tests/e2e/jsonSchemaTools.e2e.test.ts:83 |
/invalid|validation/i is loose; the SDK message starts with Input validation error. |
| 6 | LOW | DynamicToolManager.ts:452 |
Nit: TSDoc paragraph indentation. |
{
"recommendation": "COMMENT",
"summary": "All round-1 fixes are real and the PR is functionally sound; the main new issue is that the $id-strip copy runs for every JSON Schema tool, so it defeats Ajv's identity cache and AGENTS.md's loader-backed-only growth claim is now inaccurate, plus a few tests/docs that overstate what they prove.",
"findings": [
{ "severity": "MEDIUM", "file": "src/core/DynamicToolManager.ts", "line": 495, "issue": "Shallow copy made for every JSON Schema tool defeats Ajv's identity cache on re-enable; copy only when a root $id exists or reword invariant 9." },
{ "severity": "LOW", "file": "tests/e2e/jsonSchemaTools.e2e.test.ts", "line": 148, "issue": "Cross-client tests do not pin the per-manager validator property; rename or add a constructor spy." },
{ "severity": "LOW", "file": "src/core/DynamicToolManager.ts", "line": 489, "issue": "Docs broader than code for mixed shapes; type arrays now refused; add a note." },
{ "severity": "LOW", "file": "src/core/DynamicToolManager.ts", "line": 452, "issue": "Root $id strip may affect nested/absolute refs; widen note or pin with a test." },
{ "severity": "LOW", "file": "tests/e2e/jsonSchemaTools.e2e.test.ts", "line": 83, "issue": "Loose error-message regex." },
{ "severity": "LOW", "file": "src/core/DynamicToolManager.ts", "line": 452, "issue": "Nit: TSDoc indentation." }
]
}Generated by Claude Code
Fix mapping for TS review round 2Fixes are in
Checks: Generated by Claude Code |
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmUXGTprrczjscL9XyWxYz
TS review round 3:
|
| # | Sev | Location | Finding | Status |
|---|---|---|---|---|
| 1 | LOW | src/core/AGENTS.md:23, DynamicToolManager.ts:~168 |
Invariant 9 and the code comment say only loader-backed toolsets grow the Ajv cache on toggle; a schema with a root $id is copied on each enable (new cache key), so those toolsets grow it too. |
Fixed in 86bff9e (docs and comment only): both now name the root-$id case and say the growth is bounded by the client's lifetime. I kept the copy per enable instead of adding a WeakMap memo, since it is the same acknowledged trade-off and not worth more code. |
The reviewer's optional test nit (a nested $id on re-enable has no test of its own) needs no action; the reviewer traced it as correct and the nested-$id pin test covers compile.
{
"recommendation": "APPROVE",
"summary": "All six round-2 fixes are real and correct; Ajv semantics for $id stripping, nested $id refs and per-manager validator scope check out; only a LOW doc nit remains.",
"findings": [
{ "severity": "LOW", "file": "src/core/AGENTS.md", "line": 23, "issue": "Invariant 9 and the DynamicToolManager.ts comment say only loader-backed toolsets grow the Ajv cache on toggle; a schema with a root $id is copied on every enable, so those grow it too. Reword the docs or memoize the copy." }
]
}Final state at 86bff9e: typecheck ✔, build ✔, test:run ✔ 433 tests; multiClient.e2e.test.ts green.
Generated by Claude Code
Requested by Ben · project thread
Stack and merge order
T3, stacked on the base PR B1 (#35). Base branch of this PR:
claude/project-thread-1w8fky(B1's head).mainfirst. GitHub then retargets this PR tomain.v0.9.0.B1's branch is merged into this one, never rebased. Nothing here is tagged or published.
What changed
Todo item b6. A tool whose
inputSchemais a plain JSON Schema object used to register asz.looseObject({}): clients saw no parameters and any arguments reached the handler. It now goes through the SDK v2fromJsonSchema().toRegistrableInputSchema()insrc/core/DynamicToolManager.ts: Zod 4 schemas and Zod 4 raw shapes are unchanged. A JSON Schema with roottype: "object"is passed tofromJsonSchema(), sotools/listshows the real schema and arguments are validated before the handler runs (invalid arguments give anisErrorresult, the handler is not called). A missing or non-object schema still registers as an open object.$ref, unsupported$schemadialect), and anyinputSchemathat is neither a Zod 4 shape nor a JSON Schema object (no roottype: "object", or a shape mixing Zod and non-Zod values), fails the toolset's enable withE_VALIDATIONnaming the tool. Nothing is registered. The last case used to silently register as an open object.$idis dropped before compiling: the SDK's Ajv validator caches compiled schemas by root$id, so two tools sharing a$idwould get the first tool's validator whiletools/listadvertises the second schema. Stripping it avoids the cache hit; it is therefore not advertised either. Reference the root with#.DynamicToolManager, which is one per client, owns its ownAjvJsonSchemaValidator(public@modelcontextprotocol/server/validators/ajvexport, externalised byvite.config.ts). Ajv keeps every compiled schema for the validator's lifetime, so the SDK's process-wide default would hold all clients' schemas until exit. A per-client one shares nothing and is freed with the client. A client that toggles a loader-backed toolset repeatedly grows only its own cache, until the session ends.src/core/AGENTS.md(invariants 7 and 9),src/types/AGENTS.md, theMcpToolDefinitionJSDoc, and CHANGELOG[Unreleased](breaking: arguments are now validated, plus migration note).Behaviour change to know about
Handlers that relied on receiving arguments their JSON Schema forbids (missing
required, wrong type, extra properties withadditionalProperties: false) now get a validation error instead. This reverses the 2026-10-10 "keep passthrough" decision, as approved by Ben on 2026-11-04 (validation on).Multi-client acceptance gate (plan section 3a)
tests/e2e/multiClient.e2e.test.ts(B1) passes unchanged on this branch. New tests:mcp-client-idclients on one DYNAMIC server, each with its own JSON Schema toolset: independenttools/list, independent validation, A's disable leaves B workingtools/listreturns the declared schema$ids with different schemas across two managers; two tools of one manager sharing a root$id; disable then re-enable; rollback of an earlier good tool when a later JSON Schema tool is badChecks
npm run typecheck✔,npm run build✔,npm run test:run✔ 430 tests (Node 22 locally; CI is the authority)npm audit --audit-level=critical✔ (exit 0)$idis what the same-root-$idtest guards; with it removed that test fails.🤖 Generated with Claude Code
https://claude.ai/code/session_01VmUXGTprrczjscL9XyWxYz