Skip to content

[v1.x] Server revalidates every tools/call with one-shot jsonschema.validate(), re-checking the schema each time #3676

Description

@mechnotech

Server.call_tool() in src/mcp/server/lowlevel/server.py (v1.x) calls the one-shot
jsonschema.validate() twice per tools/call: on the arguments (line 536, when
validate_input=True) and on the structured result against the tool's
outputSchema (line 573). That function picks a validator class, runs
check_schema() against the meta-schema, builds a validator and resolves $refs on
every call, then throws it all away. A tool's schemas don't change between calls.

This is the server-side counterpart of #3133, which was fixed for ClientSession in
#3134. main (v2) doesn't have it on the server; v1.x still does as of 52e64dd,
and that is what v1.30.0 ships.

Reproduction (SDK only)

import asyncio, time
import jsonschema
from jsonschema import exceptions, validators
from pydantic import BaseModel
from mcp import types
from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel import server as lowlevel

class Owner(BaseModel):
    address: str
    since_block: int

class Record(BaseModel):
    name: str
    value: str
    status: str
    owner: Owner | None = None

mcp = FastMCP("repro")

@mcp.tool()
def read_record(name: str) -> Record:
    return Record(name=name, value="{}", status="confirmed",
                  owner=Owner(address="x", since_block=1))

async def main(n=500):
    schema = {t.name: t for t in await mcp.list_tools()}["read_record"].outputSchema
    instance = read_record("a").model_dump(mode="json")

    t = time.perf_counter()
    for _ in range(n):
        jsonschema.validate(instance=instance, schema=schema)
    one_shot = (time.perf_counter() - t) / n

    cls = validators.validator_for(schema)
    cls.check_schema(schema)
    v = cls(schema)
    t = time.perf_counter()
    for _ in range(n):
        exceptions.best_match(v.iter_errors(instance))
    cached = (time.perf_counter() - t) / n

    handler = mcp._mcp_server.request_handlers[types.CallToolRequest]
    req = types.CallToolRequest(method="tools/call",
        params=types.CallToolRequestParams(name="read_record", arguments={"name": "a"}))
    await handler(req)
    t = time.perf_counter()
    for _ in range(n):
        await handler(req)
    e2e = (time.perf_counter() - t) / n

    class Cached:  # same semantics as jsonschema.validate, validator reused
        ValidationError = jsonschema.ValidationError
        def validate(self, instance, schema):
            if (e := exceptions.best_match(v.iter_errors(instance))):
                raise e
    lowlevel.jsonschema = Cached()
    t = time.perf_counter()
    for _ in range(n):
        await handler(req)
    e2e_cached = (time.perf_counter() - t) / n

    print(f"outputSchema check: one-shot {one_shot*1e3:.2f} ms, cached {cached*1e3:.3f} ms")
    print(f"tools/call handler: as shipped {e2e*1e3:.2f} ms, cached {e2e_cached*1e3:.2f} ms")

asyncio.run(main())

Output (mcp 1.30.0, jsonschema 4.26.0, CPython 3.12, Linux x86_64):

outputSchema check: one-shot 1.20 ms, cached 0.022 ms   (~54x)
tools/call handler: as shipped 1.25 ms, cached 0.05 ms  (~25x)

The cost grows with the schema: FastMCP output schemas for tools that return nested
models carry $defs/$ref, and with the 2020-12 meta-schema ($dynamicRef) the
check dominates. On one of ours it was 4.8 ms per call one-shot vs 0.045 ms cached.

Seen in production

A remote MCP server on a 1-vCPU host (stateless Streamable HTTP, FastMCP, 9 tools
with structured output), profiled with py-spy under load: ~60% of the process's CPU
was in jsonschema.validators.check_schema / $ref resolution under
Server.call_tool's output validation. The server topped out at ~20 read calls/s.
With validators cached per schema (same best_match error selection, schema still
checked once), the same host does ~32 calls/s and the bottleneck moves elsewhere.

Suggested fix

Mirror #3134 on the server: keep a compiled validator per tool (or per schema, e.g.
keyed on the schema's JSON) next to _tool_cache, check the schema once when it is
compiled, and validate with best_match(validator.iter_errors(instance)) so error
messages stay exactly as today. Drop the cached validators when _tool_cache is
refreshed. Applies to both the input (line 536) and output (line 573) checks.

Happy to open a PR against v1.x if that's welcome.

Activity

  1. added
    bugSomething isn't working
    v1Affects the v1.x maintenance line
    on Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance line

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions