Skip to content

Commit 33774ff

Browse files
committed
Reject external JSON Schema $ref in tool schemas (SEP-2106)
SEP-2106 permits the full JSON Schema 2020-12 vocabulary in tool schemas, including $ref. A $ref resolving to a network URI is an SSRF / fetch-DoS vector, so per the spec implementations MUST NOT auto-dereference any $ref that is not a same-document reference. Add a shared ref-walker that rejects external $refs with ExternalSchemaRefError. The server now rejects them at tool registration; the client rejects them before validating a tool result instead of attempting to resolve them. Burn down the json-schema-ref-no-deref conformance scenario in both expected-failures files and add a driver handler for it.
1 parent fda4c54 commit 33774ff

10 files changed

Lines changed: 271 additions & 8 deletions

File tree

.github/actions/conformance/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,16 @@ async def run_tools_call(server_url: str) -> None:
185185
logger.debug(f"add_numbers result: {result}")
186186

187187

188+
@register("json-schema-ref-no-deref")
189+
async def run_json_schema_ref_no_deref(server_url: str) -> None:
190+
"""List tools whose schemas contain a network `$ref`; must not dereference it (SEP-2106)."""
191+
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
192+
async with ClientSession(read_stream, write_stream) as session:
193+
await session.initialize()
194+
tools_result = await session.list_tools()
195+
logger.debug(f"Listed tools without dereferencing network $refs: {[t.name for t in tools_result.tools]}")
196+
197+
188198
@register("sse-retry")
189199
async def run_sse_retry(server_url: str) -> None:
190200
"""Connect, initialize, list tools, call test_reconnection, close."""

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ client:
6363
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
6464
- http-custom-headers
6565
- http-invalid-tool-headers
66-
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
67-
- json-schema-ref-no-deref
6866
# SEP-2468 (authorization response iss parameter): not implemented in the client.
6967
- auth/iss-supported
7068
- auth/iss-not-advertised

.github/actions/conformance/expected-failures.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ client:
2222
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
2323
- http-custom-headers
2424
- http-invalid-tool-headers
25-
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
26-
- json-schema-ref-no-deref
2725
# SEP-2468 (authorization response iss parameter): not implemented in the client.
2826
- auth/iss-supported
2927
- auth/iss-not-advertised

docs/migration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,17 @@ Results returned from server handlers are now validated against the negotiated p
240240

241241
`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp.types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras).
242242

243+
### External JSON Schema `$ref`s are rejected (SEP-2106)
244+
245+
SEP-2106 permits the full JSON Schema 2020-12 vocabulary in tool schemas, including `$ref`. A `$ref` that resolves to a network URI is an SSRF / fetch-DoS vector, so per the spec implementations MUST NOT automatically dereference any `$ref` that is not a same-document reference (a JSON Pointer such as `#/$defs/Foo` or an `$anchor` such as `#Foo`).
246+
247+
The SDK never dereferences such refs and now rejects them outright with `ExternalSchemaRefError` (a `ValueError` subclass, importable from `mcp.shared.json_schema_ref`):
248+
249+
- **Server:** registering a tool whose generated input or output schema contains an external `$ref` raises at registration time. Schemas Pydantic generates from your type hints only ever use same-document refs, so this affects you only if you smuggle an external `$ref` into a schema (e.g. via `Field(json_schema_extra=...)`).
250+
- **Client:** validating a tool result whose output schema contains an external `$ref` raises instead of attempting to resolve it.
251+
252+
To migrate, inline the referenced schema or replace the external `$ref` with a same-document reference into `$defs`.
253+
243254
### `args` parameter removed from `ClientSessionGroup.call_tool()`
244255

245256
The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead.

src/mcp/client/session.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from mcp.shared._compat import resync_tracer
1818
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
1919
from mcp.shared.exceptions import MCPError
20+
from mcp.shared.json_schema_ref import reject_external_refs
2021
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2122
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2223
from mcp.shared.session import RequestResponder
@@ -449,7 +450,14 @@ async def call_tool(
449450
*,
450451
meta: RequestParamsMeta | None = None,
451452
) -> types.CallToolResult:
452-
"""Send a tools/call request with optional progress callback support."""
453+
"""Send a tools/call request with optional progress callback support.
454+
455+
Raises:
456+
RuntimeError: If the tool declares an output schema but the result's structured
457+
content is missing or fails validation against it.
458+
ExternalSchemaRefError: If the tool's output schema contains a `$ref` that is not
459+
a same-document reference; such refs are never dereferenced (SEP-2106).
460+
"""
453461

454462
result = await self.send_request(
455463
types.CallToolRequest(
@@ -482,6 +490,7 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) -
482490

483491
if result.structured_content is None:
484492
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
493+
reject_external_refs(output_schema, context=f"Output schema for tool {name!r}")
485494
try:
486495
validate(result.structured_content, output_schema)
487496
except ValidationError as e:

src/mcp/server/mcpserver/tools/base.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
1212
from mcp.shared._callable_inspection import is_async_callable
1313
from mcp.shared.exceptions import UrlElicitationRequiredError
14+
from mcp.shared.json_schema_ref import reject_external_refs
1415
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1516
from mcp.types import Icon, ToolAnnotations
1617

@@ -53,7 +54,12 @@ def from_function(
5354
meta: dict[str, Any] | None = None,
5455
structured_output: bool | None = None,
5556
) -> Tool:
56-
"""Create a Tool from a function."""
57+
"""Create a Tool from a function.
58+
59+
Raises:
60+
ExternalSchemaRefError: If the generated input or output schema contains a
61+
`$ref` that is not a same-document reference (SEP-2106).
62+
"""
5763
func_name = name or fn.__name__
5864

5965
validate_and_warn_tool_name(func_name)
@@ -74,6 +80,10 @@ def from_function(
7480
)
7581
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
7682

83+
reject_external_refs(parameters, context=f"Input schema for tool {func_name!r}")
84+
if func_arg_metadata.output_schema is not None:
85+
reject_external_refs(func_arg_metadata.output_schema, context=f"Output schema for tool {func_name!r}")
86+
7787
return cls(
7888
fn=fn,
7989
name=func_name,

src/mcp/shared/json_schema_ref.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""External `$ref` detection for JSON Schemas (SEP-2106).
2+
3+
SEP-2106 permits the full JSON Schema 2020-12 vocabulary in tool schemas,
4+
including `$ref`. A `$ref` resolving to a network URI is an SSRF / fetch-DoS
5+
vector: implementations MUST NOT automatically dereference `$ref` values that
6+
are not same-document references (a JSON Pointer such as `#/$defs/Foo` or an
7+
`$anchor` such as `#Foo`).
8+
9+
See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any, cast
15+
16+
17+
class ExternalSchemaRefError(ValueError):
18+
"""A JSON Schema contains a `$ref` that is not a same-document reference."""
19+
20+
21+
def is_same_document_ref(ref: str) -> bool:
22+
"""Whether `ref` is a same-document reference (`#`, `#/...` pointer, or `#anchor`)."""
23+
return ref.startswith("#")
24+
25+
26+
def find_external_refs(schema: Any) -> list[str]:
27+
"""Collect every `$ref` in `schema` that is not a same-document reference."""
28+
external: list[str] = []
29+
_walk(schema, external)
30+
return external
31+
32+
33+
def reject_external_refs(schema: Any, *, context: str) -> None:
34+
"""Raise `ExternalSchemaRefError` if `schema` contains a non-same-document `$ref`.
35+
36+
Args:
37+
schema: The JSON Schema (or fragment) to inspect.
38+
context: Human-readable label for the schema, used in the error message.
39+
40+
Raises:
41+
ExternalSchemaRefError: If any `$ref` is not a same-document reference.
42+
"""
43+
external = find_external_refs(schema)
44+
if external:
45+
raise ExternalSchemaRefError(
46+
f"{context} contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): "
47+
f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed."
48+
)
49+
50+
51+
def _walk(node: Any, external: list[str]) -> None:
52+
if isinstance(node, dict):
53+
mapping = cast("dict[str, Any]", node)
54+
ref = mapping.get("$ref")
55+
if isinstance(ref, str) and not is_same_document_ref(ref):
56+
external.append(ref)
57+
for value in mapping.values():
58+
_walk(value, external)
59+
elif isinstance(node, list):
60+
for item in cast("list[Any]", node):
61+
_walk(item, external)

tests/client/test_output_schema_validation.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from mcp import Client
77
from mcp.server import Server, ServerRequestContext
8+
from mcp.shared.json_schema_ref import ExternalSchemaRefError
89
from mcp.types import (
910
CallToolRequestParams,
1011
CallToolResult,
@@ -163,3 +164,41 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams)
163164
assert result.is_error is False
164165

165166
assert "Tool mystery_tool not listed" in caplog.text
167+
168+
169+
@pytest.mark.anyio
170+
async def test_client_does_not_dereference_network_refs():
171+
"""SEP-2106: the client MUST NOT auto-dereference network `$ref`s in tool schemas.
172+
173+
A tool advertises an input and an output schema containing a network-URI `$ref`.
174+
Listing the tool leaves the input schema untouched (the network ref is never
175+
resolved), and validating its result rejects the external output-schema ref
176+
outright instead of fetching it.
177+
"""
178+
canary_ref = "https://canary.invalid/profile-schema.json"
179+
180+
input_schema = {"type": "object", "properties": {"profile": {"$ref": canary_ref}}}
181+
output_schema = {
182+
"type": "object",
183+
"properties": {"result": {"$ref": canary_ref}, "ok": {"type": "boolean"}},
184+
"required": ["ok"],
185+
}
186+
187+
server = _make_server(
188+
tools=[
189+
Tool(
190+
name="lookup",
191+
description="Look something up",
192+
input_schema=input_schema,
193+
output_schema=output_schema,
194+
)
195+
],
196+
structured_content={"ok": True},
197+
)
198+
199+
async with Client(server) as client:
200+
tools = await client.list_tools()
201+
assert tools.tools[0].input_schema == input_schema
202+
203+
with pytest.raises(ExternalSchemaRefError, match="canary.invalid"):
204+
await client.call_tool("lookup", {})

tests/server/mcpserver/test_tool_manager.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import json
22
import logging
33
from dataclasses import dataclass
4-
from typing import Any, TypedDict
4+
from typing import Annotated, Any, TypedDict
55

66
import pytest
7-
from pydantic import BaseModel
7+
from pydantic import BaseModel, Field
88

99
from mcp.server.context import LifespanContextT, RequestT
1010
from mcp.server.mcpserver import Context, MCPServer
1111
from mcp.server.mcpserver.exceptions import ToolError
1212
from mcp.server.mcpserver.tools import Tool, ToolManager
1313
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
14+
from mcp.shared.json_schema_ref import ExternalSchemaRefError
1415
from mcp.types import CallToolResult, TextContent, ToolAnnotations
1516

1617

@@ -904,3 +905,46 @@ def test_func() -> str: # pragma: no cover
904905
# Remove with correct case
905906
manager.remove_tool("test_func")
906907
assert manager.get_tool("test_func") is None
908+
909+
910+
def test_add_tool_rejects_external_input_ref():
911+
"""SEP-2106: a tool whose input schema carries an external $ref is rejected at registration."""
912+
913+
def lookup(
914+
profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})],
915+
) -> None: # pragma: no cover
916+
...
917+
918+
manager = ToolManager()
919+
with pytest.raises(ExternalSchemaRefError, match="Input schema for tool 'lookup'"):
920+
manager.add_tool(lookup)
921+
assert manager.get_tool("lookup") is None
922+
923+
924+
def test_add_tool_rejects_external_output_ref():
925+
"""SEP-2106: a tool whose output schema carries an external $ref is rejected at registration."""
926+
927+
class Out(BaseModel):
928+
value: Annotated[str, Field(json_schema_extra={"$ref": "https://evil.example/out.json"})]
929+
930+
def lookup() -> Out: # pragma: no cover
931+
...
932+
933+
manager = ToolManager()
934+
with pytest.raises(ExternalSchemaRefError, match="Output schema for tool 'lookup'"):
935+
manager.add_tool(lookup)
936+
assert manager.get_tool("lookup") is None
937+
938+
939+
def test_add_tool_allows_same_document_refs():
940+
"""Pydantic-generated `#/$defs/...` refs from nested models must pass registration."""
941+
942+
class Inner(BaseModel):
943+
x: int
944+
945+
def good(inner: Inner) -> Inner: # pragma: no cover
946+
...
947+
948+
manager = ToolManager()
949+
tool = manager.add_tool(good)
950+
assert "$defs" in tool.parameters
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from mcp.shared.json_schema_ref import (
6+
ExternalSchemaRefError,
7+
find_external_refs,
8+
is_same_document_ref,
9+
reject_external_refs,
10+
)
11+
12+
13+
@pytest.mark.parametrize(
14+
"ref",
15+
[
16+
"#",
17+
"#/$defs/Foo",
18+
"#/properties/bar",
19+
"#Foo",
20+
],
21+
)
22+
def test_same_document_refs_allowed(ref: str):
23+
assert is_same_document_ref(ref) is True
24+
schema = {"type": "object", "properties": {"x": {"$ref": ref}}}
25+
assert find_external_refs(schema) == []
26+
reject_external_refs(schema, context="schema")
27+
28+
29+
@pytest.mark.parametrize(
30+
"ref",
31+
[
32+
"https://example.com/schema.json",
33+
"http://localhost:9999/canary.json",
34+
"https://example.com/schema.json#/$defs/Foo",
35+
"urn:example:schema",
36+
"file:///etc/passwd",
37+
"schema.json",
38+
"./local.json",
39+
"//example.com/schema.json",
40+
],
41+
)
42+
def test_external_refs_detected(ref: str):
43+
assert is_same_document_ref(ref) is False
44+
schema = {"type": "object", "properties": {"x": {"$ref": ref}}}
45+
assert find_external_refs(schema) == [ref]
46+
47+
48+
def test_reject_external_refs_raises_with_context():
49+
schema = {"properties": {"x": {"$ref": "https://evil.example/s.json"}}}
50+
with pytest.raises(ExternalSchemaRefError) as exc_info:
51+
reject_external_refs(schema, context="Output schema for tool 'lookup'")
52+
message = str(exc_info.value)
53+
assert "Output schema for tool 'lookup'" in message
54+
assert "https://evil.example/s.json" in message
55+
56+
57+
def test_find_external_refs_nested_in_lists_and_composition():
58+
schema = {
59+
"type": "object",
60+
"allOf": [
61+
{"properties": {"a": {"$ref": "#/$defs/A"}}},
62+
{"properties": {"b": {"$ref": "https://example.com/b.json"}}},
63+
],
64+
"items": [{"$ref": "https://example.com/c.json"}],
65+
"$defs": {"A": {"type": "string"}},
66+
}
67+
assert sorted(find_external_refs(schema)) == [
68+
"https://example.com/b.json",
69+
"https://example.com/c.json",
70+
]
71+
72+
73+
def test_non_string_ref_is_ignored():
74+
schema = {"$ref": {"not": "a string"}, "properties": {"x": {"$ref": 123}}}
75+
assert find_external_refs(schema) == []
76+
77+
78+
def test_scalar_and_empty_inputs():
79+
assert find_external_refs(None) == []
80+
assert find_external_refs("just a string") == []
81+
assert find_external_refs(42) == []
82+
assert find_external_refs({}) == []
83+
assert find_external_refs([]) == []

0 commit comments

Comments
 (0)