Skip to content

fix(validators): cache compiled schemas to prevent memory leak (#2605) - #2626

Open
elang2 wants to merge 2 commits into
modelcontextprotocol:mainfrom
elang2:fix/ajv-validator-memory-leak
Open

fix(validators): cache compiled schemas to prevent memory leak (#2605)#2626
elang2 wants to merge 2 commits into
modelcontextprotocol:mainfrom
elang2:fix/ajv-validator-memory-leak

Conversation

@elang2

@elang2 elang2 commented Aug 9, 2026

Copy link
Copy Markdown

Summary

Schemas without a $id field were being recompiled on every getValidator() call, leaking Ajv instances over the lifetime of a long-running server. This adds a content-keyed compilation cache to both AjvSchemaValidatorProvider and CfWorkerSchemaValidatorProvider.

Cache key uses JSON.stringify(schema) so mutated schemas get a fresh compilation rather than a stale cache hit. Non-serializable schemas (cyclic refs, BigInt) fall back gracefully to uncached compilation.

Test plan

  • 10 new tests in validatorCaching.test.ts covering cache hits, content changes, $id preservation, mutation safety, and error isolation
  • All 1443 tests pass (1433 existing + 10 new)
  • TypeScript typecheck passes
  • ESLint + Prettier clean

Fixes #2605

elang2 added 2 commits August 2, 2026 16:44
… leak (modelcontextprotocol#2605)

Add content-keyed caches to AjvJsonSchemaValidator and CfWorkerJsonSchemaValidator
so that schemas without $id are not recompiled on every getValidator() call. This
prevents Ajv's internal scope from growing without bound in long-running MCP clients
that periodically refresh their tool catalogue.

The cache key is the JSON-serialised schema content. Schemas with $id continue to use
Ajv's built-in identity cache. Non-serialisable schemas (cyclic, BigInt) fall back to
uncached compilation.
@elang2
elang2 requested a review from a team as a code owner August 9, 2026 03:20
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f5a482f

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/codemod 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 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: f5a482f

@rainblowing

Copy link
Copy Markdown

This also fixes a second reachable path that isn't mentioned in #2605 — the server side, via fromJsonSchema() — and I measured it against this PR's approach. Sharing the numbers in case they're useful for review.

The server-side call site

#2605 traces the leak through Client.listTools(). The same getValidator() is reached from fromJsonSchema(), which in @modelcontextprotocol/server@2.0.0 routes through a module-level singleton validator:

// dist/index.mjs
let _defaultValidator;
function fromJsonSchema(schema, validator) {
    return fromJsonSchema$1(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator()));
}

So the engine — and the compilations accumulating in its scope — live for the process, not for a request. Any server that builds a tool schema inside its createMcpHandler factory hands Ajv a fresh $id-less object per request and never gets it back.

That's worth calling out because the factory runs per request by design, and building schemas inside it reads as the natural thing to do. A server whose schemas are hoisted to module scope is unaffected.

Measurement

@modelcontextprotocol/server@2.0.0, Node v26.5.0, 20,000 calls, explicit global.gc() before and after, measuring retained heap:

retained per call
fresh schema object per call 105.3 MB 5,519 B
same schema object hoisted 0.4 MB 19 B
import { fromJsonSchema } from '@modelcontextprotocol/server';

const make = () => ({ type: 'object', properties: { q: { type: 'string' } }, required: ['q'] });

global.gc();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < 20000; i++) {
  const s = fromJsonSchema(make());        // hoist `make()` out to see the difference
  s['~standard'].validate({ q: 'x' });     // then drop every reference
}
global.gc();
console.log(((process.memoryUsage().heapUsed - before) / 20000).toFixed(0), 'bytes/call retained');

Against this PR's approach

Keying by JSON.stringify(schema) rather than object identity is what makes this case collapse: the objects are distinct, their content is not.

retained per call cache entries
stock 2.0.0 105.3 MB 5,519 B
content-keyed 0.2 MB 11 B 1

Caveat on that second row: I did not build this branch. I reproduced the caching strategy — content key, engine.compile(JSON.parse(key)), Map — behind the public fromJsonSchema(schema, validator) validator parameter, so it exercises the same call path but is not literally your diff. Worth someone confirming against the real branch before treating it as a verified result.

One review question

_compiledCache is a Map with no eviction, so a server generating genuinely distinct schema content per request (rather than distinct objects with identical content, which is the case here and the case in #2605) still grows without bound — the key set just moves from Ajv's scope into the new Map. Bounded and correct for a fixed tool set, which is the overwhelmingly common shape. Flagging it only because the fix reads as fully closing the leak, and for that one usage pattern it relocates it. An LRU bound, or a note in the docstring that schemas are expected to be finite, would settle it either way.

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

2 participants