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
215 lines
11 KiB
Python
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}"),
|
|
]
|
|
)
|