Repository navigation
Python: sort MCP inputSchema properties deterministically for prompt caching - #8960
Prognosticate X (Prognosticate-X) wants to merge 6 commits into
Conversation
…ompt caching Some MCP servers emit tool inputSchema properties in arbitrary order between restarts or dynamic reload events. When downstream LLM clients construct function-calling tools, unstable schema ordering invalidates the model's prompt cache across restarts. This change ensures that object schema properties are sorted deterministically by key during MCPTool.load_tools, preserving prompt cache hits without altering validation semantics. Aligns with microsoft#8245.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Nested schema properties remain unsorted, so prompt representations can still vary.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
Adds deterministic MCP tool schema ordering to improve LLM prompt-cache stability.
Changes:
- Sorts top-level
inputSchema.propertieskeys. - Adds a regression test for top-level ordering.
| File | Description |
|---|---|
python/packages/core/agent_framework/_mcp.py |
Normalizes MCP schema property order. |
python/packages/core/tests/core/test_mcp.py |
Tests deterministic top-level sorting. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| elif isinstance(input_schema.get("properties"), dict): | ||
| # Sort property keys deterministically to preserve prompt cache stability | ||
| input_schema["properties"] = dict(sorted(input_schema["properties"].items())) |
|
@microsoft-github-policy-service agree |
|
Prognosticate X (@Prognosticate-X) The current normalization sorts only the root |
|
I agree with Eduard that sorting only the root MCP 2026-07-28 recommends deterministic ordering of the tools returned by If property ordering is affecting prompt caching, I think we should first demonstrate that at the provider request/cache level. If canonicalization is needed, the provider serialization boundary seems like a better place to apply it consistently across all tools, without mutating the core schema. I’d suggest separating this from the MCP v2 work in #8245 and revisiting the design before merging. |
|
Thanks Jose Alvarez (@jpalvarezl) and Eduard van Valkenburg (@eavanvalkenburg) for the thoughtful feedback! 1. Demonstration at the Provider Request / Wire LevelTo illustrate why key ordering matters for prompt caching in Python, we tested downstream serialization using import hashlib, json
from agent_framework._tools import FunctionTool
# When an MCP server sends dictionary keys in different insertion orders across restarts/reloads:
schema_session_1 = {
"type": "object",
"properties": {
"query": {"type": "string"},
"author": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["query"],
}
schema_session_2 = {
"type": "object",
"properties": {
"limit": {"type": "integer"},
"query": {"type": "string"},
"author": {"type": "string"},
},
"required": ["query"],
}
tool_1 = FunctionTool(name="search_db", description="search", parameters=schema_session_1, func=lambda: None)
tool_2 = FunctionTool(name="search_db", description="search", parameters=schema_session_2, func=lambda: None)
# Downstream provider serialization (Python dict preserves insertion order):
wire_1 = json.dumps(tool_1.to_json_schema_spec(), sort_keys=False)
wire_2 = json.dumps(tool_2.to_json_schema_spec(), sort_keys=False)
print(hashlib.sha256(wire_1.encode()).hexdigest())
# -> 350e265a70214b94b15aa1d1b46b21ba11a1b3491b18f7d60a1ab73739615ceb
print(hashlib.sha256(wire_2.encode()).hexdigest())
# -> 713fef60d8ccc7afa1b735f42e784d154908283f0a216b53323df684877ec6e6
# Result: Byte mismatch -> 100% Prompt Cache Miss on Claude/OpenAI prefix cachingBecause LLM prompt prefix caches (Anthropic Prompt Caching and OpenAI Prefix Caching) operate on exact byte-for-byte token stream prefixes, differing JSON field order breaks the cache boundary and leads to repeated cache misses on server reconnects. 2. Recursive Sorting (Addressing Eduard van Valkenburg (@eavanvalkenburg))I have updated the PR with a recursive
A corresponding unit test 3. Provider Serialization Boundary (Addressing Jose Alvarez (@jpalvarezl))I completely agree with Jose Alvarez (@jpalvarezl)'s architectural insight — canonicalizing at the provider serialization boundary (e.g. inside In the meantime, canonicalizing upon MCP loading guarantees that dynamically discovered external tools never invalidate downstream prompt caches. If the team prefers centralizing this at the provider / |
| if isinstance(schema, dict): | ||
| result: dict[str, Any] = {} | ||
| schema_dict = cast(dict[str, Any], schema) | ||
| for key, val in schema_dict.items(): |
There was a problem hiding this comment.
Prognosticate X (@Prognosticate-X) This remains incomplete even with recursive sorting: root schemas without properties can skip the helper, and schema-object keyword order is still preserved. Rather than extending MCP-specific normalization, please centralize deterministic schema canonicalization at the FunctionTool/provider serialization boundary so every tool source gets the same byte-stable representation, with regressions for root schemas without properties and reordered top-level keywords.
There was a problem hiding this comment.
Thanks Eduard van Valkenburg (@eavanvalkenburg) — completely agree that this belongs at the FunctionTool/provider boundary rather than MCP ingestion.
I have updated the PR accordingly:
- Centralized in
FunctionTool.to_json_schema_spec(): Canonicalization is now applied directly during provider spec serialization via_canonicalize_schema(raw_parameters). In-memory tool schemas remain untouched, while any tool source (native Python tools, Pydantic models, MCP tools, OpenAPI specs) is guaranteed a byte-deterministic schema output. - Comprehensive schema keyword sorting: All dictionary keys (root keywords like
type,title,description, as well asproperties,patternProperties,$defs,definitions,dependentSchemas) and sequences (items,allOf,anyOf,oneOf) are recursively sorted deterministically. - Regressions added:
test_to_json_schema_spec_canonicalizes_reordered_top_level_keywords: verifies byte-identical wire outputs when top-level keywords and subproperties are permuted.test_to_json_schema_spec_canonicalizes_root_schema_without_properties: verifies root schemas omittingproperties(e.g. scalars or zero-argument models) are sorted deterministically.test_to_json_schema_spec_canonicalizes_nested_and_defs_deterministically: verifies deep recursive sorting across$defsand nested object properties.
- Reverted MCP-specific sorting: Removed the sorting helper from
_mcp.pyso MCP ingestion remains clean and focused solely on spec compliance.
…unctionTool boundary
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
Prognosticate X (@Prognosticate-X) The current head canonicalizes schemas only inside FunctionTool.to_json_schema_spec(), but FunctionTool.parameters() still returns the original insertion order and several providers serialize tool.parameters() directly (including Anthropic, Bedrock, Gemini, and Mistral). Those wire schemas therefore remain order-dependent, so the prompt-cache issue is not fixed consistently at the provider boundary.
Please put canonicalization behind a shared serialization helper used by every provider emitter, or canonicalize immediately before each provider builds its request. Keep parameters() raw only if that public behavior must remain unchanged, and add coverage for at least one direct-parameters() provider path.
|
Thanks Eduard van Valkenburg (@eavanvalkenburg) for catching that! You are completely right — providers such as Anthropic, Bedrock, Gemini, and Mistral serialize I have updated the implementation to canonicalize directly within
|

Motivation & Context
In MCP servers across various runtimes (Python, Rust, etc.), the dictionary iteration order of tool
inputSchemaproperties is not guaranteed to be stable across server restarts or dynamic reload notifications (notifications/tools/list_changed).When downstream LLM clients construct function-calling tools from these schemas, any variation in property key ordering alters the byte-level representation of tool definitions injected into model prompts. This causes LLM prompt caches (such as Anthropic and OpenAI prompt caching) to invalidate on restart, leading to unnecessary token overhead.
This PR addresses this problem by sorting
propertieskeys deterministically during MCP tool ingestion inMCPTool.load_tools(). This contributes to the broader MCP 2026-07-28 statelessness & caching initiative tracked in #8245.Description & Review Guide
python/packages/core/agent_framework/_mcp.py: when normalizinginput_schemaduringload_tools(), sortinput_schema["properties"]by key alphabetically if it is a dictionary.python/packages/core/tests/core/test_mcp.py: addedtest_load_tools_sorts_input_schema_properties_deterministically()to verify that property keys are alphabetically ordered in the resultingFunctionTool.parameters().input_schema["properties"]is purely additive and preserves full schema validity.Related Issue
Relates to #8245
Contribution Checklist