Skip to content

Commit 9438378

Browse files
committed
docs: extract uri-templates examples to tested docs_src/ files
Converts docs/advanced/uri-templates.md to the docs_src/ pattern used by the rest of the docs: runnable example files under docs_src/uri_templates/ included via --8<--, with tests/docs_src/test_uri_templates.py proving each claim the page makes.
1 parent 4421866 commit 9438378

8 files changed

Lines changed: 444 additions & 290 deletions

File tree

docs/advanced/uri-templates.md

Lines changed: 109 additions & 290 deletions
Large diffs are not rendered by default.

docs_src/uri_templates/__init__.py

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from mcp.server import MCPServer
2+
3+
mcp = MCPServer("Bookshop")
4+
5+
BOOKS = {
6+
"978-0441172719": {"title": "Dune", "author": "Frank Herbert"},
7+
"978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"},
8+
}
9+
10+
MANUALS = {
11+
"printing/setup.md": "# Printer setup\n\nLoad paper, then power on.",
12+
"returns.md": "# Returns policy\n\nThirty days with a receipt.",
13+
}
14+
15+
16+
@mcp.resource("books://{isbn}")
17+
def get_book(isbn: str) -> dict[str, str]:
18+
"""A single book by ISBN."""
19+
return BOOKS[isbn]
20+
21+
22+
@mcp.resource("orders://{order_id}")
23+
def get_order(order_id: int) -> dict[str, object]:
24+
"""An order by its numeric id."""
25+
return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"}
26+
27+
28+
@mcp.resource("manuals://{+path}")
29+
def read_manual(path: str) -> str:
30+
"""A staff manual page. The path keeps its slashes."""
31+
return MANUALS[path]
32+
33+
34+
@mcp.resource("reviews://{isbn}{?limit,sort}")
35+
def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str:
36+
"""Reviews of a book, optionally limited and sorted."""
37+
return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}"
38+
39+
40+
@mcp.resource("shelves://browse{/path*}")
41+
def browse_shelf(path: list[str]) -> str:
42+
"""A shelf in the category tree, addressed by segments."""
43+
return " > ".join(["catalog", *path])
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pathlib import Path
2+
3+
from mcp.server import MCPServer
4+
from mcp.shared.path_security import safe_join
5+
6+
mcp = MCPServer("Bookshop")
7+
8+
DOCS_ROOT = Path("./manuals")
9+
10+
11+
@mcp.resource("manuals://{+path}")
12+
def read_manual(path: str) -> str:
13+
"""A staff manual page, served from a directory on disk."""
14+
return safe_join(DOCS_ROOT, path).read_text()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from mcp.server import MCPServer
2+
from mcp.server.mcpserver import ResourceSecurity
3+
4+
mcp = MCPServer("Bookshop")
5+
6+
7+
@mcp.resource(
8+
"imports://preview/{+source}",
9+
security=ResourceSecurity(exempt_params={"source"}),
10+
)
11+
def preview_import(source: str) -> str:
12+
"""Preview a catalog import. `source` may be an absolute path."""
13+
return f"Would import from {source}"
14+
15+
16+
relaxed = MCPServer(
17+
"Bookshop",
18+
resource_security=ResourceSecurity(reject_path_traversal=False),
19+
)
20+
21+
22+
@relaxed.resource("imports://preview/{+source}")
23+
def preview_import_relaxed(source: str) -> str:
24+
"""The server-wide flag exempts every resource on `relaxed`."""
25+
return f"Would import from {source}"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from mcp_types import (
2+
ListResourcesResult,
3+
PaginatedRequestParams,
4+
ReadResourceRequestParams,
5+
ReadResourceResult,
6+
Resource,
7+
TextResourceContents,
8+
)
9+
10+
from mcp.server import Server, ServerRequestContext
11+
12+
RESOURCES = {
13+
"config://shop": '{"currency": "USD", "tax_rate": 0.08}',
14+
"status://health": "ok",
15+
}
16+
17+
18+
async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
19+
return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES])
20+
21+
22+
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
23+
if (text := RESOURCES.get(params.uri)) is not None:
24+
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
25+
raise ValueError(f"Unknown resource: {params.uri}")
26+
27+
28+
server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from mcp_types import (
2+
ListResourceTemplatesResult,
3+
PaginatedRequestParams,
4+
ReadResourceRequestParams,
5+
ReadResourceResult,
6+
ResourceTemplate,
7+
TextResourceContents,
8+
)
9+
10+
from mcp.server import Server, ServerRequestContext
11+
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
12+
from mcp.shared.uri_template import UriTemplate
13+
14+
TEMPLATES = {
15+
"manuals": UriTemplate.parse("manuals://{+path}"),
16+
"books": UriTemplate.parse("books://{isbn}"),
17+
}
18+
19+
MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"}
20+
BOOKS = {"978-0441172719": "Dune by Frank Herbert"}
21+
22+
23+
def read_manual_safely(path: str) -> str:
24+
if contains_path_traversal(path) or is_absolute_path(path):
25+
raise ValueError("rejected")
26+
return MANUALS[path]
27+
28+
29+
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
30+
if (matched := TEMPLATES["manuals"].match(params.uri)) is not None:
31+
text = read_manual_safely(str(matched["path"]))
32+
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
33+
34+
if (matched := TEMPLATES["books"].match(params.uri)) is not None:
35+
text = BOOKS[str(matched["isbn"])]
36+
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
37+
38+
raise ValueError(f"Unknown resource: {params.uri}")
39+
40+
41+
async def list_resource_templates(
42+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
43+
) -> ListResourceTemplatesResult:
44+
return ListResourceTemplatesResult(
45+
resource_templates=[
46+
ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items()
47+
]
48+
)
49+
50+
51+
server = Server(
52+
"Bookshop",
53+
on_read_resource=read_resource,
54+
on_list_resource_templates=list_resource_templates,
55+
)
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""`docs/advanced/uri-templates.md`: every claim the page makes, proved against the real SDK."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
from inline_snapshot import snapshot
7+
from mcp_types import INVALID_PARAMS, ErrorData, ResourceTemplate, TextResourceContents
8+
9+
from docs_src.uri_templates import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
10+
from mcp import Client, MCPError
11+
from mcp.shared.path_security import PathEscapeError, contains_path_traversal, safe_join
12+
13+
# See test_index.py for why this is a per-module mark and not a conftest hook.
14+
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
15+
16+
17+
async def test_simple_expansion_maps_the_segment_to_the_argument() -> None:
18+
"""tutorial001: `books://{isbn}` reads `books://978-...` and the matched string is the argument."""
19+
async with Client(tutorial001.mcp) as client:
20+
(content,) = (await client.read_resource("books://978-0441172719")).contents
21+
assert isinstance(content, TextResourceContents)
22+
assert content.text == snapshot('{\n "title": "Dune",\n "author": "Frank Herbert"\n}')
23+
24+
25+
async def test_an_int_parameter_is_converted_from_the_uri_string() -> None:
26+
"""tutorial001: `order_id: int` receives `12345`, not `"12345"`, so `order_id + 1` is `12346`."""
27+
async with Client(tutorial001.mcp) as client:
28+
(content,) = (await client.read_resource("orders://12345")).contents
29+
assert isinstance(content, TextResourceContents)
30+
assert content.text == snapshot('{\n "order_id": 12345,\n "next_order": 12346,\n "status": "shipped"\n}')
31+
32+
33+
async def test_plus_keeps_the_slashes_in_the_captured_value() -> None:
34+
"""tutorial001: `{+path}` matches `printing/setup.md` as one value; a plain `{path}` would not."""
35+
async with Client(tutorial001.mcp) as client:
36+
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
37+
assert isinstance(content, TextResourceContents)
38+
assert content.text == "# Printer setup\n\nLoad paper, then power on."
39+
40+
41+
async def test_omitted_query_params_fall_through_to_function_defaults() -> None:
42+
"""tutorial001: `{?limit,sort}` is lenient. No query string means `limit=10, sort="newest"`."""
43+
async with Client(tutorial001.mcp) as client:
44+
(content,) = (await client.read_resource("reviews://978-0441172719")).contents
45+
assert isinstance(content, TextResourceContents)
46+
assert content.text == "10 newest reviews of Dune"
47+
48+
49+
async def test_a_query_param_overrides_only_the_default_it_names() -> None:
50+
"""tutorial001: `?sort=top` sets `sort` and leaves `limit` at its default."""
51+
async with Client(tutorial001.mcp) as client:
52+
(content,) = (await client.read_resource("reviews://978-0441172719?sort=top")).contents
53+
assert isinstance(content, TextResourceContents)
54+
assert content.text == "10 top reviews of Dune"
55+
56+
57+
async def test_exploded_path_arrives_as_a_list_of_segments() -> None:
58+
"""tutorial001: `{/path*}` splits `/fiction/sci-fi` into `["fiction", "sci-fi"]`."""
59+
async with Client(tutorial001.mcp) as client:
60+
(content,) = (await client.read_resource("shelves://browse/fiction/sci-fi")).contents
61+
assert isinstance(content, TextResourceContents)
62+
assert content.text == "catalog > fiction > sci-fi"
63+
64+
65+
async def test_traversal_is_rejected_before_the_handler_runs() -> None:
66+
"""The `!!! check`: `../` triggers `-32602` "Unknown resource" and `read_manual` is never called."""
67+
async with Client(tutorial001.mcp) as client:
68+
with pytest.raises(MCPError) as exc_info:
69+
await client.read_resource("manuals://../etc/passwd")
70+
assert exc_info.value.error == snapshot(
71+
ErrorData(
72+
code=INVALID_PARAMS,
73+
message="Unknown resource: manuals://../etc/passwd",
74+
data={"uri": "manuals://../etc/passwd"},
75+
)
76+
)
77+
78+
79+
def test_dotdot_is_a_component_check_not_a_substring_scan() -> None:
80+
"""The page's prose: `v1.0..v2.0` passes because `..` is not a standalone path segment."""
81+
assert contains_path_traversal("../etc") is True
82+
assert contains_path_traversal("v1.0..v2.0") is False
83+
84+
85+
async def test_safe_join_serves_a_file_inside_the_base_directory(
86+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
87+
) -> None:
88+
"""tutorial002: `safe_join(DOCS_ROOT, path).read_text()` returns the file under the base."""
89+
(tmp_path / "printing").mkdir()
90+
(tmp_path / "printing" / "setup.md").write_text("# Printer setup")
91+
monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path)
92+
async with Client(tutorial002.mcp) as client:
93+
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
94+
assert isinstance(content, TextResourceContents)
95+
assert content.text == "# Printer setup"
96+
97+
98+
def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None:
99+
"""tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`."""
100+
with pytest.raises(PathEscapeError):
101+
safe_join(tmp_path, "../etc/passwd")
102+
103+
104+
async def test_exempt_params_lets_an_absolute_path_through() -> None:
105+
"""tutorial003: `exempt_params={"source"}` skips the checks for that one parameter."""
106+
async with Client(tutorial003.mcp) as client:
107+
(content,) = (await client.read_resource("imports://preview//srv/incoming/catalog.csv")).contents
108+
assert isinstance(content, TextResourceContents)
109+
assert content.text == "Would import from /srv/incoming/catalog.csv"
110+
111+
112+
async def test_server_wide_resource_security_relaxes_every_resource() -> None:
113+
"""tutorial003: `resource_security=ResourceSecurity(reject_path_traversal=False)` exempts the whole server."""
114+
async with Client(tutorial003.relaxed) as client:
115+
(content,) = (await client.read_resource("imports://preview/../sibling/catalog.csv")).contents
116+
assert isinstance(content, TextResourceContents)
117+
assert content.text == "Would import from ../sibling/catalog.csv"
118+
119+
120+
async def test_lowlevel_static_dispatch_lists_and_reads_by_exact_uri() -> None:
121+
"""tutorial004: the registry is the listing, and a known URI returns its text."""
122+
async with Client(tutorial004.server) as client:
123+
listed = (await client.list_resources()).resources
124+
assert [r.uri for r in listed] == ["config://shop", "status://health"]
125+
(content,) = (await client.read_resource("status://health")).contents
126+
assert content == TextResourceContents(uri="status://health", text="ok")
127+
128+
129+
async def test_lowlevel_unknown_uri_raises() -> None:
130+
"""tutorial004: a URI outside the registry raises and surfaces as a protocol error."""
131+
async with Client(tutorial004.server) as client:
132+
with pytest.raises(MCPError):
133+
await client.read_resource("config://missing")
134+
135+
136+
def test_uritemplate_match_returns_a_dict_or_none() -> None:
137+
"""tutorial005: `match()` extracts decoded variables, or `None` when the URI doesn't fit."""
138+
assert tutorial005.TEMPLATES["manuals"].match("manuals://printing/setup.md") == {"path": "printing/setup.md"}
139+
assert tutorial005.TEMPLATES["books"].match("manuals://nope") is None
140+
141+
142+
async def test_lowlevel_match_routes_the_request_to_the_right_template() -> None:
143+
"""tutorial005: two templates, one handler. Each concrete URI lands in its own branch."""
144+
async with Client(tutorial005.server) as client:
145+
(manual,) = (await client.read_resource("manuals://printing/setup.md")).contents
146+
assert manual == TextResourceContents(uri="manuals://printing/setup.md", text="# Printer setup")
147+
(book,) = (await client.read_resource("books://978-0441172719")).contents
148+
assert book == TextResourceContents(uri="books://978-0441172719", text="Dune by Frank Herbert")
149+
150+
151+
async def test_lowlevel_handler_applies_the_safety_checks_itself() -> None:
152+
"""tutorial005: there is no default policy down here; `read_manual_safely` is the gate."""
153+
async with Client(tutorial005.server) as client:
154+
with pytest.raises(MCPError):
155+
await client.read_resource("manuals://../etc/passwd")
156+
with pytest.raises(MCPError):
157+
await client.read_resource("nothing://matches")
158+
159+
160+
async def test_str_of_a_template_round_trips_to_the_original_string() -> None:
161+
"""tutorial005: `str(template)` is the source string, so the listing reuses the parsed templates."""
162+
assert str(tutorial005.TEMPLATES["manuals"]) == "manuals://{+path}"
163+
async with Client(tutorial005.server) as client:
164+
result = await client.list_resource_templates()
165+
assert result.resource_templates == snapshot(
166+
[
167+
ResourceTemplate(name="manuals", uri_template="manuals://{+path}"),
168+
ResourceTemplate(name="books", uri_template="books://{isbn}"),
169+
]
170+
)

0 commit comments

Comments
 (0)