feat: RFC 6570 URI templates with operator-aware security (#2356)
This commit is contained in:
parent
067f90578c
commit
24717cc8eb
23 changed files with 3800 additions and 37 deletions
269
docs/advanced/uri-templates.md
Normal file
269
docs/advanced/uri-templates.md
Normal file
|
|
@ -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 |
|
||||
|
||||
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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
docs_src/uri_templates/__init__.py
Normal file
0
docs_src/uri_templates/__init__.py
Normal file
43
docs_src/uri_templates/tutorial001.py
Normal file
43
docs_src/uri_templates/tutorial001.py
Normal file
|
|
@ -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])
|
||||
14
docs_src/uri_templates/tutorial002.py
Normal file
14
docs_src/uri_templates/tutorial002.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from pathlib import Path
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.shared.path_security import safe_join
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
DOCS_ROOT = Path("./manuals")
|
||||
|
||||
|
||||
@mcp.resource("manuals://{+path}")
|
||||
def read_manual(path: str) -> str:
|
||||
"""A staff manual page, served from a directory on disk."""
|
||||
return safe_join(DOCS_ROOT, path).read_text()
|
||||
25
docs_src/uri_templates/tutorial003.py
Normal file
25
docs_src/uri_templates/tutorial003.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import ResourceSecurity
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
"imports://preview/{+source}",
|
||||
security=ResourceSecurity(exempt_params={"source"}),
|
||||
)
|
||||
def preview_import(source: str) -> str:
|
||||
"""Preview a catalog import. `source` may be an absolute path."""
|
||||
return f"Would import from {source}"
|
||||
|
||||
|
||||
relaxed = MCPServer(
|
||||
"Bookshop",
|
||||
resource_security=ResourceSecurity(reject_path_traversal=False),
|
||||
)
|
||||
|
||||
|
||||
@relaxed.resource("imports://preview/{+source}")
|
||||
def preview_import_relaxed(source: str) -> str:
|
||||
"""The server-wide flag exempts every resource on `relaxed`."""
|
||||
return f"Would import from {source}"
|
||||
28
docs_src/uri_templates/tutorial004.py
Normal file
28
docs_src/uri_templates/tutorial004.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from mcp_types import (
|
||||
ListResourcesResult,
|
||||
PaginatedRequestParams,
|
||||
ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
RESOURCES = {
|
||||
"config://shop": '{"currency": "USD", "tax_rate": 0.08}',
|
||||
"status://health": "ok",
|
||||
}
|
||||
|
||||
|
||||
async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES])
|
||||
|
||||
|
||||
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
|
||||
if (text := RESOURCES.get(params.uri)) is not None:
|
||||
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
|
||||
raise ValueError(f"Unknown resource: {params.uri}")
|
||||
|
||||
|
||||
server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource)
|
||||
55
docs_src/uri_templates/tutorial005.py
Normal file
55
docs_src/uri_templates/tutorial005.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from mcp_types import (
|
||||
ListResourceTemplatesResult,
|
||||
PaginatedRequestParams,
|
||||
ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
ResourceTemplate,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
|
||||
from mcp.shared.uri_template import UriTemplate
|
||||
|
||||
TEMPLATES = {
|
||||
"manuals": UriTemplate.parse("manuals://{+path}"),
|
||||
"books": UriTemplate.parse("books://{isbn}"),
|
||||
}
|
||||
|
||||
MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"}
|
||||
BOOKS = {"978-0441172719": "Dune by Frank Herbert"}
|
||||
|
||||
|
||||
def read_manual_safely(path: str) -> str:
|
||||
if contains_path_traversal(path) or is_absolute_path(path):
|
||||
raise ValueError("rejected")
|
||||
return MANUALS[path]
|
||||
|
||||
|
||||
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
|
||||
if (matched := TEMPLATES["manuals"].match(params.uri)) is not None:
|
||||
text = read_manual_safely(str(matched["path"]))
|
||||
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
|
||||
|
||||
if (matched := TEMPLATES["books"].match(params.uri)) is not None:
|
||||
text = BOOKS[str(matched["isbn"])]
|
||||
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
|
||||
|
||||
raise ValueError(f"Unknown resource: {params.uri}")
|
||||
|
||||
|
||||
async def list_resource_templates(
|
||||
ctx: ServerRequestContext, params: PaginatedRequestParams | None
|
||||
) -> ListResourceTemplatesResult:
|
||||
return ListResourceTemplatesResult(
|
||||
resource_templates=[
|
||||
ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
server = Server(
|
||||
"Bookshop",
|
||||
on_read_resource=read_resource,
|
||||
on_list_resource_templates=list_resource_templates,
|
||||
)
|
||||
|
|
@ -40,6 +40,7 @@ nav:
|
|||
- Advanced:
|
||||
- Multi-round-trip requests: advanced/multi-round-trip.md
|
||||
- The low-level Server: advanced/low-level-server.md
|
||||
- URI templates: advanced/uri-templates.md
|
||||
- Pagination: advanced/pagination.md
|
||||
- Middleware: advanced/middleware.md
|
||||
- OpenTelemetry: advanced/opentelemetry.md
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ from .client.stdio import StdioServerParameters, stdio_client
|
|||
from .server.session import ServerSession
|
||||
from .server.stdio import stdio_server
|
||||
from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError
|
||||
from .shared.uri_template import InvalidUriTemplate, UriTemplate
|
||||
|
||||
__all__ = [
|
||||
"CallToolRequest",
|
||||
|
|
@ -133,7 +134,9 @@ __all__ = [
|
|||
"ToolsCapability",
|
||||
"ToolUseContent",
|
||||
"UnsubscribeRequest",
|
||||
"UriTemplate",
|
||||
"UrlElicitationRequiredError",
|
||||
"InvalidUriTemplate",
|
||||
"stdio_client",
|
||||
"stdio_server",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,16 @@
|
|||
from mcp_types import Icon
|
||||
|
||||
from .context import Context
|
||||
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
|
||||
from .server import MCPServer
|
||||
from .utilities.types import Audio, Image
|
||||
|
||||
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
|
||||
__all__ = [
|
||||
"MCPServer",
|
||||
"Context",
|
||||
"Image",
|
||||
"Audio",
|
||||
"Icon",
|
||||
"ResourceSecurity",
|
||||
"DEFAULT_RESOURCE_SECURITY",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from .base import Resource
|
||||
from .resource_manager import ResourceManager
|
||||
from .templates import ResourceTemplate
|
||||
from .templates import DEFAULT_RESOURCE_SECURITY, ResourceSecurity, ResourceSecurityError, ResourceTemplate
|
||||
from .types import (
|
||||
BinaryResource,
|
||||
DirectoryResource,
|
||||
|
|
@ -20,4 +20,7 @@ __all__ = [
|
|||
"DirectoryResource",
|
||||
"ResourceTemplate",
|
||||
"ResourceManager",
|
||||
"ResourceSecurity",
|
||||
"ResourceSecurityError",
|
||||
"DEFAULT_RESOURCE_SECURITY",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ from pydantic import AnyUrl
|
|||
|
||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||
from mcp.server.mcpserver.resources.base import Resource
|
||||
from mcp.server.mcpserver.resources.templates import ResourceTemplate
|
||||
from mcp.server.mcpserver.resources.templates import (
|
||||
DEFAULT_RESOURCE_SECURITY,
|
||||
ResourceSecurity,
|
||||
ResourceSecurityError,
|
||||
ResourceTemplate,
|
||||
)
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -63,6 +68,7 @@ class ResourceManager:
|
|||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
||||
) -> ResourceTemplate:
|
||||
"""Add a template from a function."""
|
||||
template = ResourceTemplate.from_function(
|
||||
|
|
@ -75,6 +81,7 @@ class ResourceManager:
|
|||
icons=icons,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
security=security,
|
||||
)
|
||||
self._templates[template.uri_template] = template
|
||||
return template
|
||||
|
|
@ -85,6 +92,15 @@ class ResourceManager:
|
|||
Raises:
|
||||
ResourceNotFoundError: If no resource or template matches the URI.
|
||||
ResourceError: If a matching template fails to create the resource.
|
||||
|
||||
Note:
|
||||
Pydantic's ``AnyUrl`` normalises percent-encoding and
|
||||
resolves ``..`` segments during validation, so a value
|
||||
constructed as ``AnyUrl("file:///a/%2E%2E/b")`` arrives
|
||||
here as ``file:///b``. The JSON-RPC protocol layer passes
|
||||
raw ``str`` values and is unaffected, but internal callers
|
||||
wrapping URIs in ``AnyUrl`` should be aware that security
|
||||
checks see the already-normalised form.
|
||||
"""
|
||||
uri_str = str(uri)
|
||||
logger.debug("Getting resource", extra={"uri": uri_str})
|
||||
|
|
@ -95,7 +111,11 @@ class ResourceManager:
|
|||
|
||||
# Then check templates
|
||||
for template in self._templates.values():
|
||||
if params := template.matches(uri_str):
|
||||
try:
|
||||
params = template.matches(uri_str)
|
||||
except ResourceSecurityError as e:
|
||||
raise ResourceNotFoundError(f"Unknown resource: {uri}") from e
|
||||
if params is not None:
|
||||
return await template.create_resource(uri_str, params, context=context)
|
||||
|
||||
raise ResourceNotFoundError(f"Unknown resource: {uri}")
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping, Set
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import anyio.to_thread
|
||||
from mcp_types import Annotations, Icon
|
||||
|
|
@ -18,6 +17,8 @@ from mcp.server.mcpserver.utilities.context_injection import find_context_parame
|
|||
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
from mcp.shared._callable_inspection import is_async_callable
|
||||
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
|
||||
from mcp.shared.uri_template import UriTemplate
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -26,6 +27,82 @@ if TYPE_CHECKING:
|
|||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceSecurity:
|
||||
"""Security policy applied to extracted resource template parameters.
|
||||
|
||||
These checks run after :meth:`~mcp.shared.uri_template.UriTemplate.match`
|
||||
has extracted and decoded parameter values. They catch path-traversal
|
||||
and absolute-path injection regardless of how the value was encoded in
|
||||
the URI (literal, ``%2F``, ``%5C``, ``%2E%2E``).
|
||||
|
||||
Example::
|
||||
|
||||
# Opt out for a parameter that legitimately contains ..
|
||||
@mcp.resource(
|
||||
"git://diff/{+range}",
|
||||
security=ResourceSecurity(exempt_params={"range"}),
|
||||
)
|
||||
def git_diff(range: str) -> str: ...
|
||||
"""
|
||||
|
||||
reject_path_traversal: bool = True
|
||||
"""Reject values containing ``..`` as a path component."""
|
||||
|
||||
reject_absolute_paths: bool = True
|
||||
"""Reject values that look like absolute filesystem paths."""
|
||||
|
||||
reject_null_bytes: bool = True
|
||||
"""Reject values containing NUL (``\\x00``). Null bytes defeat string
|
||||
comparisons (``"..\\x00" != ".."``) and can cause truncation in C
|
||||
extensions or subprocess calls."""
|
||||
|
||||
exempt_params: Set[str] = field(default_factory=frozenset[str])
|
||||
"""Parameter names to skip all checks for."""
|
||||
|
||||
def validate(self, params: Mapping[str, str | list[str]]) -> str | None:
|
||||
"""Check all parameter values against the configured policy.
|
||||
|
||||
Args:
|
||||
params: Extracted template parameters. List values (from
|
||||
explode variables) are checked element-wise.
|
||||
|
||||
Returns:
|
||||
The name of the first parameter that fails, or ``None`` if
|
||||
all values pass.
|
||||
"""
|
||||
for name, value in params.items():
|
||||
if name in self.exempt_params:
|
||||
continue
|
||||
values = value if isinstance(value, list) else [value]
|
||||
for v in values:
|
||||
if self.reject_null_bytes and "\0" in v:
|
||||
return name
|
||||
if self.reject_path_traversal and contains_path_traversal(v):
|
||||
return name
|
||||
if self.reject_absolute_paths and is_absolute_path(v):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
|
||||
"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected."""
|
||||
|
||||
|
||||
class ResourceSecurityError(ValueError):
|
||||
"""Raised when an extracted parameter fails :class:`ResourceSecurity` checks.
|
||||
|
||||
Distinct from a simple ``None`` non-match so that template
|
||||
iteration can stop at the first security rejection rather than
|
||||
falling through to a later, possibly more permissive, template.
|
||||
"""
|
||||
|
||||
def __init__(self, template: str, param: str) -> None:
|
||||
super().__init__(f"Parameter {param!r} of template {template!r} failed security validation")
|
||||
self.template = template
|
||||
self.param = param
|
||||
|
||||
|
||||
class ResourceTemplate(BaseModel):
|
||||
"""A template for dynamically creating resources."""
|
||||
|
||||
|
|
@ -40,6 +117,8 @@ class ResourceTemplate(BaseModel):
|
|||
fn: Callable[..., Any] = Field(exclude=True)
|
||||
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
|
||||
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
|
||||
parsed_template: UriTemplate = Field(exclude=True, description="Parsed RFC 6570 template")
|
||||
security: ResourceSecurity = Field(exclude=True, description="Path-safety policy for extracted parameters")
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
|
|
@ -54,12 +133,20 @@ class ResourceTemplate(BaseModel):
|
|||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
context_kwarg: str | None = None,
|
||||
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
||||
) -> ResourceTemplate:
|
||||
"""Create a template from a function."""
|
||||
"""Create a template from a function.
|
||||
|
||||
Raises:
|
||||
InvalidUriTemplate: If ``uri_template`` is malformed or uses
|
||||
unsupported RFC 6570 features.
|
||||
"""
|
||||
func_name = name or fn.__name__
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions") # pragma: no cover
|
||||
|
||||
parsed = UriTemplate.parse(uri_template)
|
||||
|
||||
# Find context parameter if it exists
|
||||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
|
@ -86,20 +173,35 @@ class ResourceTemplate(BaseModel):
|
|||
fn=fn,
|
||||
parameters=parameters,
|
||||
context_kwarg=context_kwarg,
|
||||
parsed_template=parsed,
|
||||
security=security,
|
||||
)
|
||||
|
||||
def matches(self, uri: str) -> dict[str, Any] | None:
|
||||
"""Check if URI matches template and extract parameters.
|
||||
def matches(self, uri: str) -> dict[str, str | list[str]] | None:
|
||||
"""Check if a URI matches this template and extract parameters.
|
||||
|
||||
Extracted parameters are URL-decoded to handle percent-encoded characters.
|
||||
Delegates to :meth:`UriTemplate.match` for RFC 6570 extraction,
|
||||
then applies this template's :class:`ResourceSecurity` policy
|
||||
(path traversal, absolute paths).
|
||||
|
||||
Returns:
|
||||
Extracted parameters on success, or ``None`` if the URI
|
||||
doesn't match the template.
|
||||
|
||||
Raises:
|
||||
ResourceSecurityError: If the URI matches but an extracted
|
||||
parameter fails security validation. Raising (rather
|
||||
than returning ``None``) prevents the resource manager
|
||||
from silently falling through to a later, possibly more
|
||||
permissive, template.
|
||||
"""
|
||||
# Convert template to regex pattern
|
||||
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
|
||||
match = re.match(f"^{pattern}$", uri)
|
||||
if match:
|
||||
# URL-decode all extracted parameter values
|
||||
return {key: unquote(value) for key, value in match.groupdict().items()}
|
||||
return None
|
||||
params = self.parsed_template.match(uri)
|
||||
if params is None:
|
||||
return None
|
||||
failed = self.security.validate(params)
|
||||
if failed is not None:
|
||||
raise ResourceSecurityError(self.uri_template, failed)
|
||||
return params
|
||||
|
||||
async def create_resource(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
import base64
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import Any, Generic, Literal, TypeVar, overload
|
||||
|
|
@ -62,7 +61,13 @@ from mcp.server.lowlevel.server import lifespan as default_lifespan
|
|||
from mcp.server.mcpserver.context import Context
|
||||
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError
|
||||
from mcp.server.mcpserver.prompts import Prompt, PromptManager
|
||||
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
|
||||
from mcp.server.mcpserver.resources import (
|
||||
DEFAULT_RESOURCE_SECURITY,
|
||||
FunctionResource,
|
||||
Resource,
|
||||
ResourceManager,
|
||||
ResourceSecurity,
|
||||
)
|
||||
from mcp.server.mcpserver.tools import Tool, ToolManager
|
||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
|
||||
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
|
||||
|
|
@ -72,6 +77,7 @@ from mcp.server.streamable_http import EventStore
|
|||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.uri_template import UriTemplate
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -150,7 +156,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
dependencies: list[str] | None = None,
|
||||
lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
|
||||
auth: AuthSettings | None = None,
|
||||
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
||||
):
|
||||
self._resource_security = resource_security
|
||||
self.settings = Settings(
|
||||
debug=debug,
|
||||
log_level=log_level,
|
||||
|
|
@ -622,6 +630,7 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
security: ResourceSecurity | None = None,
|
||||
) -> Callable[[_CallableT], _CallableT]:
|
||||
"""Decorator to register a function as a resource.
|
||||
|
||||
|
|
@ -631,8 +640,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
- bytes for binary content
|
||||
- other types will be converted to JSON
|
||||
|
||||
If the URI contains parameters (e.g. "resource://{param}") or the function
|
||||
has parameters, it will be registered as a template resource.
|
||||
If the URI contains parameters (e.g. "resource://{param}"), it is
|
||||
registered as a template resource. Otherwise it is registered as a
|
||||
static resource; function parameters on a static URI raise an error.
|
||||
|
||||
Args:
|
||||
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
|
||||
|
|
@ -643,6 +653,9 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
icons: Optional list of icons for the resource
|
||||
annotations: Optional annotations for the resource
|
||||
meta: Optional metadata dictionary for the resource
|
||||
security: Path-safety policy for extracted template parameters.
|
||||
Defaults to the server's ``resource_security`` setting.
|
||||
Only applies to template resources.
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -664,6 +677,15 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
data = await fetch_weather(city)
|
||||
return f"Weather for {city}: {data}"
|
||||
```
|
||||
|
||||
Raises:
|
||||
InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template.
|
||||
ValueError: If URI template parameters don't match the
|
||||
function's parameters, or if a parameter bound to a
|
||||
``{?...}``/``{&...}`` query variable has no default
|
||||
(the client may omit it).
|
||||
TypeError: If the decorator is applied without being called
|
||||
(``@resource`` instead of ``@resource("uri")``).
|
||||
"""
|
||||
# Check if user passed function directly instead of calling decorator
|
||||
if callable(uri):
|
||||
|
|
@ -672,27 +694,44 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
||||
)
|
||||
|
||||
# Parse once, early — surfaces malformed-template errors at
|
||||
# decoration time with a clear position, and gives us correct
|
||||
# variable names for all RFC 6570 operators.
|
||||
parsed = UriTemplate.parse(uri)
|
||||
uri_params = set(parsed.variable_names)
|
||||
|
||||
def decorator(fn: _CallableT) -> _CallableT:
|
||||
# Check if this should be a template
|
||||
sig = inspect.signature(fn)
|
||||
has_uri_params = "{" in uri and "}" in uri
|
||||
has_func_params = bool(sig.parameters)
|
||||
|
||||
if has_uri_params or has_func_params:
|
||||
# Check for Context parameter to exclude from validation
|
||||
context_param = find_context_parameter(fn)
|
||||
|
||||
# Validate that URI params match function params (excluding context)
|
||||
uri_params = set(re.findall(r"{(\w+)}", uri))
|
||||
# We need to remove the context_param from the resource function if
|
||||
# there is any.
|
||||
func_params = {p for p in sig.parameters.keys() if p != context_param}
|
||||
context_param = find_context_parameter(fn)
|
||||
func_params = {p for p in sig.parameters.keys() if p != context_param}
|
||||
|
||||
# Template/static is decided purely by the URI: variables
|
||||
# present means template, none means static.
|
||||
if uri_params:
|
||||
if uri_params != func_params:
|
||||
raise ValueError(
|
||||
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"
|
||||
)
|
||||
|
||||
# A {?...}/{&...} query variable is optional on the wire:
|
||||
# match() omits it from the extracted parameters when the
|
||||
# client leaves it out of the URI. The handler parameter
|
||||
# bound to it must therefore have a Python default; without
|
||||
# one, the author only finds out on the first request that
|
||||
# omits it, as an opaque internal error.
|
||||
missing_defaults = sorted(
|
||||
name
|
||||
for name in parsed.query_variable_names
|
||||
if sig.parameters[name].default is inspect.Parameter.empty
|
||||
)
|
||||
if missing_defaults:
|
||||
raise ValueError(
|
||||
f"Resource {uri!r}: query parameter(s) {missing_defaults} have no "
|
||||
f"default value. A client may omit a {{?...}}/{{&...}} query "
|
||||
f"parameter, so the matching handler parameter must declare a "
|
||||
f"default."
|
||||
)
|
||||
|
||||
# Register as template
|
||||
self._resource_manager.add_template(
|
||||
fn=fn,
|
||||
|
|
@ -703,9 +742,24 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
mime_type=mime_type,
|
||||
icons=icons,
|
||||
annotations=annotations,
|
||||
security=security if security is not None else self._resource_security,
|
||||
meta=meta,
|
||||
)
|
||||
else:
|
||||
if func_params:
|
||||
raise ValueError(
|
||||
f"Resource {uri!r} has no URI template variables, but the "
|
||||
f"handler declares parameters {func_params}. Add matching "
|
||||
f"{{...}} variables to the URI or remove the parameters."
|
||||
)
|
||||
if context_param is not None:
|
||||
raise ValueError(
|
||||
f"Resource {uri!r} has no URI template variables, but the "
|
||||
f"handler declares a Context parameter. Context injection "
|
||||
f"for static resources is not supported. "
|
||||
f"Add a template variable to the URI or remove the "
|
||||
f"Context parameter."
|
||||
)
|
||||
# Register as regular resource
|
||||
resource = FunctionResource.from_function(
|
||||
fn=fn,
|
||||
|
|
|
|||
176
src/mcp/shared/path_security.py
Normal file
176
src/mcp/shared/path_security.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Filesystem path safety primitives for resource handlers.
|
||||
|
||||
These functions help MCP servers reject paths that would resolve
|
||||
outside the served root when extracted URI template parameters are
|
||||
used in filesystem operations. They are standalone utilities usable from both the
|
||||
high-level :class:`~mcp.server.mcpserver.MCPServer` and lowlevel server
|
||||
implementations.
|
||||
|
||||
The canonical safe pattern::
|
||||
|
||||
from mcp.shared.path_security import safe_join
|
||||
|
||||
@mcp.resource("file://docs/{+path}")
|
||||
def read_doc(path: str) -> str:
|
||||
return safe_join("/data/docs", path).read_text()
|
||||
"""
|
||||
|
||||
import string
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["PathEscapeError", "contains_path_traversal", "is_absolute_path", "safe_join"]
|
||||
|
||||
|
||||
class PathEscapeError(ValueError):
|
||||
"""Raised by :func:`safe_join` when the resolved path escapes the base."""
|
||||
|
||||
|
||||
def contains_path_traversal(value: str) -> bool:
|
||||
r"""Check whether a value, treated as a relative path, escapes its origin.
|
||||
|
||||
This is a **base-free** check: it does not know the sandbox root, so
|
||||
it detects only whether ``..`` components would move above the
|
||||
starting point. Use :func:`safe_join` when you know the root — it
|
||||
additionally catches symlink escapes and absolute-path injection.
|
||||
|
||||
Note:
|
||||
This is a string-level check on the value as supplied. It does
|
||||
not model platform-specific filesystem normalisation (e.g. Win32
|
||||
stripping of trailing dots and spaces from the final path
|
||||
component). For filesystem access, use :func:`safe_join`, which
|
||||
resolves through the OS and verifies containment.
|
||||
|
||||
The check is component-based: ``..`` is dangerous only as a
|
||||
standalone path segment, not as a substring. Both ``/`` and ``\``
|
||||
are treated as separators.
|
||||
|
||||
Example::
|
||||
|
||||
>>> contains_path_traversal("a/b/c")
|
||||
False
|
||||
>>> contains_path_traversal("../etc")
|
||||
True
|
||||
>>> contains_path_traversal("a/../../b")
|
||||
True
|
||||
>>> contains_path_traversal("a/../b")
|
||||
False
|
||||
>>> contains_path_traversal("1.0..2.0")
|
||||
False
|
||||
>>> contains_path_traversal("..")
|
||||
True
|
||||
|
||||
Args:
|
||||
value: A string that may be used as a filesystem path.
|
||||
|
||||
Returns:
|
||||
``True`` if the path would escape its starting directory.
|
||||
"""
|
||||
depth = 0
|
||||
for part in value.replace("\\", "/").split("/"):
|
||||
if part == "..":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
return True
|
||||
elif part and part != ".":
|
||||
depth += 1
|
||||
return False
|
||||
|
||||
|
||||
def is_absolute_path(value: str) -> bool:
|
||||
r"""Check whether a value is an absolute filesystem path.
|
||||
|
||||
Absolute paths are dangerous when joined onto a base: in Python,
|
||||
``Path("/data") / "/etc/passwd"`` yields ``/etc/passwd`` — the
|
||||
absolute right-hand side silently discards the base.
|
||||
|
||||
Detects POSIX absolute (``/foo``), Windows drive-absolute
|
||||
(``C:\foo``) and drive-relative (``C:foo``), and Windows
|
||||
UNC/root-relative (``\\server\share``, ``\foo``).
|
||||
|
||||
Example::
|
||||
|
||||
>>> is_absolute_path("relative/path")
|
||||
False
|
||||
>>> is_absolute_path("/etc/passwd")
|
||||
True
|
||||
>>> is_absolute_path("C:\\Windows")
|
||||
True
|
||||
>>> is_absolute_path("")
|
||||
False
|
||||
|
||||
Args:
|
||||
value: A string that may be used as a filesystem path.
|
||||
|
||||
Returns:
|
||||
``True`` if the path is absolute on any common platform.
|
||||
"""
|
||||
if not value:
|
||||
return False
|
||||
if value[0] in ("/", "\\"):
|
||||
return True
|
||||
# Windows drive form: C:, C:\, C:foo (drive-relative). A drive-
|
||||
# relative right-hand side discards the join base when drives
|
||||
# differ, so flag it even though PureWindowsPath.is_absolute()
|
||||
# is False. This means single-letter-prefixed identifiers like
|
||||
# "x:y" also match — opt out via ResourceSecurity(exempt_params=).
|
||||
if len(value) >= 2 and value[1] == ":" and value[0] in string.ascii_letters:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def safe_join(base: str | Path, *parts: str) -> Path:
|
||||
"""Join path components onto a base, rejecting escapes.
|
||||
|
||||
Resolves the joined path and verifies it remains within ``base``.
|
||||
This is the **gold-standard** check: it catches ``..`` traversal,
|
||||
absolute-path injection, and symlink escapes that the base-free
|
||||
checks cannot.
|
||||
|
||||
The symlink check is point-in-time: a directory swapped for a
|
||||
symlink between this call and the caller's subsequent open would not
|
||||
be re-checked. Handlers serving a tree that may be modified
|
||||
concurrently should additionally open with ``O_NOFOLLOW`` or use
|
||||
platform path-confinement primitives.
|
||||
|
||||
Example::
|
||||
|
||||
>>> safe_join("/data/docs", "readme.txt")
|
||||
PosixPath('/data/docs/readme.txt')
|
||||
>>> safe_join("/data/docs", "../../../etc/passwd")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
PathEscapeError: ...
|
||||
|
||||
Args:
|
||||
base: The sandbox root. May be relative; it will be resolved.
|
||||
parts: Path components to join. Each is checked for null bytes
|
||||
and absolute form before joining.
|
||||
|
||||
Returns:
|
||||
The resolved path, verified to be within ``base`` at resolution
|
||||
time.
|
||||
|
||||
Raises:
|
||||
PathEscapeError: If any part contains a null byte, any part is
|
||||
absolute, or the resolved path is not contained within the
|
||||
resolved base.
|
||||
"""
|
||||
base_resolved = Path(base).resolve()
|
||||
|
||||
for part in parts:
|
||||
# Null bytes pass through Path construction but fail at the
|
||||
# syscall boundary with a cryptic error. Reject here so callers
|
||||
# get a clear PathEscapeError instead.
|
||||
if "\0" in part:
|
||||
raise PathEscapeError(f"Path component contains a null byte; refusing to join onto {base_resolved}")
|
||||
# Absolute parts would silently discard everything to the left
|
||||
# in Path's / operator.
|
||||
if is_absolute_path(part):
|
||||
raise PathEscapeError(f"Path component {part!r} is absolute; refusing to join onto {base_resolved}")
|
||||
|
||||
target = base_resolved.joinpath(*parts).resolve()
|
||||
|
||||
if not target.is_relative_to(base_resolved):
|
||||
raise PathEscapeError(f"Path {target} escapes base {base_resolved}")
|
||||
|
||||
return target
|
||||
1116
src/mcp/shared/uri_template.py
Normal file
1116
src/mcp/shared/uri_template.py
Normal file
File diff suppressed because it is too large
Load diff
214
tests/docs_src/test_uri_templates.py
Normal file
214
tests/docs_src/test_uri_templates.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""`docs/advanced/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}"),
|
||||
]
|
||||
)
|
||||
|
|
@ -9,6 +9,152 @@ from pydantic import BaseModel
|
|||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ResourceError
|
||||
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
|
||||
from mcp.server.mcpserver.resources.templates import (
|
||||
DEFAULT_RESOURCE_SECURITY,
|
||||
ResourceSecurity,
|
||||
ResourceSecurityError,
|
||||
)
|
||||
|
||||
|
||||
def _make(uri_template: str, security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY) -> ResourceTemplate:
|
||||
def handler(**kwargs: Any) -> str:
|
||||
raise NotImplementedError # these tests only exercise matches()
|
||||
|
||||
return ResourceTemplate.from_function(fn=handler, uri_template=uri_template, security=security)
|
||||
|
||||
|
||||
def test_matches_rfc6570_reserved_expansion():
|
||||
# {+path} allows / — the feature the old regex implementation couldn't support
|
||||
t = _make("file://docs/{+path}")
|
||||
assert t.matches("file://docs/src/main.py") == {"path": "src/main.py"}
|
||||
|
||||
|
||||
def test_matches_rejects_encoded_slash_traversal():
|
||||
# %2F decodes to / in UriTemplate.match(), giving "../../etc/passwd".
|
||||
# ResourceSecurity's traversal check then rejects the '..' components.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError, match="'name'"):
|
||||
t.matches("file://docs/..%2F..%2Fetc%2Fpasswd")
|
||||
|
||||
|
||||
def test_matches_rejects_path_traversal_by_default():
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/..")
|
||||
|
||||
|
||||
def test_matches_rejects_path_traversal_in_reserved_var():
|
||||
# Even {+path} gets the traversal check — it's semantic, not structural
|
||||
t = _make("file://docs/{+path}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/../../etc/passwd")
|
||||
|
||||
|
||||
def test_matches_rejects_absolute_path():
|
||||
t = _make("file://docs/{+path}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs//etc/passwd")
|
||||
|
||||
|
||||
def test_matches_allows_dotdot_as_substring():
|
||||
# .. is only dangerous as a path component
|
||||
t = _make("git://refs/{range}")
|
||||
assert t.matches("git://refs/v1.0..v2.0") == {"range": "v1.0..v2.0"}
|
||||
|
||||
|
||||
def test_matches_exempt_params_skip_security():
|
||||
policy = ResourceSecurity(exempt_params={"range"})
|
||||
t = _make("git://diff/{+range}", security=policy)
|
||||
assert t.matches("git://diff/../foo") == {"range": "../foo"}
|
||||
|
||||
|
||||
def test_matches_disabled_policy_allows_traversal():
|
||||
policy = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False)
|
||||
t = _make("file://docs/{name}", security=policy)
|
||||
assert t.matches("file://docs/..") == {"name": ".."}
|
||||
|
||||
|
||||
def test_matches_rejects_null_byte_by_default():
|
||||
# %00 decodes to \x00 which defeats string comparisons
|
||||
# ("..\x00" != "..") and can truncate in C extensions.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/key%00.txt")
|
||||
# Null byte also defeats the traversal check's component comparison
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/..%00%2Fsecret")
|
||||
|
||||
|
||||
def test_matches_null_byte_check_can_be_disabled():
|
||||
policy = ResourceSecurity(reject_null_bytes=False)
|
||||
t = _make("file://docs/{name}", security=policy)
|
||||
assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"}
|
||||
|
||||
|
||||
def test_security_rejection_does_not_fall_through_to_next_template():
|
||||
# A strict template's security rejection must halt iteration, not
|
||||
# fall through to a later permissive template. Previously matches()
|
||||
# returned None for both "no match" and "security failed", making
|
||||
# registration order security-critical.
|
||||
strict = _make("file://docs/{name}")
|
||||
lax = _make(
|
||||
"file://docs/{+path}",
|
||||
security=ResourceSecurity(exempt_params={"path"}),
|
||||
)
|
||||
uri = "file://docs/..%2Fsecrets"
|
||||
# Strict matches structurally then fails security -> raises.
|
||||
with pytest.raises(ResourceSecurityError) as exc:
|
||||
strict.matches(uri)
|
||||
assert exc.value.param == "name"
|
||||
# If this raised, the resource manager never reaches the lax
|
||||
# template. Verify the lax template WOULD have accepted it.
|
||||
assert lax.matches(uri) == {"path": "../secrets"}
|
||||
|
||||
|
||||
def test_matches_explode_checks_each_segment():
|
||||
t = _make("api{/parts*}")
|
||||
assert t.matches("api/a/b/c") == {"parts": ["a", "b", "c"]}
|
||||
# Any segment with traversal rejects the whole match
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("api/a/../c")
|
||||
|
||||
|
||||
def test_matches_encoded_backslash_caught_by_traversal_check():
|
||||
# %5C decodes to '\\'. The traversal check normalizes '\\' to '/'
|
||||
# and catches the '..' components.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/..%5C..%5Csecret")
|
||||
|
||||
|
||||
def test_matches_encoded_dots_caught_by_traversal_check():
|
||||
# %2E%2E decodes to '..' which the traversal check rejects.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/%2E%2E")
|
||||
|
||||
|
||||
def test_matches_mixed_encoded_and_literal_slash():
|
||||
# The literal '/' stops the simple-var regex, so the URI doesn't
|
||||
# match the template at all.
|
||||
t = _make("file://docs/{name}")
|
||||
assert t.matches("file://docs/..%2F../etc") is None
|
||||
|
||||
|
||||
def test_matches_encoded_slash_without_traversal_allowed():
|
||||
# %2F decoding to '/' is fine when there's no traversal involved.
|
||||
# UriTemplate accepts it; ResourceSecurity only blocks '..' and
|
||||
# absolute paths. Handlers that need single-segment should use
|
||||
# safe_join or validate explicitly.
|
||||
t = _make("file://docs/{name}")
|
||||
assert t.matches("file://docs/sub%2Ffile.txt") == {"name": "sub/file.txt"}
|
||||
|
||||
|
||||
def test_matches_escapes_template_literals():
|
||||
# Regression: old impl treated . as regex wildcard
|
||||
t = _make("data://v1.0/{id}")
|
||||
assert t.matches("data://v1.0/42") == {"id": "42"}
|
||||
assert t.matches("data://v1X0/42") is None
|
||||
|
||||
|
||||
class TestResourceTemplate:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue