Skip to content

Commit 35a8c32

Browse files
authored
Add RFC 6570 query parameter support to resource templates (#1971)
1 parent b549b4a commit 35a8c32

File tree

4 files changed

+450
-50
lines changed

4 files changed

+450
-50
lines changed

docs/servers/resources.mdx

Lines changed: 74 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -412,14 +412,14 @@ With these two templates defined, clients can request a variety of resources:
412412
- `repos://jlowin/fastmcp/info` → Returns info about the jlowin/fastmcp repository
413413
- `repos://prefecthq/prefect/info` → Returns info about the prefecthq/prefect repository
414414

415-
### Wildcard Parameters
415+
### RFC 6570 URI Templates
416416

417-
<VersionBadge version="2.2.4" />
418417

419-
<Tip>
420-
Please note: FastMCP's support for wildcard parameters is an **extension** of the Model Context Protocol standard, which otherwise follows RFC 6570. Since all template processing happens in the FastMCP server, this should not cause any compatibility issues with other MCP implementations.
421-
</Tip>
418+
FastMCP implements [RFC 6570 URI Templates](https://datatracker.ietf.org/doc/html/rfc6570) for resource templates, providing a standardized way to define parameterized URIs. This includes support for simple expansion, wildcard path parameters, and form-style query parameters.
422419

420+
#### Wildcard Parameters
421+
422+
<VersionBadge version="2.2.4" />
423423

424424
Resource templates support wildcard parameters that can match multiple path segments. While standard parameters (`{param}`) only match a single path segment and don't cross "/" boundaries, wildcard parameters (`{param*}`) can capture multiple segments including slashes. Wildcards capture all subsequent path segments *up until* the defined part of the URI template (whether literal or another parameter). This allows you to have multiple wildcard parameters in a single URI template.
425425

@@ -448,7 +448,7 @@ def get_path_content(filepath: str) -> str:
448448
# Mixing standard and wildcard parameters
449449
@mcp.resource("repo://{owner}/{path*}/template.py")
450450
def get_template_file(owner: str, path: str) -> dict:
451-
"""Retrieves a file from a specific repository and path, but
451+
"""Retrieves a file from a specific repository and path, but
452452
only if the resource ends with `template.py`"""
453453
# Can match repo://jlowin/fastmcp/src/resources/template.py
454454
return {
@@ -466,43 +466,88 @@ Wildcard parameters are useful when:
466466

467467
Note that like regular parameters, each wildcard parameter must still be a named parameter in your function signature, and all required function parameters must appear in the URI template.
468468

469-
### Default Values
470-
471-
<VersionBadge version="2.2.0" />
469+
#### Query Parameters
472470

473-
When creating resource templates, FastMCP enforces two rules for the relationship between URI template parameters and function parameters:
471+
<VersionBadge version="2.13.0" />
474472

475-
1. **Required Function Parameters:** All function parameters without default values (required parameters) must appear in the URI template.
476-
2. **URI Parameters:** All URI template parameters must exist as function parameters.
473+
FastMCP supports RFC 6570 form-style query parameters using the `{?param1,param2}` syntax. Query parameters provide a clean way to pass optional configuration to resources without cluttering the path.
477474

478-
However, function parameters with default values don't need to be included in the URI template. When a client requests a resource, FastMCP will:
479-
480-
- Extract parameter values from the URI for parameters included in the template
481-
- Use default values for any function parameters not in the URI template
482-
483-
This allows for flexible API designs. For example, a simple search template with optional parameters:
475+
Query parameters must be optional function parameters (have default values), while path parameters map to required function parameters. This enforces a clear separation: required data goes in the path, optional configuration in query params.
484476

485477
```python
486478
from fastmcp import FastMCP
487479

488480
mcp = FastMCP(name="DataServer")
489481

490-
@mcp.resource("search://{query}")
491-
def search_resources(query: str, max_results: int = 10, include_archived: bool = False) -> dict:
492-
"""Search for resources matching the query string."""
493-
# Only 'query' is required in the URI, the other parameters use their defaults
494-
results = perform_search(query, limit=max_results, archived=include_archived)
482+
# Basic query parameters
483+
@mcp.resource("data://{id}{?format}")
484+
def get_data(id: str, format: str = "json") -> str:
485+
"""Retrieve data in specified format."""
486+
if format == "xml":
487+
return f"<data id='{id}' />"
488+
return f'{{"id": "{id}"}}'
489+
490+
# Multiple query parameters with type coercion
491+
@mcp.resource("api://{endpoint}{?version,limit,offset}")
492+
def call_api(endpoint: str, version: int = 1, limit: int = 10, offset: int = 0) -> dict:
493+
"""Call API endpoint with pagination."""
495494
return {
496-
"query": query,
497-
"max_results": max_results,
498-
"include_archived": include_archived,
499-
"results": results
495+
"endpoint": endpoint,
496+
"version": version,
497+
"limit": limit,
498+
"offset": offset,
499+
"results": fetch_results(endpoint, version, limit, offset)
500500
}
501+
502+
# Query parameters with wildcards
503+
@mcp.resource("files://{path*}{?encoding,lines}")
504+
def read_file(path: str, encoding: str = "utf-8", lines: int = 100) -> str:
505+
"""Read file with optional encoding and line limit."""
506+
return read_file_content(path, encoding, lines)
507+
```
508+
509+
**Example requests:**
510+
- `data://123` → Uses default format `"json"`
511+
- `data://123?format=xml` → Uses format `"xml"`
512+
- `api://users?version=2&limit=50``version=2, limit=50, offset=0`
513+
- `files://src/main.py?encoding=ascii&lines=50` → Custom encoding and line limit
514+
515+
FastMCP automatically coerces query parameter string values to the correct types based on your function's type hints (`int`, `float`, `bool`, `str`).
516+
517+
**Query parameters vs. hidden defaults:**
518+
519+
Query parameters expose optional configuration to clients. To hide optional parameters from clients entirely (always use defaults), simply omit them from the URI template:
520+
521+
```python
522+
# Clients CAN override max_results via query string
523+
@mcp.resource("search://{query}{?max_results}")
524+
def search_configurable(query: str, max_results: int = 10) -> dict:
525+
return {"query": query, "limit": max_results}
526+
527+
# Clients CANNOT override max_results (not in URI template)
528+
@mcp.resource("search://{query}")
529+
def search_fixed(query: str, max_results: int = 10) -> dict:
530+
return {"query": query, "limit": max_results}
501531
```
502532

503-
With this template, clients can request `search://python` and the function will be called with `query="python", max_results=10, include_archived=False`. MCP Developers can still call the underlying `search_resources` function directly with more specific parameters.
533+
### Template Parameter Rules
534+
535+
<VersionBadge version="2.2.0" />
536+
537+
FastMCP enforces these validation rules when creating resource templates:
538+
539+
1. **Required function parameters** (no default values) must appear in the URI path template
540+
2. **Query parameters** (specified with `{?param}` syntax) must be optional function parameters with default values
541+
3. **All URI template parameters** (path and query) must exist as function parameters
542+
543+
Optional function parameters (those with default values) can be:
544+
- Included as query parameters (`{?param}`) - clients can override via query string
545+
- Omitted from URI template - always uses default value, not exposed to clients
546+
- Used in alternative path templates - enables multiple ways to access the same resource
547+
548+
**Multiple templates for one function:**
504549

505-
You can also create multiple resource templates that provide different ways to access the same underlying data by manually applying decorators to a single function:
550+
Create multiple resource templates that expose the same function through different URI patterns by manually applying decorators:
506551

507552
```python
508553
from fastmcp import FastMCP

src/fastmcp/resources/template.py

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
from collections.abc import Callable
88
from typing import Any
9-
from urllib.parse import unquote
9+
from urllib.parse import parse_qs, unquote
1010

1111
from mcp.types import Annotations
1212
from mcp.types import ResourceTemplate as MCPResourceTemplate
@@ -26,8 +26,26 @@
2626
)
2727

2828

29+
def extract_query_params(uri_template: str) -> set[str]:
30+
"""Extract query parameter names from RFC 6570 {?param1,param2} syntax."""
31+
match = re.search(r"\{\?([^}]+)\}", uri_template)
32+
if match:
33+
return {p.strip() for p in match.group(1).split(",")}
34+
return set()
35+
36+
2937
def build_regex(template: str) -> re.Pattern:
30-
parts = re.split(r"(\{[^}]+\})", template)
38+
"""Build regex pattern for URI template, handling RFC 6570 syntax.
39+
40+
Supports:
41+
- {var} - simple path parameter
42+
- {var*} - wildcard path parameter (captures multiple segments)
43+
- {?var1,var2} - query parameters (ignored in path matching)
44+
"""
45+
# Remove query parameter syntax for path matching
46+
template_without_query = re.sub(r"\{\?[^}]+\}", "", template)
47+
48+
parts = re.split(r"(\{[^}]+\})", template_without_query)
3149
pattern = ""
3250
for part in parts:
3351
if part.startswith("{") and part.endswith("}"):
@@ -43,11 +61,34 @@ def build_regex(template: str) -> re.Pattern:
4361

4462

4563
def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
64+
"""Match URI against template and extract both path and query parameters.
65+
66+
Supports RFC 6570 URI templates:
67+
- Path params: {var}, {var*}
68+
- Query params: {?var1,var2}
69+
"""
70+
# Split URI into path and query parts
71+
uri_path, _, query_string = uri.partition("?")
72+
73+
# Match path parameters
4674
regex = build_regex(uri_template)
47-
match = regex.match(uri)
48-
if match:
49-
return {k: unquote(v) for k, v in match.groupdict().items()}
50-
return None
75+
match = regex.match(uri_path)
76+
if not match:
77+
return None
78+
79+
params = {k: unquote(v) for k, v in match.groupdict().items()}
80+
81+
# Extract query parameters if present in URI and template
82+
if query_string:
83+
query_param_names = extract_query_params(uri_template)
84+
parsed_query = parse_qs(query_string)
85+
86+
for name in query_param_names:
87+
if name in parsed_query:
88+
# Take first value if multiple provided
89+
params[name] = parsed_query[name][0] # type: ignore[index]
90+
91+
return params
5192

5293

5394
class ResourceTemplate(FastMCPComponent):
@@ -206,6 +247,31 @@ async def read(self, arguments: dict[str, Any]) -> str | bytes:
206247
if context_kwarg and context_kwarg not in kwargs:
207248
kwargs[context_kwarg] = get_context()
208249

250+
# Type coercion for query parameters (which arrive as strings)
251+
# Get function signature for type hints
252+
sig = inspect.signature(self.fn)
253+
for param_name, param_value in list(kwargs.items()):
254+
if param_name in sig.parameters and isinstance(param_value, str):
255+
param = sig.parameters[param_name]
256+
annotation = param.annotation
257+
258+
# Skip if no annotation or annotation is str
259+
if annotation is inspect.Parameter.empty or annotation is str:
260+
continue
261+
262+
# Handle common type coercions
263+
try:
264+
if annotation is int:
265+
kwargs[param_name] = int(param_value)
266+
elif annotation is float:
267+
kwargs[param_name] = float(param_value)
268+
elif annotation is bool:
269+
# Handle boolean strings
270+
kwargs[param_name] = param_value.lower() in ("true", "1", "yes")
271+
except (ValueError, AttributeError):
272+
# Let validate_call handle the error
273+
pass
274+
209275
result = self.fn(**kwargs)
210276
if inspect.isawaitable(result):
211277
result = await result
@@ -245,38 +311,57 @@ def from_function(
245311

246312
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
247313

248-
# Validate that URI params match function params
249-
uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
250-
if not uri_params:
314+
# Extract path and query parameters from URI template
315+
path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
316+
query_params = extract_query_params(uri_template)
317+
all_uri_params = path_params | query_params
318+
319+
if not all_uri_params:
251320
raise ValueError("URI template must contain at least one parameter")
252321

253322
func_params = set(sig.parameters.keys())
254323
if context_kwarg:
255324
func_params.discard(context_kwarg)
256325

257-
# get the parameters that are required
326+
# Get required and optional function parameters
258327
required_params = {
259328
p
260329
for p in func_params
261330
if sig.parameters[p].default is inspect.Parameter.empty
262331
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
263332
and p != context_kwarg
264333
}
334+
optional_params = {
335+
p
336+
for p in func_params
337+
if sig.parameters[p].default is not inspect.Parameter.empty
338+
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
339+
and p != context_kwarg
340+
}
341+
342+
# Validate RFC 6570 query parameters
343+
# Query params must be optional (have defaults)
344+
if query_params:
345+
invalid_query_params = query_params - optional_params
346+
if invalid_query_params:
347+
raise ValueError(
348+
f"Query parameters {invalid_query_params} must be optional function parameters with default values"
349+
)
265350

266-
# Check if required parameters are a subset of the URI parameters
267-
if not required_params.issubset(uri_params):
351+
# Check if required parameters are a subset of the path parameters
352+
if not required_params.issubset(path_params):
268353
raise ValueError(
269-
f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
354+
f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}"
270355
)
271356

272-
# Check if the URI parameters are a subset of the function parameters (skip if **kwargs present)
357+
# Check if all URI parameters are valid function parameters (skip if **kwargs present)
273358
if not any(
274359
param.kind == inspect.Parameter.VAR_KEYWORD
275360
for param in sig.parameters.values()
276361
):
277-
if not uri_params.issubset(func_params):
362+
if not all_uri_params.issubset(func_params):
278363
raise ValueError(
279-
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
364+
f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}"
280365
)
281366

282367
description = description or inspect.getdoc(fn)

0 commit comments

Comments
 (0)