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 2 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: f5a482f 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
|
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