Skip to content

Commit 6ab862b

Browse files
committed
added Path and Query Annotations
1 parent 010c5f7 commit 6ab862b

File tree

9 files changed

+1036
-189
lines changed

9 files changed

+1036
-189
lines changed

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

Lines changed: 43 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212

1313
from mcp.server.fastmcp.resources.types import FunctionResource, Resource
1414
from 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
1616
from 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
1718
from mcp.types import Annotations, Icon
1819

1920
if 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

src/mcp/server/fastmcp/utilities/convertors.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,31 @@
22

33
import math
44
import uuid
5-
from typing import (
6-
Any,
7-
ClassVar,
8-
Generic,
9-
TypeVar,
10-
)
5+
from typing import Any, ClassVar, Generic, TypeVar, get_args
6+
7+
from pydantic import GetCoreSchemaHandler
8+
from pydantic_core import core_schema
119

1210
T = TypeVar("T")
1311

1412

1513
class Convertor(Generic[T]):
1614
regex: ClassVar[str] = ""
15+
python_type: Any = Any # type hint for runtime type
16+
17+
def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None:
18+
super().__init_subclass__(**kwargs)
19+
# Extract the concrete type from the generic base
20+
base = cls.__orig_bases__[0] # type: ignore[attr-defined]
21+
args = get_args(base)
22+
if args:
23+
cls.python_type = args[0] # type: ignore[assignment]
24+
else:
25+
raise RuntimeError(f"Bad converter definition in class {cls.__name__}")
26+
27+
@classmethod
28+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler):
29+
return core_schema.any_schema()
1730

1831
def convert(self, value: str) -> T:
1932
raise NotImplementedError()

0 commit comments

Comments
 (0)