feat: RFC 6570 URI templates with operator-aware security (#2356)

This commit is contained in:
Max 2026-06-26 20:29:17 +02:00 • committed by GitHub
commit 24717cc8eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3800 additions and 37 deletions

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

View file

@ -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:

View file

@ -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

View file

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

View 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()

View 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}"

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

View 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,
)

View file

@ -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

View file

@ -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",
]

View file

@ -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",
]

View file

@ -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",
]

View file

@ -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}")

View file

@ -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,

View file

@ -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,

View 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

File diff suppressed because it is too large Load diff

View 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}"),
]
)

View file

@ -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