-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathtemplates.py
More file actions
251 lines (207 loc) · 9.85 KB
/
Copy pathtemplates.py
File metadata and controls
251 lines (207 loc) · 9.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""Resource template functionality."""
from __future__ import annotations
import functools
from collections.abc import Callable, Mapping, Set
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import anyio.to_thread
from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import BaseModel, Field, validate_call
from mcp.server.mcpserver.exceptions import ResourceError
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
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.exceptions import MCPError
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
from mcp.shared.uri_template import UriTemplate
logger = get_logger(__name__)
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
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."""
uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)")
name: str = Field(description="Name of the resource")
title: str | None = Field(description="Human-readable title of the resource", default=None)
description: str | None = Field(description="Description of what the resource does")
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template")
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template")
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(
cls,
fn: Callable[..., Any],
uri_template: str,
name: str | None = None,
title: str | None = None,
description: str | None = None,
mime_type: str | None = None,
icons: list[Icon] | None = None,
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.
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)
# Get schema from func_metadata, excluding context parameter
func_arg_metadata = func_metadata(
fn,
skip_names=[context_kwarg] if context_kwarg is not None else [],
)
parameters = func_arg_metadata.arg_model.model_json_schema()
# ensure the arguments are properly cast
fn = validate_call(fn)
return cls(
uri_template=uri_template,
name=func_name,
title=title,
description=description or fn.__doc__ or "",
mime_type=mime_type or "text/plain",
icons=icons,
annotations=annotations,
meta=meta,
fn=fn,
parameters=parameters,
context_kwarg=context_kwarg,
parsed_template=parsed,
security=security,
)
def matches(self, uri: str) -> dict[str, str | list[str]] | None:
"""Check if a URI matches this template and extract parameters.
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.
"""
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,
uri: str,
params: dict[str, Any],
context: Context[LifespanContextT, RequestT],
) -> Resource | InputRequiredResult:
"""Create a resource from the template with the given parameters.
An `InputRequiredResult` returned by the template function is passed
through unchanged (the 2026-07-28 multi-round-trip flow); the retry's
answers arrive on `ctx.input_responses`, with `ctx.request_state`
carrying the echoed opaque state.
Raises:
ResourceError: If creating the resource fails.
"""
try:
# Add context to params if needed
params = inject_context(self.fn, params, context, self.context_kwarg)
fn = self.fn
if is_async_callable(fn):
result = await fn(**params)
else:
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))
if isinstance(result, InputRequiredResult):
return result
return FunctionResource(
uri=uri, # type: ignore
name=self.name,
title=self.title,
description=self.description,
mime_type=self.mime_type,
icons=self.icons,
annotations=self.annotations,
meta=self.meta,
fn=lambda: result, # Capture result in closure
)
except (ResourceError, MCPError):
raise
except Exception as exc:
logger.exception(f"Error creating resource from template {uri}")
raise ResourceError(f"Error creating resource from template {uri}") from exc