Files
modelcontextprotocol--pytho…/tests/docs_src/test_uri_templates.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

215 lines
11 KiB
Python

"""`docs/servers/uri-templates.md`: every claim the page makes, proved against the real SDK."""
from pathlib import Path
import pytest
from inline_snapshot import snapshot
from mcp_types import INVALID_PARAMS, ErrorData, ResourceTemplate, TextResourceContents
from docs_src.uri_templates import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.shared.path_security import PathEscapeError, contains_path_traversal, safe_join
from mcp.shared.uri_template import InvalidUriTemplate, UriTemplate
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_simple_expansion_maps_the_segment_to_the_argument() -> None:
"""tutorial001: `books://{isbn}` reads `books://978-...` and the matched string is the argument."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("books://978-0441172719")).contents
assert isinstance(content, TextResourceContents)
assert content.text == snapshot('{\n "title": "Dune",\n "author": "Frank Herbert"\n}')
async def test_an_int_parameter_is_converted_from_the_uri_string() -> None:
"""tutorial001: `order_id: int` receives `12345`, not `"12345"`, so `order_id + 1` is `12346`."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("orders://12345")).contents
assert isinstance(content, TextResourceContents)
assert content.text == snapshot('{\n "order_id": 12345,\n "next_order": 12346,\n "status": "shipped"\n}')
async def test_plus_keeps_the_slashes_in_the_captured_value() -> None:
"""tutorial001: `{+path}` matches `printing/setup.md` as one value; a plain `{path}` would not."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "# Printer setup\n\nLoad paper, then power on."
async def test_omitted_query_params_fall_through_to_function_defaults() -> None:
"""tutorial001: `{?limit,sort}` is lenient. No query string means `limit=10, sort="newest"`."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("reviews://978-0441172719")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "10 newest reviews of Dune"
async def test_a_query_param_overrides_only_the_default_it_names() -> None:
"""tutorial001: `?sort=top` sets `sort` and leaves `limit` at its default."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("reviews://978-0441172719?sort=top")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "10 top reviews of Dune"
async def test_exploded_path_arrives_as_a_list_of_segments() -> None:
"""tutorial001: `{/path*}` splits `/fiction/sci-fi` into `["fiction", "sci-fi"]`."""
async with Client(tutorial001.mcp) as client:
(content,) = (await client.read_resource("shelves://browse/fiction/sci-fi")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "catalog > fiction > sci-fi"
def test_two_adjacent_variables_are_rejected_at_parse_time() -> None:
"""'What the parser rejects': nothing separates `path` from `ext`, so the template is refused."""
with pytest.raises(InvalidUriTemplate) as exc_info:
UriTemplate.parse("manuals://{+path}{ext}")
assert str(exc_info.value) == snapshot(
"Variables 'path' and 'ext' are adjacent with no literal separator; matching cannot "
"determine where one ends and the other begins. Add a literal between them or use a single variable."
)
def test_a_self_delimiting_operator_supplies_the_separator() -> None:
"""'What the parser rejects': `{.ext}` contributes the `.` itself, so `{+path}{.ext}` is accepted."""
template = UriTemplate.parse("manuals://{+path}{.ext}")
assert template.match("manuals://printing/setup.md") == {"path": "printing/setup", "ext": "md"}
def test_a_second_multi_segment_variable_is_rejected_at_parse_time() -> None:
"""'What the parser rejects': two `{+...}` are ambiguous about which one absorbs an extra segment."""
with pytest.raises(InvalidUriTemplate) as exc_info:
UriTemplate.parse("copy://{+source}/to/{+destination}")
assert str(exc_info.value) == snapshot(
"Template contains more than one multi-segment variable ({+var}, {#var}, or explode modifier); "
"matching would be ambiguous"
)
def test_a_query_parameter_without_a_python_default_is_rejected_at_decoration_time() -> None:
"""'What the parser rejects': a client may omit `{?limit}`, so the bound parameter must declare a default."""
strict = MCPServer("Bookshop")
with pytest.raises(ValueError) as exc_info:
@strict.resource("reviews://{isbn}{?limit}")
def list_reviews(isbn: str, limit: int) -> None:
"""Reviews of a book."""
assert str(exc_info.value) == snapshot(
"Resource 'reviews://{isbn}{?limit}': query parameter(s) ['limit'] have no default value. "
"A client may omit a {?...}/{&...} query parameter, so the matching handler parameter "
"must declare a default."
)
async def test_traversal_is_rejected_before_the_handler_runs() -> None:
"""The `!!! check`: `../` triggers `-32602` "Unknown resource" and `read_manual` is never called."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("manuals://../etc/passwd")
assert exc_info.value.error == snapshot(
ErrorData(
code=INVALID_PARAMS,
message="Unknown resource: manuals://../etc/passwd",
data={"uri": "manuals://../etc/passwd"},
)
)
def test_dotdot_is_a_component_check_not_a_substring_scan() -> None:
"""The page's prose: `v1.0..v2.0` passes because `..` is not a standalone path segment."""
assert contains_path_traversal("../etc") is True
assert contains_path_traversal("v1.0..v2.0") is False
async def test_safe_join_serves_a_file_inside_the_base_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""tutorial002: `safe_join(DOCS_ROOT, path).read_text()` returns the file under the base."""
(tmp_path / "printing").mkdir()
(tmp_path / "printing" / "setup.md").write_text("# Printer setup")
monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path)
async with Client(tutorial002.mcp) as client:
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "# Printer setup"
def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None:
"""tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`."""
with pytest.raises(PathEscapeError):
safe_join(tmp_path, "../etc/passwd")
async def test_exempt_params_lets_an_absolute_path_through() -> None:
"""tutorial003: `exempt_params={"source"}` skips the checks for that one parameter."""
async with Client(tutorial003.mcp) as client:
(content,) = (await client.read_resource("imports://preview//srv/incoming/catalog.csv")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "Would import from /srv/incoming/catalog.csv"
async def test_server_wide_resource_security_relaxes_every_resource() -> None:
"""tutorial003: `resource_security=ResourceSecurity(reject_path_traversal=False)` exempts the whole server."""
async with Client(tutorial003.relaxed) as client:
(content,) = (await client.read_resource("imports://preview/../sibling/catalog.csv")).contents
assert isinstance(content, TextResourceContents)
assert content.text == "Would import from ../sibling/catalog.csv"
async def test_lowlevel_static_dispatch_lists_and_reads_by_exact_uri() -> None:
"""tutorial004: the registry is the listing, and a known URI returns its text."""
async with Client(tutorial004.server) as client:
listed = (await client.list_resources()).resources
assert [r.uri for r in listed] == ["config://shop", "status://health"]
(content,) = (await client.read_resource("status://health")).contents
assert content == TextResourceContents(uri="status://health", text="ok")
async def test_lowlevel_unknown_uri_raises() -> None:
"""tutorial004: a URI outside the registry raises and surfaces as a protocol error."""
async with Client(tutorial004.server) as client:
with pytest.raises(MCPError):
await client.read_resource("config://missing")
def test_uritemplate_match_returns_a_dict_or_none() -> None:
"""tutorial005: `match()` extracts decoded variables, or `None` when the URI doesn't fit."""
assert tutorial005.TEMPLATES["manuals"].match("manuals://printing/setup.md") == {"path": "printing/setup.md"}
assert tutorial005.TEMPLATES["books"].match("manuals://nope") is None
async def test_lowlevel_match_routes_the_request_to_the_right_template() -> None:
"""tutorial005: two templates, one handler. Each concrete URI lands in its own branch."""
async with Client(tutorial005.server) as client:
(manual,) = (await client.read_resource("manuals://printing/setup.md")).contents
assert manual == TextResourceContents(uri="manuals://printing/setup.md", text="# Printer setup")
(book,) = (await client.read_resource("books://978-0441172719")).contents
assert book == TextResourceContents(uri="books://978-0441172719", text="Dune by Frank Herbert")
async def test_lowlevel_handler_applies_the_safety_checks_itself() -> None:
"""tutorial005: there is no default policy down here; `read_manual_safely` is the gate."""
async with Client(tutorial005.server) as client:
with pytest.raises(MCPError):
await client.read_resource("manuals://../etc/passwd")
with pytest.raises(MCPError):
await client.read_resource("nothing://matches")
async def test_str_of_a_template_round_trips_to_the_original_string() -> None:
"""tutorial005: `str(template)` is the source string, so the listing reuses the parsed templates."""
assert str(tutorial005.TEMPLATES["manuals"]) == "manuals://{+path}"
async with Client(tutorial005.server) as client:
result = await client.list_resource_templates()
assert result.resource_templates == snapshot(
[
ResourceTemplate(name="manuals", uri_template="manuals://{+path}"),
ResourceTemplate(name="books", uri_template="books://{isbn}"),
]
)