Skip to content

Commit c75e71c

Browse files
committed
feat: RFC 6570 URI templates with operator-aware security
Replace the regex-based resource template matcher with a linear-time RFC 6570 implementation (mcp.shared.uri_template.UriTemplate), add filesystem path-safety primitives (mcp.shared.path_security), and wire a configurable ResourceSecurity policy into MCPServer. The matcher is a two-ended linear scan with no backtracking. Rather than handling ambiguous templates with scan-time special cases, the parser rejects them up front: two variables adjacent with no literal between them (including a variable adjacent to the multi-segment variable), more than one multi-segment variable, and more than one {?...} expression all raise InvalidUriTemplate when the decorator runs. Operators that emit their own delimiter ({.ext}, {/seg}, {;name}) anchor themselves and still compose with a multi-segment variable. A handler parameter bound to an optional {?...}/{&...} query variable must declare a Python default; this is also checked at decoration time so the mistake cannot reach a request. ResourceSecurity rejects extracted parameter values that contain a null byte, look like an absolute path, or would resolve outside their starting directory. A rejection is indistinguishable on the wire from a not-found resource (-32602) and halts template iteration so a later permissive template is never tried. safe_join() is exported for filesystem handlers. UriTemplate is re-exported at the top level so clients can expand a template a server advertises. Beyond the example-based suite, two seeded property tests cover the whole space the parser accepts: match(expand(v)) round-trips and re-expands to the same URI for every accepted template, and match() never raises on any input. Docs: a tested reference page at docs/advanced/uri-templates.md with runnable examples under docs_src/uri_templates/, a forward link from the resources tutorial, and migration notes for every behaviour change.
1 parent 067f905 commit c75e71c

23 files changed

Lines changed: 3800 additions & 37 deletions

docs/advanced/uri-templates.md

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# URI templates and path safety
2+
3+
This is the reference for the URI-template syntax that
4+
[`@mcp.resource`](../tutorial/resources.md) accepts, and for the
5+
path-safety policy the SDK applies to extracted values. For an
6+
introduction to what resources are and when to use them, start with
7+
**Resources**; this page assumes you're already comfortable declaring a
8+
resource and want the full operator set, the security knobs, or the
9+
low-level wiring.
10+
11+
The template syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570).
12+
The SDK supports a subset chosen for matching incoming `resources/read`
13+
URIs, plus a security layer that rejects values that would resolve
14+
outside the directory you intend to serve. For the protocol-level
15+
details (message formats, lifecycle, pagination) see the
16+
[MCP resources specification](https://modelcontextprotocol.io/specification/latest/server/resources).
17+
18+
## The full operator set
19+
20+
**Resources** showed one placeholder, `{user_id}`. There are four more
21+
operator forms; here they are on one server so you can see them next to
22+
each other:
23+
24+
```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41"
25+
--8<-- "docs_src/uri_templates/tutorial001.py"
26+
```
27+
28+
Each highlighted decorator is a different way of carving up the URI.
29+
The sections below walk them top to bottom.
30+
31+
### Simple expansion: `{name}`
32+
33+
`books://{isbn}` is the form you already know. The placeholder maps to
34+
the `isbn` parameter, so a client reading `books://978-0441172719` calls
35+
`get_book("978-0441172719")`.
36+
37+
A plain `{name}` stops at the first `/`. `books://978/extra` does not
38+
match because the slash after `978` ends the capture and `/extra` is
39+
left over.
40+
41+
### Type conversion
42+
43+
Extracted values arrive as strings, but you can declare a more specific
44+
type and the SDK will convert. `orders://{order_id}` lands in a function
45+
whose parameter is `order_id: int`, so reading `orders://12345` calls
46+
`get_order(12345)`, not `get_order("12345")`. The handler does
47+
arithmetic on it (`order_id + 1`) without a cast.
48+
49+
### Multi-segment paths: `{+name}`
50+
51+
To capture a value that contains slashes, use `{+name}`. With
52+
`manuals://{+path}`:
53+
54+
* `manuals://returns.md` gives `path = "returns.md"`
55+
* `manuals://printing/setup.md` gives `path = "printing/setup.md"`
56+
57+
Reach for `{+name}` whenever the value is hierarchical: filesystem
58+
paths, nested object keys, URL paths you're proxying.
59+
60+
### Query parameters: `{?a,b,c}`
61+
62+
`reviews://{isbn}{?limit,sort}` puts `limit` and `sort` after the `?`.
63+
The path identifies *which* book; the query tunes *how* you read it.
64+
65+
Query params are matched leniently: order doesn't matter, extras are
66+
ignored, and omitted params fall through to your function defaults. So
67+
`reviews://978-0441172719` uses `limit=10, sort="newest"`, and
68+
`reviews://978-0441172719?sort=top` overrides only `sort`.
69+
70+
### Path segments as a list: `{/name*}`
71+
72+
If you want each path segment as a separate list item rather than one
73+
string with slashes, use `{/name*}`. With `shelves://browse{/path*}`, a
74+
client reading `shelves://browse/fiction/sci-fi` calls
75+
`browse_shelf(["fiction", "sci-fi"])`.
76+
77+
### Template reference
78+
79+
The most common patterns:
80+
81+
| Pattern | Example input | You get |
82+
|--------------|-----------------------|-------------------------|
83+
| `{name}` | `alice` | `"alice"` |
84+
| `{name}` | `docs/intro.md` | *no match* (stops at `/`) |
85+
| `{+path}` | `docs/intro.md` | `"docs/intro.md"` |
86+
| `{.ext}` | `.json` | `"json"` |
87+
| `{/segment}` | `/v2` | `"v2"` |
88+
| `{?key}` | `?key=value` | `"value"` |
89+
| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` |
90+
| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` |
91+
92+
### What the parser rejects
93+
94+
A few template shapes are caught up front rather than failing on the
95+
first request. `@mcp.resource` parses the template when the decorator
96+
runs, so none of these ever reach a running server.
97+
98+
`UriTemplate.parse()` raises `InvalidUriTemplate` for:
99+
100+
* **Two variables with nothing between them.** `manuals://{+path}{ext}`
101+
is rejected: matching can't tell where `path` ends and `ext` begins.
102+
Put a literal between them (`manuals://{+path}/{ext}`), or use an
103+
operator that supplies its own delimiter. `manuals://{+path}{.ext}`
104+
is accepted because `{.ext}` contributes the `.` itself.
105+
* **More than one multi-segment variable.** At most one of `{+var}`,
106+
`{#var}`, or an exploded variable (`{/var*}`, `{.var*}`, `{;var*}`)
107+
per template. Two are inherently ambiguous: there is no principled
108+
way to decide which one absorbs an extra segment.
109+
* **The usual syntax errors**: an unclosed brace, a variable name used
110+
twice, or an RFC 6570 feature the SDK doesn't support, such as the
111+
`{var:3}` prefix modifier or the `{?vars*}` query explode.
112+
113+
On top of that, `@mcp.resource` raises `ValueError` when a handler
114+
parameter is bound to a query variable in the template's trailing
115+
`{?...}`/`{&...}` run but has no Python default. Those variables are
116+
matched leniently (a client may leave any of them out), so a parameter
117+
without a default would only surface as an opaque internal error on the
118+
first request that omits it. `reviews://{isbn}{?limit,sort}` in the
119+
server above is the well-formed version: `limit` and `sort` both carry
120+
defaults.
121+
122+
## Security
123+
124+
Template parameters come from the client. If they flow into filesystem
125+
or database operations unchecked, values like `../../etc/passwd` can
126+
resolve outside the directory you intended to serve.
127+
128+
### What the SDK checks by default
129+
130+
Before your handler runs, the SDK rejects any parameter that:
131+
132+
* would escape its starting directory via `..` components
133+
* looks like an absolute path (`/etc/passwd`, `C:\Windows`) or a
134+
Windows drive-relative one (`C:foo`). A drive-relative value and a
135+
namespaced identifier like `x:y` are indistinguishable as strings,
136+
so any single-letter-plus-colon value is rejected by default;
137+
exempt the parameter if it legitimately receives such values
138+
* contains a null byte (`\x00`)
139+
140+
The `..` check is component-based, not a substring scan. Values like
141+
`v1.0..v2.0` or `HEAD~3..HEAD` pass because `..` is not a standalone
142+
path segment there.
143+
144+
These checks apply to the decoded value, so they catch traversal
145+
regardless of how it was encoded in the URI (`../etc`, `..%2Fetc`,
146+
`%2E%2E/etc`, `..%5Cetc`, `%00` all get caught).
147+
148+
!!! check
149+
Read `manuals://../etc/passwd` from the server above and the request
150+
is rejected outright: template matching stops at the first failure,
151+
so no later (potentially more permissive) template is tried as a
152+
fallback. The client sees the same `-32602` "Unknown resource" error
153+
it would for a URI that matches no template at all, and
154+
`read_manual` never runs.
155+
156+
### Filesystem handlers: use safe_join
157+
158+
The built-in checks stop the common cases but can't know your sandbox
159+
boundary. For filesystem access, use `safe_join` to resolve the path
160+
and verify it stays inside your base directory:
161+
162+
```python title="server.py" hl_lines="4 14"
163+
--8<-- "docs_src/uri_templates/tutorial002.py"
164+
```
165+
166+
`safe_join` catches symlink escapes, `..` sequences, and absolute-path
167+
tricks that a simple string check would miss. If the resolved path
168+
escapes `DOCS_ROOT`, it raises `PathEscapeError`, which surfaces to the
169+
client as a `ResourceError`.
170+
171+
### When the defaults get in the way
172+
173+
Sometimes the checks block legitimate values. A catalog-import tool
174+
might intentionally receive an absolute path, or a parameter might be a
175+
relative reference like `../sibling` that your handler interprets
176+
safely without touching the filesystem. Exempt that parameter, or relax
177+
the policy for the whole server:
178+
179+
```python title="server.py" hl_lines="9 16-19"
180+
--8<-- "docs_src/uri_templates/tutorial003.py"
181+
```
182+
183+
* `security=ResourceSecurity(exempt_params={"source"})` on the decorator
184+
skips the checks for that one parameter on that one resource. The
185+
rest of the server keeps the default policy.
186+
* `resource_security=` on the `MCPServer` constructor sets the default
187+
for every resource. Here `relaxed` turns off the `..` check entirely.
188+
189+
The configurable checks:
190+
191+
| Setting | Default | What it does |
192+
|-------------------------|---------|-------------------------------------|
193+
| `reject_path_traversal` | `True` | Rejects `..` sequences that escape the starting directory |
194+
| `reject_absolute_paths` | `True` | Rejects `/foo`, `C:\foo`, UNC paths, and drive-relative `C:foo` (also catches `x:y`) |
195+
| `reject_null_bytes` | `True` | Rejects values containing `\x00` |
196+
| `exempt_params` | empty | Parameter names to skip checks for |
197+
198+
These checks are a heuristic pre-filter; for filesystem access,
199+
`safe_join` remains the containment boundary.
200+
201+
!!! tip
202+
If your handler can't fulfil the request (the file doesn't exist,
203+
the id is unknown), raise an exception. The SDK turns it into an
204+
error response. See **Handling errors** for the difference between a
205+
protocol error and a tool error.
206+
207+
## Resources on the low-level Server
208+
209+
If you're building on the low-level `Server` (see **The low-level
210+
Server**), you register handlers for the `resources/list` and
211+
`resources/read` protocol methods directly. There's no decorator; you
212+
return the protocol types yourself.
213+
214+
### Static resources
215+
216+
For fixed URIs, keep a registry and dispatch on exact match:
217+
218+
```python title="server.py" hl_lines="18 22 28"
219+
--8<-- "docs_src/uri_templates/tutorial004.py"
220+
```
221+
222+
The list handler tells clients what's available; the read handler
223+
serves the content. Check your registry first, fall through to
224+
templates (below) if you have any, then raise for anything else.
225+
226+
### Templates
227+
228+
The template engine `MCPServer` uses lives in `mcp.shared.uri_template`
229+
and works on its own. You get the same parsing and matching; you wire
230+
up the routing and security policy yourself.
231+
232+
```python title="server.py" hl_lines="14-17 23-26 30 34 46"
233+
--8<-- "docs_src/uri_templates/tutorial005.py"
234+
```
235+
236+
Three things are happening in the highlighted lines:
237+
238+
* **Parse once, match per request.** `UriTemplate.parse()` builds the
239+
template; `template.match(uri)` returns the extracted variables as a
240+
`dict`, or `None` if the URI doesn't fit. URL decoding happens inside
241+
`match()`; the decoded values are returned as-is without path-safety
242+
validation. Values come out as strings: convert them yourself
243+
(`int(matched["id"])`, `Path(matched["path"])`).
244+
* **Apply the safety checks yourself.** The `..` and absolute-path
245+
checks `MCPServer` runs by default live in `mcp.shared.path_security`.
246+
`read_manual_safely` calls them before touching `MANUALS`. If a
247+
parameter isn't a filesystem path (an ISBN, a search query), skip the
248+
checks for that value: you control the policy per handler rather than
249+
through a config object.
250+
* **List the templates from the same source.** Clients discover
251+
templates through `resources/templates/list`. `str(template)` gives
252+
back the original template string, so the listing and the matcher
253+
share one source of truth.
254+
255+
## Recap
256+
257+
* `{name}` matches one segment; `{+name}` keeps the slashes; `{?a,b}`
258+
pulls from the query string; `{/name*}` splits segments into a list.
259+
* Two variables with nothing between them, or a second multi-segment
260+
variable, are rejected at parse time. A parameter bound to a trailing
261+
`{?...}`/`{&...}` query variable must declare a Python default.
262+
* Annotate the parameter (`order_id: int`) and the SDK converts.
263+
* The default security policy rejects `..`, absolute paths, and null
264+
bytes before your handler runs; override per resource with
265+
`security=ResourceSecurity(...)` or server-wide with
266+
`resource_security=`.
267+
* For filesystem access, `safe_join` is the containment boundary.
268+
* On the low-level `Server`, parse with `UriTemplate.parse()`, match
269+
with `.match()`, and apply `mcp.shared.path_security` yourself.

docs/migration.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,76 @@ Reading a missing resource now returns JSON-RPC error code `-32602` (invalid par
609609

610610
The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`).
611611

612+
### Resource templates: matching behavior changes
613+
614+
Resource template matching has been rewritten with RFC 6570 support.
615+
Several behaviors have changed:
616+
617+
**Path-safety checks applied by default.** Extracted parameter values
618+
containing `..` as a path component, a null byte, or looking like an
619+
absolute path (`/etc/passwd`, `C:\Windows`) now cause the read to
620+
fail — the client receives an "Unknown resource" error and template
621+
iteration stops, so a strict template's rejection does not fall
622+
through to a later permissive template. This is checked on the
623+
decoded value, so `..%2Fetc`, `%2E%2E`, and `%00` are caught too.
624+
Note that `..` is only flagged as a standalone path component, so
625+
values like `v1.0..v2.0` or `HEAD~3..HEAD` are unaffected.
626+
627+
If a parameter legitimately needs to receive absolute paths or
628+
traversal sequences, exempt it:
629+
630+
```python
631+
from mcp.server.mcpserver import ResourceSecurity
632+
633+
@mcp.resource(
634+
"inspect://file/{+target}",
635+
security=ResourceSecurity(exempt_params={"target"}),
636+
)
637+
def inspect_file(target: str) -> str: ...
638+
```
639+
640+
**Template literals and structural delimiters match exactly.** The
641+
previous matcher built a regex without escaping, so `.` matched any
642+
character and simple `{var}` swallowed `?`, `#`, `&`, and `,`. Now
643+
`data://v1.0/{id}` no longer matches `data://v1X0/42`, and
644+
`api://{id}` no longer matches `api://foo?x=1` — use `api://{id}{?x}`
645+
to capture the query parameter.
646+
647+
**`{var}` now matches an empty value.** A simple expression captures
648+
zero or more characters, so `tickets://{ticket_id}` now matches
649+
`tickets://` with `ticket_id=""` (v1.x's `[^/]+` regex required at
650+
least one). This makes `match` round-trip `expand` for empty values — RFC 6570
651+
expands an empty string to nothing — but handlers that assumed a
652+
non-empty value should validate it explicitly.
653+
654+
**Template syntax errors surface at decoration time.** Unclosed
655+
braces, duplicate variable names, and unsupported syntax raise
656+
`InvalidUriTemplate` when the decorator runs rather than `re.error`
657+
on first match. Two variables with no literal between them are also
658+
rejected — matching cannot tell where one ends and the next begins —
659+
so `{name}{+path}` raises. Write `{name}/{+path}`, or use an operator
660+
that emits its own delimiter: `{+path}{.ext}` is fine because the `.`
661+
operator contributes a literal `.` between the two. A handler
662+
parameter bound to a query variable in the template's trailing
663+
`{?...}`/`{&...}` run — the variables `match()` treats as optional,
664+
listed by `UriTemplate.query_variable_names` — must declare a Python
665+
default: a client may omit those, so a handler that requires one now
666+
raises `ValueError` when the decorator runs instead of failing on the
667+
first request that leaves it out. (A `{&...}` expression with no
668+
preceding `{?...}` is not in that run: it is matched strictly, may
669+
not be omitted, and needs no default.)
670+
671+
**Static URIs with Context-only handlers now error.** A non-template
672+
URI paired with a handler that takes only a `Context` parameter
673+
previously registered but was silently unreachable (the resource
674+
could never be read). This now raises `ValueError` at decoration time.
675+
Context injection for static resources is not supported — use a
676+
template with at least one variable or access context through other
677+
means.
678+
679+
See [URI templates](advanced/uri-templates.md) for the full template syntax,
680+
security configuration, and filesystem safety utilities.
681+
612682
### Registering lowlevel handlers from `MCPServer`
613683

614684
`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods:

docs/tutorial/resources.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for,
9292

9393
A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one.
9494

95+
The placeholder syntax is RFC 6570: `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](../advanced/uri-templates.md)** for the full reference.
96+
9597
`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **The Context** chapter covers what it gives you.
9698

9799
## What you return

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])

0 commit comments

Comments
 (0)