Merge branch 'main' into switch-kvstore

This commit is contained in:
William Easton 2025-10-02 09:11:03 -05:00 committed by GitHub
commit 5480cb5ba6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 612 additions and 85 deletions

View file

@ -81,9 +81,12 @@ jobs:
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task
--mcp-config /tmp/mcp-config/mcp-servers.json
settings: |
{
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
"model": "claude-sonnet-4-5-20250929",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}

View file

@ -0,0 +1,139 @@
name: The Martian Issue Triage
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue_number:
description: "Issue to triage"
required: true
type: string
concurrency:
group: triage-issue-${{ github.event.issue.number || inputs.issue_number }}
cancel-in-progress: true
jobs:
marvin-issue-triage:
if: github.actor == 'strawgate'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: read
id-token: write
steps:
- name: Checkout base repository
uses: actions/checkout@v5
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Set triage prompt
id: triage-prompt
run: |
cat >> $GITHUB_OUTPUT << 'EOF'
PROMPT<<PROMPT_END
You're an issue triage assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients.
Your triage process is broke up into 2 steps:
# Lay of the land
1. Call the generate_agents_md tool to get a high-level summary of the project you're working in
2. Get the issue ${{ github.event.issue.number || inputs.issue_number }} in the GitHub repository: ${{ github.repository }}.
3. Use the issue and pull request search tools to scour the repository for related issues and pull requests
3. Call the code-search, get_files, etc. tools to search the repository to identify the related classes, methods, tests, etc that are relevant to the issue.
# Recommendations
Once you have enough background, you will thoroughly review the issue and you will outline a single high-quality recommendation for how to resolve the issue that is deeply rooted in the codebase, conventions, and best practices. If you do not have a high quality recommendation, you will share your findings and indicate why you don't have a recommendation.
# Example Output
The Calculator.divide method on the main branch of jlowin/fastmcp currently raises a ValueError with the message "Division by zero" when the divisor is 0. This behavior aligns with the "Actual Behavior" described in your bug report.
While raising a ValueError is a standard way to handle invalid input in Python, the suggestion of handling division by zero "gracefully with a clear error message" is a valid improvement. A more specific exception type would allow consumers of the calculator API to differentiate between various types of ValueErrors.
We have identified an open pull request, #654, titled "Fix division by zero handling." This pull request introduces a custom DivisionByZeroError and a safe_divide function, which aligns with your desired "Expected Behavior." However, an inconsistency was found in the fix/division-by-zero branch associated with this pull request: the Calculator.divide method itself has not been updated to utilize the new DivisionByZeroError or the safe_divide function, and still raises a generic ValueError.
Next Steps:
It is recommended to update the existing pull request #654 to fully integrate the DivisionByZeroError and safe_divide function into the Calculator.divide method. This would ensure that the calculator consistently raises the more specific error, fulfilling the goal of graceful error handling with a clear message.
#### Related Issues and Pull Requests
| Repository | Issue or PR | Title | Confidence |
| --- | --- | --- | --- |
| jlowin/fastmcp | [Add matrix operations support](https://github.com/jlowin/fastmcp/pull/680) | Add matrix operations support | [High ⓘ](## "This pull request directly addresses the feature request for adding matrix operations to the calculator.") |
| jlowin/fastmcp | [Add matrix operations support](https://github.com/jlowin/fastmcp/issues/681) | Add matrix operations support | [High ⓘ](## "This issue directly addresses the feature request for adding matrix operations to the calculator.") |
#### Related Files
| Repository | File | Confidence | Sections |
| --- | --- | --- | --- |
| modelcontextprotocol/python-sdk | [test_calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py) | [High ⓘ](## "This file contains the test cases for the Calculator class, including a test that specifically asserts a ValueError is raised for division by zero, confirming the current intended behavior.") | [25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-27) |
| modelcontextprotocol/python-sdk | [calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py) | [High ⓘ](## "This file contains the implementation of the Calculator class, specifically the `divide` method which raises the ValueError when dividing by zero, matching the bug report.") | [29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-32) |
#### Related Webpages
| Name | URL | Confidence |
| --- | --- | --- |
| Handling Division by Zero Best Practices | https://my-blog-about-division-by-zero.com/handling+division+by+zero+in+calculator | [High ⓘ](## "This webpage provides general best practices for handling division by zero in calculator applications and in Python, which is directly relevant to the issue and potential solutions.") |
IMPORTANT: You will not make branches or pull requests. Your ONLY action will be investigating the issue, locating related issues,
pull requests, and files in the repository and reporting your findings.
PROMPT_END
EOF
- name: Setup GitHub MCP Server
run: |
mkdir -p /tmp/mcp-config
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
{
"mcpServers": {
"repository-summary": {
"type": "http",
"url": "https://agents-md-generator.fastmcp.app/mcp"
},
"code-search": {
"type": "http",
"url": "https://github-code-search.fastmcp.app/mcp"
},
"github-research": {
"command": "uvx",
"args": [
"github-research-mcp"
],
"env": {
"DISABLE_SUMMARIES": true,
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}
}
}
EOF
- name: Run Marvin for Issue Triage
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
bot_name: "Marvin Context Protocol"
prompt: ${{ steps.triage-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools WebSearch,WebFetch,mcp__repository-summary,mcp__code-search,mcp__github-research
--mcp-config /tmp/mcp-config/mcp-servers.json
settings: |
{
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}

View file

@ -1,5 +1,6 @@
name: Marvin Label Triage
description: Automatically triage GitHub issues and PRs using Marvin
# Automatically triage GitHub issues and PRs using Marvin
on:
issues:
types: [opened]
@ -17,14 +18,13 @@ concurrency:
cancel-in-progress: false
jobs:
triage-issue:
label-issue-or-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
id-token: write
steps:
- name: Checkout base repository
@ -124,42 +124,21 @@ jobs:
PROMPT_END
EOF
- name: Setup GitHub MCP Server
run: |
mkdir -p /tmp/mcp-config
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-7aced2b"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}
}
}
EOF
- name: Run Marvin for Issue Triage
- name: Run Marvin for Issue Triage
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
bot_name: "Marvin Context Protocol"
prompt: ${{ steps.triage-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
allowed_non_write_users: "*" # Required for issue triage workflow, if users without repo write access create issues
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__get_pull_request_files
--mcp-config /tmp/mcp-config/mcp-servers.json
settings: |
{
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
"model": "claude-sonnet-4-5-20250929",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}

View file

@ -66,7 +66,13 @@ jobs:
trigger_phrase: "/marvin"
allowed_bots: "*"
claude_args: |
--model claude-sonnet-4-5-20250929
--allowedTools WebSearch,WebFetch,Bash(uv:*),Bash(pre-commit:*),Bash(pytest:*),Bash(ruff:*),Bash(ty:*),Bash(git:*),Bash(gh:*),mcp__github__add_issue_comment,mcp__github__create_issue,mcp__github__get_issue,mcp__github__list_issues,mcp__github__search_issues,mcp__github__update_issue,mcp__github__update_issue_comment,mcp__github__create_pull_request,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__list_pull_requests,mcp__github__update_pull_request,mcp__github__update_pull_request_branch,mcp__github__update_pull_request_comment,mcp__github__merge_pull_request
additional_permissions: |
actions: read
settings: |
{
"model": "claude-sonnet-4-5-20250929",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}

View file

@ -412,15 +412,15 @@ With these two templates defined, clients can request a variety of resources:
- `repos://jlowin/fastmcp/info` → Returns info about the jlowin/fastmcp repository
- `repos://prefecthq/prefect/info` → Returns info about the prefecthq/prefect repository
### Wildcard Parameters
### RFC 6570 URI Templates
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.
#### Wildcard Parameters
<VersionBadge version="2.2.4" />
<Tip>
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.
</Tip>
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.
```python {15, 23}
@ -448,7 +448,7 @@ def get_path_content(filepath: str) -> str:
# Mixing standard and wildcard parameters
@mcp.resource("repo://{owner}/{path*}/template.py")
def get_template_file(owner: str, path: str) -> dict:
"""Retrieves a file from a specific repository and path, but
"""Retrieves a file from a specific repository and path, but
only if the resource ends with `template.py`"""
# Can match repo://jlowin/fastmcp/src/resources/template.py
return {
@ -466,43 +466,88 @@ Wildcard parameters are useful when:
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.
### Default Values
#### Query Parameters
<VersionBadge version="2.2.0" />
<VersionBadge version="2.13.0" />
When creating resource templates, FastMCP enforces two rules for the relationship between URI template parameters and function parameters:
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.
1. **Required Function Parameters:** All function parameters without default values (required parameters) must appear in the URI template.
2. **URI Parameters:** All URI template parameters must exist as function parameters.
However, function parameters with default values don't need to be included in the URI template. When a client requests a resource, FastMCP will:
- Extract parameter values from the URI for parameters included in the template
- Use default values for any function parameters not in the URI template
This allows for flexible API designs. For example, a simple search template with optional parameters:
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.
```python
from fastmcp import FastMCP
mcp = FastMCP(name="DataServer")
@mcp.resource("search://{query}")
def search_resources(query: str, max_results: int = 10, include_archived: bool = False) -> dict:
"""Search for resources matching the query string."""
# Only 'query' is required in the URI, the other parameters use their defaults
results = perform_search(query, limit=max_results, archived=include_archived)
# Basic query parameters
@mcp.resource("data://{id}{?format}")
def get_data(id: str, format: str = "json") -> str:
"""Retrieve data in specified format."""
if format == "xml":
return f"<data id='{id}' />"
return f'{{"id": "{id}"}}'
# Multiple query parameters with type coercion
@mcp.resource("api://{endpoint}{?version,limit,offset}")
def call_api(endpoint: str, version: int = 1, limit: int = 10, offset: int = 0) -> dict:
"""Call API endpoint with pagination."""
return {
"query": query,
"max_results": max_results,
"include_archived": include_archived,
"results": results
"endpoint": endpoint,
"version": version,
"limit": limit,
"offset": offset,
"results": fetch_results(endpoint, version, limit, offset)
}
# Query parameters with wildcards
@mcp.resource("files://{path*}{?encoding,lines}")
def read_file(path: str, encoding: str = "utf-8", lines: int = 100) -> str:
"""Read file with optional encoding and line limit."""
return read_file_content(path, encoding, lines)
```
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.
**Example requests:**
- `data://123` → Uses default format `"json"`
- `data://123?format=xml` → Uses format `"xml"`
- `api://users?version=2&limit=50` → `version=2, limit=50, offset=0`
- `files://src/main.py?encoding=ascii&lines=50` → Custom encoding and line limit
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:
FastMCP automatically coerces query parameter string values to the correct types based on your function's type hints (`int`, `float`, `bool`, `str`).
**Query parameters vs. hidden defaults:**
Query parameters expose optional configuration to clients. To hide optional parameters from clients entirely (always use defaults), simply omit them from the URI template:
```python
# Clients CAN override max_results via query string
@mcp.resource("search://{query}{?max_results}")
def search_configurable(query: str, max_results: int = 10) -> dict:
return {"query": query, "limit": max_results}
# Clients CANNOT override max_results (not in URI template)
@mcp.resource("search://{query}")
def search_fixed(query: str, max_results: int = 10) -> dict:
return {"query": query, "limit": max_results}
```
### Template Parameter Rules
<VersionBadge version="2.2.0" />
FastMCP enforces these validation rules when creating resource templates:
1. **Required function parameters** (no default values) must appear in the URI path template
2. **Query parameters** (specified with `{?param}` syntax) must be optional function parameters with default values
3. **All URI template parameters** (path and query) must exist as function parameters
Optional function parameters (those with default values) can be:
- Included as query parameters (`{?param}`) - clients can override via query string
- Omitted from URI template - always uses default value, not exposed to clients
- Used in alternative path templates - enables multiple ways to access the same resource
**Multiple templates for one function:**
Create multiple resource templates that expose the same function through different URI patterns by manually applying decorators:
```python
from fastmcp import FastMCP

View file

@ -6,7 +6,7 @@ import inspect
import re
from collections.abc import Callable
from typing import Any
from urllib.parse import unquote
from urllib.parse import parse_qs, unquote
from mcp.types import Annotations
from mcp.types import ResourceTemplate as MCPResourceTemplate
@ -26,8 +26,26 @@ from fastmcp.utilities.types import (
)
def extract_query_params(uri_template: str) -> set[str]:
"""Extract query parameter names from RFC 6570 {?param1,param2} syntax."""
match = re.search(r"\{\?([^}]+)\}", uri_template)
if match:
return {p.strip() for p in match.group(1).split(",")}
return set()
def build_regex(template: str) -> re.Pattern:
parts = re.split(r"(\{[^}]+\})", template)
"""Build regex pattern for URI template, handling RFC 6570 syntax.
Supports:
- {var} - simple path parameter
- {var*} - wildcard path parameter (captures multiple segments)
- {?var1,var2} - query parameters (ignored in path matching)
"""
# Remove query parameter syntax for path matching
template_without_query = re.sub(r"\{\?[^}]+\}", "", template)
parts = re.split(r"(\{[^}]+\})", template_without_query)
pattern = ""
for part in parts:
if part.startswith("{") and part.endswith("}"):
@ -43,11 +61,34 @@ def build_regex(template: str) -> re.Pattern:
def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
"""Match URI against template and extract both path and query parameters.
Supports RFC 6570 URI templates:
- Path params: {var}, {var*}
- Query params: {?var1,var2}
"""
# Split URI into path and query parts
uri_path, _, query_string = uri.partition("?")
# Match path parameters
regex = build_regex(uri_template)
match = regex.match(uri)
if match:
return {k: unquote(v) for k, v in match.groupdict().items()}
return None
match = regex.match(uri_path)
if not match:
return None
params = {k: unquote(v) for k, v in match.groupdict().items()}
# Extract query parameters if present in URI and template
if query_string:
query_param_names = extract_query_params(uri_template)
parsed_query = parse_qs(query_string)
for name in query_param_names:
if name in parsed_query:
# Take first value if multiple provided
params[name] = parsed_query[name][0] # type: ignore[index]
return params
class ResourceTemplate(FastMCPComponent):
@ -206,6 +247,31 @@ class FunctionResourceTemplate(ResourceTemplate):
if context_kwarg and context_kwarg not in kwargs:
kwargs[context_kwarg] = get_context()
# Type coercion for query parameters (which arrive as strings)
# Get function signature for type hints
sig = inspect.signature(self.fn)
for param_name, param_value in list(kwargs.items()):
if param_name in sig.parameters and isinstance(param_value, str):
param = sig.parameters[param_name]
annotation = param.annotation
# Skip if no annotation or annotation is str
if annotation is inspect.Parameter.empty or annotation is str:
continue
# Handle common type coercions
try:
if annotation is int:
kwargs[param_name] = int(param_value)
elif annotation is float:
kwargs[param_name] = float(param_value)
elif annotation is bool:
# Handle boolean strings
kwargs[param_name] = param_value.lower() in ("true", "1", "yes")
except (ValueError, AttributeError):
# Let validate_call handle the error
pass
result = self.fn(**kwargs)
if inspect.isawaitable(result):
result = await result
@ -245,16 +311,19 @@ class FunctionResourceTemplate(ResourceTemplate):
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
# Validate that URI params match function params
uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
if not uri_params:
# Extract path and query parameters from URI template
path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
query_params = extract_query_params(uri_template)
all_uri_params = path_params | query_params
if not all_uri_params:
raise ValueError("URI template must contain at least one parameter")
func_params = set(sig.parameters.keys())
if context_kwarg:
func_params.discard(context_kwarg)
# get the parameters that are required
# Get required and optional function parameters
required_params = {
p
for p in func_params
@ -262,21 +331,37 @@ class FunctionResourceTemplate(ResourceTemplate):
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
and p != context_kwarg
}
optional_params = {
p
for p in func_params
if sig.parameters[p].default is not inspect.Parameter.empty
and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
and p != context_kwarg
}
# Check if required parameters are a subset of the URI parameters
if not required_params.issubset(uri_params):
# Validate RFC 6570 query parameters
# Query params must be optional (have defaults)
if query_params:
invalid_query_params = query_params - optional_params
if invalid_query_params:
raise ValueError(
f"Query parameters {invalid_query_params} must be optional function parameters with default values"
)
# Check if required parameters are a subset of the path parameters
if not required_params.issubset(path_params):
raise ValueError(
f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}"
)
# Check if the URI parameters are a subset of the function parameters (skip if **kwargs present)
# Check if all URI parameters are valid function parameters (skip if **kwargs present)
if not any(
param.kind == inspect.Parameter.VAR_KEYWORD
for param in sig.parameters.values()
):
if not uri_params.issubset(func_params):
if not all_uri_params.issubset(func_params):
raise ValueError(
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}"
)
description = description or inspect.getdoc(fn)

View file

@ -186,7 +186,7 @@ def log_server_banner(
case "stdio":
display_transport = "STDIO"
info_table.add_row("🖥", "Server name:", server.name)
info_table.add_row("🖥", "Server name:", server.name)
info_table.add_row("📦", "Transport:", display_transport)
# Show connection info based on transport
@ -200,7 +200,7 @@ def log_server_banner(
# Add version information with explicit style overrides
info_table.add_row("", "", "")
info_table.add_row(
"🏎",
"🏎",
"FastMCP version:",
Text(fastmcp.__version__, style="dim white", no_wrap=True),
)

View file

@ -103,7 +103,7 @@ class TestResourceTemplate:
# This should fail - 'unknown' is not a function parameter
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI parameters",
match="Required function arguments .* must be a subset of the URI path parameters",
):
ResourceTemplate.from_function(
fn=my_func,
@ -131,7 +131,7 @@ class TestResourceTemplate:
# This should fail - required param is not in URI
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI parameters",
match="Required function arguments .* must be a subset of the URI path parameters",
):
ResourceTemplate.from_function(
fn=func_with_required,
@ -156,7 +156,7 @@ class TestResourceTemplate:
# This fails - missing one required param
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI parameters",
match="Required function arguments .* must be a subset of the URI path parameters",
):
ResourceTemplate.from_function(
fn=multi_required,
@ -707,3 +707,250 @@ class TestContextHandling:
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "42"
class TestQueryParameterExtraction:
"""Test basic query parameter extraction from URIs."""
async def test_single_query_param(self):
"""Test resource template with single query parameter."""
def get_data(id: str, format: str = "json") -> str:
return f"Data {id} in {format}"
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format}",
name="test",
)
# Match without query param (uses default)
params = template.matches("data://123")
assert params == {"id": "123"}
# Match with query param
params = template.matches("data://123?format=xml")
assert params == {"id": "123", "format": "xml"}
async def test_multiple_query_params(self):
"""Test resource template with multiple query parameters."""
def get_items(category: str, page: int = 1, limit: int = 10) -> str:
return f"Category {category}, page {page}, limit {limit}"
template = ResourceTemplate.from_function(
fn=get_items,
uri_template="items://{category}{?page,limit}",
name="test",
)
# No query params
params = template.matches("items://books")
assert params == {"category": "books"}
# One query param
params = template.matches("items://books?page=2")
assert params == {"category": "books", "page": "2"}
# Both query params
params = template.matches("items://books?page=2&limit=20")
assert params == {"category": "books", "page": "2", "limit": "20"}
class TestQueryParameterTypeCoercion:
"""Test type coercion for query parameters."""
async def test_int_coercion(self):
"""Test integer type coercion for query parameters."""
def get_page(resource: str, page: int = 1) -> dict:
return {"resource": resource, "page": page, "type": type(page).__name__}
template = ResourceTemplate.from_function(
fn=get_page,
uri_template="resource://{resource}{?page}",
name="test",
)
# Create resource with string query param
resource = await template.create_resource(
"resource://docs?page=5",
{"resource": "docs", "page": "5"},
)
content = await resource.read()
assert '"page":5' in content
assert '"type":"int"' in content
async def test_bool_coercion(self):
"""Test boolean type coercion for query parameters."""
def get_config(name: str, enabled: bool = False) -> dict:
return {"name": name, "enabled": enabled, "type": type(enabled).__name__}
template = ResourceTemplate.from_function(
fn=get_config,
uri_template="config://{name}{?enabled}",
name="test",
)
# Test true value
resource = await template.create_resource(
"config://feature?enabled=true",
{"name": "feature", "enabled": "true"},
)
content = await resource.read()
assert '"enabled":true' in content
# Test false value
resource = await template.create_resource(
"config://feature?enabled=false",
{"name": "feature", "enabled": "false"},
)
content = await resource.read()
assert '"enabled":false' in content
async def test_float_coercion(self):
"""Test float type coercion for query parameters."""
def get_metrics(service: str, threshold: float = 0.5) -> dict:
return {
"service": service,
"threshold": threshold,
"type": type(threshold).__name__,
}
template = ResourceTemplate.from_function(
fn=get_metrics,
uri_template="metrics://{service}{?threshold}",
name="test",
)
resource = await template.create_resource(
"metrics://api?threshold=0.95",
{"service": "api", "threshold": "0.95"},
)
content = await resource.read()
assert '"threshold":0.95' in content
assert '"type":"float"' in content
class TestQueryParameterValidation:
"""Test validation rules for query parameters."""
def test_query_params_must_be_optional(self):
"""Test that query parameters must have default values."""
def invalid_func(id: str, format: str) -> str:
return f"Data {id} in {format}"
with pytest.raises(
ValueError,
match="Query parameters .* must be optional function parameters with default values",
):
ResourceTemplate.from_function(
fn=invalid_func,
uri_template="data://{id}{?format}",
name="test",
)
def test_required_params_in_path(self):
"""Test that required parameters must be in path."""
def valid_func(id: str, format: str = "json") -> str:
return f"Data {id} in {format}"
# This should work - required param in path, optional in query
template = ResourceTemplate.from_function(
fn=valid_func,
uri_template="data://{id}{?format}",
name="test",
)
assert template.uri_template == "data://{id}{?format}"
class TestQueryParameterWithDefaults:
"""Test that missing query parameters use default values."""
async def test_missing_query_param_uses_default(self):
"""Test that missing query parameters fall back to defaults."""
def get_data(id: str, format: str = "json", verbose: bool = False) -> dict:
return {"id": id, "format": format, "verbose": verbose}
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format,verbose}",
name="test",
)
# No query params - should use defaults
resource = await template.create_resource(
"data://123",
{"id": "123"},
)
content = await resource.read()
assert '"format":"json"' in content
assert '"verbose":false' in content
async def test_partial_query_params(self):
"""Test providing only some query parameters."""
def get_data(
id: str, format: str = "json", limit: int = 10, offset: int = 0
) -> dict:
return {"id": id, "format": format, "limit": limit, "offset": offset}
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{id}{?format,limit,offset}",
name="test",
)
# Provide only some query params
resource = await template.create_resource(
"data://123?limit=20",
{"id": "123", "limit": "20"},
)
content = await resource.read()
assert '"format":"json"' in content # default
assert '"limit":20' in content # provided
assert '"offset":0' in content # default
class TestQueryParameterWithWildcards:
"""Test query parameters combined with wildcard path parameters."""
async def test_wildcard_with_query_params(self):
"""Test combining wildcard path params with query params."""
def get_file(path: str, encoding: str = "utf-8", lines: int = 100) -> dict:
return {"path": path, "encoding": encoding, "lines": lines}
template = ResourceTemplate.from_function(
fn=get_file,
uri_template="files://{path*}{?encoding,lines}",
name="test",
)
# Match path with query params
params = template.matches("files://src/test/data.txt?encoding=ascii&lines=50")
assert params == {
"path": "src/test/data.txt",
"encoding": "ascii",
"lines": "50",
}
# Create resource
resource = await template.create_resource(
"files://src/test/data.txt?lines=50",
{"path": "src/test/data.txt", "lines": "50"},
)
content = await resource.read()
assert '"path":"src/test/data.txt"' in content
assert '"encoding":"utf-8"' in content # default
assert '"lines":50' in content # provided

View file

@ -1748,7 +1748,7 @@ class TestResourceTemplates:
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI parameters",
match="Required function arguments .* must be a subset of the URI path parameters",
):
@mcp.resource("resource://{name}/data")
@ -1775,7 +1775,7 @@ class TestResourceTemplates:
with pytest.raises(
ValueError,
match="Required function arguments .* must be a subset of the URI parameters",
match="Required function arguments .* must be a subset of the URI path parameters",
):
@mcp.resource("resource://{org}/{repo}/data")
@ -1869,6 +1869,29 @@ class TestResourceTemplates:
result = await client.read_resource(AnyUrl("resource://test/data"))
assert result[0].text == "Template resource: test/data" # type: ignore[attr-defined]
async def test_template_with_query_params(self):
"""Test RFC 6570 query parameters in resource templates."""
mcp = FastMCP()
@mcp.resource("data://{id}{?format,limit}")
def get_data(id: str, format: str = "json", limit: int = 10) -> str:
return f"id={id}, format={format}, limit={limit}"
async with Client(mcp) as client:
# No query params - uses defaults
result = await client.read_resource(AnyUrl("data://123"))
assert result[0].text == "id=123, format=json, limit=10" # type: ignore[attr-defined]
# One query param
result = await client.read_resource(AnyUrl("data://123?format=xml"))
assert result[0].text == "id=123, format=xml, limit=10" # type: ignore[attr-defined]
# Multiple query params
result = await client.read_resource(
AnyUrl("data://123?format=csv&limit=50")
)
assert result[0].text == "id=123, format=csv, limit=50" # type: ignore[attr-defined]
async def test_templates_match_in_order_of_definition(self):
"""
If a wildcard template is defined first, it will take priority over another