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
8 changed files with 3800 additions and 37 deletions

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