diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index 7e3b5a27b..84a88f063 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -17,6 +17,7 @@ jobs: permissions: contents: read issues: write + id-token: write steps: - name: Checkout repository @@ -29,21 +30,22 @@ jobs: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - - name: Create dedupe prompt + - name: Set dedupe prompt + id: dedupe-prompt run: | - mkdir -p /tmp/claude-prompts - cat > /tmp/claude-prompts/dedupe-prompt.txt << 'EOF' + cat >> $GITHUB_OUTPUT << 'EOF' + PROMPT< /tmp/claude-prompts/triage-prompt.txt << 'EOF' + cat >> $GITHUB_OUTPUT << 'EOF' + PROMPT< list[Route]: """Get the routes for this authentication provider. @@ -93,30 +89,13 @@ class AuthProvider(TokenVerifierProtocol): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata, but the + provider does not create the actual MCP endpoint route. Returns: - List of routes for this provider, including protected MCP endpoints if provided + List of routes for this provider (excluding the MCP endpoint itself) """ - - routes = [] - - # Add protected MCP endpoint if provided - if mcp_path and mcp_endpoint: - resource_metadata_url = self._get_resource_url( - "/.well-known/oauth-protected-resource" - ) - - routes.append( - Route( - mcp_path, - endpoint=RequireAuthMiddleware( - mcp_endpoint, self.required_scopes, resource_metadata_url - ), - ) - ) - - return routes + return [] def get_middleware(self) -> list: """Get HTTP application-level middleware for this auth provider. @@ -225,14 +204,13 @@ class RemoteAuthProvider(AuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes for this provider. - Creates protected resource metadata routes and optionally wraps MCP endpoints with auth. + Creates protected resource metadata routes. """ - # Start with base routes (protected MCP endpoint) - routes = super().get_routes(mcp_path, mcp_endpoint) + # Start with base routes + routes = super().get_routes(mcp_path) # Get the resource URL based on the MCP path resource_url = self._get_resource_url(mcp_path) @@ -326,14 +304,12 @@ class OAuthProvider( def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth authorization server routes and optional protected resource routes. This method creates the full set of OAuth routes including: - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.) - Optional protected resource routes - - Protected MCP endpoints if provided Returns: List of OAuth routes @@ -366,7 +342,7 @@ class OAuthProvider( ) oauth_routes.extend(protected_routes) - # Add protected MCP endpoint from base class - oauth_routes.extend(super().get_routes(mcp_path, mcp_endpoint)) + # Add base routes + oauth_routes.extend(super().get_routes(mcp_path)) return oauth_routes diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 6bf340da8..6c373391e 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -857,7 +857,6 @@ class OAuthProxy(OAuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes with custom proxy token handler. @@ -866,10 +865,10 @@ class OAuthProxy(OAuthProvider): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata. """ # Get standard OAuth routes from parent class - routes = super().get_routes(mcp_path, mcp_endpoint) + routes = super().get_routes(mcp_path) custom_routes = [] token_route_found = False diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py index 1195cdfb9..03ae1d007 100644 --- a/src/fastmcp/server/auth/providers/descope.py +++ b/src/fastmcp/server/auth/providers/descope.py @@ -7,8 +7,6 @@ for seamless MCP client authentication. from __future__ import annotations -from typing import Any - import httpx from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict @@ -127,7 +125,6 @@ class DescopeProvider(RemoteAuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes including Descope authorization server metadata forwarding. @@ -136,10 +133,10 @@ class DescopeProvider(RemoteAuthProvider): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path, mcp_endpoint) + routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward Descope OAuth authorization server metadata with FastMCP customizations.""" diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index 8c6a0a79d..a0c568515 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -7,8 +7,6 @@ authentication for seamless MCP client authentication. from __future__ import annotations -from typing import Any - import httpx from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict @@ -135,7 +133,6 @@ class ScalekitProvider(RemoteAuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes including Scalekit authorization server metadata forwarding. @@ -144,10 +141,10 @@ class ScalekitProvider(RemoteAuthProvider): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path, mcp_endpoint) + routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward Scalekit OAuth authorization server metadata with FastMCP customizations.""" diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 298d5c82a..d69576c58 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -10,8 +10,6 @@ Choose based on your WorkOS setup and authentication requirements. from __future__ import annotations -from typing import Any - import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator @@ -364,7 +362,6 @@ class AuthKitProvider(RemoteAuthProvider): def get_routes( self, mcp_path: str | None = None, - mcp_endpoint: Any | None = None, ) -> list[Route]: """Get OAuth routes including AuthKit authorization server metadata forwarding. @@ -373,10 +370,10 @@ class AuthKitProvider(RemoteAuthProvider): Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - mcp_endpoint: The MCP endpoint handler to protect with auth + This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path, mcp_endpoint) + routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward AuthKit OAuth authorization server metadata with FastMCP customizations.""" diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 21271129a..a5e41daf6 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -167,16 +167,25 @@ def create_sse_app( # Get auth middleware from the provider auth_middleware = auth.get_middleware() - # Get auth routes including protected MCP endpoint - auth_routes = auth.get_routes( - mcp_path=sse_path, - mcp_endpoint=handle_sse, - ) - + # Get auth provider's own routes (OAuth endpoints, metadata, etc) + auth_routes = auth.get_routes(mcp_path=sse_path) server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) - # Manually wrap the SSE message endpoint with RequireAuthMiddleware + # Create protected SSE endpoint route with GET method only + server_routes.append( + Route( + sse_path, + endpoint=RequireAuthMiddleware( + handle_sse, + auth.required_scopes, + auth._get_resource_url("/.well-known/oauth-protected-resource"), + ), + methods=["GET"], + ) + ) + + # Wrap the SSE message endpoint with RequireAuthMiddleware server_routes.append( Mount( message_path, @@ -274,14 +283,22 @@ def create_streamable_http_app( # Get auth middleware from the provider auth_middleware = auth.get_middleware() - # Get auth routes including protected MCP endpoint - auth_routes = auth.get_routes( - mcp_path=streamable_http_path, - mcp_endpoint=streamable_http_app, - ) - + # Get auth provider's own routes (OAuth endpoints, metadata, etc) + auth_routes = auth.get_routes(mcp_path=streamable_http_path) server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) + + # Create protected HTTP endpoint route + server_routes.append( + Route( + streamable_http_path, + endpoint=RequireAuthMiddleware( + streamable_http_app, + auth.required_scopes, + auth._get_resource_url("/.well-known/oauth-protected-resource"), + ), + ) + ) else: # No auth required server_routes.append( diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py index 6f71cd3ac..a88965435 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py @@ -28,19 +28,19 @@ class UVEnvironment(Environment): examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], ) - requirements: str | None = Field( + requirements: Path | None = Field( default=None, description="Path to requirements.txt file", examples=["requirements.txt", "../requirements/prod.txt"], ) - project: str | None = Field( + project: Path | None = Field( default=None, description="Path to project directory containing pyproject.toml", examples=[".", "../my-project"], ) - editable: list[str] | None = Field( + editable: list[Path] | None = Field( default=None, description="Directories to install in editable mode", examples=[[".", "../my-package"], ["/path/to/package"]], @@ -64,7 +64,7 @@ class UVEnvironment(Environment): # Add project if specified if self.project: - args.extend(["--project", str(self.project)]) + args.extend(["--project", str(self.project.resolve())]) # Add Python version if specified (only if no project, as project has its own Python) if self.python and not self.project: @@ -78,12 +78,12 @@ class UVEnvironment(Environment): # Add requirements file if self.requirements: - args.extend(["--with-requirements", str(self.requirements)]) + args.extend(["--with-requirements", str(self.requirements.resolve())]) # Add editable packages if self.editable: for editable_path in self.editable: - args.extend(["--with-editable", str(editable_path)]) + args.extend(["--with-editable", str(editable_path.resolve())]) # Add the command args.extend(command) diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index c269d0eda..1345d7f59 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -291,9 +291,9 @@ class MCPServerConfig(BaseModel): environment = UVEnvironment( python=python, dependencies=dependencies, - requirements=requirements, - project=project, - editable=[editable] if editable else None, + requirements=Path(requirements) if requirements else None, + project=Path(project) if project else None, + editable=[Path(editable)] if editable else None, ) # Build deployment config if any deployment args provided diff --git a/src/fastmcp/utilities/mcp_server_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json index e027cf967..aa1f59ce4 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/schema.json +++ b/src/fastmcp/utilities/mcp_server_config/v1/schema.json @@ -250,6 +250,7 @@ "requirements": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -267,6 +268,7 @@ "project": { "anyOf": [ { + "format": "path", "type": "string" }, { @@ -285,6 +287,7 @@ "anyOf": [ { "items": { + "format": "path", "type": "string" }, "type": "array" diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 7865e45c5..1a5d307eb 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -66,9 +66,10 @@ class TestEnvironment: env = config.environment assert env.python == "3.12" assert env.dependencies == ["requests", "numpy>=2.0"] - assert env.requirements == "requirements.txt" - assert env.project == "." - assert env.editable == ["../my-package"] + # Paths are stored as Path objects + assert env.requirements == Path("requirements.txt") + assert env.project == Path(".") + assert env.editable == [Path("../my-package")] def test_needs_uv(self): """Test needs_uv() method.""" @@ -112,12 +113,16 @@ class TestEnvironment: assert "--python" not in cmd assert "3.12" not in cmd assert "--project" in cmd - assert "." in cmd + # Project path should be resolved to absolute path + project_idx = cmd.index("--project") + assert Path(cmd[project_idx + 1]).is_absolute() assert "--with" in cmd assert "requests" in cmd assert "numpy" in cmd assert "--with-requirements" in cmd - assert "requirements.txt" in cmd + # Requirements path should be resolved to absolute path + req_idx = cmd.index("--with-requirements") + assert Path(cmd[req_idx + 1]).is_absolute() # Command args should be at the end assert "fastmcp" in cmd[-3:] assert "run" in cmd[-2:] diff --git a/tests/cli/test_mcp_server_config_integration.py b/tests/cli/test_mcp_server_config_integration.py index 05a98687c..219532b53 100644 --- a/tests/cli/test_mcp_server_config_integration.py +++ b/tests/cli/test_mcp_server_config_integration.py @@ -234,10 +234,11 @@ class TestPathResolution: assert config.environment is not None uv_cmd = config.environment.build_command(["fastmcp", "run"]) - # Should include requirements file + # Should include requirements file with absolute path assert "--with-requirements" in uv_cmd req_idx = uv_cmd.index("--with-requirements") + 1 - assert uv_cmd[req_idx] == "requirements.txt" + assert Path(uv_cmd[req_idx]).is_absolute() + assert Path(uv_cmd[req_idx]).name == "requirements.txt" class TestConfigValidation: diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index a60b975ac..f2fe86605 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -130,18 +130,18 @@ class TestTimeout: async def test_timeout(self, sse_server: str): with pytest.raises( McpError, - match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds", + match="Timed out while waiting for response to ClientRequest. Waited 0.03 seconds", ): async with Client( transport=SSETransport(sse_server), - timeout=0.01, + timeout=0.03, ) as client: await client.call_tool("sleep", {"seconds": 0.1}) async def test_timeout_tool_call(self, sse_server: str): async with Client(transport=SSETransport(sse_server)) as client: with pytest.raises(McpError, match="Timed out"): - await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03) async def test_timeout_tool_call_overrides_client_timeout_if_lower( self, sse_server: str @@ -151,7 +151,7 @@ class TestTimeout: timeout=2, ) as client: with pytest.raises(McpError, match="Timed out"): - await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03) async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower( self, sse_server: str @@ -165,4 +165,4 @@ class TestTimeout: transport=SSETransport(sse_server), timeout=0.1, ) as client: - await client.call_tool("sleep", {"seconds": 0.01}, timeout=2) + await client.call_tool("sleep", {"seconds": 0.03}, timeout=2) diff --git a/tests/client/transports/test_uv_transport.py b/tests/client/transports/test_uv_transport.py index 45a2c3cfc..020f084ba 100644 --- a/tests/client/transports/test_uv_transport.py +++ b/tests/client/transports/test_uv_transport.py @@ -84,7 +84,7 @@ async def test_uv_transport_module(): with_packages=["fastmcp"], command="my_module", module=True, - project_directory=tmpdir, + project_directory=Path(tmpdir), keep_alive=False, ) ) diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index a14aab2f5..f463c2913 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -1,5 +1,7 @@ """Tests for CLI utility functions.""" +from pathlib import Path + from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment @@ -16,14 +18,14 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_editable(self): """Test building uv command with editable package.""" - editable_path = "/path/to/package" + editable_path = Path("/path/to/package") env = UVEnvironment(editable=[editable_path]) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--with-editable", - editable_path, + str(editable_path.resolve()), "fastmcp", "run", "server.py", @@ -64,14 +66,14 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_requirements(self): """Test building uv command with requirements file.""" - requirements_path = "/path/to/requirements.txt" + requirements_path = Path("/path/to/requirements.txt") env = UVEnvironment(requirements=requirements_path) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--with-requirements", - requirements_path, + str(requirements_path.resolve()), "fastmcp", "run", "server.py", @@ -80,14 +82,14 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_project(self): """Test building uv command with project directory.""" - project_path = "/path/to/project" + project_path = Path("/path/to/project") env = UVEnvironment(project=project_path) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--project", - project_path, + str(project_path.resolve()), "fastmcp", "run", "server.py", @@ -96,8 +98,8 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_with_everything(self): """Test building uv command with all options.""" - requirements_path = "/path/to/requirements.txt" - editable_path = "/local/pkg" + requirements_path = Path("/path/to/requirements.txt") + editable_path = Path("/local/pkg") env = UVEnvironment( python="3.10", dependencies=["pandas", "numpy"], @@ -115,9 +117,9 @@ class TestEnvironmentBuildUVRunCommand: "--with", "pandas", "--with-requirements", - requirements_path, + str(requirements_path.resolve()), "--with-editable", - editable_path, + str(editable_path.resolve()), "fastmcp", "run", "server.py", @@ -129,23 +131,24 @@ class TestEnvironmentBuildUVRunCommand: def test_build_uv_run_command_project_with_extras(self): """Test that project flag works with additional dependencies.""" - project_path = "/path/to/project" + project_path = Path("/path/to/project") + editable_path = Path("/pkg") env = UVEnvironment( project=project_path, python="3.10", # Should be ignored with project dependencies=["pandas"], # Should be added on top of project - editable=["/pkg"], # Should be added on top of project + editable=[editable_path], # Should be added on top of project ) cmd = env.build_command(["fastmcp", "run", "server.py"]) expected = [ "uv", "run", "--project", - project_path, + str(project_path.resolve()), "--with", "pandas", "--with-editable", - "/pkg", + str(editable_path.resolve()), "fastmcp", "run", "server.py", @@ -168,17 +171,17 @@ class TestEnvironmentNeedsUV: def test_needs_uv_with_requirements(self): """Test that needs_uv returns True with requirements.""" - env = UVEnvironment(requirements="/path/to/requirements.txt") + env = UVEnvironment(requirements=Path("/path/to/requirements.txt")) assert env._must_run_with_uv() is True def test_needs_uv_with_project(self): """Test that needs_uv returns True with project.""" - env = UVEnvironment(project="/path/to/project") + env = UVEnvironment(project=Path("/path/to/project")) assert env._must_run_with_uv() is True def test_needs_uv_with_editable(self): """Test that needs_uv returns True with editable.""" - env = UVEnvironment(editable=["/pkg"]) + env = UVEnvironment(editable=[Path("/pkg")]) assert env._must_run_with_uv() is True def test_needs_uv_empty(self):