Memory leak: AjvJsonSchemaValidator.getValidator() recompiles schemas without $id on every call
Summary
Client.listTools() permanently retains one compiled Ajv validator per tool
with an outputSchema, on every call. In a long-running client that
periodically refreshes its tool catalogue, heap usage grows without bound
until the process hits its memory limit and aborts.
The tool schemas themselves are correct and unchanged between calls — the
validators are recompiled and retained regardless.
Root cause
Present in 1.30.0 (latest on npm at the time of writing) at
src/validation/ajv-provider.ts (shipped as
dist/{cjs,esm}/validation/ajv-provider.js), and still present on main
after the monorepo restructuring — the same pattern appears in
packages/core/src/validators/ajvProvider.ts and in the multi-dialect
packages/core-internal/src/validators/ajvProvider.ts (where it now applies
to up to three lazily created Ajv engines, each accumulating compilations
independently). The getValidator docstring says "The validator is compiled
once and can be reused multiple times", which suggests caching was the
intent — but it only happens on the $id branch.
From 1.30.0:
getValidator(schema) {
const ajvValidator = '$id' in schema && typeof schema.$id === 'string'
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
: this._ajv.compile(schema); // <-- no caching
...
}
The cache lookup (this._ajv.getSchema) is only reached when the schema
carries an $id. MCP tool schemas typically do not, so the fallback
this._ajv.compile(schema) runs unconditionally.
ajv.compile() is not a pure function: every compiled validator is added to
the Ajv instance's internal scope and stays reachable for the lifetime of that
instance. Since AjvJsonSchemaValidator holds a single long-lived _ajv, each
compilation is retained forever.
The call site is src/client/index.ts, which runs on every tools/list
response:
for (const tool of tools) {
if (tool.outputSchema) {
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema);
this._cachedToolOutputValidators.set(tool.name, toolValidator);
}
...
}
_cachedToolOutputValidators is keyed by tool name, so the map itself stays
bounded — the previous entry is simply overwritten. But the underlying
compiled validator remains referenced by Ajv's scope. It becomes unreachable
from the map while still being retained, which is what makes this a leak
rather than an intentional cache.
Reproduction
Self-contained, no transport or server implementation required beyond the
in-memory pair. npm i @modelcontextprotocol/sdk@1.30.0, then
node --expose-gc repro.mjs:
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const TOOL_COUNT = 50
const ROUNDS = 40
// Tools whose outputSchema has NO $id — the common case for MCP servers.
const tools = Array.from({ length: TOOL_COUNT }, (_, i) => ({
name: `tool_${i}`,
description: `Tool ${i}`,
inputSchema: { type: 'object', properties: { q: { type: 'string' } } },
outputSchema: {
type: 'object',
properties: {
type: { type: 'string' },
query: { type: 'string' },
results: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
label: { type: 'string' },
score: { type: 'number' },
},
required: ['id'],
additionalProperties: false,
},
},
},
required: ['type'],
additionalProperties: false,
},
}))
const server = new Server({ name: 'repro', version: '1.0.0' }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }))
const client = new Client({ name: 'repro-client', version: '1.0.0' }, { capabilities: {} })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
const mb = () => { global.gc?.(); return process.memoryUsage().heapUsed / 1024 / 1024 }
await client.listTools() // warm-up, so one-off allocations are excluded
const before = mb()
for (let i = 0; i < ROUNDS; i++) await client.listTools()
const after = mb()
const calls = ROUNDS * TOOL_COUNT
console.log(`retained : ${(after - before).toFixed(1)} MB`)
console.log(`per validation : ${(((after - before) * 1024 * 1024) / calls / 1024).toFixed(1)} KB`)
await client.close()
await server.close()
Results (measured after an explicit global.gc())
|
retained |
per validation |
| v1.30.0 as published |
12.4 MB |
6.4 KB |
| with the fix below |
0.2 MB |
0.1 KB |
The retention is proportional to the number of calls and does not plateau.
Adding an $id to the schemas also makes it disappear, which confirms the
branch is the deciding factor.
Heap snapshot evidence
A snapshot diff of a long-running client process (V8
--heapsnapshot-signal, before/after a burst of tools/list calls) shows the
growth is entirely compiled Ajv code:
Δ MB Δ count type | name
3.4 76561 object|Object
1.8 16669 array|(object elements)
1.1 3554 code|
0.5 1 string|(function anonymous(self,scope) {
const schema35 = scope.schema[35]; ... return function validate35(...)
0.5 1 string|(function anonymous(self,scope) {
const schema38 = scope.schema[38]; ... return function validate38(...)
...
One new validateN function per schema per call, with monotonically
increasing scope.schema[N] indices — i.e. Ajv's scope keeps growing.
Real-world impact
Observed in a long-running client that refreshes its tool catalogue on a
schedule across several hundred tools: roughly 80 KB retained per
tools/list, which was enough to walk the process into its memory limit
within hours and abort it with SIGABRT
(FATAL ERROR: Reached heap limit Allocation failed).
Suggested fix
Memoise compilations of schemas without $id, keyed by schema content. The
set of distinct tool schemas is finite, so the cache converges rather than
growing without bound:
class AjvJsonSchemaValidator {
constructor(ajv) {
this._ajv = ajv ?? createDefaultAjvInstance();
+ this._compiledCache = new Map();
}
+
+ _getCompiled(schema) {
+ if ('$id' in schema && typeof schema.$id === 'string') {
+ return this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema);
+ }
+ let key;
+ try {
+ key = JSON.stringify(schema);
+ } catch {
+ return this._ajv.compile(schema); // cyclic schema: not cacheable
+ }
+ let cached = this._compiledCache.get(key);
+ if (cached === undefined) {
+ // Compile a fresh structural copy, NOT the caller's object — see
+ // "Note on Ajv's identity cache" below.
+ cached = this._ajv.compile(JSON.parse(key));
+ this._compiledCache.set(key, cached);
+ }
+ return cached;
+ }
+
getValidator(schema) {
- const ajvValidator = '$id' in schema && typeof schema.$id === 'string'
- ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
- : this._ajv.compile(schema);
+ const ajvValidator = this._getCompiled(schema);
Note on Ajv's identity cache (this bit is easy to get wrong)
The obvious version of this fix — cached = this._ajv.compile(schema) — is
subtly incorrect, and I shipped that first before catching it.
Ajv keeps its own cache keyed by schema object identity. If a caller
mutates a schema object in place, ajv.compile(sameObject) returns the
validator compiled from the object's earlier state. A content-keyed cache
would then store that stale validator under the new content key, and every
later schema with that content would silently get the wrong validator.
Compiling JSON.parse(key) sidesteps this: the object handed to Ajv is always
fresh, so its identity cache can never return a validator belonging to
different content.
This was only caught by running the correctness tests against patched and
unpatched builds and diffing the results — against the unpatched build alone,
the failure looks like pre-existing behaviour.
Behavioural notes
- With the fix, mutating a schema object in place and calling
getValidator()
again yields a validator for the new content. The current implementation
returns a stale validator in that case (via Ajv's identity cache). The fix
is therefore stricter than the status quo here. I consider that an
improvement, but it is a behaviour change and worth a maintainer's judgement.
- Compiling
JSON.parse(key) means the compiled schema is the JSON projection
of the input (e.g. undefined-valued properties are dropped). For schemas
arriving over the MCP wire this is an identity operation; for exotic
programmatic schemas it is a minor semantic difference. Non-serialisable
schemas (cyclic, BigInt) fall back to uncached compilation.
- The content key is order-sensitive (
JSON.stringify): two schemas with the
same content but different property order compile twice. That is
conservative — never incorrect, just a missed dedup.
Verification
16 assertions covering: changed schema recompiles, no stale hits, no cache
poisoning across objects with identical content, shared validators not
clobbering each other's errors, interleaved use, and the $id path. All pass
with the fix; all except the two mutation-semantics assertions also pass
without it.
Alternatives
A structural alternative would be for Client to skip recompilation when the
tool list is unchanged. Caching in the validator provider fixes it for every
caller though, including server/index.ts, which calls getValidator() on the
same code path for elicitation schemas.
If unbounded growth for pathologically dynamic schemas is a concern, note that
capping the cache does not bound memory on its own: Ajv's scope is the
actual retainer, so entries would also need ajv.removeSchema() or a
periodically recycled Ajv instance.
src/validation/cfworker-provider.ts has the same shape and is likely
affected in the same way; I have not measured it.
Environment
@modelcontextprotocol/sdk 1.30.0 (latest on npm); pattern verified still
present on main (see Root cause)
- Node.js 22.x and 24.x — measured on both, identical behaviour, so this is not
a V8/GC artefact of a particular release line
- Linux x64
Memory leak:
AjvJsonSchemaValidator.getValidator()recompiles schemas without$idon every callSummary
Client.listTools()permanently retains one compiled Ajv validator per toolwith an
outputSchema, on every call. In a long-running client thatperiodically refreshes its tool catalogue, heap usage grows without bound
until the process hits its memory limit and aborts.
The tool schemas themselves are correct and unchanged between calls — the
validators are recompiled and retained regardless.
Root cause
Present in 1.30.0 (latest on npm at the time of writing) at
src/validation/ajv-provider.ts(shipped asdist/{cjs,esm}/validation/ajv-provider.js), and still present onmainafter the monorepo restructuring — the same pattern appears in
packages/core/src/validators/ajvProvider.tsand in the multi-dialectpackages/core-internal/src/validators/ajvProvider.ts(where it now appliesto up to three lazily created Ajv engines, each accumulating compilations
independently). The
getValidatordocstring says "The validator is compiledonce and can be reused multiple times", which suggests caching was the
intent — but it only happens on the
$idbranch.From 1.30.0:
The cache lookup (
this._ajv.getSchema) is only reached when the schemacarries an
$id. MCP tool schemas typically do not, so the fallbackthis._ajv.compile(schema)runs unconditionally.ajv.compile()is not a pure function: every compiled validator is added tothe Ajv instance's internal scope and stays reachable for the lifetime of that
instance. Since
AjvJsonSchemaValidatorholds a single long-lived_ajv, eachcompilation is retained forever.
The call site is
src/client/index.ts, which runs on everytools/listresponse:
_cachedToolOutputValidatorsis keyed by tool name, so the map itself staysbounded — the previous entry is simply overwritten. But the underlying
compiled validator remains referenced by Ajv's scope. It becomes unreachable
from the map while still being retained, which is what makes this a leak
rather than an intentional cache.
Reproduction
Self-contained, no transport or server implementation required beyond the
in-memory pair.
npm i @modelcontextprotocol/sdk@1.30.0, thennode --expose-gc repro.mjs:Results (measured after an explicit
global.gc())The retention is proportional to the number of calls and does not plateau.
Adding an
$idto the schemas also makes it disappear, which confirms thebranch is the deciding factor.
Heap snapshot evidence
A snapshot diff of a long-running client process (V8
--heapsnapshot-signal, before/after a burst oftools/listcalls) shows thegrowth is entirely compiled Ajv code:
One new
validateNfunction per schema per call, with monotonicallyincreasing
scope.schema[N]indices — i.e. Ajv's scope keeps growing.Real-world impact
Observed in a long-running client that refreshes its tool catalogue on a
schedule across several hundred tools: roughly 80 KB retained per
tools/list, which was enough to walk the process into its memory limitwithin hours and abort it with
SIGABRT(
FATAL ERROR: Reached heap limit Allocation failed).Suggested fix
Memoise compilations of schemas without
$id, keyed by schema content. Theset of distinct tool schemas is finite, so the cache converges rather than
growing without bound:
class AjvJsonSchemaValidator { constructor(ajv) { this._ajv = ajv ?? createDefaultAjvInstance(); + this._compiledCache = new Map(); } + + _getCompiled(schema) { + if ('$id' in schema && typeof schema.$id === 'string') { + return this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema); + } + let key; + try { + key = JSON.stringify(schema); + } catch { + return this._ajv.compile(schema); // cyclic schema: not cacheable + } + let cached = this._compiledCache.get(key); + if (cached === undefined) { + // Compile a fresh structural copy, NOT the caller's object — see + // "Note on Ajv's identity cache" below. + cached = this._ajv.compile(JSON.parse(key)); + this._compiledCache.set(key, cached); + } + return cached; + } + getValidator(schema) { - const ajvValidator = '$id' in schema && typeof schema.$id === 'string' - ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) - : this._ajv.compile(schema); + const ajvValidator = this._getCompiled(schema);Note on Ajv's identity cache (this bit is easy to get wrong)
The obvious version of this fix —
cached = this._ajv.compile(schema)— issubtly incorrect, and I shipped that first before catching it.
Ajv keeps its own cache keyed by schema object identity. If a caller
mutates a schema object in place,
ajv.compile(sameObject)returns thevalidator compiled from the object's earlier state. A content-keyed cache
would then store that stale validator under the new content key, and every
later schema with that content would silently get the wrong validator.
Compiling
JSON.parse(key)sidesteps this: the object handed to Ajv is alwaysfresh, so its identity cache can never return a validator belonging to
different content.
This was only caught by running the correctness tests against patched and
unpatched builds and diffing the results — against the unpatched build alone,
the failure looks like pre-existing behaviour.
Behavioural notes
getValidator()again yields a validator for the new content. The current implementation
returns a stale validator in that case (via Ajv's identity cache). The fix
is therefore stricter than the status quo here. I consider that an
improvement, but it is a behaviour change and worth a maintainer's judgement.
JSON.parse(key)means the compiled schema is the JSON projectionof the input (e.g.
undefined-valued properties are dropped). For schemasarriving over the MCP wire this is an identity operation; for exotic
programmatic schemas it is a minor semantic difference. Non-serialisable
schemas (cyclic, BigInt) fall back to uncached compilation.
JSON.stringify): two schemas with thesame content but different property order compile twice. That is
conservative — never incorrect, just a missed dedup.
Verification
16 assertions covering: changed schema recompiles, no stale hits, no cache
poisoning across objects with identical content, shared validators not
clobbering each other's
errors, interleaved use, and the$idpath. All passwith the fix; all except the two mutation-semantics assertions also pass
without it.
Alternatives
A structural alternative would be for
Clientto skip recompilation when thetool list is unchanged. Caching in the validator provider fixes it for every
caller though, including
server/index.ts, which callsgetValidator()on thesame code path for elicitation schemas.
If unbounded growth for pathologically dynamic schemas is a concern, note that
capping the cache does not bound memory on its own: Ajv's scope is the
actual retainer, so entries would also need
ajv.removeSchema()or aperiodically recycled Ajv instance.
src/validation/cfworker-provider.tshas the same shape and is likelyaffected in the same way; I have not measured it.
Environment
@modelcontextprotocol/sdk1.30.0 (latest on npm); pattern verified stillpresent on
main(see Root cause)a V8/GC artefact of a particular release line