|
| 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` — which 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. |
0 commit comments