Skip to content

Commit a53b892

Browse files
committed
Align SEP-2106 $ref handling to the spec
The SEP requires implementations to never auto-dereference network $refs (a MUST NOT, already satisfied: the SDK has no $ref-fetching code) and to reject a schema only when validation hits an unresolved external $ref (a conditional SHOULD). Drop the eager registration-time rejection added earlier - it forbade legitimate, never-fetched external $refs, going beyond the spec. Keep the conformance burn-down and add a client test confirming an unexercised network $ref in an output schema is not dereferenced.
1 parent 4d8f900 commit a53b892

6 files changed

Lines changed: 46 additions & 136 deletions

File tree

docs/migration.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,6 @@ 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 in tool schemas (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 never 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-
Registering a tool whose generated input or output schema contains an external `$ref` now raises `ExternalSchemaRefError` (from `mcp.server.mcpserver.utilities.func_metadata`). Schemas pydantic generates from your type hints only ever use same-document refs, so this affects you only if you inject an external `$ref` into a schema, e.g. via `Field(json_schema_extra=...)`. To migrate, inline the referenced schema or point the `$ref` at a same-document `$defs` entry.
248-
249243
### `args` parameter removed from `ClientSessionGroup.call_tool()`
250244

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

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from mcp.server.mcpserver.exceptions import ToolError
1010
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
11-
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, StrictJsonSchema, func_metadata
11+
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
1414
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
@@ -53,12 +53,7 @@ def from_function(
5353
meta: dict[str, Any] | None = None,
5454
structured_output: bool | None = None,
5555
) -> Tool:
56-
"""Create a Tool from a function.
57-
58-
Raises:
59-
ExternalSchemaRefError: If the generated input or output schema contains a
60-
`$ref` that is not a same-document reference (SEP-2106).
61-
"""
56+
"""Create a Tool from a function."""
6257
func_name = name or fn.__name__
6358

6459
validate_and_warn_tool_name(func_name)
@@ -77,7 +72,7 @@ def from_function(
7772
skip_names=[context_kwarg] if context_kwarg is not None else [],
7873
structured_output=structured_output,
7974
)
80-
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True, schema_generator=StrictJsonSchema)
75+
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
8176

8277
return cls(
8378
fn=fn,

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
import pydantic_core
1212
from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model
1313
from pydantic.fields import FieldInfo
14-
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue, JsonSchemaWarningKind
15-
from pydantic_core import CoreSchema
14+
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
1615
from typing_extensions import is_typeddict
1716
from typing_inspection.introspection import (
1817
UNKNOWN,
@@ -30,55 +29,16 @@
3029
logger = get_logger(__name__)
3130

3231

33-
class ExternalSchemaRefError(Exception):
34-
"""A tool schema contains a `$ref` that is not a same-document reference.
35-
36-
Deliberately not a `ValueError`: schema generation treats a `ValueError` as
37-
an unserializable type and degrades gracefully, but an external `$ref` is a
38-
hard error that must surface at tool registration.
39-
"""
40-
41-
4232
class StrictJsonSchema(GenerateJsonSchema):
43-
"""Render tool schemas, raising on pydantic warnings and external `$ref`s.
33+
"""A JSON schema generator that raises exceptions instead of emitting warnings.
4434
45-
Warnings (e.g. a non-serializable type) become errors so they surface at tool
46-
registration instead of silently producing a degenerate schema. External
47-
`$ref`s, which pydantic never emits itself but a user can inject via
48-
`Field(json_schema_extra=...)`, are an SSRF / fetch-DoS vector and are
49-
rejected for the same reason (SEP-2106).
50-
51-
See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications
35+
This is used to detect non-serializable types during schema generation.
5236
"""
5337

5438
def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
39+
# Raise an exception instead of emitting a warning
5540
raise ValueError(f"JSON schema warning: {kind} - {detail}")
5641

57-
def generate(self, schema: CoreSchema, mode: Any = "validation") -> JsonSchemaValue:
58-
json_schema = super().generate(schema, mode)
59-
external = sorted(_find_external_refs(json_schema))
60-
if external:
61-
raise ExternalSchemaRefError(
62-
f"Tool schema contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): "
63-
f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed."
64-
)
65-
return json_schema
66-
67-
68-
def _find_external_refs(node: Any) -> set[str]:
69-
external: set[str] = set()
70-
if isinstance(node, dict):
71-
mapping = cast("dict[str, Any]", node)
72-
ref = mapping.get("$ref")
73-
if isinstance(ref, str) and not ref.startswith("#"):
74-
external.add(ref)
75-
for value in mapping.values():
76-
external |= _find_external_refs(value)
77-
elif isinstance(node, list):
78-
for item in cast("list[Any]", node):
79-
external |= _find_external_refs(item)
80-
return external
81-
8242

8343
class ArgModelBase(BaseModel):
8444
"""A model representing the arguments to a function."""

tests/client/test_output_schema_validation.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,38 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams)
163163
assert result.is_error is False
164164

165165
assert "Tool mystery_tool not listed" in caplog.text
166+
167+
168+
@pytest.mark.anyio
169+
async def test_client_does_not_dereference_network_ref():
170+
"""SEP-2106: validating a result must not fetch a network `$ref` in the output schema.
171+
172+
The output schema references a network URI under a property the structured content
173+
never sets, so a compliant client validates without resolving (and therefore without
174+
fetching) the ref.
175+
"""
176+
output_schema = {
177+
"type": "object",
178+
"properties": {
179+
"ok": {"type": "boolean"},
180+
"profile": {"$ref": "https://canary.invalid/profile-schema.json"},
181+
},
182+
"required": ["ok"],
183+
}
184+
185+
server = _make_server(
186+
tools=[
187+
Tool(
188+
name="lookup",
189+
description="Look something up",
190+
input_schema={"type": "object"},
191+
output_schema=output_schema,
192+
)
193+
],
194+
structured_content={"ok": True},
195+
)
196+
197+
async with Client(server) as client:
198+
result = await client.call_tool("lookup", {})
199+
assert result.is_error is False
200+
assert result.structured_content == {"ok": True}

tests/server/mcpserver/test_func_metadata.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic import BaseModel, Field
1414

1515
from mcp.server.mcpserver.exceptions import InvalidSignature
16-
from mcp.server.mcpserver.utilities.func_metadata import ExternalSchemaRefError, StrictJsonSchema, func_metadata
16+
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1717
from mcp.types import CallToolResult
1818

1919

@@ -1191,34 +1191,3 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc
11911191

11921192
assert meta.output_schema is not None
11931193
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
1194-
1195-
1196-
def test_strict_json_schema_allows_same_document_refs():
1197-
class Inner(BaseModel):
1198-
x: int
1199-
1200-
class Model(BaseModel):
1201-
inner: Inner
1202-
1203-
schema = Model.model_json_schema(schema_generator=StrictJsonSchema)
1204-
assert "$defs" in schema
1205-
assert schema["properties"]["inner"]["$ref"] == "#/$defs/Inner"
1206-
1207-
1208-
def test_strict_json_schema_rejects_external_ref_in_property():
1209-
class Model(BaseModel):
1210-
profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})]
1211-
1212-
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"):
1213-
Model.model_json_schema(schema_generator=StrictJsonSchema)
1214-
1215-
1216-
def test_strict_json_schema_rejects_external_ref_nested_in_list():
1217-
class Model(BaseModel):
1218-
items: Annotated[
1219-
list[str],
1220-
Field(json_schema_extra={"prefixItems": [{"$ref": "https://evil.example/a.json"}]}),
1221-
]
1222-
1223-
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/a.json"):
1224-
Model.model_json_schema(schema_generator=StrictJsonSchema)

tests/server/mcpserver/test_tool_manager.py

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

66
import pytest
7-
from pydantic import BaseModel, Field
7+
from pydantic import BaseModel
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
13-
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, ExternalSchemaRefError, FuncMetadata
13+
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
1414
from mcp.types import CallToolResult, TextContent, ToolAnnotations
1515

1616

@@ -904,46 +904,3 @@ def test_func() -> str: # pragma: no cover
904904
# Remove with correct case
905905
manager.remove_tool("test_func")
906906
assert manager.get_tool("test_func") is None
907-
908-
909-
def test_add_tool_rejects_external_input_ref():
910-
"""SEP-2106: a tool whose input schema carries an external $ref is rejected at registration."""
911-
912-
def lookup(
913-
profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})],
914-
) -> None: # pragma: no cover
915-
...
916-
917-
manager = ToolManager()
918-
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"):
919-
manager.add_tool(lookup)
920-
assert manager.get_tool("lookup") is None
921-
922-
923-
def test_add_tool_rejects_external_output_ref():
924-
"""SEP-2106: a tool whose output schema carries an external $ref is rejected at registration."""
925-
926-
class Out(BaseModel):
927-
value: Annotated[str, Field(json_schema_extra={"$ref": "https://evil.example/out.json"})]
928-
929-
def lookup() -> Out: # pragma: no cover
930-
...
931-
932-
manager = ToolManager()
933-
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/out.json"):
934-
manager.add_tool(lookup)
935-
assert manager.get_tool("lookup") is None
936-
937-
938-
def test_add_tool_allows_same_document_refs():
939-
"""Pydantic-generated `#/$defs/...` refs from nested models must pass registration."""
940-
941-
class Inner(BaseModel):
942-
x: int
943-
944-
def good(inner: Inner) -> Inner: # pragma: no cover
945-
...
946-
947-
manager = ToolManager()
948-
tool = manager.add_tool(good)
949-
assert "$defs" in tool.parameters

0 commit comments

Comments
 (0)