1212
1313from mcp .server .fastmcp .resources .types import FunctionResource , Resource
1414from mcp .server .fastmcp .utilities .context_injection import find_context_parameter , inject_context
15- from mcp .server .fastmcp .utilities .convertors import CONVERTOR_TYPES , Convertor
15+ from mcp .server .fastmcp .utilities .convertors import Convertor
1616from mcp .server .fastmcp .utilities .func_metadata import func_metadata , use_defaults_on_optional_validation_error
17+ from mcp .server .fastmcp .utilities .param_validation import validate_and_sync_params
1718from mcp .types import Annotations , Icon
1819
1920if TYPE_CHECKING :
@@ -35,13 +36,21 @@ class ResourceTemplate(BaseModel):
3536 fn : Callable [..., Any ] = Field (exclude = True )
3637 parameters : dict [str , Any ] = Field (description = "JSON schema for function parameters" )
3738 context_kwarg : str | None = Field (None , description = "Name of the kwarg that should receive context" )
38- _compiled_pattern : re .Pattern [str ] | None = None
39- _convertors : dict [str , Convertor [Any ]] | None = None
40- required_params : set [str ] = Field (
39+ compiled_pattern : re .Pattern [str ] | None = Field (
40+ default = None , description = "Compiled regular expression pattern for matching the URI template."
41+ )
42+ convertors : dict [str , Convertor [Any ]] | None = Field (
43+ default = None , description = "Mapping of parameter names to their respective type converters."
44+ )
45+ path_params : set [str ] = Field (
4146 default_factory = set ,
4247 description = "Set of required parameters from the path component" ,
4348 )
44- optional_params : set [str ] = Field (
49+ required_query_params : set [str ] = Field (
50+ default_factory = set ,
51+ description = "Set of required parameters specified in the query component" ,
52+ )
53+ optional_query_params : set [str ] = Field (
4554 default_factory = set ,
4655 description = "Set of optional parameters specified in the query component" ,
4756 )
@@ -83,34 +92,9 @@ def from_function(
8392 final_fn = use_defaults_on_optional_validation_error (validated_fn )
8493
8594 # Extract required and optional params from the original function's signature
86- required_params , optional_params = cls ._analyze_function_params (original_fn )
87-
88- # Extract path parameters from URI template
89- path_params : set [str ] = set (re .findall (r"{\s*(\w+)(?::[^}]+)?\s*}" , re .sub (r"{\?.+?}" , "" , uri_template )))
90-
91- # Extract query parameters from the URI template if present
92- query_param_match = re .search (r"{(\?(?:\w+,)*\w+)}" , uri_template )
93- query_params : set [str ] = set ()
94- if query_param_match :
95- # Extract query parameters from {?param1,param2,...} syntax
96- query_str = query_param_match .group (1 )
97- query_params = set (query_str [1 :].split ("," )) # Remove the leading '?' and split
98-
99- if context_kwarg :
100- required_params .remove (context_kwarg )
101-
102- # Validate path parameters match required function parameters
103- if path_params != required_params :
104- raise ValueError (
105- f"Mismatch between URI path parameters { path_params } and required function parameters { required_params } "
106- )
107-
108- # Validate query parameters are a subset of optional function parameters
109- if not query_params .issubset (optional_params ):
110- invalid_params : set [str ] = query_params - optional_params
111- raise ValueError (
112- f"Query parameters { invalid_params } do not match optional function parameters { optional_params } "
113- )
95+ (path_params , required_query_params , optional_query_params , convertors , compiled_pattern ) = (
96+ validate_and_sync_params (original_fn , uri_template )
97+ )
11498
11599 return cls (
116100 uri_template = uri_template ,
@@ -122,92 +106,52 @@ def from_function(
122106 annotations = annotations ,
123107 fn = final_fn ,
124108 parameters = parameters ,
125- required_params = required_params ,
126- optional_params = optional_params ,
127109 context_kwarg = context_kwarg ,
110+ path_params = path_params ,
111+ required_query_params = required_query_params ,
112+ optional_query_params = optional_query_params ,
113+ convertors = convertors ,
114+ compiled_pattern = compiled_pattern ,
128115 )
129116
130- def _generate_pattern (self ) -> tuple [re .Pattern [str ], dict [str , Convertor [Any ]]]:
131- """Compile the URI template into a regex pattern and associated converters."""
132- path_template = re .sub (r"\{\?.*?\}" , "" , self .uri_template )
133- parts = path_template .strip ("/" ).split ("/" )
134- pattern_parts : list [str ] = []
135- converters : dict [str , Convertor [Any ]] = {}
136- # generate the regex pattern
137- for i , part in enumerate (parts ):
138- match = re .fullmatch (r"\{(\w+)(?::(\w+))?\}" , part )
139- if match :
140- name , type_ = match .groups ()
141- type_ = type_ or "str"
142-
143- if type_ not in CONVERTOR_TYPES :
144- raise ValueError (f"Unknown convertor type '{ type_ } '" )
145-
146- conv = CONVERTOR_TYPES [type_ ]
147- converters [name ] = conv
148-
149- # path type must be last
150- if type_ == "path" and i != len (parts ) - 1 :
151- raise ValueError ("Path parameters must appear last in the template" )
152-
153- pattern_parts .append (f"(?P<{ name } >{ conv .regex } )" )
154- else :
155- pattern_parts .append (re .escape (part ))
156-
157- return re .compile ("^" + "/" .join (pattern_parts ) + "$" ), converters
158-
159- @staticmethod
160- def _analyze_function_params (fn : Callable [..., Any ]) -> tuple [set [str ], set [str ]]:
161- """Analyze function signature to extract required and optional parameters.
162- This should operate on the original, unwrapped function.
163- """
164- # Ensure we are looking at the original function if it was wrapped elsewhere
165- original_fn_for_analysis = inspect .unwrap (fn )
166- required_params : set [str ] = set ()
167- optional_params : set [str ] = set ()
168-
169- signature = inspect .signature (original_fn_for_analysis )
170- for name , param in signature .parameters .items ():
171- # Parameters with default values are optional
172- if param .default is param .empty :
173- required_params .add (name )
174- else :
175- optional_params .add (name )
176-
177- return required_params , optional_params
178-
179117 def matches (self , uri : str ) -> dict [str , Any ] | None :
180118 """Check if URI matches template and extract parameters."""
181- if not self ._compiled_pattern or not self ._convertors :
182- self . _compiled_pattern , self . _convertors = self . _generate_pattern ( )
119+ if not self .compiled_pattern or not self .convertors :
120+ raise RuntimeError ( "Pattern did not compile for matching" )
183121
184122 # Split URI into path and query parts
185123 if "?" in uri :
186124 path , query = uri .split ("?" , 1 )
187125 else :
188126 path , query = uri , ""
189127
190- match = self ._compiled_pattern .match (path .strip ("/" ))
128+ match = self .compiled_pattern .match (path .strip ("/" ))
191129 if not match :
192130 return None
193131
194- # Extract path parameters
195- # try to convert them into respective types
196132 params : dict [str , Any ] = {}
197- for name , conv in self ._convertors .items ():
133+
134+ # ---- Extract and convert path parameters ----
135+ for name , conv in self .convertors .items ():
198136 raw_value = match .group (name )
199137 try :
200138 params [name ] = conv .convert (raw_value )
201139 except Exception as e :
202- raise ValueError (f"Failed to convert '{ raw_value } ' for '{ name } ': { e } " )
140+ raise RuntimeError (f"Failed to convert '{ raw_value } ' for '{ name } ': { e } " )
141+
142+ # ---- Parse and merge query parameters ----
143+ query_dict = urllib .parse .parse_qs (query ) if query else {}
144+
145+ # Normalize and flatten query params
146+ for key , values in query_dict .items ():
147+ value = values [0 ] if values else None
148+ if key in self .required_query_params or key in self .optional_query_params :
149+ params [key ] = value
203150
204- # Parse and add query parameters if present
205- if query :
206- query_params = urllib .parse .parse_qs (query )
207- for key , value in query_params .items ():
208- if key in self .optional_params :
209- # Use the first value if multiple are provided
210- params [key ] = value [0 ] if value else None
151+ # ---- Validate required query parameters ----
152+ missing_required = [key for key in self .required_query_params if key not in params ]
153+ if missing_required :
154+ raise ValueError (f"Missing required query parameters: { missing_required } " )
211155
212156 return params
213157
@@ -225,7 +169,7 @@ async def create_resource(
225169 fn_params = {
226170 name : value
227171 for name , value in params .items ()
228- if name in self .required_params or name in self .optional_params
172+ if name in self .path_params or name in self .required_query_params or name in self . optional_query_params
229173 }
230174 # Add context to params
231175 fn_params = inject_context (self .fn , fn_params , context , self .context_kwarg ) # type: ignore
0 commit comments