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