Skip to content

Commit b0786d1

Browse files
committed
Reject external $ref via the tool schema generator
Move external-$ref rejection into StrictJsonSchema (now in its own _schema_generator.py) so it happens during schema generation, driven by the schema_generator parameter of model_json_schema, rather than walking the schema afterwards. The output-schema path re-raises ExternalSchemaRefError instead of swallowing it as an unserializable-type ValueError. Drop the standalone mcp.shared.json_schema_ref helper and the client-side check (the client receives schemas, it never generates them, and already never fetches network refs).
1 parent 69ea4fb commit b0786d1

9 files changed

Lines changed: 112 additions & 214 deletions

File tree

src/mcp/client/session.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
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
2120
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2221
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2322
from mcp.shared.session import RequestResponder
@@ -450,14 +449,7 @@ async def call_tool(
450449
*,
451450
meta: RequestParamsMeta | None = None,
452451
) -> types.CallToolResult:
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-
"""
452+
"""Send a tools/call request with optional progress callback support."""
461453

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

491483
if result.structured_content is None:
492484
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}")
494485
try:
495486
validate(result.structured_content, output_schema)
496487
except ValidationError as e:

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
from pydantic import BaseModel, Field
88

99
from mcp.server.mcpserver.exceptions import ToolError
10+
from mcp.server.mcpserver.utilities._schema_generator import StrictJsonSchema
1011
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
1112
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
1213
from mcp.shared._callable_inspection import is_async_callable
1314
from mcp.shared.exceptions import UrlElicitationRequiredError
14-
from mcp.shared.json_schema_ref import reject_external_refs
1515
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1616
from mcp.types import Icon, ToolAnnotations
1717

@@ -78,11 +78,7 @@ def from_function(
7878
skip_names=[context_kwarg] if context_kwarg is not None else [],
7979
structured_output=structured_output,
8080
)
81-
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
82-
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}")
81+
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True, schema_generator=StrictJsonSchema)
8682

8783
return cls(
8884
fn=fn,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""JSON Schema generator for tool schemas.
2+
3+
Centralizes the `GenerateJsonSchema` subclass used when rendering tool input and
4+
output schemas. On top of turning pydantic's schema warnings into errors, it
5+
enforces SEP-2106: a `$ref` that is not a same-document reference (a JSON Pointer
6+
such as `#/$defs/Foo` or an `$anchor` such as `#Foo`) is an SSRF / fetch-DoS
7+
vector and MUST NOT appear in a tool schema.
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+
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue, JsonSchemaWarningKind
17+
from pydantic_core import CoreSchema
18+
19+
20+
class ExternalSchemaRefError(ValueError):
21+
"""A tool schema contains a `$ref` that is not a same-document reference."""
22+
23+
24+
class StrictJsonSchema(GenerateJsonSchema):
25+
"""Render tool schemas, raising on pydantic warnings and external `$ref`s.
26+
27+
Warnings (e.g. a non-serializable type) become errors so they surface at tool
28+
registration instead of silently producing a degenerate schema. External
29+
`$ref`s -- which pydantic never emits itself, but a user can inject via
30+
`Field(json_schema_extra=...)` -- are rejected for the same reason (SEP-2106).
31+
"""
32+
33+
def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
34+
raise ValueError(f"JSON schema warning: {kind} - {detail}")
35+
36+
def generate(self, schema: CoreSchema, mode: Any = "validation") -> JsonSchemaValue:
37+
json_schema = super().generate(schema, mode)
38+
_reject_external_refs(json_schema)
39+
return json_schema
40+
41+
42+
def _reject_external_refs(json_schema: JsonSchemaValue) -> None:
43+
external = sorted(_find_external_refs(json_schema))
44+
if external:
45+
raise ExternalSchemaRefError(
46+
f"Tool schema 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 _find_external_refs(node: Any) -> set[str]:
52+
external: set[str] = set()
53+
if isinstance(node, dict):
54+
mapping = cast("dict[str, Any]", node)
55+
ref = mapping.get("$ref")
56+
if isinstance(ref, str) and not ref.startswith("#"):
57+
external.add(ref)
58+
for value in mapping.values():
59+
external |= _find_external_refs(value)
60+
elif isinstance(node, list):
61+
for item in cast("list[Any]", node):
62+
external |= _find_external_refs(item)
63+
return external

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

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
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, JsonSchemaWarningKind
1514
from typing_extensions import is_typeddict
1615
from typing_inspection.introspection import (
1716
UNKNOWN,
@@ -22,24 +21,14 @@
2221
)
2322

2423
from mcp.server.mcpserver.exceptions import InvalidSignature
24+
from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError, StrictJsonSchema
2525
from mcp.server.mcpserver.utilities.logging import get_logger
2626
from mcp.server.mcpserver.utilities.types import Audio, Image
2727
from mcp.types import CallToolResult, ContentBlock, TextContent
2828

2929
logger = get_logger(__name__)
3030

3131

32-
class StrictJsonSchema(GenerateJsonSchema):
33-
"""A JSON schema generator that raises exceptions instead of emitting warnings.
34-
35-
This is used to detect non-serializable types during schema generation.
36-
"""
37-
38-
def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
39-
# Raise an exception instead of emitting a warning
40-
raise ValueError(f"JSON schema warning: {kind} - {detail}")
41-
42-
4332
class ArgModelBase(BaseModel):
4433
"""A model representing the arguments to a function."""
4534

@@ -398,6 +387,9 @@ def _try_create_model_and_schema(
398387
# Use StrictJsonSchema to raise exceptions instead of warnings
399388
try:
400389
schema = model.model_json_schema(schema_generator=StrictJsonSchema)
390+
except ExternalSchemaRefError:
391+
# SEP-2106: an external $ref is a hard error, not an unserializable type.
392+
raise
401393
except (
402394
PydanticUserError,
403395
TypeError,

src/mcp/shared/json_schema_ref.py

Lines changed: 0 additions & 61 deletions
This file was deleted.

tests/client/test_output_schema_validation.py

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

66
from mcp import Client
77
from mcp.server import Server, ServerRequestContext
8-
from mcp.shared.json_schema_ref import ExternalSchemaRefError
98
from mcp.types import (
109
CallToolRequestParams,
1110
CallToolResult,
@@ -164,41 +163,3 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams)
164163
assert result.is_error is False
165164

166165
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", {})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from typing import Annotated, Any
4+
5+
import pytest
6+
from pydantic import BaseModel, Field
7+
8+
from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError, StrictJsonSchema
9+
10+
11+
def test_same_document_refs_pass():
12+
class Inner(BaseModel):
13+
x: int
14+
15+
class Model(BaseModel):
16+
inner: Inner
17+
18+
schema = Model.model_json_schema(schema_generator=StrictJsonSchema)
19+
assert "$defs" in schema
20+
assert schema["properties"]["inner"]["$ref"] == "#/$defs/Inner"
21+
22+
23+
def test_external_ref_in_property_rejected():
24+
class Model(BaseModel):
25+
profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})]
26+
27+
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"):
28+
Model.model_json_schema(schema_generator=StrictJsonSchema)
29+
30+
31+
def test_external_ref_nested_in_list_rejected():
32+
class Model(BaseModel):
33+
items: Annotated[
34+
list[str],
35+
Field(json_schema_extra={"prefixItems": [{"$ref": "https://evil.example/a.json"}]}),
36+
]
37+
38+
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/a.json"):
39+
Model.model_json_schema(schema_generator=StrictJsonSchema)

tests/server/mcpserver/test_tool_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
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._schema_generator import ExternalSchemaRefError
1314
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
14-
from mcp.shared.json_schema_ref import ExternalSchemaRefError
1515
from mcp.types import CallToolResult, TextContent, ToolAnnotations
1616

1717

@@ -916,7 +916,7 @@ def lookup(
916916
...
917917

918918
manager = ToolManager()
919-
with pytest.raises(ExternalSchemaRefError, match="Input schema for tool 'lookup'"):
919+
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"):
920920
manager.add_tool(lookup)
921921
assert manager.get_tool("lookup") is None
922922

@@ -931,7 +931,7 @@ def lookup() -> Out: # pragma: no cover
931931
...
932932

933933
manager = ToolManager()
934-
with pytest.raises(ExternalSchemaRefError, match="Output schema for tool 'lookup'"):
934+
with pytest.raises(ExternalSchemaRefError, match="https://evil.example/out.json"):
935935
manager.add_tool(lookup)
936936
assert manager.get_tool("lookup") is None
937937

0 commit comments

Comments
 (0)