Skip to content

fix(core-internal): cache AJV validators for schemas without $id - #2699

Open
kyletser wants to merge 1 commit into
modelcontextprotocol:mainfrom
kyletser:fix/ajv-validator-cache
Open

fix(core-internal): cache AJV validators for schemas without $id#2699
kyletser wants to merge 1 commit into
modelcontextprotocol:mainfrom
kyletser:fix/ajv-validator-cache

Conversation

@kyletser

Copy link
Copy Markdown

Fixes #2605.

What

AjvJsonSchemaValidator.getValidator() recompiled any schema without a usable $id on every call (engine.compile(schema)), and the AJV engine retains every compiled validator forever. Long-running clients that periodically refresh their tool catalogue — e.g. a Client calling listTools() repeatedly — therefore grew the heap without bound until the process aborted.

Root cause

getValidator() only deduplicates through AJV's $id-keyed cache (engine.getSchema($id)); the no-$id branch always called engine.compile(). The docstring already states "The validator is compiled once and can be reused multiple times", but caching only happened on the $id path.

Fix

Cache compiled validators for schemas without $id by the schema's JSON serialization, so each distinct schema compiles at most once per provider instance:

  • packages/core-internal/src/validators/ajvProvider.ts: new _compiledBySource map + _compiledValidator() helper; the $id path is unchanged.
  • Schema with $id → unchanged behavior (AJV getSchema lookup, compile on miss).
  • Structurally identical schemas (different object identity) now share one compiled validator.
  • Distinct schemas still compile independently; the cache is bounded by the number of distinct schemas the caller actually uses.

Changeset

Added .changeset/fix-ajv-validator-cache.md (@modelcontextprotocol/core-internal patch).

Verification

  • pnpm typecheck:all — all packages pass
  • pnpm build:all — passes
  • pnpm --filter @modelcontextprotocol/core-internal test — 1450/1452 pass
  • New test/validators/ajvProviderCache.test.ts (5 cases): identical schema compiles once; structurally identical schemas hit the cache; distinct schemas compile independently; $id path unchanged; cached validator still validates correctly.
  • eslint on the touched files — clean

Pre-existing failures, unrelated to this change

  • schemaTwinConformance.test.ts reports 2 failures: the vendored 2026-07-28 twin under corpus/schema-twins/ is 184,637 bytes while manifest.json pins 180,695 ("twin provenance integrity"). This file is untouched by this PR and reproduces on a clean main checkout; it needs a pnpm fetch:schema-twins refresh by a maintainer.
  • The local lint:all docs-snippets sync check (pnpm sync:snippets) is out of date on main and unrelated to this PR; CI does not run lint:all.

How to review

  1. packages/core-internal/src/validators/ajvProvider.ts_compiledValidator() + _compiledBySource (the only runtime change, ~20 lines).
  2. packages/core-internal/test/validators/ajvProviderCache.test.ts — regression coverage using a fake engine that counts compile() calls.
  3. Reproduce the leak: with the old code, calling getValidator(sameSchemaNoId) twice calls compile() twice; with this change, once.

@kyletser
kyletser requested a review from a team as a code owner August 21, 2026 14:53
Copilot AI lite review requested due to automatic review settings August 21, 2026 14:53
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dcbbab9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2699

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2699

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2699

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2699

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2699

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2699

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2699

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2699

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2699

commit: dcbbab9

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes repeated AJV compilation for schemas without usable $id by caching validators by schema serialization.

Changes:

  • Adds compiled-validator caching.
  • Adds regression tests for cache behavior.
  • Adds a patch changeset.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Summary
packages/core-internal/test/validators/ajvProviderCache.test.ts Adds caching and validation regression tests.
packages/core-internal/src/validators/ajvProvider.ts Implements schema-source caching. Critical: compile a snapshot to prevent cache poisoning when schemas mutate in place, and add coverage for that case.
.changeset/fix-ajv-validator-cache.md Documents the patch release.
Suppressed comments (2)

packages/core-internal/src/validators/ajvProvider.ts:166

  • JSON.stringify can throw for programmatically supplied non-serializable schema objects (for example, cyclic schemas or BigInt values). This makes getValidator() fail before AJV sees the schema, whereas the previous implementation delegated compilation to the configured engine. Treat serialization as a best-effort cache key and fall back to engine.compile(schema) without caching when it fails.
        const key = JSON.stringify(schema);

packages/core-internal/src/validators/ajvProvider.ts:166

  • JSON Schema object member order is not significant, but JSON.stringify preserves insertion order. Consequently, the same schema with reordered keywords or properties gets a separate AJV compilation; repeated tools/list responses that vary key order can still retain duplicate validators. Use a canonical serialization that recursively sorts object keys if this cache is intended to deduplicate structural equality.
        const key = JSON.stringify(schema);

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

if (cached !== undefined) {
return cached;
}
const compiled = engine.compile(schema);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — fixed. _compiledValidator now compiles a snapshot derived from the cache key (engine.compile(JSON.parse(key))) instead of the caller's live object, so AJV's identity-based compilation cache cannot be poisoned by in-place mutation. Also addressed the two suppressed notes: JSON.stringify is wrapped and falls back to an uncached engine.compile() for non-serializable schemas, and the cache key is now canonical (recursively sorted object keys) so structurally identical schemas with different key order share one compilation. Added 3 regression tests: in-place mutation re-compiles under a fresh key, key-order variation hits the same cache entry, and cyclic schemas compile without caching.

AjvJsonSchemaValidator recompiled schemas without an $id on every
getValidator() call, and AJV retained each compiled validator forever.
Long-running clients that periodically refresh their tool catalogue
(e.g. via Client.listTools()) therefore grew the heap without bound
until the process aborted (modelcontextprotocol#2605).

Identical schemas are now cached by their JSON serialization, so each
distinct schema compiles at most once. Schemas with an $id keep using
the existing $id-based lookup.
@kyletser
kyletser force-pushed the fix/ajv-validator-cache branch from fb9ba17 to dcbbab9 Compare August 21, 2026 15:07
@slegarraga

Copy link
Copy Markdown

Great fix — reviewed in depth alongside #2626 since both address #2605. The core approach here is the strongest of the two: canonical content keys (sortJsonKeys means structurally identical schemas share one compilation regardless of key order) and compiling the JSON.parse(key) snapshot to defeat Ajv's identity-based cache on in-place mutation are both exactly right. Tests cover the mutation-poisoning and cyclic-schema edges well. Node >=20 engines make toSorted() fine.

Two things worth considering before merge:

  1. The cache is still unbounded by distinct schemas. This fixes the per-call leak, but a client whose tool catalogue genuinely evolves over time (schemas come and go) will grow _compiledBySource forever — each distinct schema ever seen stays keyed. A small LRU or FIFO cap (e.g. evict oldest beyond ~1000 entries) would close that path. For what it's worth, fix(validators): cache compiled schemas to prevent memory leak (#2605) #2626's thread converged on adding exactly this after review.

  2. cfWorkerProvider.ts has the same uncached-compile pattern for its Validator instantiation and isn't touched here. If the leak matters for the AJV provider it presumably matters for the Cloudflare one too; fix(validators): cache compiled schemas to prevent memory leak (#2605) #2626 does patch that file (and correctly includes the dialect in its cache key there).

Neither blocks the idea of the fix; 1 is the one I'd insist on before this ships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak: AjvJsonSchemaValidator.getValidator() recompiles schemas without $id on every call

3 participants