Skip to content

feat(tools): inline local $ref in tool inputSchema (#2384)#2448

Open
MukundaKatta wants to merge 3 commits intomodelcontextprotocol:mainfrom
MukundaKatta:feat/dereference-local-refs-in-tool-schemas
Open

feat(tools): inline local $ref in tool inputSchema (#2384)#2448
MukundaKatta wants to merge 3 commits intomodelcontextprotocol:mainfrom
MukundaKatta:feat/dereference-local-refs-in-tool-schemas

Conversation

@MukundaKatta
Copy link
Copy Markdown

Why

Pydantic's `model_json_schema()` emits `$ref`/`$defs` for nested models.
LLM clients consuming `tools/list` often cannot resolve `$ref` — they
serialize referenced parameters as stringified JSON instead of structured
objects. (See e.g. the Notion MCP server case in
anthropics/claude-code#18260.)

typescript-sdk addressed this in
modelcontextprotocol/typescript-sdk#1563
by inlining local `$ref` pointers. This PR adds the Python equivalent
so all SDKs emit LLM-consumable schemas.

What

  • New module `mcp/server/mcpserver/utilities/schema.py` with
    `dereference_local_refs(schema)` that inlines `$ref` pointers to local
    `#/$defs/...` (and legacy `#/definitions/...`).
  • Applied at `Tool.from_function` to the schema produced by pydantic.

Behavior

Case Behavior
Simple local ref Inlined; `$defs` pruned
Diamond (A→D, B→D) D resolved once, both uses share the inlined value
Cycle (Node → Node) Ref left in place; cyclic `$defs` entries preserved
Sibling keywords Merged per JSON Schema 2020-12 (siblings override resolved)
Legacy `definitions` Handled; `$defs` wins if both present
External `$ref` Untouched
Unknown local ref Untouched
No `$defs` in schema Returned unchanged
Input mutation None — original schema preserved

Tests

11 new unit tests in `tests/server/mcpserver/utilities/test_schema.py`
covering each of the cases above. Existing `tools/` tests continue to pass.

Related

…textprotocol#2384)

Pydantic's model_json_schema() emits $ref/$defs for nested models, but LLM
clients consuming tools/list often cannot resolve $ref and serialize
referenced parameters as stringified JSON instead of structured objects.

Inline local $ref pointers in tool schemas so they're self-contained
and LLM-consumable, matching behavior in typescript-sdk (modelcontextprotocol#1563) and go-sdk.

Behavior:
- Caches resolved defs (diamond references resolve each def once)
- Cycles left in place with $defs entries preserved (degraded but correct)
- Sibling keywords alongside $ref preserved per JSON Schema 2020-12
- External $ref and unknown local refs untouched
- Input schema not mutated

Changes:
- Add mcp/server/mcpserver/utilities/schema.py with dereference_local_refs()
- Apply to tool inputSchema at Tool.from_function()
- 11 unit tests covering simple refs, diamond, cycles, siblings, legacy
  'definitions' keyword, external refs, unknown refs, nested arrays/objects,
  input immutability

Closes modelcontextprotocol#2384

Signed-off-by: Mukunda Katta <mukunda.vjcs6@gmail.com>
…delcontextprotocol#2384)

After the dereference_local_refs fix, the UserInput model's properties
land directly under tool.parameters["properties"]["user"]["properties"]
instead of inside the $defs container. Update the test assertions to
match the new (LLM-consumable) schema shape.

This is the only test in test_tool_manager.py that was checking the
$defs location; all other tests check tool behavior or per-property
fields that are unaffected by the inlining.

Signed-off-by: Mukunda Katta <mukunda.vjcs6@gmail.com>
CI was failing on two checks (not test failures — earlier diagnostic was wrong):

1. pre-commit / Ruff Format — base.py had a multi-line wrap ruff wanted single-line.
2. Coverage check — project requires 100% coverage; schema.py was at 92.86%
   with 3 uncovered lines (empty/null $defs short-circuits + a defensive
   non-dict branch).

Changes:
- Apply ruff format to base.py.
- Mark the defensive non-dict branch in inline() with '# pragma: no cover'
  with comment explaining it's only reachable for non-JSON-shaped values.
- Add 3 tests covering the previously-uncovered branches:
  * empty $defs container ('{}')
  * null $defs ('None')
  * refs nested inside a list (anyOf array)

Local verification: 349 server tests pass, dereference_local_refs at 100%
coverage (14 tests), ruff check clean, ruff format clean.

Signed-off-by: Mukunda Katta <mukunda.vjcs6@gmail.com>
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.

1 participant