Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 269 additions & 0 deletions docs/advanced/uri-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
# URI templates and path safety

This is the reference for the URI-template syntax that
[`@mcp.resource`](../tutorial/resources.md) accepts, and for the
path-safety policy the SDK applies to extracted values. For an
introduction to what resources are and when to use them, start with
**Resources**; this page assumes you're already comfortable declaring a
resource and want the full operator set, the security knobs, or the
low-level wiring.

The template syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570).
The SDK supports a subset chosen for matching incoming `resources/read`
URIs, plus a security layer that rejects values that would resolve
outside the directory you intend to serve. For the protocol-level
details (message formats, lifecycle, pagination) see the
[MCP resources specification](https://modelcontextprotocol.io/specification/latest/server/resources).

## The full operator set

**Resources** showed one placeholder, `{user_id}`. There are four more
operator forms; here they are on one server so you can see them next to
each other:

```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41"
--8<-- "docs_src/uri_templates/tutorial001.py"
```

Each highlighted decorator is a different way of carving up the URI.
The sections below walk them top to bottom.

### Simple expansion: `{name}`

`books://{isbn}` is the form you already know. The placeholder maps to
the `isbn` parameter, so a client reading `books://978-0441172719` calls
`get_book("978-0441172719")`.

A plain `{name}` stops at the first `/`. `books://978/extra` does not
match because the slash after `978` ends the capture and `/extra` is
left over.

### Type conversion

Extracted values arrive as strings, but you can declare a more specific
type and the SDK will convert. `orders://{order_id}` lands in a function
whose parameter is `order_id: int`, so reading `orders://12345` calls
`get_order(12345)`, not `get_order("12345")`. The handler does
arithmetic on it (`order_id + 1`) without a cast.

### Multi-segment paths: `{+name}`

To capture a value that contains slashes, use `{+name}`. With
`manuals://{+path}`:

* `manuals://returns.md` gives `path = "returns.md"`
* `manuals://printing/setup.md` gives `path = "printing/setup.md"`

Reach for `{+name}` whenever the value is hierarchical: filesystem
paths, nested object keys, URL paths you're proxying.

### Query parameters: `{?a,b,c}`

`reviews://{isbn}{?limit,sort}` puts `limit` and `sort` after the `?`.
The path identifies *which* book; the query tunes *how* you read it.

Query params are matched leniently: order doesn't matter, extras are
ignored, and omitted params fall through to your function defaults. So
`reviews://978-0441172719` uses `limit=10, sort="newest"`, and
`reviews://978-0441172719?sort=top` overrides only `sort`.

### Path segments as a list: `{/name*}`

If you want each path segment as a separate list item rather than one
string with slashes, use `{/name*}`. With `shelves://browse{/path*}`, a
client reading `shelves://browse/fiction/sci-fi` calls
`browse_shelf(["fiction", "sci-fi"])`.

### Template reference

The most common patterns:

| Pattern | Example input | You get |
|--------------|-----------------------|-------------------------|
| `{name}` | `alice` | `"alice"` |
| `{name}` | `docs/intro.md` | *no match* (stops at `/`) |
| `{+path}` | `docs/intro.md` | `"docs/intro.md"` |
| `{.ext}` | `.json` | `"json"` |
| `{/segment}` | `/v2` | `"v2"` |
| `{?key}` | `?key=value` | `"value"` |
| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` |
| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` |

### What the parser rejects

A few template shapes are caught up front rather than failing on the
first request. `@mcp.resource` parses the template when the decorator
runs, so none of these ever reach a running server.

`UriTemplate.parse()` raises `InvalidUriTemplate` for:

* **Two variables with nothing between them.** `manuals://{+path}{ext}`
is rejected: matching can't tell where `path` ends and `ext` begins.
Put a literal between them (`manuals://{+path}/{ext}`), or use an
operator that supplies its own delimiter. `manuals://{+path}{.ext}`
is accepted because `{.ext}` contributes the `.` itself.
* **More than one multi-segment variable.** At most one of `{+var}`,
`{#var}`, or an exploded variable (`{/var*}`, `{.var*}`, `{;var*}`)
per template. Two are inherently ambiguous: there is no principled
way to decide which one absorbs an extra segment.
* **The usual syntax errors**: an unclosed brace, a variable name used
twice, or an RFC 6570 feature the SDK doesn't support, such as the
`{var:3}` prefix modifier or the `{?vars*}` query explode.

On top of that, `@mcp.resource` raises `ValueError` when a handler
parameter is bound to a query variable in the template's trailing
`{?...}`/`{&...}` run but has no Python default. Those variables are
matched leniently (a client may leave any of them out), so a parameter
without a default would only surface as an opaque internal error on the
first request that omits it. `reviews://{isbn}{?limit,sort}` in the
server above is the well-formed version: `limit` and `sort` both carry
defaults.

## Security

Template parameters come from the client. If they flow into filesystem
or database operations unchecked, values like `../../etc/passwd` can
resolve outside the directory you intended to serve.

### What the SDK checks by default

Before your handler runs, the SDK rejects any parameter that:

* would escape its starting directory via `..` components
* looks like an absolute path (`/etc/passwd`, `C:\Windows`) or a
Windows drive-relative one (`C:foo`). A drive-relative value and a
namespaced identifier like `x:y` are indistinguishable as strings,
so any single-letter-plus-colon value is rejected by default;
exempt the parameter if it legitimately receives such values
* contains a null byte (`\x00`)

The `..` check is component-based, not a substring scan. Values like
`v1.0..v2.0` or `HEAD~3..HEAD` pass because `..` is not a standalone
path segment there.

These checks apply to the decoded value, so they catch traversal
regardless of how it was encoded in the URI (`../etc`, `..%2Fetc`,
`%2E%2E/etc`, `..%5Cetc`, `%00` all get caught).

!!! check
Read `manuals://../etc/passwd` from the server above and the request
is rejected outright: template matching stops at the first failure,
so no later (potentially more permissive) template is tried as a
fallback. The client sees the same `-32602` "Unknown resource" error
it would for a URI that matches no template at all, and
`read_manual` never runs.

### Filesystem handlers: use safe_join

The built-in checks stop the common cases but can't know your sandbox
boundary. For filesystem access, use `safe_join` to resolve the path
and verify it stays inside your base directory:

```python title="server.py" hl_lines="4 14"
--8<-- "docs_src/uri_templates/tutorial002.py"
```

`safe_join` catches symlink escapes, `..` sequences, and absolute-path
tricks that a simple string check would miss. If the resolved path
escapes `DOCS_ROOT`, it raises `PathEscapeError`, which surfaces to the
client as a `ResourceError`.

### When the defaults get in the way

Sometimes the checks block legitimate values. A catalog-import tool
might intentionally receive an absolute path, or a parameter might be a
relative reference like `../sibling` that your handler interprets
safely without touching the filesystem. Exempt that parameter, or relax
the policy for the whole server:

```python title="server.py" hl_lines="9 16-19"
--8<-- "docs_src/uri_templates/tutorial003.py"
```

* `security=ResourceSecurity(exempt_params={"source"})` on the decorator
skips the checks for that one parameter on that one resource. The
rest of the server keeps the default policy.
* `resource_security=` on the `MCPServer` constructor sets the default
for every resource. Here `relaxed` turns off the `..` check entirely.

The configurable checks:

| Setting | Default | What it does |
|-------------------------|---------|-------------------------------------|
| `reject_path_traversal` | `True` | Rejects `..` sequences that escape the starting directory |
| `reject_absolute_paths` | `True` | Rejects `/foo`, `C:\foo`, UNC paths, and drive-relative `C:foo` (also catches `x:y`) |
| `reject_null_bytes` | `True` | Rejects values containing `\x00` |
| `exempt_params` | empty | Parameter names to skip checks for |
Comment thread
maxisbey marked this conversation as resolved.

These checks are a heuristic pre-filter; for filesystem access,
`safe_join` remains the containment boundary.

!!! tip
If your handler can't fulfil the request (the file doesn't exist,
the id is unknown), raise an exception. The SDK turns it into an
error response. See **Handling errors** for the difference between a
protocol error and a tool error.

## Resources on the low-level Server

If you're building on the low-level `Server` (see **The low-level
Server**), you register handlers for the `resources/list` and
`resources/read` protocol methods directly. There's no decorator; you
return the protocol types yourself.

### Static resources

For fixed URIs, keep a registry and dispatch on exact match:

```python title="server.py" hl_lines="18 22 28"
--8<-- "docs_src/uri_templates/tutorial004.py"
```

The list handler tells clients what's available; the read handler
serves the content. Check your registry first, fall through to
templates (below) if you have any, then raise for anything else.

### Templates

The template engine `MCPServer` uses lives in `mcp.shared.uri_template`
and works on its own. You get the same parsing and matching; you wire
up the routing and security policy yourself.

```python title="server.py" hl_lines="14-17 23-26 30 34 46"
--8<-- "docs_src/uri_templates/tutorial005.py"
```

Three things are happening in the highlighted lines:

* **Parse once, match per request.** `UriTemplate.parse()` builds the
template; `template.match(uri)` returns the extracted variables as a
`dict`, or `None` if the URI doesn't fit. URL decoding happens inside
`match()`; the decoded values are returned as-is without path-safety
validation. Values come out as strings: convert them yourself
(`int(matched["id"])`, `Path(matched["path"])`).
* **Apply the safety checks yourself.** The `..` and absolute-path
checks `MCPServer` runs by default live in `mcp.shared.path_security`.
`read_manual_safely` calls them before touching `MANUALS`. If a
parameter isn't a filesystem path (an ISBN, a search query), skip the
checks for that value: you control the policy per handler rather than
through a config object.
* **List the templates from the same source.** Clients discover
templates through `resources/templates/list`. `str(template)` gives
back the original template string, so the listing and the matcher
share one source of truth.

## Recap

* `{name}` matches one segment; `{+name}` keeps the slashes; `{?a,b}`
pulls from the query string; `{/name*}` splits segments into a list.
* Two variables with nothing between them, or a second multi-segment
variable, are rejected at parse time. A parameter bound to a trailing
`{?...}`/`{&...}` query variable must declare a Python default.
* Annotate the parameter (`order_id: int`) and the SDK converts.
* The default security policy rejects `..`, absolute paths, and null
bytes before your handler runs; override per resource with
`security=ResourceSecurity(...)` or server-wide with
`resource_security=`.
* For filesystem access, `safe_join` is the containment boundary.
* On the low-level `Server`, parse with `UriTemplate.parse()`, match
with `.match()`, and apply `mcp.shared.path_security` yourself.
70 changes: 70 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,76 @@ Reading a missing resource now returns JSON-RPC error code `-32602` (invalid par

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`).

### Resource templates: matching behavior changes

Resource template matching has been rewritten with RFC 6570 support.
Several behaviors have changed:

**Path-safety checks applied by default.** Extracted parameter values
containing `..` as a path component, a null byte, or looking like an
absolute path (`/etc/passwd`, `C:\Windows`) now cause the read to
fail — the client receives an "Unknown resource" error and template
iteration stops, so a strict template's rejection does not fall
through to a later permissive template. This is checked on the
decoded value, so `..%2Fetc`, `%2E%2E`, and `%00` are caught too.
Note that `..` is only flagged as a standalone path component, so
values like `v1.0..v2.0` or `HEAD~3..HEAD` are unaffected.

If a parameter legitimately needs to receive absolute paths or
traversal sequences, exempt it:

```python
from mcp.server.mcpserver import ResourceSecurity

@mcp.resource(
"inspect://file/{+target}",
security=ResourceSecurity(exempt_params={"target"}),
)
def inspect_file(target: str) -> str: ...
```

**Template literals and structural delimiters match exactly.** The
previous matcher built a regex without escaping, so `.` matched any
character and simple `{var}` swallowed `?`, `#`, `&`, and `,`. Now
`data://v1.0/{id}` no longer matches `data://v1X0/42`, and
`api://{id}` no longer matches `api://foo?x=1` — use `api://{id}{?x}`
to capture the query parameter.

**`{var}` now matches an empty value.** A simple expression captures
zero or more characters, so `tickets://{ticket_id}` now matches
`tickets://` with `ticket_id=""` (v1.x's `[^/]+` regex required at
least one). This makes `match` round-trip `expand` for empty values — RFC 6570
expands an empty string to nothing — but handlers that assumed a
non-empty value should validate it explicitly.

**Template syntax errors surface at decoration time.** Unclosed
braces, duplicate variable names, and unsupported syntax raise
`InvalidUriTemplate` when the decorator runs rather than `re.error`
on first match. Two variables with no literal between them are also
rejected — matching cannot tell where one ends and the next begins —
so `{name}{+path}` raises. Write `{name}/{+path}`, or use an operator
that emits its own delimiter: `{+path}{.ext}` is fine because the `.`
operator contributes a literal `.` between the two. A handler
parameter bound to a query variable in the template's trailing
`{?...}`/`{&...}` run — the variables `match()` treats as optional,
listed by `UriTemplate.query_variable_names` — must declare a Python
default: a client may omit those, so a handler that requires one now
raises `ValueError` when the decorator runs instead of failing on the
first request that leaves it out. (A `{&...}` expression with no
preceding `{?...}` is not in that run: it is matched strictly, may
not be omitted, and needs no default.)

**Static URIs with Context-only handlers now error.** A non-template
URI paired with a handler that takes only a `Context` parameter
previously registered but was silently unreachable (the resource
could never be read). This now raises `ValueError` at decoration time.
Context injection for static resources is not supported — use a
template with at least one variable or access context through other
means.

See [URI templates](advanced/uri-templates.md) for the full template syntax,
security configuration, and filesystem safety utilities.

### Registering lowlevel handlers from `MCPServer`

`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:
Expand Down
2 changes: 2 additions & 0 deletions docs/tutorial/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for,

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

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.

`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.

## What you return
Expand Down
Empty file.
43 changes: 43 additions & 0 deletions docs_src/uri_templates/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from mcp.server import MCPServer

mcp = MCPServer("Bookshop")

BOOKS = {
"978-0441172719": {"title": "Dune", "author": "Frank Herbert"},
"978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"},
}

MANUALS = {
"printing/setup.md": "# Printer setup\n\nLoad paper, then power on.",
"returns.md": "# Returns policy\n\nThirty days with a receipt.",
}


@mcp.resource("books://{isbn}")
def get_book(isbn: str) -> dict[str, str]:
"""A single book by ISBN."""
return BOOKS[isbn]


@mcp.resource("orders://{order_id}")
def get_order(order_id: int) -> dict[str, object]:
"""An order by its numeric id."""
return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"}


@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page. The path keeps its slashes."""
return MANUALS[path]


@mcp.resource("reviews://{isbn}{?limit,sort}")
def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str:
"""Reviews of a book, optionally limited and sorted."""
return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}"


@mcp.resource("shelves://browse{/path*}")
def browse_shelf(path: list[str]) -> str:
"""A shelf in the category tree, addressed by segments."""
return " > ".join(["catalog", *path])
Loading
Loading