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

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,