Skip to content

Cache compiled output-schema validators on ClientSession#3134

Open
jlowin wants to merge 1 commit into
modelcontextprotocol:mainfrom
jlowin:perf/cache-tool-output-schema-validators
Open

Cache compiled output-schema validators on ClientSession#3134
jlowin wants to merge 1 commit into
modelcontextprotocol:mainfrom
jlowin:perf/cache-tool-output-schema-validators

Conversation

@jlowin

@jlowin jlowin commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #3133.

ClientSession.validate_tool_result called one-shot jsonschema.validate() on every
tool result. That rebuilds the validator class, re-runs check_schema(), and re-crawls
$refs on each call, then throws the whole thing away. Compiling costs ~60x what
validating costs, so on an in-memory call_tool it was the dominant term. This compiles
the validator once and reuses it.

Measured end-to-end on an in-memory call_tool (median of 2000 calls after warm-up):

output schema before after
flat, two properties 0.313 ms 0.066 ms
nested, with $defs/$ref 0.832 ms 0.130 ms

Behavior is unchanged. The cache stores the schema object it compiled alongside the
validator and only reuses it on an identity match — _absorb_tool_listing assigns a
freshly parsed schema object on every listing, so a server that changes a tool's output
schema forces a rebuild rather than being served the stale validator. A failed
check_schema() is never cached, so an invalid schema still raises
RuntimeError("Invalid schema for tool ...") on every call, as before. Validation errors
still go through best_match, which is what jsonschema.validate() used internally, so
error messages are identical. The jsonschema import stays inside the method to keep it
(and its attrs/referencing tree) off the cold-start path for clients that never
validate.

Tests cover the reuse (the regression guard) and the changed-schema rebuild (the
correctness guard), plus the invalid-schema path — which was previously marked
# pragma: no cover; those pragmas are now removed.


AI assistance disclosure: this change was written with Claude Code. I reviewed the design
and the diff, ran the suite, coverage, pyright, and ruff locally, and can speak to it.

`validate_tool_result` called one-shot `jsonschema.validate()` on every tool
result, which rebuilt the validator class, re-ran `check_schema`, and re-crawled
`$ref`s each time. Compile the validator once per schema instead, keyed on the
identity of the cached schema object so a relisted tool can never reuse a
validator built from its previous schema.

Refs modelcontextprotocol#3133.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/client/session.py">

<violation number="1" location="src/mcp/client/session.py:367">
P2: A complete tool relist removes stale output schemas but leaves their compiled validators in this new cache. Sessions connected to servers whose tool set changes over time will retain every removed tool's schema and validator indefinitely, even though those entries can no longer be used. Consider pruning `_tool_output_validators` with the same `names` set when `complete` is true.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/client/session.py
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
# Compiled output-schema validators, each paired with the schema object it was built
# from so a re-listed tool can't be served a validator for its previous schema.
self._tool_output_validators: dict[str, tuple[dict[str, Any], Validator]] = {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A complete tool relist removes stale output schemas but leaves their compiled validators in this new cache. Sessions connected to servers whose tool set changes over time will retain every removed tool's schema and validator indefinitely, even though those entries can no longer be used. Consider pruning _tool_output_validators with the same names set when complete is true.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/session.py, line 367:

<comment>A complete tool relist removes stale output schemas but leaves their compiled validators in this new cache. Sessions connected to servers whose tool set changes over time will retain every removed tool's schema and validator indefinitely, even though those entries can no longer be used. Consider pruning `_tool_output_validators` with the same `names` set when `complete` is true.</comment>

<file context>
@@ -357,6 +362,9 @@ def __init__(
         self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
+        # Compiled output-schema validators, each paired with the schema object it was built
+        # from so a re-listed tool can't be served a validator for its previous schema.
+        self._tool_output_validators: dict[str, tuple[dict[str, Any], Validator]] = {}
         self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
         self._initialize_result: types.InitializeResult | None = None
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client revalidates tool results with one-shot jsonschema.validate(), recompiling the validator on every call

1 participant