Skip to content

Commit 0018eea

Browse files
committed
feat: integrate UriTemplate into MCPServer resource templates
Refactors the internal `ResourceTemplate` to use the RFC 6570 `UriTemplate` engine for matching, and adds a configurable `ResourceSecurity` policy for path-safety checks on extracted parameters. `ResourceTemplate.matches()` now: - Delegates to `UriTemplate.match()` for full RFC 6570 Level 1-3 support (plus path-style explode). `{+path}` can match multi-segment paths. - Enforces structural integrity: `%2F` smuggled into a simple `{var}` is rejected. - Applies `ResourceSecurity` policy: path traversal (`..` components) and absolute paths rejected by default, with per-parameter exemption available. The `@mcp.resource()` decorator now parses the template once at decoration time via `UriTemplate.parse()`, replacing the regex-based param extraction that couldn't handle operators like `{+path}`. Malformed templates surface immediately with a clear `InvalidUriTemplate` including position info. Also fixes the pre-existing bug where template literals were not regex-escaped (a `.` in the template acted as a wildcard).
1 parent e5ecf50 commit 0018eea

File tree

5 files changed

+198
-20
lines changed

5 files changed

+198
-20
lines changed

src/mcp/server/mcpserver/resources/resource_manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pydantic import AnyUrl
99

1010
from mcp.server.mcpserver.resources.base import Resource
11-
from mcp.server.mcpserver.resources.templates import ResourceTemplate
11+
from mcp.server.mcpserver.resources.templates import DEFAULT_RESOURCE_SECURITY, ResourceSecurity, ResourceTemplate
1212
from mcp.server.mcpserver.utilities.logging import get_logger
1313
from mcp.types import Annotations, Icon
1414

@@ -64,6 +64,7 @@ def add_template(
6464
icons: list[Icon] | None = None,
6565
annotations: Annotations | None = None,
6666
meta: dict[str, Any] | None = None,
67+
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
6768
) -> ResourceTemplate:
6869
"""Add a template from a function."""
6970
template = ResourceTemplate.from_function(
@@ -76,6 +77,7 @@ def add_template(
7677
icons=icons,
7778
annotations=annotations,
7879
meta=meta,
80+
security=security,
7981
)
8082
self._templates[template.uri_template] = template
8183
return template

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,82 @@
33
from __future__ import annotations
44

55
import inspect
6-
import re
7-
from collections.abc import Callable
6+
from collections.abc import Callable, Mapping
7+
from dataclasses import dataclass, field
88
from typing import TYPE_CHECKING, Any
9-
from urllib.parse import unquote
109

1110
from pydantic import BaseModel, Field, validate_call
1211

1312
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
1413
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1514
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
15+
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
16+
from mcp.shared.uri_template import UriTemplate
1617
from mcp.types import Annotations, Icon
1718

1819
if TYPE_CHECKING:
1920
from mcp.server.context import LifespanContextT, RequestT
2021
from mcp.server.mcpserver.context import Context
2122

2223

24+
@dataclass(frozen=True)
25+
class ResourceSecurity:
26+
"""Security policy applied to extracted resource template parameters.
27+
28+
These checks run **after** :meth:`~mcp.shared.uri_template.UriTemplate.match`
29+
has already enforced structural integrity (e.g., rejected ``%2F`` in
30+
simple ``{var}``). They catch semantic attacks that structural checks
31+
cannot: ``..`` traversal and absolute-path injection work even with
32+
perfectly-formed URI components.
33+
34+
Example::
35+
36+
# Opt out for a parameter that legitimately contains ..
37+
@mcp.resource(
38+
"git://diff/{+range}",
39+
security=ResourceSecurity(exempt_params=frozenset({"range"})),
40+
)
41+
def git_diff(range: str) -> str: ...
42+
"""
43+
44+
reject_path_traversal: bool = True
45+
"""Reject values containing ``..`` as a path component."""
46+
47+
reject_absolute_paths: bool = True
48+
"""Reject values that look like absolute filesystem paths."""
49+
50+
exempt_params: frozenset[str] = field(default_factory=frozenset[str])
51+
"""Parameter names to skip all checks for."""
52+
53+
def validate(self, params: Mapping[str, str | list[str]]) -> bool:
54+
"""Check all parameter values against the configured policy.
55+
56+
Args:
57+
params: Extracted template parameters. List values (from
58+
explode variables) are checked element-wise.
59+
60+
Returns:
61+
``True`` if all values pass; ``False`` on first violation.
62+
"""
63+
for name, value in params.items():
64+
if name in self.exempt_params:
65+
continue
66+
values = value if isinstance(value, list) else [value]
67+
for v in values:
68+
if self.reject_path_traversal and contains_path_traversal(v):
69+
return False
70+
if self.reject_absolute_paths and is_absolute_path(v):
71+
return False
72+
return True
73+
74+
75+
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
76+
"""Secure-by-default policy: traversal and absolute paths rejected."""
77+
78+
UNSAFE_RESOURCE_SECURITY = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False)
79+
"""No path checks. Use only when parameters are never used as filesystem paths."""
80+
81+
2382
class ResourceTemplate(BaseModel):
2483
"""A template for dynamically creating resources."""
2584

@@ -34,6 +93,8 @@ class ResourceTemplate(BaseModel):
3493
fn: Callable[..., Any] = Field(exclude=True)
3594
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
3695
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
96+
parsed_template: UriTemplate = Field(exclude=True, description="Parsed RFC 6570 template")
97+
security: ResourceSecurity = Field(exclude=True, description="Path-safety policy for extracted parameters")
3798

3899
@classmethod
39100
def from_function(
@@ -48,12 +109,20 @@ def from_function(
48109
annotations: Annotations | None = None,
49110
meta: dict[str, Any] | None = None,
50111
context_kwarg: str | None = None,
112+
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
51113
) -> ResourceTemplate:
52-
"""Create a template from a function."""
114+
"""Create a template from a function.
115+
116+
Raises:
117+
InvalidUriTemplate: If ``uri_template`` is malformed or uses
118+
unsupported RFC 6570 features.
119+
"""
53120
func_name = name or fn.__name__
54121
if func_name == "<lambda>":
55122
raise ValueError("You must provide a name for lambda functions") # pragma: no cover
56123

124+
parsed = UriTemplate.parse(uri_template)
125+
57126
# Find context parameter if it exists
58127
if context_kwarg is None: # pragma: no branch
59128
context_kwarg = find_context_parameter(fn)
@@ -80,20 +149,28 @@ def from_function(
80149
fn=fn,
81150
parameters=parameters,
82151
context_kwarg=context_kwarg,
152+
parsed_template=parsed,
153+
security=security,
83154
)
84155

85-
def matches(self, uri: str) -> dict[str, Any] | None:
86-
"""Check if URI matches template and extract parameters.
156+
def matches(self, uri: str) -> dict[str, str | list[str]] | None:
157+
"""Check if a URI matches this template and extract parameters.
158+
159+
Delegates to :meth:`UriTemplate.match` for RFC 6570 matching
160+
with structural integrity (``%2F`` smuggling rejected for simple
161+
vars), then applies this template's :class:`ResourceSecurity`
162+
policy (path traversal, absolute paths).
87163
88-
Extracted parameters are URL-decoded to handle percent-encoded characters.
164+
Returns:
165+
Extracted parameters on success, or ``None`` if the URI
166+
doesn't match or a parameter fails security validation.
89167
"""
90-
# Convert template to regex pattern
91-
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
92-
match = re.match(f"^{pattern}$", uri)
93-
if match:
94-
# URL-decode all extracted parameter values
95-
return {key: unquote(value) for key, value in match.groupdict().items()}
96-
return None
168+
params = self.parsed_template.match(uri)
169+
if params is None:
170+
return None
171+
if not self.security.validate(params):
172+
return None
173+
return params
97174

98175
async def create_resource(
99176
self,

src/mcp/server/mcpserver/server.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import base64
66
import inspect
77
import json
8-
import re
98
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
109
from contextlib import AbstractAsyncContextManager, asynccontextmanager
1110
from typing import Any, Generic, Literal, TypeVar, overload
@@ -43,6 +42,7 @@
4342
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
4443
from mcp.server.transport_security import TransportSecuritySettings
4544
from mcp.shared.exceptions import MCPError
45+
from mcp.shared.uri_template import UriTemplate
4646
from mcp.types import (
4747
Annotations,
4848
BlobResourceContents,
@@ -668,6 +668,13 @@ async def get_weather(city: str) -> str:
668668
data = await fetch_weather(city)
669669
return f"Weather for {city}: {data}"
670670
```
671+
672+
Raises:
673+
InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template.
674+
ValueError: If URI template parameters don't match the
675+
function's parameters.
676+
TypeError: If the decorator is applied without being called
677+
(``@resource`` instead of ``@resource("uri")``).
671678
"""
672679
# Check if user passed function directly instead of calling decorator
673680
if callable(uri):
@@ -676,18 +683,21 @@ async def get_weather(city: str) -> str:
676683
"Did you forget to call it? Use @resource('uri') instead of @resource"
677684
)
678685

686+
# Parse once, early — surfaces malformed-template errors at
687+
# decoration time with a clear position, and gives us correct
688+
# variable names for all RFC 6570 operators.
689+
parsed = UriTemplate.parse(uri)
690+
uri_params = set(parsed.variable_names)
691+
679692
def decorator(fn: _CallableT) -> _CallableT:
680693
# Check if this should be a template
681694
sig = inspect.signature(fn)
682-
has_uri_params = "{" in uri and "}" in uri
683695
has_func_params = bool(sig.parameters)
684696

685-
if has_uri_params or has_func_params:
697+
if uri_params or has_func_params:
686698
# Check for Context parameter to exclude from validation
687699
context_param = find_context_parameter(fn)
688700

689-
# Validate that URI params match function params (excluding context)
690-
uri_params = set(re.findall(r"{(\w+)}", uri))
691701
# We need to remove the context_param from the resource function if
692702
# there is any.
693703
func_params = {p for p in sig.parameters.keys() if p != context_param}

tests/server/mcpserver/resources/test_resource_template.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,80 @@
66

77
from mcp.server.mcpserver import Context, MCPServer
88
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
9+
from mcp.server.mcpserver.resources.templates import (
10+
DEFAULT_RESOURCE_SECURITY,
11+
UNSAFE_RESOURCE_SECURITY,
12+
ResourceSecurity,
13+
)
914
from mcp.types import Annotations
1015

1116

17+
def _make(uri_template: str, security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY) -> ResourceTemplate:
18+
def handler(**kwargs: Any) -> str:
19+
return "ok"
20+
21+
return ResourceTemplate.from_function(fn=handler, uri_template=uri_template, security=security)
22+
23+
24+
def test_matches_rfc6570_reserved_expansion():
25+
# {+path} allows / — the feature the old regex implementation couldn't support
26+
t = _make("file://docs/{+path}")
27+
assert t.matches("file://docs/src/main.py") == {"path": "src/main.py"}
28+
29+
30+
def test_matches_rejects_encoded_slash_in_simple_var():
31+
# Path traversal via encoded slash: %2F smuggled into a simple {var}
32+
t = _make("file://docs/{name}")
33+
assert t.matches("file://docs/..%2F..%2Fetc%2Fpasswd") is None
34+
35+
36+
def test_matches_rejects_path_traversal_by_default():
37+
t = _make("file://docs/{name}")
38+
assert t.matches("file://docs/..") is None
39+
40+
41+
def test_matches_rejects_path_traversal_in_reserved_var():
42+
# Even {+path} gets the traversal check — it's semantic, not structural
43+
t = _make("file://docs/{+path}")
44+
assert t.matches("file://docs/../../etc/passwd") is None
45+
46+
47+
def test_matches_rejects_absolute_path():
48+
t = _make("file://docs/{+path}")
49+
assert t.matches("file://docs//etc/passwd") is None
50+
51+
52+
def test_matches_allows_dotdot_as_substring():
53+
# .. is only dangerous as a path component
54+
t = _make("git://refs/{range}")
55+
assert t.matches("git://refs/v1.0..v2.0") == {"range": "v1.0..v2.0"}
56+
57+
58+
def test_matches_exempt_params_skip_security():
59+
policy = ResourceSecurity(exempt_params=frozenset({"range"}))
60+
t = _make("git://diff/{+range}", security=policy)
61+
assert t.matches("git://diff/../foo") == {"range": "../foo"}
62+
63+
64+
def test_matches_unsafe_policy_disables_checks():
65+
t = _make("file://docs/{name}", security=UNSAFE_RESOURCE_SECURITY)
66+
assert t.matches("file://docs/..") == {"name": ".."}
67+
68+
69+
def test_matches_explode_checks_each_segment():
70+
t = _make("api{/parts*}")
71+
assert t.matches("api/a/b/c") == {"parts": ["a", "b", "c"]}
72+
# Any segment with traversal rejects the whole match
73+
assert t.matches("api/a/../c") is None
74+
75+
76+
def test_matches_escapes_template_literals():
77+
# Regression: old impl treated . as regex wildcard
78+
t = _make("data://v1.0/{id}")
79+
assert t.matches("data://v1.0/42") == {"id": "42"}
80+
assert t.matches("data://v1X0/42") is None
81+
82+
1283
class TestResourceTemplate:
1384
"""Test ResourceTemplate functionality."""
1485

tests/server/mcpserver/test_server.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mcp.server.mcpserver.utilities.types import Audio, Image
2020
from mcp.server.transport_security import TransportSecuritySettings
2121
from mcp.shared.exceptions import MCPError
22+
from mcp.shared.uri_template import InvalidUriTemplate
2223
from mcp.types import (
2324
AudioContent,
2425
BlobResourceContents,
@@ -141,6 +142,23 @@ async def test_add_resource_decorator_incorrect_usage(self):
141142
def get_data(x: str) -> str: # pragma: no cover
142143
return f"Data: {x}"
143144

145+
async def test_resource_decorator_rfc6570_reserved_expansion(self):
146+
# Regression: old regex-based param extraction couldn't see `path`
147+
# in `{+path}` and failed with a confusing mismatch error.
148+
mcp = MCPServer()
149+
150+
@mcp.resource("file://docs/{+path}")
151+
def read_doc(path: str) -> str:
152+
raise NotImplementedError
153+
154+
templates = await mcp.list_resource_templates()
155+
assert [t.uri_template for t in templates] == ["file://docs/{+path}"]
156+
157+
async def test_resource_decorator_rejects_malformed_template(self):
158+
mcp = MCPServer()
159+
with pytest.raises(InvalidUriTemplate, match="Unclosed expression"):
160+
mcp.resource("file://{name")
161+
144162

145163
class TestDnsRebindingProtection:
146164
"""Tests for automatic DNS rebinding protection on localhost.

0 commit comments

Comments
 (0)