From 35a8c32f61dc894c7c1d402028d179ad0595d307 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 30 Sep 2025 16:28:09 -0400
Subject: [PATCH 1/6] Add RFC 6570 query parameter support to resource
templates (#1971)
---
docs/servers/resources.mdx | 105 ++++++---
src/fastmcp/resources/template.py | 117 ++++++++--
tests/resources/test_resource_template.py | 253 +++++++++++++++++++++-
tests/server/test_server_interactions.py | 27 ++-
4 files changed, 451 insertions(+), 51 deletions(-)
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index 1da6b088d..60d03a6c4 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -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
-
-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.
-
-
-
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
-
+
-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""
+ 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
+
+
+
+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
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index da18c7045..3e249c3d6 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -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)
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 68178208c..16abb5c75 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -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
diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py
index a5644e0be..030cd4223 100644
--- a/tests/server/test_server_interactions.py
+++ b/tests/server/test_server_interactions.py
@@ -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
From f80378238167d79ddc91d911fdfb2251518a46b3 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 30 Sep 2025 16:38:51 -0400
Subject: [PATCH 2/6] Improve env vars for marvin (#1972)
---
.github/workflows/marvin-dedupe-issues.yml | 7 +++--
.github/workflows/marvin-label-triage.yml | 31 +++-------------------
.github/workflows/marvin.yml | 8 +++++-
3 files changed, 16 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml
index 84a88f063..3da916bf2 100644
--- a/.github/workflows/marvin-dedupe-issues.yml
+++ b/.github/workflows/marvin-dedupe-issues.yml
@@ -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 }}"
+ }
}
diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml
index 549bbe217..8d92702d4 100644
--- a/.github/workflows/marvin-label-triage.yml
+++ b/.github/workflows/marvin-label-triage.yml
@@ -24,7 +24,6 @@ jobs:
contents: read
issues: write
pull-requests: write
- id-token: write
steps:
- name: Checkout base repository
@@ -124,30 +123,6 @@ 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
uses: anthropics/claude-code-action@v1
with:
@@ -156,10 +131,12 @@ jobs:
prompt: ${{ steps.triage-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
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 }}"
+ }
}
diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml
index 8b16e5928..165edb7ad 100644
--- a/.github/workflows/marvin.yml
+++ b/.github/workflows/marvin.yml
@@ -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 }}"
+ }
+ }
From 3f8fbbff61c105656324a99712a6dacc2bb32b1e Mon Sep 17 00:00:00 2001
From: zzstoatzz
Date: Tue, 30 Sep 2025 20:40:23 -0500
Subject: [PATCH 3/6] =?UTF-8?q?cli:=20fix=20banner=20width=20by=20removing?=
=?UTF-8?q?=20emoji=20variation=20selectors=20(=F0=9F=96=A5=EF=B8=8F?=
=?UTF-8?q?=E2=86=92=F0=9F=96=A5,=20=F0=9F=8F=8E=EF=B8=8F=E2=86=92?=
=?UTF-8?q?=F0=9F=8F=8E)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
rm unnecessary diff
---
src/fastmcp/utilities/cli.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py
index 2ae540d89..63be0ec6c 100644
--- a/src/fastmcp/utilities/cli.py
+++ b/src/fastmcp/utilities/cli.py
@@ -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),
)
From ea81cfd08bf11252623a62b84e71c41f186eddd5 Mon Sep 17 00:00:00 2001
From: William Easton
Date: Wed, 1 Oct 2025 12:21:47 -0500
Subject: [PATCH 4/6] Add a new martian
---
.github/workflows/marvin-issue-triage.yml | 138 ++++++++++++++++++++++
1 file changed, 138 insertions(+)
create mode 100644 .github/workflows/marvin-issue-triage.yml
diff --git a/.github/workflows/marvin-issue-triage.yml b/.github/workflows/marvin-issue-triage.yml
new file mode 100644
index 000000000..4a3da4de3
--- /dev/null
+++ b/.github/workflows/marvin-issue-triage.yml
@@ -0,0 +1,138 @@
+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:
+ 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< /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 }}"
+ }
From ccc5dc8194bf088fe69c12e54ef4833f20b11a0d Mon Sep 17 00:00:00 2001
From: William Easton
Date: Wed, 1 Oct 2025 12:25:52 -0500
Subject: [PATCH 5/6] Limit the martian
---
.github/workflows/marvin-issue-triage.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/marvin-issue-triage.yml b/.github/workflows/marvin-issue-triage.yml
index 4a3da4de3..d1fa4e175 100644
--- a/.github/workflows/marvin-issue-triage.yml
+++ b/.github/workflows/marvin-issue-triage.yml
@@ -16,6 +16,7 @@ concurrency:
jobs:
marvin-issue-triage:
+ if: github.actor == 'strawgate'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
From 22e5a167147855ec88206a278ed833f2dacbe74a Mon Sep 17 00:00:00 2001
From: William Easton
Date: Wed, 1 Oct 2025 12:29:07 -0500
Subject: [PATCH 6/6] Fix marvin label triage for non-contributors
---
.github/workflows/marvin-label-triage.yml | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml
index 8d92702d4..64d91eada 100644
--- a/.github/workflows/marvin-label-triage.yml
+++ b/.github/workflows/marvin-label-triage.yml
@@ -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,7 +18,7 @@ concurrency:
cancel-in-progress: false
jobs:
- triage-issue:
+ label-issue-or-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -123,13 +124,14 @@ jobs:
PROMPT_END
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: |
--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