mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Merge branch 'main' into switch-kvstore
This commit is contained in:
commit
5480cb5ba6
9 changed files with 612 additions and 85 deletions
7
.github/workflows/marvin-dedupe-issues.yml
vendored
7
.github/workflows/marvin-dedupe-issues.yml
vendored
|
|
@ -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 }}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
.github/workflows/marvin-issue-triage.yml
vendored
Normal file
139
.github/workflows/marvin-issue-triage.yml
vendored
Normal 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 }}"
|
||||
}
|
||||
39
.github/workflows/marvin-label-triage.yml
vendored
39
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -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 }}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
.github/workflows/marvin.yml
vendored
8
.github/workflows/marvin.yml
vendored
|
|
@ -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 }}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue