Skip to content

Commit 79bc4b8

Browse files
author
Agasthya Kasturi
committed
Added RFC 6570 complaint form style query expansion as optional parameters to resources
1 parent c2ca8e0 commit 79bc4b8

File tree

7 files changed

+614
-138
lines changed

7 files changed

+614
-138
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"sse-starlette>=1.6.1",
3131
"pydantic-settings>=2.5.2",
3232
"uvicorn>=0.23.1",
33+
"pyright>=1.1.391",
3334
]
3435

3536
[project.optional-dependencies]

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

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import inspect
66
import re
7+
import urllib.parse
78
from collections.abc import Callable
89
from typing import Any
910

@@ -16,7 +17,7 @@ class ResourceTemplate(BaseModel):
1617
"""A template for dynamically creating resources."""
1718

1819
uri_template: str = Field(
19-
description="URI template with parameters (e.g. weather://{city}/current)"
20+
description="URI template with parameters (e.g. weather://{city}/current{?units,format})"
2021
)
2122
name: str = Field(description="Name of the resource")
2223
description: str | None = Field(description="Description of what the resource does")
@@ -27,6 +28,14 @@ class ResourceTemplate(BaseModel):
2728
parameters: dict[str, Any] = Field(
2829
description="JSON schema for function parameters"
2930
)
31+
required_params: set[str] = Field(
32+
default_factory=set,
33+
description="Set of required parameters from the path component",
34+
)
35+
optional_params: set[str] = Field(
36+
default_factory=set,
37+
description="Set of optional parameters specified in the query component",
38+
)
3039

3140
@classmethod
3241
def from_function(
@@ -48,29 +57,113 @@ def from_function(
4857
# ensure the arguments are properly cast
4958
fn = validate_call(fn)
5059

60+
# Extract required and optional parameters from function signature
61+
required_params, optional_params = cls._analyze_function_params(fn)
62+
63+
# Extract path parameters from URI template
64+
path_params: set[str] = set(
65+
re.findall(r"{(\w+)}", re.sub(r"{(\?.+?)}", "", uri_template))
66+
)
67+
68+
# Extract query parameters from the URI template if present
69+
query_param_match = re.search(r"{(\?(?:\w+,)*\w+)}", uri_template)
70+
query_params: set[str] = set()
71+
if query_param_match:
72+
# Extract query parameters from {?param1,param2,...} syntax
73+
query_str = query_param_match.group(1)
74+
query_params = set(
75+
query_str[1:].split(",")
76+
) # Remove the leading '?' and split
77+
78+
# Validate path parameters match required function parameters
79+
print(f"path_params: {path_params}")
80+
print(f"required_params: {required_params}")
81+
if path_params != required_params:
82+
raise ValueError(
83+
f"Mismatch between URI path parameters {path_params} "
84+
f"and required function parameters {required_params}"
85+
)
86+
87+
# Validate query parameters are a subset of optional function parameters
88+
if not query_params.issubset(optional_params):
89+
invalid_params: set[str] = query_params - optional_params
90+
raise ValueError(
91+
f"Query parameters {invalid_params} do not match optional "
92+
f"function parameters {optional_params}"
93+
)
94+
5195
return cls(
5296
uri_template=uri_template,
5397
name=func_name,
5498
description=description or fn.__doc__ or "",
5599
mime_type=mime_type or "text/plain",
56100
fn=fn,
57101
parameters=parameters,
102+
required_params=required_params,
103+
optional_params=optional_params,
58104
)
59105

106+
@staticmethod
107+
def _analyze_function_params(fn: Callable[..., Any]) -> tuple[set[str], set[str]]:
108+
"""Analyze function signature to extract required and optional parameters."""
109+
required_params: set[str] = set()
110+
optional_params: set[str] = set()
111+
112+
signature = inspect.signature(fn)
113+
for name, param in signature.parameters.items():
114+
# Parameters with default values are optional
115+
if param.default is param.empty:
116+
required_params.add(name)
117+
else:
118+
optional_params.add(name)
119+
120+
return required_params, optional_params
121+
60122
def matches(self, uri: str) -> dict[str, Any] | None:
61123
"""Check if URI matches template and extract parameters."""
62-
# Convert template to regex pattern
63-
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
64-
match = re.match(f"^{pattern}$", uri)
65-
if match:
66-
return match.groupdict()
67-
return None
124+
# Split URI into path and query parts
125+
if "?" in uri:
126+
path, query = uri.split("?", 1)
127+
else:
128+
path, query = uri, ""
129+
130+
# Remove the query parameter part from the template for matching
131+
path_template = re.sub(r"{(\?.+?)}", "", self.uri_template)
132+
133+
# Convert template to regex pattern for path part
134+
pattern = path_template.replace("{", "(?P<").replace("}", ">[^/]+)")
135+
match = re.match(f"^{pattern}$", path)
136+
137+
if not match:
138+
return None
139+
140+
# Extract path parameters
141+
params = match.groupdict()
142+
143+
# Parse and add query parameters if present
144+
if query:
145+
query_params = urllib.parse.parse_qs(query)
146+
for key, value in query_params.items():
147+
if key in self.optional_params:
148+
# Use the first value if multiple are provided
149+
params[key] = value[0] if value else None
150+
151+
return params
68152

69153
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
70154
"""Create a resource from the template with the given parameters."""
71155
try:
156+
# Prepare parameters for function call
157+
# For optional parameters not in URL, use their default values
158+
fn_params = {}
159+
160+
# First add extracted parameters
161+
for name, value in params.items():
162+
if name in self.required_params or name in self.optional_params:
163+
fn_params[name] = value
164+
72165
# Call function and check if result is a coroutine
73-
result = self.fn(**params)
166+
result = self.fn(**fn_params)
74167
if inspect.iscoroutine(result):
75168
result = await result
76169

src/mcp/server/fastmcp/server.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import inspect
66
import json
7-
import re
87
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
98
from contextlib import (
109
AbstractAsyncContextManager,
@@ -327,6 +326,15 @@ def resource(
327326
If the URI contains parameters (e.g. "resource://{param}") or the function
328327
has parameters, it will be registered as a template resource.
329328
329+
Function parameters in the path are required,
330+
while parameters with default values
331+
can be optionally provided as query parameters using RFC 6570 form-style query
332+
expansion syntax: {?param1,param2,...}
333+
334+
Examples:
335+
- resource://{category}/{id}{?filter,sort,limit}
336+
- resource://{user_id}/profile{?format,fields}
337+
330338
Args:
331339
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
332340
name: Optional name for the resource
@@ -347,6 +355,19 @@ def get_data() -> str:
347355
def get_weather(city: str) -> str:
348356
return f"Weather for {city}"
349357
358+
@server.resource("resource://{city}/weather{?units}")
359+
def get_weather_with_options(city: str, units: str = "metric") -> str:
360+
# Can be called with resource://paris/weather?units=imperial
361+
return f"Weather for {city} in {units} units"
362+
363+
@server.resource("resource://{category}/{id}
364+
{?filter,sort,limit}")
365+
def get_item(category: str, id: str, filter: str = "all", sort: str = "name"
366+
, limit: int = 10) -> str:
367+
# Can be called with resource://electronics/1234?filter=new&sort=price&limit=20
368+
return f"Item {id} in {category}, filtered by {filter}, sorted by {sort}
369+
, limited to {limit}"
370+
350371
@server.resource("resource://{city}/weather")
351372
async def get_weather(city: str) -> str:
352373
data = await fetch_weather(city)
@@ -365,16 +386,6 @@ def decorator(fn: AnyFunction) -> AnyFunction:
365386
has_func_params = bool(inspect.signature(fn).parameters)
366387

367388
if has_uri_params or has_func_params:
368-
# Validate that URI params match function params
369-
uri_params = set(re.findall(r"{(\w+)}", uri))
370-
func_params = set(inspect.signature(fn).parameters.keys())
371-
372-
if uri_params != func_params:
373-
raise ValueError(
374-
f"Mismatch between URI parameters {uri_params} "
375-
f"and function parameters {func_params}"
376-
)
377-
378389
# Register as template
379390
self._resource_manager.add_template(
380391
fn=fn,

tests/issues/test_141_resource_templates.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,85 @@ async def test_resource_template_edge_cases():
2121
def get_user_post(user_id: str, post_id: str) -> str:
2222
return f"Post {post_id} by user {user_id}"
2323

24-
# Test case 2: Template with optional parameter (should fail)
25-
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
26-
27-
@mcp.resource("resource://users/{user_id}/profile")
28-
def get_user_profile(user_id: str, optional_param: str | None = None) -> str:
29-
return f"Profile for user {user_id}"
24+
# Test case 2: Template with valid optional parameters
25+
# using form-style query expansion
26+
@mcp.resource("resource://users/{user_id}/profile{?format,fields}")
27+
def get_user_profile(
28+
user_id: str, format: str = "json", fields: str = "basic"
29+
) -> str:
30+
return f"Profile for user {user_id} in {format} format with fields: {fields}"
3031

3132
# Test case 3: Template with mismatched parameters
32-
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
33+
with pytest.raises(
34+
ValueError,
35+
match="Mismatch between URI path parameters .* and "
36+
"required function parameters .*",
37+
):
3338

3439
@mcp.resource("resource://users/{user_id}/profile")
3540
def get_user_profile_mismatch(different_param: str) -> str:
3641
return f"Profile for user {different_param}"
3742

38-
# Test case 4: Template with extra function parameters
39-
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
43+
# Test case 4: Template with extra required function parameters
44+
with pytest.raises(
45+
ValueError,
46+
match="Mismatch between URI path parameters .* and "
47+
"required function parameters .*",
48+
):
4049

4150
@mcp.resource("resource://users/{user_id}/profile")
4251
def get_user_profile_extra(user_id: str, extra_param: str) -> str:
4352
return f"Profile for user {user_id}"
4453

4554
# Test case 5: Template with missing function parameters
46-
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
55+
with pytest.raises(
56+
ValueError,
57+
match="Mismatch between URI path parameters .* and "
58+
"required function parameters .*",
59+
):
4760

4861
@mcp.resource("resource://users/{user_id}/profile/{section}")
4962
def get_user_profile_missing(user_id: str) -> str:
5063
return f"Profile for user {user_id}"
5164

65+
# Test case 6: Invalid query parameter in template (not optional in function)
66+
with pytest.raises(
67+
ValueError,
68+
match="Mismatch between URI path parameters .* and "
69+
"required function parameters .*",
70+
):
71+
72+
@mcp.resource("resource://users/{user_id}/profile{?required_param}")
73+
def get_user_profile_invalid_query(user_id: str, required_param: str) -> str:
74+
return f"Profile for user {user_id}"
75+
76+
# Test case 7: Make sure the resource with form-style query parameters works
77+
async with client_session(mcp._mcp_server) as client:
78+
result = await client.read_resource(AnyUrl("resource://users/123/profile"))
79+
assert isinstance(result.contents[0], TextResourceContents)
80+
assert (
81+
result.contents[0].text
82+
== "Profile for user 123 in json format with fields: basic"
83+
)
84+
85+
result = await client.read_resource(
86+
AnyUrl("resource://users/123/profile?format=xml")
87+
)
88+
assert isinstance(result.contents[0], TextResourceContents)
89+
assert (
90+
result.contents[0].text
91+
== "Profile for user 123 in xml format with fields: basic"
92+
)
93+
94+
result = await client.read_resource(
95+
AnyUrl("resource://users/123/profile?format=xml&fields=detailed")
96+
)
97+
assert isinstance(result.contents[0], TextResourceContents)
98+
assert (
99+
result.contents[0].text
100+
== "Profile for user 123 in xml format with fields: detailed"
101+
)
102+
52103
# Verify valid template works
53104
result = await mcp.read_resource("resource://users/123/posts/456")
54105
result_list = list(result)

0 commit comments

Comments
 (0)