From 33774ff1553ba75a71813428e30304a835378e78 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:24:23 +0200 Subject: [PATCH 01/11] 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. --- .github/actions/conformance/client.py | 10 +++ .../expected-failures.2026-07-28.yml | 2 - .../actions/conformance/expected-failures.yml | 2 - docs/migration.md | 11 +++ src/mcp/client/session.py | 11 ++- src/mcp/server/mcpserver/tools/base.py | 12 ++- src/mcp/shared/json_schema_ref.py | 61 ++++++++++++++ tests/client/test_output_schema_validation.py | 39 +++++++++ tests/server/mcpserver/test_tool_manager.py | 48 ++++++++++- tests/shared/test_json_schema_ref.py | 83 +++++++++++++++++++ 10 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 src/mcp/shared/json_schema_ref.py create mode 100644 tests/shared/test_json_schema_ref.py diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index a438cb29ee..427b7e1ff0 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -185,6 +185,16 @@ async def run_tools_call(server_url: str) -> None: logger.debug(f"add_numbers result: {result}") +@register("json-schema-ref-no-deref") +async def run_json_schema_ref_no_deref(server_url: str) -> None: + """List tools whose schemas contain a network `$ref`; must not dereference it (SEP-2106).""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + tools_result = await session.list_tools() + logger.debug(f"Listed tools without dereferencing network $refs: {[t.name for t in tools_result.tools]}") + + @register("sse-retry") async def run_sse_retry(server_url: str) -> None: """Connect, initialize, list tools, call test_reconnection, close.""" diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 02a3ef81af..4c2fce283b 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -63,8 +63,6 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index cc6071205b..c1f1d71db9 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -22,8 +22,6 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/docs/migration.md b/docs/migration.md index 675c5b747a..a732302eb5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -240,6 +240,17 @@ Results returned from server handlers are now validated against the negotiated p `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). +### External JSON Schema `$ref`s are rejected (SEP-2106) + +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`). + +The SDK never dereferences such refs and now rejects them outright with `ExternalSchemaRefError` (a `ValueError` subclass, importable from `mcp.shared.json_schema_ref`): + +- **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=...)`). +- **Client:** validating a tool result whose output schema contains an external `$ref` raises instead of attempting to resolve it. + +To migrate, inline the referenced schema or replace the external `$ref` with a same-document reference into `$defs`. + ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index cd18a67541..45777fa93a 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -17,6 +17,7 @@ from mcp.shared._compat import resync_tracer from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT from mcp.shared.exceptions import MCPError +from mcp.shared.json_schema_ref import reject_external_refs from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder @@ -449,7 +450,14 @@ async def call_tool( *, meta: RequestParamsMeta | None = None, ) -> types.CallToolResult: - """Send a tools/call request with optional progress callback support.""" + """Send a tools/call request with optional progress callback support. + + Raises: + RuntimeError: If the tool declares an output schema but the result's structured + content is missing or fails validation against it. + ExternalSchemaRefError: If the tool's output schema contains a `$ref` that is not + a same-document reference; such refs are never dereferenced (SEP-2106). + """ result = await self.send_request( types.CallToolRequest( @@ -482,6 +490,7 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) - if result.structured_content is None: raise RuntimeError(f"Tool {name} has an output schema but did not return structured content") + reject_external_refs(output_schema, context=f"Output schema for tool {name!r}") try: validate(result.structured_content, output_schema) except ValidationError as e: diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 754313eb8a..d231e48d09 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -11,6 +11,7 @@ from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.shared.json_schema_ref import reject_external_refs from mcp.shared.tool_name_validation import validate_and_warn_tool_name from mcp.types import Icon, ToolAnnotations @@ -53,7 +54,12 @@ def from_function( meta: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: - """Create a Tool from a function.""" + """Create a Tool from a function. + + Raises: + ExternalSchemaRefError: If the generated input or output schema contains a + `$ref` that is not a same-document reference (SEP-2106). + """ func_name = name or fn.__name__ validate_and_warn_tool_name(func_name) @@ -74,6 +80,10 @@ def from_function( ) parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) + reject_external_refs(parameters, context=f"Input schema for tool {func_name!r}") + if func_arg_metadata.output_schema is not None: + reject_external_refs(func_arg_metadata.output_schema, context=f"Output schema for tool {func_name!r}") + return cls( fn=fn, name=func_name, diff --git a/src/mcp/shared/json_schema_ref.py b/src/mcp/shared/json_schema_ref.py new file mode 100644 index 0000000000..1a24eeb3fd --- /dev/null +++ b/src/mcp/shared/json_schema_ref.py @@ -0,0 +1,61 @@ +"""External `$ref` detection for JSON 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: implementations MUST NOT automatically dereference `$ref` values that +are not same-document references (a JSON Pointer such as `#/$defs/Foo` or an +`$anchor` such as `#Foo`). + +See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications +""" + +from __future__ import annotations + +from typing import Any, cast + + +class ExternalSchemaRefError(ValueError): + """A JSON Schema contains a `$ref` that is not a same-document reference.""" + + +def is_same_document_ref(ref: str) -> bool: + """Whether `ref` is a same-document reference (`#`, `#/...` pointer, or `#anchor`).""" + return ref.startswith("#") + + +def find_external_refs(schema: Any) -> list[str]: + """Collect every `$ref` in `schema` that is not a same-document reference.""" + external: list[str] = [] + _walk(schema, external) + return external + + +def reject_external_refs(schema: Any, *, context: str) -> None: + """Raise `ExternalSchemaRefError` if `schema` contains a non-same-document `$ref`. + + Args: + schema: The JSON Schema (or fragment) to inspect. + context: Human-readable label for the schema, used in the error message. + + Raises: + ExternalSchemaRefError: If any `$ref` is not a same-document reference. + """ + external = find_external_refs(schema) + if external: + raise ExternalSchemaRefError( + f"{context} contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): " + f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed." + ) + + +def _walk(node: Any, external: list[str]) -> None: + if isinstance(node, dict): + mapping = cast("dict[str, Any]", node) + ref = mapping.get("$ref") + if isinstance(ref, str) and not is_same_document_ref(ref): + external.append(ref) + for value in mapping.values(): + _walk(value, external) + elif isinstance(node, list): + for item in cast("list[Any]", node): + _walk(item, external) diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index d78197b5c3..48b03bfbc9 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -5,6 +5,7 @@ from mcp import Client from mcp.server import Server, ServerRequestContext +from mcp.shared.json_schema_ref import ExternalSchemaRefError from mcp.types import ( CallToolRequestParams, CallToolResult, @@ -163,3 +164,41 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) assert result.is_error is False assert "Tool mystery_tool not listed" in caplog.text + + +@pytest.mark.anyio +async def test_client_does_not_dereference_network_refs(): + """SEP-2106: the client MUST NOT auto-dereference network `$ref`s in tool schemas. + + A tool advertises an input and an output schema containing a network-URI `$ref`. + Listing the tool leaves the input schema untouched (the network ref is never + resolved), and validating its result rejects the external output-schema ref + outright instead of fetching it. + """ + canary_ref = "https://canary.invalid/profile-schema.json" + + input_schema = {"type": "object", "properties": {"profile": {"$ref": canary_ref}}} + output_schema = { + "type": "object", + "properties": {"result": {"$ref": canary_ref}, "ok": {"type": "boolean"}}, + "required": ["ok"], + } + + server = _make_server( + tools=[ + Tool( + name="lookup", + description="Look something up", + input_schema=input_schema, + output_schema=output_schema, + ) + ], + structured_content={"ok": True}, + ) + + async with Client(server) as client: + tools = await client.list_tools() + assert tools.tools[0].input_schema == input_schema + + with pytest.raises(ExternalSchemaRefError, match="canary.invalid"): + await client.call_tool("lookup", {}) diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 01b362fb50..958e3a1d2b 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -1,16 +1,17 @@ import json import logging from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Annotated, Any, TypedDict import pytest -from pydantic import BaseModel +from pydantic import BaseModel, Field from mcp.server.context import LifespanContextT, RequestT from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata +from mcp.shared.json_schema_ref import ExternalSchemaRefError from mcp.types import CallToolResult, TextContent, ToolAnnotations @@ -904,3 +905,46 @@ def test_func() -> str: # pragma: no cover # Remove with correct case manager.remove_tool("test_func") assert manager.get_tool("test_func") is None + + +def test_add_tool_rejects_external_input_ref(): + """SEP-2106: a tool whose input schema carries an external $ref is rejected at registration.""" + + def lookup( + profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})], + ) -> None: # pragma: no cover + ... + + manager = ToolManager() + with pytest.raises(ExternalSchemaRefError, match="Input schema for tool 'lookup'"): + manager.add_tool(lookup) + assert manager.get_tool("lookup") is None + + +def test_add_tool_rejects_external_output_ref(): + """SEP-2106: a tool whose output schema carries an external $ref is rejected at registration.""" + + class Out(BaseModel): + value: Annotated[str, Field(json_schema_extra={"$ref": "https://evil.example/out.json"})] + + def lookup() -> Out: # pragma: no cover + ... + + manager = ToolManager() + with pytest.raises(ExternalSchemaRefError, match="Output schema for tool 'lookup'"): + manager.add_tool(lookup) + assert manager.get_tool("lookup") is None + + +def test_add_tool_allows_same_document_refs(): + """Pydantic-generated `#/$defs/...` refs from nested models must pass registration.""" + + class Inner(BaseModel): + x: int + + def good(inner: Inner) -> Inner: # pragma: no cover + ... + + manager = ToolManager() + tool = manager.add_tool(good) + assert "$defs" in tool.parameters diff --git a/tests/shared/test_json_schema_ref.py b/tests/shared/test_json_schema_ref.py new file mode 100644 index 0000000000..578f81d3ad --- /dev/null +++ b/tests/shared/test_json_schema_ref.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pytest + +from mcp.shared.json_schema_ref import ( + ExternalSchemaRefError, + find_external_refs, + is_same_document_ref, + reject_external_refs, +) + + +@pytest.mark.parametrize( + "ref", + [ + "#", + "#/$defs/Foo", + "#/properties/bar", + "#Foo", + ], +) +def test_same_document_refs_allowed(ref: str): + assert is_same_document_ref(ref) is True + schema = {"type": "object", "properties": {"x": {"$ref": ref}}} + assert find_external_refs(schema) == [] + reject_external_refs(schema, context="schema") + + +@pytest.mark.parametrize( + "ref", + [ + "https://example.com/schema.json", + "http://localhost:9999/canary.json", + "https://example.com/schema.json#/$defs/Foo", + "urn:example:schema", + "file:///etc/passwd", + "schema.json", + "./local.json", + "//example.com/schema.json", + ], +) +def test_external_refs_detected(ref: str): + assert is_same_document_ref(ref) is False + schema = {"type": "object", "properties": {"x": {"$ref": ref}}} + assert find_external_refs(schema) == [ref] + + +def test_reject_external_refs_raises_with_context(): + schema = {"properties": {"x": {"$ref": "https://evil.example/s.json"}}} + with pytest.raises(ExternalSchemaRefError) as exc_info: + reject_external_refs(schema, context="Output schema for tool 'lookup'") + message = str(exc_info.value) + assert "Output schema for tool 'lookup'" in message + assert "https://evil.example/s.json" in message + + +def test_find_external_refs_nested_in_lists_and_composition(): + schema = { + "type": "object", + "allOf": [ + {"properties": {"a": {"$ref": "#/$defs/A"}}}, + {"properties": {"b": {"$ref": "https://example.com/b.json"}}}, + ], + "items": [{"$ref": "https://example.com/c.json"}], + "$defs": {"A": {"type": "string"}}, + } + assert sorted(find_external_refs(schema)) == [ + "https://example.com/b.json", + "https://example.com/c.json", + ] + + +def test_non_string_ref_is_ignored(): + schema = {"$ref": {"not": "a string"}, "properties": {"x": {"$ref": 123}}} + assert find_external_refs(schema) == [] + + +def test_scalar_and_empty_inputs(): + assert find_external_refs(None) == [] + assert find_external_refs("just a string") == [] + assert find_external_refs(42) == [] + assert find_external_refs({}) == [] + assert find_external_refs([]) == [] From 69ea4fb7656aa0730bf1689d64acaed2a48e4c2f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:26:48 +0200 Subject: [PATCH 02/11] Drop conformance burn-down and migration docs from this PR Move the json-schema-ref-no-deref driver handler, both expected-failures updates, and the migration guide entry out of this PR; they will land separately. This PR is now scoped to the core SEP-2106 code and tests. --- .github/actions/conformance/client.py | 10 ---------- .../conformance/expected-failures.2026-07-28.yml | 2 ++ .github/actions/conformance/expected-failures.yml | 2 ++ docs/migration.md | 11 ----------- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 427b7e1ff0..a438cb29ee 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -185,16 +185,6 @@ async def run_tools_call(server_url: str) -> None: logger.debug(f"add_numbers result: {result}") -@register("json-schema-ref-no-deref") -async def run_json_schema_ref_no_deref(server_url: str) -> None: - """List tools whose schemas contain a network `$ref`; must not dereference it (SEP-2106).""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tools_result = await session.list_tools() - logger.debug(f"Listed tools without dereferencing network $refs: {[t.name for t in tools_result.tools]}") - - @register("sse-retry") async def run_sse_retry(server_url: str) -> None: """Connect, initialize, list tools, call test_reconnection, close.""" diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 4c2fce283b..02a3ef81af 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -63,6 +63,8 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers + # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. + - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index c1f1d71db9..cc6071205b 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -22,6 +22,8 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers + # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. + - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/docs/migration.md b/docs/migration.md index a732302eb5..675c5b747a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -240,17 +240,6 @@ Results returned from server handlers are now validated against the negotiated p `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). -### External JSON Schema `$ref`s are rejected (SEP-2106) - -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`). - -The SDK never dereferences such refs and now rejects them outright with `ExternalSchemaRefError` (a `ValueError` subclass, importable from `mcp.shared.json_schema_ref`): - -- **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=...)`). -- **Client:** validating a tool result whose output schema contains an external `$ref` raises instead of attempting to resolve it. - -To migrate, inline the referenced schema or replace the external `$ref` with a same-document reference into `$defs`. - ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. From b0786d1628a794accc13fe9f7ce566aece70de52 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:34:31 +0200 Subject: [PATCH 03/11] 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). --- src/mcp/client/session.py | 11 +-- src/mcp/server/mcpserver/tools/base.py | 8 +- .../mcpserver/utilities/_schema_generator.py | 63 ++++++++++++++ .../mcpserver/utilities/func_metadata.py | 16 +--- src/mcp/shared/json_schema_ref.py | 61 -------------- tests/client/test_output_schema_validation.py | 39 --------- .../server/mcpserver/test_schema_generator.py | 39 +++++++++ tests/server/mcpserver/test_tool_manager.py | 6 +- tests/shared/test_json_schema_ref.py | 83 ------------------- 9 files changed, 112 insertions(+), 214 deletions(-) create mode 100644 src/mcp/server/mcpserver/utilities/_schema_generator.py delete mode 100644 src/mcp/shared/json_schema_ref.py create mode 100644 tests/server/mcpserver/test_schema_generator.py delete mode 100644 tests/shared/test_json_schema_ref.py diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 45777fa93a..cd18a67541 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -17,7 +17,6 @@ from mcp.shared._compat import resync_tracer from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT from mcp.shared.exceptions import MCPError -from mcp.shared.json_schema_ref import reject_external_refs from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder @@ -450,14 +449,7 @@ async def call_tool( *, meta: RequestParamsMeta | None = None, ) -> types.CallToolResult: - """Send a tools/call request with optional progress callback support. - - Raises: - RuntimeError: If the tool declares an output schema but the result's structured - content is missing or fails validation against it. - ExternalSchemaRefError: If the tool's output schema contains a `$ref` that is not - a same-document reference; such refs are never dereferenced (SEP-2106). - """ + """Send a tools/call request with optional progress callback support.""" result = await self.send_request( types.CallToolRequest( @@ -490,7 +482,6 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) - if result.structured_content is None: raise RuntimeError(f"Tool {name} has an output schema but did not return structured content") - reject_external_refs(output_schema, context=f"Output schema for tool {name!r}") try: validate(result.structured_content, output_schema) except ValidationError as e: diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index d231e48d09..fc29d79385 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -7,11 +7,11 @@ from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.utilities._schema_generator import StrictJsonSchema from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.shared.json_schema_ref import reject_external_refs from mcp.shared.tool_name_validation import validate_and_warn_tool_name from mcp.types import Icon, ToolAnnotations @@ -78,11 +78,7 @@ def from_function( skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=structured_output, ) - parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) - - reject_external_refs(parameters, context=f"Input schema for tool {func_name!r}") - if func_arg_metadata.output_schema is not None: - reject_external_refs(func_arg_metadata.output_schema, context=f"Output schema for tool {func_name!r}") + parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True, schema_generator=StrictJsonSchema) return cls( fn=fn, diff --git a/src/mcp/server/mcpserver/utilities/_schema_generator.py b/src/mcp/server/mcpserver/utilities/_schema_generator.py new file mode 100644 index 0000000000..ec3f50cb92 --- /dev/null +++ b/src/mcp/server/mcpserver/utilities/_schema_generator.py @@ -0,0 +1,63 @@ +"""JSON Schema generator for tool schemas. + +Centralizes the `GenerateJsonSchema` subclass used when rendering tool input and +output schemas. On top of turning pydantic's schema warnings into errors, it +enforces SEP-2106: a `$ref` that is not a same-document reference (a JSON Pointer +such as `#/$defs/Foo` or an `$anchor` such as `#Foo`) is an SSRF / fetch-DoS +vector and MUST NOT appear in a tool schema. + +See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications +""" + +from __future__ import annotations + +from typing import Any, cast + +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue, JsonSchemaWarningKind +from pydantic_core import CoreSchema + + +class ExternalSchemaRefError(ValueError): + """A tool schema contains a `$ref` that is not a same-document reference.""" + + +class StrictJsonSchema(GenerateJsonSchema): + """Render tool schemas, raising on pydantic warnings and external `$ref`s. + + Warnings (e.g. a non-serializable type) become errors so they surface at tool + registration instead of silently producing a degenerate schema. External + `$ref`s -- which pydantic never emits itself, but a user can inject via + `Field(json_schema_extra=...)` -- are rejected for the same reason (SEP-2106). + """ + + def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: + raise ValueError(f"JSON schema warning: {kind} - {detail}") + + def generate(self, schema: CoreSchema, mode: Any = "validation") -> JsonSchemaValue: + json_schema = super().generate(schema, mode) + _reject_external_refs(json_schema) + return json_schema + + +def _reject_external_refs(json_schema: JsonSchemaValue) -> None: + external = sorted(_find_external_refs(json_schema)) + if external: + raise ExternalSchemaRefError( + f"Tool schema contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): " + f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed." + ) + + +def _find_external_refs(node: Any) -> set[str]: + external: set[str] = set() + if isinstance(node, dict): + mapping = cast("dict[str, Any]", node) + ref = mapping.get("$ref") + if isinstance(ref, str) and not ref.startswith("#"): + external.add(ref) + for value in mapping.values(): + external |= _find_external_refs(value) + elif isinstance(node, list): + for item in cast("list[Any]", node): + external |= _find_external_refs(item) + return external diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 6c553fbab9..76f2872bfa 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -11,7 +11,6 @@ import pydantic_core from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo -from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind from typing_extensions import is_typeddict from typing_inspection.introspection import ( UNKNOWN, @@ -22,6 +21,7 @@ ) from mcp.server.mcpserver.exceptions import InvalidSignature +from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError, StrictJsonSchema from mcp.server.mcpserver.utilities.logging import get_logger from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.types import CallToolResult, ContentBlock, TextContent @@ -29,17 +29,6 @@ logger = get_logger(__name__) -class StrictJsonSchema(GenerateJsonSchema): - """A JSON schema generator that raises exceptions instead of emitting warnings. - - This is used to detect non-serializable types during schema generation. - """ - - def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: - # Raise an exception instead of emitting a warning - raise ValueError(f"JSON schema warning: {kind} - {detail}") - - class ArgModelBase(BaseModel): """A model representing the arguments to a function.""" @@ -398,6 +387,9 @@ def _try_create_model_and_schema( # Use StrictJsonSchema to raise exceptions instead of warnings try: schema = model.model_json_schema(schema_generator=StrictJsonSchema) + except ExternalSchemaRefError: + # SEP-2106: an external $ref is a hard error, not an unserializable type. + raise except ( PydanticUserError, TypeError, diff --git a/src/mcp/shared/json_schema_ref.py b/src/mcp/shared/json_schema_ref.py deleted file mode 100644 index 1a24eeb3fd..0000000000 --- a/src/mcp/shared/json_schema_ref.py +++ /dev/null @@ -1,61 +0,0 @@ -"""External `$ref` detection for JSON 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: implementations MUST NOT automatically dereference `$ref` values that -are not same-document references (a JSON Pointer such as `#/$defs/Foo` or an -`$anchor` such as `#Foo`). - -See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications -""" - -from __future__ import annotations - -from typing import Any, cast - - -class ExternalSchemaRefError(ValueError): - """A JSON Schema contains a `$ref` that is not a same-document reference.""" - - -def is_same_document_ref(ref: str) -> bool: - """Whether `ref` is a same-document reference (`#`, `#/...` pointer, or `#anchor`).""" - return ref.startswith("#") - - -def find_external_refs(schema: Any) -> list[str]: - """Collect every `$ref` in `schema` that is not a same-document reference.""" - external: list[str] = [] - _walk(schema, external) - return external - - -def reject_external_refs(schema: Any, *, context: str) -> None: - """Raise `ExternalSchemaRefError` if `schema` contains a non-same-document `$ref`. - - Args: - schema: The JSON Schema (or fragment) to inspect. - context: Human-readable label for the schema, used in the error message. - - Raises: - ExternalSchemaRefError: If any `$ref` is not a same-document reference. - """ - external = find_external_refs(schema) - if external: - raise ExternalSchemaRefError( - f"{context} contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): " - f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed." - ) - - -def _walk(node: Any, external: list[str]) -> None: - if isinstance(node, dict): - mapping = cast("dict[str, Any]", node) - ref = mapping.get("$ref") - if isinstance(ref, str) and not is_same_document_ref(ref): - external.append(ref) - for value in mapping.values(): - _walk(value, external) - elif isinstance(node, list): - for item in cast("list[Any]", node): - _walk(item, external) diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index 48b03bfbc9..d78197b5c3 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -5,7 +5,6 @@ from mcp import Client from mcp.server import Server, ServerRequestContext -from mcp.shared.json_schema_ref import ExternalSchemaRefError from mcp.types import ( CallToolRequestParams, CallToolResult, @@ -164,41 +163,3 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) assert result.is_error is False assert "Tool mystery_tool not listed" in caplog.text - - -@pytest.mark.anyio -async def test_client_does_not_dereference_network_refs(): - """SEP-2106: the client MUST NOT auto-dereference network `$ref`s in tool schemas. - - A tool advertises an input and an output schema containing a network-URI `$ref`. - Listing the tool leaves the input schema untouched (the network ref is never - resolved), and validating its result rejects the external output-schema ref - outright instead of fetching it. - """ - canary_ref = "https://canary.invalid/profile-schema.json" - - input_schema = {"type": "object", "properties": {"profile": {"$ref": canary_ref}}} - output_schema = { - "type": "object", - "properties": {"result": {"$ref": canary_ref}, "ok": {"type": "boolean"}}, - "required": ["ok"], - } - - server = _make_server( - tools=[ - Tool( - name="lookup", - description="Look something up", - input_schema=input_schema, - output_schema=output_schema, - ) - ], - structured_content={"ok": True}, - ) - - async with Client(server) as client: - tools = await client.list_tools() - assert tools.tools[0].input_schema == input_schema - - with pytest.raises(ExternalSchemaRefError, match="canary.invalid"): - await client.call_tool("lookup", {}) diff --git a/tests/server/mcpserver/test_schema_generator.py b/tests/server/mcpserver/test_schema_generator.py new file mode 100644 index 0000000000..6d165426e6 --- /dev/null +++ b/tests/server/mcpserver/test_schema_generator.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Annotated, Any + +import pytest +from pydantic import BaseModel, Field + +from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError, StrictJsonSchema + + +def test_same_document_refs_pass(): + class Inner(BaseModel): + x: int + + class Model(BaseModel): + inner: Inner + + schema = Model.model_json_schema(schema_generator=StrictJsonSchema) + assert "$defs" in schema + assert schema["properties"]["inner"]["$ref"] == "#/$defs/Inner" + + +def test_external_ref_in_property_rejected(): + class Model(BaseModel): + profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})] + + with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"): + Model.model_json_schema(schema_generator=StrictJsonSchema) + + +def test_external_ref_nested_in_list_rejected(): + class Model(BaseModel): + items: Annotated[ + list[str], + Field(json_schema_extra={"prefixItems": [{"$ref": "https://evil.example/a.json"}]}), + ] + + with pytest.raises(ExternalSchemaRefError, match="https://evil.example/a.json"): + Model.model_json_schema(schema_generator=StrictJsonSchema) diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 958e3a1d2b..9f4419e63d 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -10,8 +10,8 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.tools import Tool, ToolManager +from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata -from mcp.shared.json_schema_ref import ExternalSchemaRefError from mcp.types import CallToolResult, TextContent, ToolAnnotations @@ -916,7 +916,7 @@ def lookup( ... manager = ToolManager() - with pytest.raises(ExternalSchemaRefError, match="Input schema for tool 'lookup'"): + with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"): manager.add_tool(lookup) assert manager.get_tool("lookup") is None @@ -931,7 +931,7 @@ def lookup() -> Out: # pragma: no cover ... manager = ToolManager() - with pytest.raises(ExternalSchemaRefError, match="Output schema for tool 'lookup'"): + with pytest.raises(ExternalSchemaRefError, match="https://evil.example/out.json"): manager.add_tool(lookup) assert manager.get_tool("lookup") is None diff --git a/tests/shared/test_json_schema_ref.py b/tests/shared/test_json_schema_ref.py deleted file mode 100644 index 578f81d3ad..0000000000 --- a/tests/shared/test_json_schema_ref.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import pytest - -from mcp.shared.json_schema_ref import ( - ExternalSchemaRefError, - find_external_refs, - is_same_document_ref, - reject_external_refs, -) - - -@pytest.mark.parametrize( - "ref", - [ - "#", - "#/$defs/Foo", - "#/properties/bar", - "#Foo", - ], -) -def test_same_document_refs_allowed(ref: str): - assert is_same_document_ref(ref) is True - schema = {"type": "object", "properties": {"x": {"$ref": ref}}} - assert find_external_refs(schema) == [] - reject_external_refs(schema, context="schema") - - -@pytest.mark.parametrize( - "ref", - [ - "https://example.com/schema.json", - "http://localhost:9999/canary.json", - "https://example.com/schema.json#/$defs/Foo", - "urn:example:schema", - "file:///etc/passwd", - "schema.json", - "./local.json", - "//example.com/schema.json", - ], -) -def test_external_refs_detected(ref: str): - assert is_same_document_ref(ref) is False - schema = {"type": "object", "properties": {"x": {"$ref": ref}}} - assert find_external_refs(schema) == [ref] - - -def test_reject_external_refs_raises_with_context(): - schema = {"properties": {"x": {"$ref": "https://evil.example/s.json"}}} - with pytest.raises(ExternalSchemaRefError) as exc_info: - reject_external_refs(schema, context="Output schema for tool 'lookup'") - message = str(exc_info.value) - assert "Output schema for tool 'lookup'" in message - assert "https://evil.example/s.json" in message - - -def test_find_external_refs_nested_in_lists_and_composition(): - schema = { - "type": "object", - "allOf": [ - {"properties": {"a": {"$ref": "#/$defs/A"}}}, - {"properties": {"b": {"$ref": "https://example.com/b.json"}}}, - ], - "items": [{"$ref": "https://example.com/c.json"}], - "$defs": {"A": {"type": "string"}}, - } - assert sorted(find_external_refs(schema)) == [ - "https://example.com/b.json", - "https://example.com/c.json", - ] - - -def test_non_string_ref_is_ignored(): - schema = {"$ref": {"not": "a string"}, "properties": {"x": {"$ref": 123}}} - assert find_external_refs(schema) == [] - - -def test_scalar_and_empty_inputs(): - assert find_external_refs(None) == [] - assert find_external_refs("just a string") == [] - assert find_external_refs(42) == [] - assert find_external_refs({}) == [] - assert find_external_refs([]) == [] From 9b8d0aa73dc0882fa900f1ba0209aec2d4356975 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:39:46 +0200 Subject: [PATCH 04/11] Inline the tool schema generator into func_metadata func_metadata already defined StrictJsonSchema; keep the SEP-2106 external-$ref rejection there instead of a separate _schema_generator module. --- src/mcp/server/mcpserver/tools/base.py | 3 +- .../mcpserver/utilities/_schema_generator.py | 63 ------------------- .../mcpserver/utilities/func_metadata.py | 48 +++++++++++++- tests/server/mcpserver/test_func_metadata.py | 33 +++++++++- .../server/mcpserver/test_schema_generator.py | 39 ------------ tests/server/mcpserver/test_tool_manager.py | 3 +- 6 files changed, 81 insertions(+), 108 deletions(-) delete mode 100644 src/mcp/server/mcpserver/utilities/_schema_generator.py delete mode 100644 tests/server/mcpserver/test_schema_generator.py diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index fc29d79385..bbbbb51a89 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -7,9 +7,8 @@ from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import ToolError -from mcp.server.mcpserver.utilities._schema_generator import StrictJsonSchema from mcp.server.mcpserver.utilities.context_injection import find_context_parameter -from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, StrictJsonSchema, func_metadata from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.shared.tool_name_validation import validate_and_warn_tool_name diff --git a/src/mcp/server/mcpserver/utilities/_schema_generator.py b/src/mcp/server/mcpserver/utilities/_schema_generator.py deleted file mode 100644 index ec3f50cb92..0000000000 --- a/src/mcp/server/mcpserver/utilities/_schema_generator.py +++ /dev/null @@ -1,63 +0,0 @@ -"""JSON Schema generator for tool schemas. - -Centralizes the `GenerateJsonSchema` subclass used when rendering tool input and -output schemas. On top of turning pydantic's schema warnings into errors, it -enforces SEP-2106: a `$ref` that is not a same-document reference (a JSON Pointer -such as `#/$defs/Foo` or an `$anchor` such as `#Foo`) is an SSRF / fetch-DoS -vector and MUST NOT appear in a tool schema. - -See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications -""" - -from __future__ import annotations - -from typing import Any, cast - -from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue, JsonSchemaWarningKind -from pydantic_core import CoreSchema - - -class ExternalSchemaRefError(ValueError): - """A tool schema contains a `$ref` that is not a same-document reference.""" - - -class StrictJsonSchema(GenerateJsonSchema): - """Render tool schemas, raising on pydantic warnings and external `$ref`s. - - Warnings (e.g. a non-serializable type) become errors so they surface at tool - registration instead of silently producing a degenerate schema. External - `$ref`s -- which pydantic never emits itself, but a user can inject via - `Field(json_schema_extra=...)` -- are rejected for the same reason (SEP-2106). - """ - - def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: - raise ValueError(f"JSON schema warning: {kind} - {detail}") - - def generate(self, schema: CoreSchema, mode: Any = "validation") -> JsonSchemaValue: - json_schema = super().generate(schema, mode) - _reject_external_refs(json_schema) - return json_schema - - -def _reject_external_refs(json_schema: JsonSchemaValue) -> None: - external = sorted(_find_external_refs(json_schema)) - if external: - raise ExternalSchemaRefError( - f"Tool schema contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): " - f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed." - ) - - -def _find_external_refs(node: Any) -> set[str]: - external: set[str] = set() - if isinstance(node, dict): - mapping = cast("dict[str, Any]", node) - ref = mapping.get("$ref") - if isinstance(ref, str) and not ref.startswith("#"): - external.add(ref) - for value in mapping.values(): - external |= _find_external_refs(value) - elif isinstance(node, list): - for item in cast("list[Any]", node): - external |= _find_external_refs(item) - return external diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 76f2872bfa..343a84ced0 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -11,6 +11,8 @@ import pydantic_core from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue, JsonSchemaWarningKind +from pydantic_core import CoreSchema from typing_extensions import is_typeddict from typing_inspection.introspection import ( UNKNOWN, @@ -21,7 +23,6 @@ ) from mcp.server.mcpserver.exceptions import InvalidSignature -from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError, StrictJsonSchema from mcp.server.mcpserver.utilities.logging import get_logger from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.types import CallToolResult, ContentBlock, TextContent @@ -29,6 +30,51 @@ logger = get_logger(__name__) +class ExternalSchemaRefError(ValueError): + """A tool schema contains a `$ref` that is not a same-document reference.""" + + +class StrictJsonSchema(GenerateJsonSchema): + """Render tool schemas, raising on pydantic warnings and external `$ref`s. + + Warnings (e.g. a non-serializable type) become errors so they surface at tool + registration instead of silently producing a degenerate schema. External + `$ref`s -- which pydantic never emits itself, but a user can inject via + `Field(json_schema_extra=...)` -- are an SSRF / fetch-DoS vector and are + rejected for the same reason (SEP-2106). + + See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications + """ + + def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: + raise ValueError(f"JSON schema warning: {kind} - {detail}") + + def generate(self, schema: CoreSchema, mode: Any = "validation") -> JsonSchemaValue: + json_schema = super().generate(schema, mode) + external = sorted(_find_external_refs(json_schema)) + if external: + raise ExternalSchemaRefError( + f"Tool schema contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): " + f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed." + ) + return json_schema + + +def _find_external_refs(node: Any) -> set[str]: + external: set[str] = set() + if isinstance(node, dict): + mapping = cast("dict[str, Any]", node) + ref = mapping.get("$ref") + if isinstance(ref, str) and not ref.startswith("#"): + external.add(ref) + for value in mapping.values(): + external |= _find_external_refs(value) + elif isinstance(node, list): + for item in cast("list[Any]", node): + external |= _find_external_refs(item) + return external + + class ArgModelBase(BaseModel): """A model representing the arguments to a function.""" diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 2763b3f503..b03e7fd5bf 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import InvalidSignature -from mcp.server.mcpserver.utilities.func_metadata import func_metadata +from mcp.server.mcpserver.utilities.func_metadata import ExternalSchemaRefError, StrictJsonSchema, func_metadata from mcp.types import CallToolResult @@ -1191,3 +1191,34 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc assert meta.output_schema is not None assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} + + +def test_strict_json_schema_allows_same_document_refs(): + class Inner(BaseModel): + x: int + + class Model(BaseModel): + inner: Inner + + schema = Model.model_json_schema(schema_generator=StrictJsonSchema) + assert "$defs" in schema + assert schema["properties"]["inner"]["$ref"] == "#/$defs/Inner" + + +def test_strict_json_schema_rejects_external_ref_in_property(): + class Model(BaseModel): + profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})] + + with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"): + Model.model_json_schema(schema_generator=StrictJsonSchema) + + +def test_strict_json_schema_rejects_external_ref_nested_in_list(): + class Model(BaseModel): + items: Annotated[ + list[str], + Field(json_schema_extra={"prefixItems": [{"$ref": "https://evil.example/a.json"}]}), + ] + + with pytest.raises(ExternalSchemaRefError, match="https://evil.example/a.json"): + Model.model_json_schema(schema_generator=StrictJsonSchema) diff --git a/tests/server/mcpserver/test_schema_generator.py b/tests/server/mcpserver/test_schema_generator.py deleted file mode 100644 index 6d165426e6..0000000000 --- a/tests/server/mcpserver/test_schema_generator.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from typing import Annotated, Any - -import pytest -from pydantic import BaseModel, Field - -from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError, StrictJsonSchema - - -def test_same_document_refs_pass(): - class Inner(BaseModel): - x: int - - class Model(BaseModel): - inner: Inner - - schema = Model.model_json_schema(schema_generator=StrictJsonSchema) - assert "$defs" in schema - assert schema["properties"]["inner"]["$ref"] == "#/$defs/Inner" - - -def test_external_ref_in_property_rejected(): - class Model(BaseModel): - profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})] - - with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"): - Model.model_json_schema(schema_generator=StrictJsonSchema) - - -def test_external_ref_nested_in_list_rejected(): - class Model(BaseModel): - items: Annotated[ - list[str], - Field(json_schema_extra={"prefixItems": [{"$ref": "https://evil.example/a.json"}]}), - ] - - with pytest.raises(ExternalSchemaRefError, match="https://evil.example/a.json"): - Model.model_json_schema(schema_generator=StrictJsonSchema) diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 9f4419e63d..cbeb925ce3 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -10,8 +10,7 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.tools import Tool, ToolManager -from mcp.server.mcpserver.utilities._schema_generator import ExternalSchemaRefError -from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata +from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, ExternalSchemaRefError, FuncMetadata from mcp.types import CallToolResult, TextContent, ToolAnnotations From f7366a6a4244a4b6486594bb7592ec5da690546d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:44:45 +0200 Subject: [PATCH 05/11] Make ExternalSchemaRefError a hard error, not a ValueError Subclassing Exception (not ValueError) keeps it from being swallowed by the schema-generation fallback that degrades unserializable types, so the explicit re-raise is no longer needed. Drop em-dashes from the docstrings. --- .../server/mcpserver/utilities/func_metadata.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 343a84ced0..8b04733473 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -30,8 +30,13 @@ logger = get_logger(__name__) -class ExternalSchemaRefError(ValueError): - """A tool schema contains a `$ref` that is not a same-document reference.""" +class ExternalSchemaRefError(Exception): + """A tool schema contains a `$ref` that is not a same-document reference. + + Deliberately not a `ValueError`: schema generation treats a `ValueError` as + an unserializable type and degrades gracefully, but an external `$ref` is a + hard error that must surface at tool registration. + """ class StrictJsonSchema(GenerateJsonSchema): @@ -39,8 +44,8 @@ class StrictJsonSchema(GenerateJsonSchema): Warnings (e.g. a non-serializable type) become errors so they surface at tool registration instead of silently producing a degenerate schema. External - `$ref`s -- which pydantic never emits itself, but a user can inject via - `Field(json_schema_extra=...)` -- are an SSRF / fetch-DoS vector and are + `$ref`s, which pydantic never emits itself but a user can inject via + `Field(json_schema_extra=...)`, are an SSRF / fetch-DoS vector and are rejected for the same reason (SEP-2106). See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications @@ -433,9 +438,6 @@ def _try_create_model_and_schema( # Use StrictJsonSchema to raise exceptions instead of warnings try: schema = model.model_json_schema(schema_generator=StrictJsonSchema) - except ExternalSchemaRefError: - # SEP-2106: an external $ref is a hard error, not an unserializable type. - raise except ( PydanticUserError, TypeError, From 4d8f900f9c264611808575e7d03e81e96246e5b0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:50:38 +0200 Subject: [PATCH 06/11] Burn down json-schema-ref-no-deref conformance scenario Add a driver handler for the scenario (the missing handler was why it failed, not actual dereferencing) and remove it from both expected-failures baselines. Document the server-side external-$ref rejection in the migration guide. --- .github/actions/conformance/client.py | 10 ++++++++++ .../conformance/expected-failures.2026-07-28.yml | 2 -- .github/actions/conformance/expected-failures.yml | 2 -- docs/migration.md | 6 ++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index a438cb29ee..427b7e1ff0 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -185,6 +185,16 @@ async def run_tools_call(server_url: str) -> None: logger.debug(f"add_numbers result: {result}") +@register("json-schema-ref-no-deref") +async def run_json_schema_ref_no_deref(server_url: str) -> None: + """List tools whose schemas contain a network `$ref`; must not dereference it (SEP-2106).""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + tools_result = await session.list_tools() + logger.debug(f"Listed tools without dereferencing network $refs: {[t.name for t in tools_result.tools]}") + + @register("sse-retry") async def run_sse_retry(server_url: str) -> None: """Connect, initialize, list tools, call test_reconnection, close.""" diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 02a3ef81af..4c2fce283b 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -63,8 +63,6 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index cc6071205b..c1f1d71db9 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -22,8 +22,6 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/docs/migration.md b/docs/migration.md index 675c5b747a..4b27ed9403 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -240,6 +240,12 @@ Results returned from server handlers are now validated against the negotiated p `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). +### External JSON Schema `$ref`s are rejected in tool schemas (SEP-2106) + +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`). + +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. + ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. From a53b89242492f59276cf1e8cf81cc3b9b6864489 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 18:00:22 +0200 Subject: [PATCH 07/11] 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. --- docs/migration.md | 6 --- src/mcp/server/mcpserver/tools/base.py | 11 ++--- .../mcpserver/utilities/func_metadata.py | 48 ++---------------- tests/client/test_output_schema_validation.py | 35 +++++++++++++ tests/server/mcpserver/test_func_metadata.py | 33 +------------ tests/server/mcpserver/test_tool_manager.py | 49 ++----------------- 6 files changed, 46 insertions(+), 136 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 4b27ed9403..675c5b747a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -240,12 +240,6 @@ Results returned from server handlers are now validated against the negotiated p `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). -### External JSON Schema `$ref`s are rejected in tool schemas (SEP-2106) - -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`). - -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. - ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index bbbbb51a89..754313eb8a 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -8,7 +8,7 @@ from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.utilities.context_injection import find_context_parameter -from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, StrictJsonSchema, func_metadata +from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.shared.tool_name_validation import validate_and_warn_tool_name @@ -53,12 +53,7 @@ def from_function( meta: dict[str, Any] | None = None, structured_output: bool | None = None, ) -> Tool: - """Create a Tool from a function. - - Raises: - ExternalSchemaRefError: If the generated input or output schema contains a - `$ref` that is not a same-document reference (SEP-2106). - """ + """Create a Tool from a function.""" func_name = name or fn.__name__ validate_and_warn_tool_name(func_name) @@ -77,7 +72,7 @@ def from_function( skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=structured_output, ) - parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True, schema_generator=StrictJsonSchema) + parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) return cls( fn=fn, diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 8b04733473..6c553fbab9 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -11,8 +11,7 @@ import pydantic_core from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo -from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue, JsonSchemaWarningKind -from pydantic_core import CoreSchema +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind from typing_extensions import is_typeddict from typing_inspection.introspection import ( UNKNOWN, @@ -30,55 +29,16 @@ logger = get_logger(__name__) -class ExternalSchemaRefError(Exception): - """A tool schema contains a `$ref` that is not a same-document reference. - - Deliberately not a `ValueError`: schema generation treats a `ValueError` as - an unserializable type and degrades gracefully, but an external `$ref` is a - hard error that must surface at tool registration. - """ - - class StrictJsonSchema(GenerateJsonSchema): - """Render tool schemas, raising on pydantic warnings and external `$ref`s. + """A JSON schema generator that raises exceptions instead of emitting warnings. - Warnings (e.g. a non-serializable type) become errors so they surface at tool - registration instead of silently producing a degenerate schema. External - `$ref`s, which pydantic never emits itself but a user can inject via - `Field(json_schema_extra=...)`, are an SSRF / fetch-DoS vector and are - rejected for the same reason (SEP-2106). - - See: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications + This is used to detect non-serializable types during schema generation. """ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: + # Raise an exception instead of emitting a warning raise ValueError(f"JSON schema warning: {kind} - {detail}") - def generate(self, schema: CoreSchema, mode: Any = "validation") -> JsonSchemaValue: - json_schema = super().generate(schema, mode) - external = sorted(_find_external_refs(json_schema)) - if external: - raise ExternalSchemaRefError( - f"Tool schema contains external $ref(s) that MUST NOT be dereferenced (SEP-2106): " - f"{', '.join(external)}. Only same-document references (e.g. '#/$defs/Foo') are allowed." - ) - return json_schema - - -def _find_external_refs(node: Any) -> set[str]: - external: set[str] = set() - if isinstance(node, dict): - mapping = cast("dict[str, Any]", node) - ref = mapping.get("$ref") - if isinstance(ref, str) and not ref.startswith("#"): - external.add(ref) - for value in mapping.values(): - external |= _find_external_refs(value) - elif isinstance(node, list): - for item in cast("list[Any]", node): - external |= _find_external_refs(item) - return external - class ArgModelBase(BaseModel): """A model representing the arguments to a function.""" diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index d78197b5c3..bca7a790a3 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -163,3 +163,38 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) assert result.is_error is False assert "Tool mystery_tool not listed" in caplog.text + + +@pytest.mark.anyio +async def test_client_does_not_dereference_network_ref(): + """SEP-2106: validating a result must not fetch a network `$ref` in the output schema. + + The output schema references a network URI under a property the structured content + never sets, so a compliant client validates without resolving (and therefore without + fetching) the ref. + """ + output_schema = { + "type": "object", + "properties": { + "ok": {"type": "boolean"}, + "profile": {"$ref": "https://canary.invalid/profile-schema.json"}, + }, + "required": ["ok"], + } + + server = _make_server( + tools=[ + Tool( + name="lookup", + description="Look something up", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + ], + structured_content={"ok": True}, + ) + + async with Client(server) as client: + result = await client.call_tool("lookup", {}) + assert result.is_error is False + assert result.structured_content == {"ok": True} diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index b03e7fd5bf..2763b3f503 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import InvalidSignature -from mcp.server.mcpserver.utilities.func_metadata import ExternalSchemaRefError, StrictJsonSchema, func_metadata +from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.types import CallToolResult @@ -1191,34 +1191,3 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc assert meta.output_schema is not None assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} - - -def test_strict_json_schema_allows_same_document_refs(): - class Inner(BaseModel): - x: int - - class Model(BaseModel): - inner: Inner - - schema = Model.model_json_schema(schema_generator=StrictJsonSchema) - assert "$defs" in schema - assert schema["properties"]["inner"]["$ref"] == "#/$defs/Inner" - - -def test_strict_json_schema_rejects_external_ref_in_property(): - class Model(BaseModel): - profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})] - - with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"): - Model.model_json_schema(schema_generator=StrictJsonSchema) - - -def test_strict_json_schema_rejects_external_ref_nested_in_list(): - class Model(BaseModel): - items: Annotated[ - list[str], - Field(json_schema_extra={"prefixItems": [{"$ref": "https://evil.example/a.json"}]}), - ] - - with pytest.raises(ExternalSchemaRefError, match="https://evil.example/a.json"): - Model.model_json_schema(schema_generator=StrictJsonSchema) diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index cbeb925ce3..01b362fb50 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -1,16 +1,16 @@ import json import logging from dataclasses import dataclass -from typing import Annotated, Any, TypedDict +from typing import Any, TypedDict import pytest -from pydantic import BaseModel, Field +from pydantic import BaseModel from mcp.server.context import LifespanContextT, RequestT from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.tools import Tool, ToolManager -from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, ExternalSchemaRefError, FuncMetadata +from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata from mcp.types import CallToolResult, TextContent, ToolAnnotations @@ -904,46 +904,3 @@ def test_func() -> str: # pragma: no cover # Remove with correct case manager.remove_tool("test_func") assert manager.get_tool("test_func") is None - - -def test_add_tool_rejects_external_input_ref(): - """SEP-2106: a tool whose input schema carries an external $ref is rejected at registration.""" - - def lookup( - profile: Annotated[dict[str, Any], Field(json_schema_extra={"$ref": "https://evil.example/s.json"})], - ) -> None: # pragma: no cover - ... - - manager = ToolManager() - with pytest.raises(ExternalSchemaRefError, match="https://evil.example/s.json"): - manager.add_tool(lookup) - assert manager.get_tool("lookup") is None - - -def test_add_tool_rejects_external_output_ref(): - """SEP-2106: a tool whose output schema carries an external $ref is rejected at registration.""" - - class Out(BaseModel): - value: Annotated[str, Field(json_schema_extra={"$ref": "https://evil.example/out.json"})] - - def lookup() -> Out: # pragma: no cover - ... - - manager = ToolManager() - with pytest.raises(ExternalSchemaRefError, match="https://evil.example/out.json"): - manager.add_tool(lookup) - assert manager.get_tool("lookup") is None - - -def test_add_tool_allows_same_document_refs(): - """Pydantic-generated `#/$defs/...` refs from nested models must pass registration.""" - - class Inner(BaseModel): - x: int - - def good(inner: Inner) -> Inner: # pragma: no cover - ... - - manager = ToolManager() - tool = manager.add_tool(good) - assert "$defs" in tool.parameters From 7a2e7abe1ee3a4e3064c43b7393226032032710a Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 18:06:07 +0200 Subject: [PATCH 08/11] Reuse the initialize handler for json-schema-ref-no-deref The scenario only requires the client to call tools/list against a server advertising a network $ref; ClientSession never resolves $refs, so the existing initialize handler (initialize + list_tools) already satisfies it. Register it under both names instead of duplicating it. --- .github/actions/conformance/client.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 427b7e1ff0..cc5871c2a6 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -164,6 +164,10 @@ async def handle_callback(self) -> tuple[str, str | None]: @register("initialize") +# SEP-2106: json-schema-ref-no-deref only requires the client to call tools/list against a +# server advertising a tool with a network $ref. ClientSession never walks inputSchema or +# resolves $refs, so plain initialize + list_tools satisfies it; no dedicated handler needed. +@register("json-schema-ref-no-deref") async def run_initialize(server_url: str) -> None: """Connect, initialize, list tools, close.""" async with streamable_http_client(url=server_url) as (read_stream, write_stream): @@ -185,16 +189,6 @@ async def run_tools_call(server_url: str) -> None: logger.debug(f"add_numbers result: {result}") -@register("json-schema-ref-no-deref") -async def run_json_schema_ref_no_deref(server_url: str) -> None: - """List tools whose schemas contain a network `$ref`; must not dereference it (SEP-2106).""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tools_result = await session.list_tools() - logger.debug(f"Listed tools without dereferencing network $refs: {[t.name for t in tools_result.tools]}") - - @register("sse-retry") async def run_sse_retry(server_url: str) -> None: """Connect, initialize, list tools, call test_reconnection, close.""" From a06d8e1d7cf2fc30b9a99d1f4fddba47b7921e3e Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 18:07:05 +0200 Subject: [PATCH 09/11] Give json-schema-ref-no-deref its own handler One @register per function: revert the stacked decorators and add a dedicated handler. It just initializes and lists tools (ClientSession never resolves $refs), with a docstring explaining why that is sufficient. --- .github/actions/conformance/client.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index cc5871c2a6..036845b95f 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -164,10 +164,6 @@ async def handle_callback(self) -> tuple[str, str | None]: @register("initialize") -# SEP-2106: json-schema-ref-no-deref only requires the client to call tools/list against a -# server advertising a tool with a network $ref. ClientSession never walks inputSchema or -# resolves $refs, so plain initialize + list_tools satisfies it; no dedicated handler needed. -@register("json-schema-ref-no-deref") async def run_initialize(server_url: str) -> None: """Connect, initialize, list tools, close.""" async with streamable_http_client(url=server_url) as (read_stream, write_stream): @@ -178,6 +174,18 @@ async def run_initialize(server_url: str) -> None: logger.debug("Listed tools successfully") +@register("json-schema-ref-no-deref") +async def run_json_schema_ref_no_deref(server_url: str) -> None: + """Initialize and list tools; the scenario fails only if the client fetches a network $ref. + + ClientSession never walks inputSchema or resolves $refs, so listing is enough (SEP-2106). + """ + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + + @register("tools_call") async def run_tools_call(server_url: str) -> None: """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" From 2c47fc4e7deb3bee5037c5ffab260bb6d8960d35 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 18:18:49 +0200 Subject: [PATCH 10/11] Guard the no-deref test with a socket tripwire Patch socket.connect / create_connection to fail on any outbound connection while validating a tool result, proving the network $ref is never fetched. --- tests/client/test_output_schema_validation.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index bca7a790a3..0ffdb82779 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -1,4 +1,5 @@ import logging +import socket from typing import Any import pytest @@ -166,13 +167,20 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) @pytest.mark.anyio -async def test_client_does_not_dereference_network_ref(): +async def test_client_does_not_dereference_network_ref(monkeypatch: pytest.MonkeyPatch): """SEP-2106: validating a result must not fetch a network `$ref` in the output schema. The output schema references a network URI under a property the structured content - never sets, so a compliant client validates without resolving (and therefore without - fetching) the ref. + never sets. A socket guard fails the test if the client opens any connection while + validating, proving the ref is never dereferenced. """ + + def no_network(*args: object, **kwargs: object) -> None: + raise AssertionError("client attempted a network connection while validating a tool result") # pragma: no cover + + monkeypatch.setattr(socket.socket, "connect", no_network) + monkeypatch.setattr(socket, "create_connection", no_network) + output_schema = { "type": "object", "properties": { From c7522f232492378a9dff0e2b74e60fac55f0189f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 18:20:49 +0200 Subject: [PATCH 11/11] Drop the Python no-deref unit test The conformance scenario already proves no-fetch end to end with a real canary HTTP server; the unit test was a redundant local mirror. --- tests/client/test_output_schema_validation.py | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index 0ffdb82779..d78197b5c3 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -1,5 +1,4 @@ import logging -import socket from typing import Any import pytest @@ -164,45 +163,3 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) assert result.is_error is False assert "Tool mystery_tool not listed" in caplog.text - - -@pytest.mark.anyio -async def test_client_does_not_dereference_network_ref(monkeypatch: pytest.MonkeyPatch): - """SEP-2106: validating a result must not fetch a network `$ref` in the output schema. - - The output schema references a network URI under a property the structured content - never sets. A socket guard fails the test if the client opens any connection while - validating, proving the ref is never dereferenced. - """ - - def no_network(*args: object, **kwargs: object) -> None: - raise AssertionError("client attempted a network connection while validating a tool result") # pragma: no cover - - monkeypatch.setattr(socket.socket, "connect", no_network) - monkeypatch.setattr(socket, "create_connection", no_network) - - output_schema = { - "type": "object", - "properties": { - "ok": {"type": "boolean"}, - "profile": {"$ref": "https://canary.invalid/profile-schema.json"}, - }, - "required": ["ok"], - } - - server = _make_server( - tools=[ - Tool( - name="lookup", - description="Look something up", - input_schema={"type": "object"}, - output_schema=output_schema, - ) - ], - structured_content={"ok": True}, - ) - - async with Client(server) as client: - result = await client.call_tool("lookup", {}) - assert result.is_error is False - assert result.structured_content == {"ok": True}