[v1.x] Resolve tool output-schema references within the schema document only (#3396)
This commit is contained in:
parent
b22271305c
commit
5ebdfed406
2 changed files with 41 additions and 1 deletions
|
|
@ -428,17 +428,24 @@ class ClientSession(
|
||||||
|
|
||||||
if output_schema is not None:
|
if output_schema is not None:
|
||||||
from jsonschema import SchemaError, ValidationError, validate
|
from jsonschema import SchemaError, ValidationError, validate
|
||||||
|
from referencing import Registry
|
||||||
|
from referencing.exceptions import Unresolvable
|
||||||
|
|
||||||
if result.structuredContent is None:
|
if result.structuredContent is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Tool {name} has an output schema but did not return structured content"
|
f"Tool {name} has an output schema but did not return structured content"
|
||||||
) # pragma: no cover
|
) # pragma: no cover
|
||||||
|
# An explicit empty registry: `$ref`s resolve within the schema document and the bundled metaschemas.
|
||||||
|
registry: Registry[Any] = Registry()
|
||||||
try:
|
try:
|
||||||
validate(result.structuredContent, output_schema)
|
validate(result.structuredContent, output_schema, registry=registry)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover
|
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover
|
||||||
except SchemaError as e: # pragma: no cover
|
except SchemaError as e: # pragma: no cover
|
||||||
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
|
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
|
||||||
|
except Unresolvable as e:
|
||||||
|
# A `$ref` did not resolve within the schema document.
|
||||||
|
raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead")
|
@deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import logging
|
import logging
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from referencing.exceptions import Unresolvable
|
||||||
|
|
||||||
from mcp.server.lowlevel import Server
|
from mcp.server.lowlevel import Server
|
||||||
from mcp.shared.memory import (
|
from mcp.shared.memory import (
|
||||||
|
|
@ -215,3 +217,34 @@ class TestClientOutputSchemaValidation:
|
||||||
|
|
||||||
# Check that warning was logged
|
# Check that warning was logged
|
||||||
assert "Tool mystery_tool not listed" in caplog.text
|
assert "Tool mystery_tool not listed" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# jsonschema's fallback retriever emits this DeprecationWarning; keep it a plain warning so the
|
||||||
|
# assertions below decide the outcome rather than the suite's warnings-as-errors filter.
|
||||||
|
@pytest.mark.filterwarnings("default:Automatically retrieving remote references:DeprecationWarning")
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_output_schema_ref_outside_the_document_is_rejected(tmp_path: Path):
|
||||||
|
"""A `$ref` to a URI outside the output schema is not resolved, and a result whose validation
|
||||||
|
reaches one fails as an invalid schema (spec `$ref` resolution; applying it to `file:` URIs too
|
||||||
|
is SDK-defined)."""
|
||||||
|
target = tmp_path / "schema.json"
|
||||||
|
target.write_text("{}", encoding="utf-8")
|
||||||
|
server = Server("test-server")
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools():
|
||||||
|
return [
|
||||||
|
Tool(name="probe", description="", inputSchema={"type": "object"}, outputSchema={"$ref": target.as_uri()})
|
||||||
|
]
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict[str, Any]):
|
||||||
|
return {"v": 1}
|
||||||
|
|
||||||
|
with bypass_server_output_validation():
|
||||||
|
async with client_session(server) as client:
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
await client.call_tool("probe", {})
|
||||||
|
# SDK-authored prefix only; the tail is `referencing`'s text.
|
||||||
|
assert str(exc_info.value).startswith("Invalid schema for tool probe: ")
|
||||||
|
assert isinstance(exc_info.value.__cause__, Unresolvable)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue