Skip to content

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

Description

@UberBlackCat

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions