33from __future__ import annotations
44
55import inspect
6- import re
7- from collections . abc import Callable
6+ from collections . abc import Callable , Mapping
7+ from dataclasses import dataclass , field
88from typing import TYPE_CHECKING , Any
9- from urllib .parse import unquote
109
1110from pydantic import BaseModel , Field , validate_call
1211
1312from mcp .server .mcpserver .resources .types import FunctionResource , Resource
1413from mcp .server .mcpserver .utilities .context_injection import find_context_parameter , inject_context
1514from 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
1617from mcp .types import Annotations , Icon
1718
1819if 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+
2382class 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 ,
0 commit comments