fix(validators): cache compiled schemas to prevent memory leak (#2605) - #2626
fix(validators): cache compiled schemas to prevent memory leak (#2605)#2626elang2 wants to merge 3 commits into
Conversation
… 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.
🦋 Changeset detectedLatest commit: fc0ed61 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
This also fixes a second reachable path that isn't mentioned in #2605 — the server side, via The server-side call site#2605 traces the leak through // 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 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
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 approachKeying by
Caveat on that second row: I did not build this branch. I reproduced the caching strategy — content key, One review question
|
|
Good catch. I'll add a size cap so the map doesn't grow forever in that edge case. |
Add MAX_CACHE_SIZE guard to both AjvJsonSchemaValidator and CfWorkerJsonSchemaValidator. Past the cap, schemas are still compiled correctly but not stored, preventing unbounded Map growth for the pathological case of distinct schema content per request. The common case (fixed tool set) converges well below this limit.
Summary
Schemas without a
$idfield were being recompiled on everygetValidator()call, leaking Ajv instances over the lifetime of a long-running server. This adds a content-keyed compilation cache to bothAjvSchemaValidatorProviderandCfWorkerSchemaValidatorProvider.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
validatorCaching.test.tscovering cache hits, content changes,$idpreservation, mutation safety, and error isolationFixes #2605