Python: Sanitize MCP tool schemas before sending to LLM APIs#4607
Python: Sanitize MCP tool schemas before sending to LLM APIs#4607giles17 wants to merge 2 commits intomicrosoft:mainfrom
Conversation
MCP servers (e.g. matlab-mcp-core-server) can produce inputSchema dicts with standard JSON Schema fields that OpenAI-compatible API backends reject. Add sanitize_schema_for_api() utility that: - Resolves $ref pointers inline from $defs/definitions - Strips unsupported root-level keys ($schema, $id, title) - Removes $defs/definitions after resolution - Ensures type: object when properties is present - Deep-copies to avoid mutating cached schemas Apply sanitization in: - OpenAIResponsesClient._prepare_tools_for_openai() (also fixes pre-existing bug where additionalProperties=False mutated the cache) - FunctionTool.to_json_schema_spec() (covers Chat Completions + Ollama) Fixes microsoft#4540 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a Python-side sanitization step for MCP/JSON-Schema tool parameter schemas before sending them to OpenAI-compatible LLM APIs, addressing backends that reject common JSON Schema metadata and $ref/$defs usage (Fixes #4540).
Changes:
- Introduces
sanitize_schema_for_api()(and_resolve_refs()) to inline local$reffrom$defs/definitionsand strip unsupported root-level metadata keys. - Applies schema sanitization in
FunctionTool.to_json_schema_spec()(Chat Completions / Ollama paths) andOpenAIResponsesClient._prepare_tools_for_openai()(Responses path), also avoiding cached-schema mutation. - Adds unit tests for schema sanitization/ref resolution and integration tests for the Responses tool-prep path.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| python/packages/core/agent_framework/_tools.py | Adds schema sanitization + $ref resolution and uses it when emitting JSON-schema tool specs. |
| python/packages/core/agent_framework/openai/_responses_client.py | Sanitizes FunctionTool parameter schemas before building Responses API tool definitions and prevents mutating cached schemas. |
| python/packages/core/tests/core/test_tools.py | Adds unit tests covering sanitization behaviors and _resolve_refs deep-copy behavior. |
| python/packages/core/tests/openai/test_openai_responses_client.py | Adds integration tests validating sanitized tool schemas and no cached-schema mutation in Responses tool prep. |
You can also share your feedback on Copilot code review. Take the survey.
- Merge both $defs and definitions so refs using either prefix resolve - Remove redundant cast(list[Any], ...) flagged by mypy - Add test for schema with both $defs and definitions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
moonbox3
left a comment
There was a problem hiding this comment.
Automated Code Review
Reviewers: 4 | Confidence: 91%
✗ Correctness
The new
_resolve_refsfunction has no guard against circular$refreferences, which are common in JSON Schema (e.g., tree-structured types). A self-referential schema from an MCP server will cause infinite recursion and aRecursionErrorat runtime, crashing the request. Additionally, the unresolvable-$reffallback path skips recursion on sibling values, so nested$refs in those siblings would silently survive unsanitized.
✗ Security Reliability
The new
_resolve_refsfunction has no cycle detection and will hit Python's recursion limit (or stack overflow) on circular$refschemas. Circular references are valid in JSON Schema (e.g., tree or linked-list types) and can appear in schemas from untrusted MCP servers, making this a reliability issue at a trust boundary. The rest of the sanitization logic and tests are solid.
✓ Test Coverage
The new
sanitize_schema_for_apiand_resolve_refsfunctions are thoroughly unit-tested with 13 cases covering empty input, root-key stripping, simple/nested/array $ref resolution, deep-copy safety, and a real-world Go-jsonschema scenario. Integration tests for_prepare_tools_for_openaicover sanitization and non-mutation of cached schemas. However, there are two notable gaps: (1) theto_json_schema_spec()method was also changed to callsanitize_schema_for_apibut has no corresponding test, and (2) there is no test for circular $ref chains, which would currently cause infinite recursion in_resolve_refs. A minor gap is that the sibling-key merging logic in_resolve_refs(keeping e.g.descriptionfrom a $ref node) is exercised by no test.
✗ Design Approach
The approach of sanitizing JSON Schema at the API boundary is sound and well-tested. However,
_resolve_refsperforms recursive inlining of$refpointers without any cycle detection. Circular$refis valid JSON Schema (e.g., a tree node referencing itself) and is common in real-world schemas. This will cause infinite recursion and a stack overflow, which is a design-level gap — the function needs to either detect cycles and stop, or cap recursion depth.
Flagged Issues
- _resolve_refs will infinitely recurse on circular $ref schemas (e.g. a 'Node' type whose 'children' field references itself). This is a common JSON Schema pattern and will cause RecursionError at runtime when an MCP server returns such a schema.
- _resolve_refs has no cycle detection: a circular $ref (e.g., TypeA → TypeB → TypeA, or self-referencing TypeA → TypeA) will cause unbounded recursion and a RecursionError. Since MCP server schemas are untrusted input, this is a denial-of-service / crash risk.
- _resolve_refs has no cycle detection: a self-referential $ref (e.g. a tree node type) will cause infinite recursion / stack overflow. Circular $ref is valid JSON Schema and common in practice. The function should track a visited set of definition names (or cap depth) and emit a best-effort fallback when a cycle is detected.
Suggestions
- Add a
seenset parameter to_resolve_refstracking which definition names are currently being resolved. If a def_name is already inseen, stop recursion (e.g., return the node without the$refkey). This prevents infinite recursion on self-referential schemas. - In the unresolvable-
$reffallback branch, recurse into the remaining sibling values rather than returning them as-is, so any nested$refs in siblings are still resolved. - Add a
_seen(set of ref names) or max-depth parameter to_resolve_refsto break cycles, e.g., treat a re-visited ref the same as an unresolvable one. - Add a test with a self-referencing $ref (and a mutually-recursive pair) to assert graceful handling instead of RecursionError.
- Add a test for
FunctionTool.to_json_schema_spec()with a schema containing$schema/$ref/$defsto verify that code path also sanitizes. Currently only_prepare_tools_for_openaihas integration coverage;to_json_schema_specis a separate call site with no test. - Add a test (and likely a fix) for circular
$refchains (e.g. TypeA → TypeB → TypeA)._resolve_refswill currently recurse infinitely and raiseRecursionError. Consider adding aseenset to guard against cycles. - Add a test for the sibling-key merging behaviour in
_resolve_refs(lines 697-700), e.g. a$refnode that also carries adescriptionoverride — verify the description appears in the resolved output. - Consider stripping
titlefrom nested nodes as well, not just the root — some LLM APIs (e.g. Gemini) rejecttitleat any level in the schema.
Automated review by moonbox3's agents
| defs: The top-level ``$defs`` / ``definitions`` mapping to resolve against. | ||
|
|
||
| Returns: | ||
| A new dict with ``$ref`` pointers replaced by their resolved definitions. | ||
| """ | ||
| if "$ref" in schema: | ||
| ref_path: str = schema["$ref"] | ||
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | ||
| for prefix in ("#/$defs/", "#/definitions/"): |
There was a problem hiding this comment.
Circular $ref bug: if defs['A'] itself contains a $ref back to 'A' (directly or transitively), this _resolve_refs(resolved, defs) call recurses infinitely, raising RecursionError. Add a seen: set[str] parameter (defaulting to an empty set) and skip resolution when def_name in seen.
| defs: The top-level ``$defs`` / ``definitions`` mapping to resolve against. | |
| Returns: | |
| A new dict with ``$ref`` pointers replaced by their resolved definitions. | |
| """ | |
| if "$ref" in schema: | |
| ref_path: str = schema["$ref"] | |
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | |
| for prefix in ("#/$defs/", "#/definitions/"): | |
| if def_name in defs: | |
| if def_name in _seen: | |
| # Circular reference — return without the $ref to break the cycle | |
| return {k: v for k, v in schema.items() if k != "$ref"} | |
| resolved = dict(defs[def_name]) | |
| # Merge any sibling keys (e.g. description) from the referring node | |
| for k, v in schema.items(): | |
| if k != "$ref" and k not in resolved: | |
| resolved[k] = v | |
| return _resolve_refs(resolved, defs, _seen | {def_name}) |
| for prefix in ("#/$defs/", "#/definitions/"): | ||
| if ref_path.startswith(prefix): | ||
| def_name = ref_path[len(prefix) :] | ||
| if def_name in defs: |
There was a problem hiding this comment.
This fallback drops $ref but does not recurse into the remaining sibling values. If any sibling key contains a nested dict with a resolvable $ref, it will not be resolved.
| if def_name in defs: | |
| return _resolve_refs({k: v for k, v in schema.items() if k != "$ref"}, defs) |
| if "$ref" in schema: | ||
| ref_path: str = schema["$ref"] | ||
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | ||
| for prefix in ("#/$defs/", "#/definitions/"): | ||
| if ref_path.startswith(prefix): | ||
| def_name = ref_path[len(prefix) :] | ||
| if def_name in defs: | ||
| resolved = dict(defs[def_name]) | ||
| # Merge any sibling keys (e.g. description) from the referring node | ||
| for k, v in schema.items(): | ||
| if k != "$ref" and k not in resolved: | ||
| resolved[k] = v | ||
| return _resolve_refs(resolved, defs) |
There was a problem hiding this comment.
Circular $ref chains (common in JSON Schema for recursive types like trees) will recurse until RecursionError. Track visited ref names and bail out when a cycle is detected.
| if "$ref" in schema: | |
| ref_path: str = schema["$ref"] | |
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | |
| for prefix in ("#/$defs/", "#/definitions/"): | |
| if ref_path.startswith(prefix): | |
| def_name = ref_path[len(prefix) :] | |
| if def_name in defs: | |
| resolved = dict(defs[def_name]) | |
| # Merge any sibling keys (e.g. description) from the referring node | |
| for k, v in schema.items(): | |
| if k != "$ref" and k not in resolved: | |
| resolved[k] = v | |
| return _resolve_refs(resolved, defs) | |
| if "$ref" in schema: | |
| ref_path: str = schema["$ref"] | |
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | |
| for prefix in ("#/$defs/", "#/definitions/"): | |
| if ref_path.startswith(prefix): | |
| def_name = ref_path[len(prefix) :] | |
| if def_name in defs and def_name not in _seen: | |
| resolved = dict(defs[def_name]) | |
| # Merge any sibling keys (e.g. description) from the referring node | |
| for k, v in schema.items(): | |
| if k != "$ref" and k not in resolved: | |
| resolved[k] = v | |
| return _resolve_refs(resolved, defs, _seen | {def_name}) |
| if ref_path.startswith(prefix): | ||
| def_name = ref_path[len(prefix) :] | ||
| if def_name in defs: | ||
| resolved = dict(defs[def_name]) |
There was a problem hiding this comment.
Circular $ref chains (A → B → A) will cause infinite recursion here. Consider adding a seen: set[str] parameter to detect cycles and break out gracefully. There is no test covering this edge case.
| resolved = dict(defs[def_name]) | |
| def _resolve_refs(schema: dict[str, Any], defs: dict[str, Any], _seen: set[str] | None = None) -> dict[str, Any]: | |
| if _seen is None: | |
| _seen = set() | |
| if "$ref" in schema: | |
| ref_path: str = schema["$ref"] | |
| if ref_path in _seen: | |
| return {k: v for k, v in schema.items() if k != "$ref"} | |
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | |
| for prefix in ("#/$defs/", "#/definitions/"): | |
| if ref_path.startswith(prefix): | |
| def_name = ref_path[len(prefix) :] | |
| if def_name in defs: | |
| resolved = dict(defs[def_name]) | |
| # Merge any sibling keys (e.g. description) from the referring node | |
| for k, v in schema.items(): | |
| if k != "$ref" and k not in resolved: | |
| resolved[k] = v | |
| return _resolve_refs(resolved, defs, _seen | {ref_path}) | |
| # Unresolvable $ref — drop it and keep sibling keys as a best-effort fallback | |
| return {k: v for k, v in schema.items() if k != "$ref"} |
| assert result["properties"]["b"] == {"type": "integer"} | ||
|
|
||
|
|
||
| # endregion |
There was a problem hiding this comment.
There is no test that FunctionTool.to_json_schema_spec() produces a sanitized schema. This is a separate integration point from _prepare_tools_for_openai and should have its own test to prevent regressions.
| assert result["properties"]["b"] == {"type": "integer"} | ||
|
|
||
|
|
||
| # endregion |
There was a problem hiding this comment.
Missing test for _resolve_refs sibling-key merging: when a $ref node has additional keys (e.g. description), those should appear in the resolved output. Currently no test exercises lines 697-700 of _tools.py.
| Returns: | ||
| A new dict with ``$ref`` pointers replaced by their resolved definitions. | ||
| """ | ||
| if "$ref" in schema: | ||
| ref_path: str = schema["$ref"] | ||
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | ||
| for prefix in ("#/$defs/", "#/definitions/"): | ||
| if ref_path.startswith(prefix): | ||
| def_name = ref_path[len(prefix) :] | ||
| if def_name in defs: | ||
| resolved = dict(defs[def_name]) |
There was a problem hiding this comment.
This recursive call will loop forever on circular $ref (e.g., a tree node whose children items reference the node definition itself). Pass a seen: set[str] through the recursion and short-circuit when a definition name is revisited — for example, emit {} or {"type": "object"} as a fallback for the cyclic reference.
| Returns: | |
| A new dict with ``$ref`` pointers replaced by their resolved definitions. | |
| """ | |
| if "$ref" in schema: | |
| ref_path: str = schema["$ref"] | |
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | |
| for prefix in ("#/$defs/", "#/definitions/"): | |
| if ref_path.startswith(prefix): | |
| def_name = ref_path[len(prefix) :] | |
| if def_name in defs: | |
| resolved = dict(defs[def_name]) | |
| if "$ref" in schema: | |
| ref_path: str = schema["$ref"] | |
| # Only handle local fragment references: #/$defs/Name or #/definitions/Name | |
| for prefix in ("#/$defs/", "#/definitions/"): | |
| if ref_path.startswith(prefix): | |
| def_name = ref_path[len(prefix) :] | |
| if def_name in defs: | |
| if def_name in _seen: | |
| # Circular reference — emit best-effort fallback | |
| return {k: v for k, v in schema.items() if k != "$ref"} | |
| resolved = dict(defs[def_name]) | |
| # Merge any sibling keys (e.g. description) from the referring node | |
| for k, v in schema.items(): | |
| if k != "$ref" and k not in resolved: | |
| resolved[k] = v | |
| return _resolve_refs(resolved, defs, _seen | {def_name}) | |
| # Unresolvable $ref — drop it and keep sibling keys as a best-effort fallback | |
| return {k: v for k, v in schema.items() if k != "$ref"} |
Motivation and Context
Fixes #4540
MCP servers built with schema-generating libraries (e.g. Go's
google/jsonschema-go, used by matlab-mcp-core-server) produceinputSchemadicts containing standard JSON Schema fields ($schema,$id,title,$defs,$ref) that OpenAI-compatible API backends (LM Studio, Aliyun) reject with400 invalid_request_error.Simple MCP servers like
mcp-server-calculatorproduce minimal schemas that work fine, making this a class-specific incompatibility rather than a general MCP failure.Description
New utility:
sanitize_schema_for_api()in_tools.pyProduces a clean copy of an MCP tool schema suitable for LLM API
parametersfields:$refpointers inline from$defs/definitions(recursive)$schema,$id,title)$defs/definitionsafter resolutiontype: "object"whenpropertiesis present buttypeis missingApplied in two code paths:
OpenAIResponsesClient._prepare_tools_for_openai()— also fixes a pre-existing bug whereparams["additionalProperties"] = Falsemutated the cached schema dict returned byparameters()FunctionTool.to_json_schema_spec()— covers Chat Completions and Ollama clientsTests:
sanitize_schema_for_apiand_resolve_refsintest_tools.pytest_openai_responses_client.pyvalidating the end-to-end path through_prepare_tools_for_openai()Contribution Checklist