diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 000000000..3321da678 --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,6 @@ +{ + "setup-worktree": [ + "uv sync", + "uv run pre-commit install" + ] +} diff --git a/README_OPENAPI.md b/README_OPENAPI.md deleted file mode 100644 index cb0d5f9c0..000000000 --- a/README_OPENAPI.md +++ /dev/null @@ -1,246 +0,0 @@ -# FastMCP OpenAPI Integration - -This document explains how FastMCP's OpenAPI integration works, what features are supported, and how to extend it. The OpenAPI functionality is split across two main files: - -- `server/openapi.py` - High-level FastMCP server implementation and MCP component creation -- `utilities/openapi.py` - Low-level OpenAPI parsing and intermediate representation - -## Architecture Overview - -``` -OpenAPI Spec → Parse → HTTPRoute IR → Create MCP Components → FastMCP Server -``` - -### 1. Parsing Phase (`utilities/openapi.py`) - -OpenAPI specifications are parsed into an intermediate representation (IR) that normalizes differences between OpenAPI 3.0 and 3.1: - -- **Input**: Raw OpenAPI spec (dict) -- **Output**: List of `HTTPRoute` objects with normalized parameter information -- **Key Classes**: - - `HTTPRoute` - Represents a single operation - - `ParameterInfo` - Represents a parameter with location, style, explode, etc. - - `RequestBodyInfo` - Represents request body information - - `ResponseInfo` - Represents response information - -### 2. Component Creation Phase (`server/openapi.py`) - -HTTPRoute objects are converted into FastMCP components based on route mapping rules: - -- **Tools** (`OpenAPITool`) - HTTP operations that can be called -- **Resources** (`OpenAPIResource`) - HTTP endpoints that return data -- **Resource Templates** (`OpenAPIResourceTemplate`) - Parameterized resources - -## Parameter Handling - -FastMCP supports various OpenAPI parameter serialization styles and formats: - -### Supported Parameter Locations -- `query` - Query string parameters -- `path` - Path parameters -- `header` - HTTP headers -- `cookie` - Cookie parameters (parsed but not used in requests) - -### Supported Parameter Styles - -#### Query Parameters -- **`form`** (default) - Standard query parameter format - - `explode=true` (default): `?tags=red&tags=blue` - - `explode=false`: `?tags=red,blue` -- **`deepObject`** - Object parameters with bracket notation - - `explode=true`: `?filter[name]=John&filter[age]=30` - - `explode=false`: Falls back to JSON string (non-standard, logs warning) - -#### Path Parameters -- **`simple`** (default) - Comma-separated for arrays: `/users/1,2,3` - -#### Header Parameters -- **`simple`** (default) - Standard header format - -### Parameter Type Support - -#### Arrays -- String arrays with `explode=true/false` -- Number arrays with `explode=true/false` -- Boolean arrays with `explode=true/false` -- Complex object arrays (basic support, may not handle all cases) - -#### Objects -- Objects with `deepObject` style and `explode=true` -- Objects with other styles fall back to JSON serialization - -#### Primitives -- Strings, numbers, booleans -- Enums -- Default values - -## Request Body Handling - -### Supported Content Types -- `application/json` - JSON request bodies - -### Schema Support -- Object schemas with properties -- Array schemas -- Primitive schemas -- Schema references (`$ref` to local schemas only) -- Required properties -- Default values - -## Response Handling - -### Content Type Detection -- `application/json` - Parsed as JSON -- `text/*` - Returned as text -- `application/xml` - Returned as text -- Other types - Returned as binary - -### Output Schema Generation -- Success response schemas (200, 201, 202, 204) -- Object response wrapping for MCP compliance -- Schema compression (removes unused `$defs`) - -## Route Mapping - -Routes are mapped to MCP component types using `RouteMap` configurations: - -```python -RouteMap( - methods=["GET", "POST"], # HTTP methods to match - pattern=r"/api/users/.*", # Regex pattern for path - mcp_type=MCPType.RESOURCE_TEMPLATE, # Target component type - tags={"user"}, # OpenAPI tags to match (AND condition) - mcp_tags={"fastmcp-user"} # Tags to add to created components -) -``` - -### Default Behavior -- All routes become **Tools** by default -- Use route maps to override specific patterns - -### Component Types -- `MCPType.TOOL` - Callable operations -- `MCPType.RESOURCE` - Static data endpoints -- `MCPType.RESOURCE_TEMPLATE` - Parameterized data endpoints -- `MCPType.EXCLUDE` - Skip route entirely - -## Known Limitations & Edge Cases - -### Parameter Edge Cases -1. **Parameter Name Collisions** - When path/query parameters have same names as request body properties, non-body parameters get `__location` suffixes -2. **Complex Array Serialization** - Limited support for arrays containing objects -3. **Cookie Parameters** - Parsed but not used in requests -4. **Non-standard Combinations** - e.g., `deepObject` with `explode=false` - -### Request Body Edge Cases -1. **Content Type Priority** - Only first available content type is used -2. **Nested Objects** - Deep nesting may not serialize correctly -3. **Binary Content** - No support for file uploads or binary data - -### Response Edge Cases -1. **Multiple Content Types** - Only JSON-compatible types are used for output schemas -2. **Error Responses** - Not used for MCP output schema generation -3. **Response Headers** - Not captured or exposed - -### Schema Edge Cases -1. **External References** - `$ref` to external files not supported -2. **Circular References** - May cause issues in schema processing -3. **Polymorphism** - `oneOf`/`anyOf`/`allOf` limited support - -## Debugging Tips - -### Common Issues -1. **"Unknown tool/resource"** - Check route mapping configuration -2. **Parameter not found** - Check for name collisions or incorrect style/explode -3. **Invalid request format** - Check parameter serialization and content types -4. **Schema validation errors** - Check for external refs or complex schemas - -### Debugging Tools -```python -# Parse routes to inspect intermediate representation -routes = parse_openapi_to_http_routes(openapi_spec) -for route in routes: - print(f"{route.method} {route.path}") - for param in route.parameters: - print(f" {param.name} ({param.location}): style={param.style}, explode={param.explode}") - -# Check component creation -server = FastMCP.from_openapi(openapi_spec, client) -tools = await server.get_tools() -print(f"Created {len(tools)} tools: {list(tools.keys())}") -``` - -### Logging -- Set `FASTMCP_LOG_LEVEL=DEBUG` to see detailed parameter processing -- Look for warnings about non-standard parameter combinations -- Check for schema parsing errors in logs - -## Extension Points - -### Adding New Parameter Styles -1. Add style handling in `utilities/openapi.py` - `ParameterInfo` class -2. Implement serialization logic in `server/openapi.py` - `OpenAPITool.run()` -3. Add tests for parsing and serialization - -### Adding New Content Types -1. Extend request body handling in `OpenAPITool.run()` -2. Add response parsing logic for new types -3. Update content type priority in utilities - -### Custom Route Mapping -Use `route_map_fn` for complex routing logic: - -```python -def custom_mapper(route: HTTPRoute, current_type: MCPType) -> MCPType: - if route.path.startswith("/admin"): - return MCPType.EXCLUDE - return current_type - -server = FastMCP.from_openapi(spec, client, route_map_fn=custom_mapper) -``` - -## Testing Patterns - -### Unit Tests -- Test parameter parsing with various styles/explode combinations -- Test route mapping with different patterns and tags -- Test schema generation and compression - -### Integration Tests -- Mock HTTP client to verify actual request parameters -- Test end-to-end component creation and execution -- Test error handling and edge cases - -### Example Test Pattern -```python -async def test_parameter_style(): - # 1. Create OpenAPI spec with specific parameter configuration - spec = {"openapi": "3.1.0", ...} - - # 2. Parse and create components - routes = parse_openapi_to_http_routes(spec) - tool = OpenAPITool(mock_client, routes[0], ...) - - # 3. Execute and verify request parameters - await tool.run({"param": "value"}) - actual_params = mock_client.request.call_args.kwargs["params"] - assert actual_params == expected_params -``` - -## Testing - -OpenAPI functionality is tested across multiple files in `tests/server/openapi/`: - -- `test_basic_functionality.py` - Core component creation and execution -- `test_explode_integration.py` - Parameter explode behavior -- `test_deepobject_style.py` - DeepObject style parameter encoding -- `test_parameter_collisions.py` - Parameter name collision handling -- `test_openapi_path_parameters.py` - Path parameter serialization -- `test_configuration.py` - Route mapping and MCP names -- `test_description_propagation.py` - Schema and description handling - -When adding new OpenAPI features, create focused test files rather than adding to existing monolithic files. - ---- - -*This document should be updated when new OpenAPI features are added or when edge cases are discovered and addressed.* \ No newline at end of file diff --git a/Windows_Notes.md b/Windows_Notes.md deleted file mode 100644 index f2f9445eb..000000000 --- a/Windows_Notes.md +++ /dev/null @@ -1,58 +0,0 @@ -# Getting your development environment set up properly -To get your environment up and running properly, you'll need a slightly different set of commands that are windows specific: -```bash -uv venv -.venv\Scripts\activate -uv pip install -e ".[dev]" -``` - -This will install the package in editable mode, and install the development dependencies. - - -# Fixing `AttributeError: module 'collections' has no attribute 'Callable'` -- open `.venv\Lib\site-packages\pyreadline\py3k_compat.py` -- change `return isinstance(x, collections.Callable)` to -``` -from collections.abc import Callable -return isinstance(x, Callable) -``` - -# Helpful notes -For developing FastMCP -## Install local development version of FastMCP into a local FastMCP project server -- ensure -- change directories to your FastMCP Server location so you can install it in your .venv -- run `.venv\Scripts\activate` to activate your virtual environment -- Then run a series of commands to uninstall the old version and install the new -```bash -# First uninstall -uv pip uninstall fastmcp - -# Clean any build artifacts in your fastmcp directory -cd C:\path\to\fastmcp -del /s /q *.egg-info - -# Then reinstall in your weather project -cd C:\path\to\new\fastmcp_server -uv pip install --no-cache-dir -e C:\Users\justj\PycharmProjects\fastmcp - -# Check that it installed properly and has the correct git hash -pip show fastmcp -``` - -## Running the FastMCP server with Inspector -MCP comes with a node.js application called Inspector that can be used to inspect the FastMCP server. To run the inspector, you'll need to install node.js and npm. Then you can run the following commands: -```bash -fastmcp dev server.py -``` -This will launch a web app on http://localhost:5173/ that you can use to inspect the FastMCP server. - -## If you start development before creating a fork - your get out of jail free card -- Add your fork as a new remote to your local repository `git remote add fork git@github.com:YOUR-USERNAME/REPOSITORY-NAME.git` - - This will add your repo, short named 'fork', as a remote to your local repository -- Verify that it was added correctly by running `git remote -v` -- Commit your changes -- Push your changes to your fork `git push fork ` -- Create your pull request on GitHub - - diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 2ceefe482..2d2595a86 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx" -This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. +This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id. ## Configuration @@ -49,8 +49,39 @@ Create an App registration in Azure Portal to get the credentials needed for aut If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider. + + - **Expose an API**: Configure your Application ID URI and define scopes + - Go to **Expose an API** in the App registration sidebar. + - Click **Set** next to "Application ID URI" and choose one of: + - Keep the default `api://{client_id}` + - Set a custom value, following the supported formats (see [Identifier URI restrictions](https://learn.microsoft.com/en-us/entra/identity-platform/identifier-uri-restrictions)) + - Click **Add a scope** and create a scope your app will require, for example: + - Scope name: `read` (or `write`, etc.) + - Admin consent display name/description: as appropriate for your org + - Who can consent: as needed (Admins only or Admins and users) + + - **Configure Access Token Version**: Ensure your app uses access token v2 + - Go to **Manifest** in the App registration sidebar. + - Find the `requestedAccessTokenVersion` property and set it to `2`: + ```json + "api": { + "requestedAccessTokenVersion": 2 + } + ``` + - Click **Save** at the top of the manifest editor. + + + Access token v2 is required for FastMCP's Azure integration to work correctly. If this is not set, you may encounter authentication errors. + + + + In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. + + + + After registration, navigate to **Certificates & secrets** in your app's settings. @@ -91,7 +122,11 @@ auth_provider = AzureProvider( client_secret="your-client-secret", # Your Azure App Client Secret tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) base_url="http://localhost:8000", # Must match your App registration - required_scopes=["User.Read", "email", "openid", "profile"], # Microsoft Graph permissions + required_scopes=["your-scope"], # Name of scope created when configuring your App + # identifier_uri defaults to api://{client_id} + # identifier_uri="api://your-api-id", + # Optional: request additional upstream scopes in the authorize request + # additional_authorize_scopes=["User.Read", "offline_access", "openid", "email"], # redirect_path="/auth/callback" # Default value, customize if needed ) @@ -215,12 +250,16 @@ Public URL of your FastMCP server for OAuth callbacks Redirect path configured in your Azure App registration - -Comma-, space-, or JSON-separated list of required Microsoft Graph scopes + +Comma-, space-, or JSON-separated list of required scopes for your API. These are validated on tokens and used as defaults if the client does not request specific scopes. - -HTTP request timeout for Microsoft Graph API calls + +Comma-, space-, or JSON-separated list of additional scopes to include in the authorization request without prefixing. Use this to request upstream scopes such as Microsoft Graph permissions. These are not used for token validation. + + + +Application ID URI used to prefix scopes during authorization. @@ -234,7 +273,11 @@ FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149 FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET=your-client-secret-here FASTMCP_SERVER_AUTH_AZURE_TENANT_ID=08541b6e-646d-43de-a0eb-834e6713d6d5 FASTMCP_SERVER_AUTH_AZURE_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=User.Read,email,profile +FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=read,write +# Optional custom API configuration +# FASTMCP_SERVER_AUTH_AZURE_IDENTIFIER_URI=api://your-api-id +# Request additional upstream scopes (optional) +# FASTMCP_SERVER_AUTH_AZURE_ADDITIONAL_AUTHORIZE_SCOPES=User.Read,Mail.Read ``` With environment variables set, your server code simplifies to: diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 66aeac978..f157e9c2d 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -116,7 +116,7 @@ def hello() -> str: You can run it with: ```bash -fastmcp run server.py:custom_name +fastmcp run server.py:my_server ``` #### Factory Function diff --git a/docs/patterns/tool-transformation.mdx b/docs/patterns/tool-transformation.mdx index 5f274fd75..730996d51 100644 --- a/docs/patterns/tool-transformation.mdx +++ b/docs/patterns/tool-transformation.mdx @@ -548,7 +548,7 @@ Provide your own schema that differs from the parent. The tool must return data **Remove Output Schema** ```python -Tool.from_tool(parent_tool, output_schema=False) +Tool.from_tool(parent_tool, output_schema=None) ``` Removes the output schema declaration. Automatic structured content still works for object-like returns (dict, dataclass, Pydantic models) but primitive types won't be structured. @@ -566,8 +566,139 @@ Use a transform function returning `ToolResult` for complete control over both c Tool transformation is a flexible feature that supports many powerful patterns. Here are a few common use cases to give you ideas. +### Exposing Client Methods as Tools + +A powerful use case for tool transformation is exposing methods from existing Python clients (GitHub clients, API clients, database clients, etc.) directly as MCP tools. This pattern eliminates boilerplate wrapper functions and treats tools as annotations around client methods. + +**Without Tool Transformation**, you typically create wrapper functions that duplicate annotations: + +```python +async def get_repository( + owner: Annotated[str, "The owner of the repository."], + repo: Annotated[str, "The name of the repository."], +) -> Repository: + """Get basic information about a GitHub repository.""" + return await github_client.get_repository(owner=owner, repo=repo) +``` + +**With Tool Transformation**, you can wrap the client method directly: + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool +from fastmcp.tools.tool_transform import ArgTransform + +mcp = FastMCP("GitHub Tools") + +# Wrap a client method directly as a tool +get_repo_tool = Tool.from_tool( + tool=Tool.from_function(fn=github_client.get_repository), + description="Get basic information about a GitHub repository.", + transform_args={ + "owner": ArgTransform(description="The owner of the repository."), + "repo": ArgTransform(description="The name of the repository."), + } +) + +mcp.add_tool(get_repo_tool) +``` + +This pattern keeps the implementation in your client and treats the tool as an annotation layer, avoiding duplicate code. + +#### Hiding Client-Specific Arguments + +Client methods often have internal parameters (debug flags, auth tokens, rate limit settings) that shouldn't be exposed to LLMs. Use `hide=True` with a default value to handle these automatically: + +```python +get_issues_tool = Tool.from_tool( + tool=Tool.from_function(fn=github_client.get_issues), + description="Get issues from a GitHub repository.", + transform_args={ + "owner": ArgTransform(description="The owner of the repository."), + "repo": ArgTransform(description="The name of the repository."), + "limit": ArgTransform(description="Maximum number of issues to return."), + # Hide internal parameters + "include_debug_info": ArgTransform(hide=True, default=False), + "error_on_not_found": ArgTransform(hide=True, default=True), + } +) + +mcp.add_tool(get_issues_tool) +``` + +The LLM only sees `owner`, `repo`, and `limit`. Internal parameters are supplied automatically. + +#### Reusable Argument Patterns + +When wrapping multiple client methods, you can define reusable argument transformations. This scales well for larger tool sets and keeps annotations consistent: + +```python +from fastmcp import FastMCP +from fastmcp.tools import Tool +from fastmcp.tools.tool_transform import ArgTransform + +mcp = FastMCP("GitHub Tools") + +# Define reusable argument patterns +OWNER_ARG = ArgTransform(description="The repository owner.") +REPO_ARG = ArgTransform(description="The repository name.") +LIMIT_ARG = ArgTransform(description="Maximum number of items to return.") +HIDE_ERROR = ArgTransform(hide=True, default=True) + +def create_github_tools(client): + """Create tools from GitHub client methods with shared argument patterns.""" + + owner_repo_args = { + "owner": OWNER_ARG, + "repo": REPO_ARG, + } + + error_args = { + "error_on_not_found": HIDE_ERROR, + } + + return [ + Tool.from_tool( + tool=Tool.from_function(fn=client.get_repository), + description="Get basic information about a GitHub repository.", + transform_args={**owner_repo_args, **error_args} + ), + Tool.from_tool( + tool=Tool.from_function(fn=client.get_issue), + description="Get a specific issue from a repository.", + transform_args={ + **owner_repo_args, + "issue_number": ArgTransform(description="The issue number."), + "limit_comments": LIMIT_ARG, + **error_args, + } + ), + Tool.from_tool( + tool=Tool.from_function(fn=client.get_pull_request), + description="Get a specific pull request from a repository.", + transform_args={ + **owner_repo_args, + "pull_request_number": ArgTransform(description="The PR number."), + "limit_comments": LIMIT_ARG, + **error_args, + } + ), + ] + +# Add all tools to the server +for tool in create_github_tools(github_client): + mcp.add_tool(tool) +``` + +This pattern provides several benefits: + +- **No duplicate implementation**: Logic stays in the client +- **Consistent annotations**: Reusable argument patterns ensure consistency +- **Easy maintenance**: Update the client, not wrapper functions +- **Scalable**: Easily add new tools by wrapping additional client methods + ### Adapting Remote or Generated Tools -This is one of the most common reasons to use tool transformation. Tools from remote servers (via a [proxy](/servers/proxy)) or generated from an [OpenAPI spec](/integrations/openapi) are often too generic for direct use by an LLM. You can use transformation to create a simpler, more intuitive version for your specific needs. +This is one of the most common reasons to use tool transformation. Tools from remote MCP servers (via a [proxy](/servers/proxy)) or generated from an [OpenAPI spec](/integrations/openapi) are often too generic for direct use by an LLM. You can use transformation to create a simpler, more intuitive version for your specific needs. ### Chaining Transformations You can chain transformations by using an already transformed tool as the parent for a new transformation. This lets you build up complex behaviors in layers, for example, first renaming arguments, and then adding validation logic to the renamed tool. diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index a5109ea0a..c06276e26 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -10,9 +10,9 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; -OAuth Proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, AWS, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like Descope and WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. +The OAuth proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, AWS, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like Descope and WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. -MCP clients expect to register automatically and obtain credentials on the fly, but traditional providers require manual app registration through their developer consoles. OAuth Proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider. When a client attempts to register, the proxy returns your fixed credentials. When a client initiates authorization, the proxy handles the complexity of callback forwarding—storing the client's dynamic callback URL, using its own fixed callback with the provider, then forwarding back to the client after token exchange. +MCP clients expect to register automatically and obtain credentials on the fly, but traditional providers require manual app registration through their developer consoles. The OAuth proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider. When a client attempts to register, the proxy returns your fixed credentials. When a client initiates authorization, the proxy handles the complexity of callback forwarding—storing the client's dynamic callback URL, using its own fixed callback with the provider, then forwarding back to the client after token exchange. This approach enables any MCP client (whether using random localhost ports or fixed URLs like Claude.ai) to authenticate with any traditional OAuth provider, all while maintaining full OAuth 2.1 and PKCE security. @@ -20,7 +20,7 @@ This approach enables any MCP client (whether using random localhost ports or fi For providers that support OIDC discovery (Auth0, Google with OIDC configuration, Azure AD), consider using [`OIDC Proxy`](/servers/auth/oidc-proxy) for automatic configuration. OIDC Proxy - extends OAuth Proxy to automatically discover endpoints from the provider's + extends the OAuth proxy to automatically discover endpoints from the provider's `/.well-known/openid-configuration` URL, simplifying setup. @@ -28,7 +28,7 @@ This approach enables any MCP client (whether using random localhost ports or fi ### Provider Setup Requirements -Before using OAuth Proxy, you need to register your application with your OAuth provider: +Before using the OAuth proxy, you need to register your application with your OAuth provider: 1. **Register your application** in the provider's developer console (GitHub Settings, Google Cloud Console, Azure Portal, etc.) 2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: @@ -41,12 +41,12 @@ Before using OAuth Proxy, you need to register your application with your OAuth The redirect URI you configure with your provider must exactly match your FastMCP server's URL plus the callback path. If you customize `redirect_path` - in OAuth Proxy, update your provider's redirect URI accordingly. + in the OAuth proxy, update your provider's redirect URI accordingly. ### Basic Setup -Here's how to implement OAuth Proxy with any provider: +Here's how to implement the OAuth proxy with any provider: ```python from fastmcp import FastMCP @@ -203,48 +203,6 @@ auth = OAuthProxy(..., client_storage=InMemoryStorage()) -### Provider-Specific Parameters - -Some OAuth providers require additional parameters beyond the standard OAuth2 flow. Use `extra_authorize_params` and `extra_token_params` to handle these requirements: - -#### Auth0 Example - -Auth0 requires an `audience` parameter to issue JWT tokens instead of opaque tokens: - -```python -auth = OAuthProxy( - upstream_authorization_endpoint="https://your-domain.auth0.com/authorize", - upstream_token_endpoint="https://your-domain.auth0.com/oauth/token", - upstream_client_id="your-auth0-client-id", - upstream_client_secret="your-auth0-client-secret", - - # Auth0 requires audience for JWT tokens - extra_authorize_params={ - "audience": "https://your-api-identifier.com" - }, - extra_token_params={ - "audience": "https://your-api-identifier.com" - }, - - token_verifier=JWTVerifier( - jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", - issuer="https://your-domain.auth0.com/", - audience="https://your-api-identifier.com" - ), - - base_url="https://your-server.com" -) -``` - -#### RFC 8707 Resource Indicators - -MCP clients can specify target resources using the standard `resource` parameter (RFC 8707). This is automatically forwarded when present: - -```python -# Client code (automatic - no server configuration needed) -# The resource parameter is passed through from AuthorizationParams -``` - ### Using Built-in Providers FastMCP includes pre-configured providers for common services: @@ -263,21 +221,57 @@ mcp = FastMCP(name="My Server", auth=auth) Available providers include `GitHubProvider`, `GoogleProvider`, and others. These handle token verification automatically. +### Token Verification + +The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from your provider. Different providers use different token formats: + +- **JWT tokens** (Google, Azure): Use `JWTVerifier` with the provider's JWKS endpoint +- **Opaque tokens** (GitHub, Discord): Use provider-specific verifiers or implement custom validation + +See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider. + ### Scope Configuration -OAuth scopes are configured through your `TokenVerifier`. Set `required_scopes` to automatically request the permissions your application needs: +OAuth scopes control what permissions your application requests from users. They're configured through your `TokenVerifier` (required for the OAuth proxy to validate tokens from your provider). Set `required_scopes` to automatically request the permissions your application needs: ```python JWTVerifier(..., required_scopes = ["read:user", "write:data"]) ``` -Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. +Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. See the [Token Verification](#token-verification) section below for detailed setup. -## How It Works +### Custom Parameters + +Some OAuth providers require additional parameters beyond the standard OAuth2 flow. Use `extra_authorize_params` and `extra_token_params` to pass provider-specific requirements. For example, Auth0 requires an `audience` parameter to issue JWT tokens instead of opaque tokens: + +```python +auth = OAuthProxy( + upstream_authorization_endpoint="https://your-domain.auth0.com/authorize", + upstream_token_endpoint="https://your-domain.auth0.com/oauth/token", + upstream_client_id="your-auth0-client-id", + upstream_client_secret="your-auth0-client-secret", + + # Auth0-specific audience parameter + extra_authorize_params={"audience": "https://your-api-identifier.com"}, + extra_token_params={"audience": "https://your-api-identifier.com"}, + + token_verifier=JWTVerifier( + jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", + issuer="https://your-domain.auth0.com/", + audience="https://your-api-identifier.com" + ), + base_url="https://your-server.com" +) +``` + +The proxy also automatically forwards RFC 8707 `resource` parameters from MCP clients to upstream providers that support them. + +## OAuth Flow ```mermaid sequenceDiagram participant Client as MCP Client
(localhost:random) + participant User as User participant Proxy as FastMCP OAuth Proxy
(server:8000) participant Provider as OAuth Provider
(GitHub, etc.) @@ -285,25 +279,27 @@ sequenceDiagram Client->>Proxy: 1. POST /register
redirect_uri: localhost:54321/callback Proxy-->>Client: 2. Returns fixed upstream credentials - Note over Client, Proxy: Authorization with PKCE & Callback Forwarding + Note over Client, User: Authorization with User Consent Client->>Proxy: 3. GET /authorize
redirect_uri=localhost:54321/callback
code_challenge=CLIENT_CHALLENGE Note over Proxy: Store transaction with client PKCE
Generate proxy PKCE pair - Proxy->>Provider: 4. Redirect to provider
redirect_uri=server:8000/auth/callback
code_challenge=PROXY_CHALLENGE + Proxy->>User: 4. Show consent page
(client details, redirect URI, scopes) + User->>Proxy: 5. Approve/deny consent + Proxy->>Provider: 6. Redirect to provider
redirect_uri=server:8000/auth/callback
code_challenge=PROXY_CHALLENGE Note over Provider, Proxy: Provider Callback - Provider->>Proxy: 5. GET /auth/callback
with authorization code - Proxy->>Provider: 6. Exchange code for tokens
code_verifier=PROXY_VERIFIER - Provider-->>Proxy: 7. Access & refresh tokens + Provider->>Proxy: 7. GET /auth/callback
with authorization code + Proxy->>Provider: 8. Exchange code for tokens
code_verifier=PROXY_VERIFIER + Provider-->>Proxy: 9. Access & refresh tokens Note over Proxy, Client: Client Callback Forwarding - Proxy->>Client: 8. Redirect to localhost:54321/callback
with new authorization code + Proxy->>Client: 10. Redirect to localhost:54321/callback
with new authorization code Note over Client, Proxy: Token Exchange - Client->>Proxy: 9. POST /token with code
code_verifier=CLIENT_VERIFIER - Proxy-->>Client: 10. Returns stored provider tokens + Client->>Proxy: 11. POST /token with code
code_verifier=CLIENT_VERIFIER + Proxy-->>Client: 12. Returns stored provider tokens ``` -The flow diagram above illustrates the complete OAuth Proxy pattern. Let's understand each phase: +The flow diagram above illustrates the complete OAuth proxy pattern. Let's understand each phase: ### Registration Phase @@ -315,9 +311,10 @@ The client initiates OAuth by redirecting to the proxy's `/authorize` endpoint. 1. Stores the client's transaction with its PKCE challenge 2. Generates its own PKCE parameters for upstream security -3. Redirects to the upstream provider using the fixed callback URL +3. Shows the user a consent page with the client's details, redirect URI, and requested scopes +4. If the user approves (or the client was previously approved), redirects to the upstream provider using the fixed callback URL -This dual-PKCE approach maintains end-to-end security at both the client-to-proxy and proxy-to-provider layers. +This dual-PKCE approach maintains end-to-end security at both the client-to-proxy and proxy-to-provider layers. The consent step protects against confused deputy attacks by ensuring you explicitly approve each client before it can complete authorization. ### Callback Phase @@ -336,7 +333,7 @@ This entire flow is transparent to the MCP client—it experiences a standard OA ### PKCE Forwarding -OAuth Proxy automatically handles PKCE (Proof Key for Code Exchange) when working with providers that support or require it. The proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE, ensuring end-to-end security at both layers. +The OAuth proxy automatically handles PKCE (Proof Key for Code Exchange) when working with providers that support or require it. The proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE, ensuring end-to-end security at both layers. This is enabled by default via the `forward_pkce` parameter and works seamlessly with providers like Google, Azure AD, and GitHub. Only disable it for legacy providers that don't support PKCE: @@ -350,7 +347,7 @@ auth = OAuthProxy( ### Redirect URI Validation -While OAuth Proxy accepts all redirect URIs by default (for DCR compatibility), you can restrict which clients can connect by specifying allowed patterns: +While the OAuth proxy accepts all redirect URIs by default (for DCR compatibility), you can restrict which clients can connect by specifying allowed patterns: ```python # Allow only localhost clients (common for development) @@ -375,20 +372,29 @@ auth = OAuthProxy( Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. -## Token Verification +## Security -OAuth Proxy requires a compatible `TokenVerifier` to validate tokens from your provider. Different providers use different token formats: +### Confused Deputy Attacks -- **JWT tokens** (Google, Azure): Use `JWTVerifier` with the provider's JWKS endpoint -- **Opaque tokens** (GitHub, Discord): Use provider-specific verifiers or implement custom validation + -See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider. +A confused deputy attack allows a malicious client to steal your authorization by tricking you into granting it access under your identity. + +The OAuth proxy works by bridging DCR clients to traditional auth providers, which means that multiple MCP clients connect through a single upstream OAuth application. An attacker can exploit this shared application by registering a malicious client with their own redirect URI, then sending you an authorization link. When you click it, your browser goes through the OAuth flow—but since you may have already authorized this OAuth app before, the provider might auto-approve the request. The authorization code then gets sent to the attacker's redirect URI instead of a legitimate client, giving them access under your credentials. + +#### Mitigation + +FastMCP's OAuth proxy requires you to explicitly consent whenever any new or unrecognized client attempts to connect to your server. Before any authorization happens, you see a consent page showing the client's details, redirect URI, and requested scopes. This gives you the opportunity to review and deny suspicious requests. Once you approve a client, it's remembered so you don't see the consent page again for that client. The consent mechanism is implemented with CSRF tokens and cryptographically signed cookies to prevent tampering. + +**Learn more:** +- [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem) - Official specification guidance +- [Confused Deputy Attacks Explained](https://den.dev/blog/mcp-confused-deputy-api-management/) - Detailed walkthrough by Den Delimarsky ## Environment Configuration -For production deployments, configure OAuth Proxy through environment variables instead of hardcoding credentials: +For production deployments, configure the OAuth proxy through environment variables instead of hardcoding credentials: ```bash # Specify the provider implementation diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index e08d0c358..22f5e2ee8 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -10,15 +10,15 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; -OIDC Proxy enables FastMCP servers to authenticate with OIDC providers that **don't support Dynamic Client Registration (DCR)** out of the box. This includes OAuth providers like: Auth0, Google, Azure, AWS, etc. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. +The OIDC proxy enables FastMCP servers to authenticate with OIDC providers that **don't support Dynamic Client Registration (DCR)** out of the box. This includes OAuth providers like: Auth0, Google, Azure, AWS, etc. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. -The OIDC Proxy is built upon [`OAuthProxy`](/servers/auth/oauth-proxy) so it has all the same functionality under the covers. +The OIDC proxy is built upon [`OAuthProxy`](/servers/auth/oauth-proxy) so it has all the same functionality under the covers. ## Implementation ### Provider Setup Requirements -Before using OIDC Proxy, you need to register your application with your OAuth provider: +Before using the OIDC proxy, you need to register your application with your OAuth provider: 1. **Register your application** in the provider's developer console (Auth0 Applications, Google Cloud Console, Azure Portal, etc.) 2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: @@ -30,12 +30,12 @@ Before using OIDC Proxy, you need to register your application with your OAuth p The redirect URI you configure with your provider must exactly match your FastMCP server's URL plus the callback path. If you customize `redirect_path` - in OAuth Proxy, update your provider's redirect URI accordingly. + in the OIDC proxy, update your provider's redirect URI accordingly. ### Basic Setup -Here's how to implement OIDC Proxy with any provider: +Here's how to implement the OIDC proxy with any provider: ```python from fastmcp import FastMCP @@ -172,7 +172,7 @@ Dynamic clients created by the proxy will automatically include these scopes in -For production deployments, configure OIDC Proxy through environment variables instead of hardcoding credentials: +For production deployments, configure the OIDC proxy through environment variables instead of hardcoding credentials: ```bash # Specify the provider implementation diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 370f072de..4ca1895ce 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -41,6 +41,8 @@ FastMCP supports [MCP proxying](/servers/proxy), which allows you to mirror a lo You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time. +Prefixing rules for tools, prompts, resources, and templates are identical across importing, mounting, and proxies. + ## Importing (Static Composition) The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). An optional `prefix` can be provided to avoid naming conflicts. If no prefix is provided, components are imported without modification. When multiple servers are imported with the same prefix (or no prefix), the most recently imported server's components take precedence. @@ -261,6 +263,64 @@ main_server.mount(remote_proxy, prefix="remote") +## Tag Filtering with Composition + + + +When using `include_tags` or `exclude_tags` on a parent server, these filters apply **recursively** to all components, including those from mounted or imported servers. This allows you to control which components are exposed at the parent level, regardless of how your application is composed. + +```python +import asyncio +from fastmcp import FastMCP, Client + +# Create a subserver with tools tagged for different environments +api_server = FastMCP(name="APIServer") + +@api_server.tool(tags={"production"}) +def prod_endpoint() -> str: + """Production-ready endpoint.""" + return "Production data" + +@api_server.tool(tags={"development"}) +def dev_endpoint() -> str: + """Development-only endpoint.""" + return "Debug data" + +# Mount the subserver with production tag filtering at parent level +prod_app = FastMCP(name="ProductionApp", include_tags={"production"}) +prod_app.mount(api_server, prefix="api") + +# Test the filtering +async def test_filtering(): + async with Client(prod_app) as client: + tools = await client.list_tools() + print("Available tools:", [t.name for t in tools]) + # Shows: ['api_prod_endpoint'] + # The 'api_dev_endpoint' is filtered out + + # Calling the filtered tool raises an error + try: + await client.call_tool("api_dev_endpoint") + except Exception as e: + print(f"Filtered tool not accessible: {e}") + +if __name__ == "__main__": + asyncio.run(test_filtering()) +``` + +### How Recursive Filtering Works + +Tag filters apply in the following order: + +1. **Child Server Filters**: Each mounted/imported server first applies its own `include_tags`/`exclude_tags` to its components. +2. **Parent Server Filters**: The parent server then applies its own `include_tags`/`exclude_tags` to all components, including those from child servers. + +This ensures that parent server tag policies act as a global policy for everything the parent server exposes, no matter how your application is composed. + + +This filtering applies to both **listing** (e.g., `list_tools()`) and **execution** (e.g., `call_tool()`). Filtered components are neither visible nor executable through the parent server. + + ## Resource Prefix Formats diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 72b213788..90a935edf 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -89,10 +89,12 @@ Note that the MCP SDK may perform additional operations like listing tools for c This hierarchy allows you to target your middleware logic with the right level of specificity. Use `on_message` for broad concerns like logging, `on_request` for authentication, and `on_call_tool` for tool-specific logic like performance monitoring. ### Available Hooks + - `on_message`: Called for all MCP messages (requests and notifications) - `on_request`: Called specifically for MCP requests (that expect responses) - `on_notification`: Called specifically for MCP notifications (fire-and-forget) + - `on_call_tool`: Called when tools are being executed - `on_read_resource`: Called when resources are being read - `on_get_prompt`: Called when prompts are being retrieved @@ -100,6 +102,11 @@ This hierarchy allows you to target your middleware logic with the right level o - `on_list_resources`: Called when listing available resources - `on_list_resource_templates`: Called when listing resource templates - `on_list_prompts`: Called when listing available prompts + +- `on_initialize`: Called when a client connects and initializes the session (returns `None`) + +The `on_initialize` hook receives the client's initialization request but **returns `None`** rather than a result. The initialization response is handled internally by the MCP protocol and cannot be modified by middleware. This hook is useful for client detection, logging connections, or initializing session state, but not for modifying the initialization handshake itself. + ## Component Access in Middleware diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index c57ed571b..24ebcfa29 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -81,6 +81,10 @@ def data_analysis_prompt( Sets the explicit prompt name exposed via MCP. If not provided, uses the function name
+ + A human-readable title for the prompt + + Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose @@ -340,4 +344,4 @@ The duplicate behavior options are: - `"warn"` (default): Logs a warning, and the new prompt replaces the old one. - `"error"`: Raises a `ValueError`, preventing the duplicate registration. - `"replace"`: Silently replaces the existing prompt with the new one. -- `"ignore"`: Keeps the original prompt and ignores the new registration attempt. \ No newline at end of file +- `"ignore"`: Keeps the original prompt and ignores the new registration attempt. diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index 82daccb5d..7cbb6d90c 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -245,11 +245,29 @@ config = { # Create a unified proxy to multiple servers composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy") -# Tools and resources are accessible with prefixes: -# - weather_get_forecast, calendar_add_event -# - weather://weather/icons/sunny, calendar://calendar/events/today +# Tools, resources, prompts, and templates are accessible with prefixes: +# - Tools: weather_get_forecast, calendar_add_event +# - Prompts: weather_daily_summary, calendar_quick_add +# - Resources: weather://weather/icons/sunny, calendar://calendar/events/today +# - Templates: weather://weather/locations/{id}, calendar://calendar/events/{date} ``` +## Component Prefixing + +When proxying one or more servers, component names are prefixed the same way as with mounting and importing: + +- Tools: `{prefix}_{tool_name}` +- Prompts: `{prefix}_{prompt_name}` +- Resources: `protocol://{prefix}/path/to/resource` (default path format) +- Resource templates: `protocol://{prefix}/...` and template names are also prefixed + +These rules apply uniformly whether you: +- Mount a proxy on another server +- Create a multi-server proxy from an `MCPConfig` +- Use `FastMCP.as_proxy()` directly + +For resource URI prefix formats (path vs legacy protocol style) and configuration options, see Server Composition → Resource Prefix Formats. + ## Mirrored Components @@ -332,4 +350,3 @@ def custom_client_factory(): proxy = FastMCPProxy(client_factory=custom_client_factory) ``` - diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index cb27d80ac..1fc903659 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -344,6 +344,7 @@ Common global settings include: - **`mask_error_details`**: Whether to hide detailed error information from clients, set with `FASTMCP_MASK_ERROR_DETAILS` - **`resource_prefix_format`**: How to format resource prefixes ("path" or "protocol"), set with `FASTMCP_RESOURCE_PREFIX_FORMAT` - **`include_fastmcp_meta`**: Whether to include FastMCP metadata in component responses (default: True), set with `FASTMCP_INCLUDE_FASTMCP_META` +- **`env_file`**: Path to the environment file to load settings from (default: ".env"), set with `FASTMCP_ENV_FILE`. Useful when your project uses a `.env` file with syntax incompatible with python-dotenv ### Transport-Specific Configuration diff --git a/examples/mount_example.py b/examples/mount_example.py index 761689783..5e42b67a1 100644 --- a/examples/mount_example.py +++ b/examples/mount_example.py @@ -104,7 +104,7 @@ async def get_server_details(): print(f" - Imported from news app: {news_resources}") # Let's try to access resources using the prefixed URI - weather_data = await app._mcp_read_resource(uri="weather://weather/forecast") + weather_data = await app._read_resource_mcp(uri="weather://weather/forecast") print(f"\nWeather data from prefixed URI: {weather_data}") diff --git a/examples/serializer.py b/examples/serializer.py index 4ee5ca17a..ffd8ee117 100644 --- a/examples/serializer.py +++ b/examples/serializer.py @@ -21,7 +21,7 @@ def get_example_data() -> dict: async def example_usage(): - result = await server._mcp_call_tool("get_example_data", {}) + result = await server._call_tool_mcp("get_example_data", {}) print("Tool Result:") print(result) print("This is an example of using a custom serializer with FastMCP.") diff --git a/pyproject.toml b/pyproject.toml index f643b353b..00e4b7d09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", + "py-key-value-aio[disk,memory]>=0.2.1", "websockets>=15.0.1", ] @@ -64,6 +65,7 @@ dev = [ "pytest-flakefinder", "pytest-httpx>=0.35.0", "pytest-report>=0.2.1", + "pytest-retry>=1.7.0", "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff", diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 86c83b7ab..0e088953d 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -1,34 +1,33 @@ from __future__ import annotations import asyncio +import time import webbrowser from asyncio import Future from collections.abc import AsyncGenerator -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Literal +from typing import Any from urllib.parse import urlparse import anyio import httpx +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, + OAuthToken, ) -from mcp.shared.auth import ( - OAuthToken as OAuthToken, -) -from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError +from pydantic import AnyHttpUrl +from typing_extensions import override from uvicorn.server import Server -from fastmcp import settings as fastmcp_global_settings from fastmcp.client.oauth_callback import ( create_oauth_callback_server, ) from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import JSONFileStorage __all__ = ["OAuth"] @@ -41,174 +40,6 @@ class ClientNotFoundError(Exception): pass -class StoredToken(BaseModel): - """Token storage format with absolute expiry time.""" - - token_payload: OAuthToken - expires_at: datetime | None - - -# Create TypeAdapter at module level for efficient parsing -stored_token_adapter = TypeAdapter(StoredToken) - - -def default_cache_dir() -> Path: - return fastmcp_global_settings.home / "oauth-mcp-client-cache" - - -class FileTokenStorage(TokenStorage): - """ - File-based token storage implementation for OAuth credentials and tokens. - Implements the mcp.client.auth.TokenStorage protocol. - - Each instance is tied to a specific server URL for proper token isolation. - Uses JSONFileStorage internally for consistent file handling. - """ - - def __init__(self, server_url: str, cache_dir: Path | None = None): - """Initialize storage for a specific server URL.""" - self.server_url = server_url - # Use JSONFileStorage for actual file operations - self._storage = JSONFileStorage(cache_dir or default_cache_dir()) - - @staticmethod - def get_base_url(url: str) -> str: - """Extract the base URL (scheme + host) from a URL.""" - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}" - - def _get_storage_key(self, file_type: Literal["client_info", "tokens"]) -> str: - """Get the storage key for the specified data type. - - JSONFileStorage will handle making the key filesystem-safe. - """ - base_url = self.get_base_url(self.server_url) - return f"{base_url}_{file_type}" - - def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path: - """Get the file path for the specified cache file type. - - This method is kept for backward compatibility with tests that access _get_file_path. - """ - key = self._get_storage_key(file_type) - return self._storage._get_file_path(key) - - async def get_tokens(self) -> OAuthToken | None: - """Load tokens from file storage.""" - key = self._get_storage_key("tokens") - data = await self._storage.get(key) - - if data is None: - return None - - try: - # Parse and validate as StoredToken - stored = stored_token_adapter.validate_python(data) - - # Check if token is expired - if stored.expires_at is not None: - now = datetime.now(timezone.utc) - if now >= stored.expires_at: - logger.debug( - f"Token expired for {self.get_base_url(self.server_url)}" - ) - return None - - # Recalculate expires_in to be correct relative to now - if stored.token_payload.expires_in is not None: - remaining = stored.expires_at - now - stored.token_payload.expires_in = max( - 0, int(remaining.total_seconds()) - ) - - return stored.token_payload - - except ValidationError as e: - logger.debug( - f"Could not validate tokens for {self.get_base_url(self.server_url)}: {e}" - ) - return None - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Save tokens to file storage.""" - key = self._get_storage_key("tokens") - - # Calculate absolute expiry time if expires_in is present - expires_at = None - if tokens.expires_in is not None: - expires_at = datetime.now(timezone.utc) + timedelta( - seconds=tokens.expires_in - ) - - # Create StoredToken and save using storage - # Note: JSONFileStorage will wrap this in {"data": ..., "timestamp": ...} - stored = StoredToken(token_payload=tokens, expires_at=expires_at) - await self._storage.set(key, stored.model_dump(mode="json")) - logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}") - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Load client information from file storage.""" - key = self._get_storage_key("client_info") - data = await self._storage.get(key) - - if data is None: - return None - - try: - client_info = OAuthClientInformationFull.model_validate(data) - # Check if we have corresponding valid tokens - # If no tokens exist, the OAuth flow was incomplete and we should - # force a fresh client registration - tokens = await self.get_tokens() - if tokens is None: - logger.debug( - f"No tokens found for client info at {self.get_base_url(self.server_url)}. " - "OAuth flow may have been incomplete. Clearing client info to force fresh registration." - ) - # Clear the incomplete client info - await self._storage.delete(key) - return None - - return client_info - except ValidationError as e: - logger.debug( - f"Could not validate client info for {self.get_base_url(self.server_url)}: {e}" - ) - return None - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Save client information to file storage.""" - key = self._get_storage_key("client_info") - await self._storage.set(key, client_info.model_dump(mode="json")) - logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}") - - def clear(self) -> None: - """Clear all cached data for this server. - - Note: This is a synchronous method for backward compatibility. - Uses direct file operations instead of async storage methods. - """ - file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"] - for file_type in file_types: - # Use the file path directly for synchronous deletion - path = self._get_file_path(file_type) - path.unlink(missing_ok=True) - logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}") - - @classmethod - def clear_all(cls, cache_dir: Path | None = None) -> None: - """Clear all cached data for all servers.""" - cache_dir = cache_dir or default_cache_dir() - if not cache_dir.exists(): - return - - file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"] - for file_type in file_types: - for file in cache_dir.glob(f"*_{file_type}.json"): - file.unlink(missing_ok=True) - logger.info("Cleared all OAuth client cache data.") - - async def check_if_auth_required( mcp_url: str, httpx_kwargs: dict[str, Any] | None = None ) -> bool: @@ -239,6 +70,70 @@ async def check_if_auth_required( return True +class TokenStorageAdapter(TokenStorage): + _server_url: str + _key_value_store: AsyncKeyValue + _storage_oauth_token: PydanticAdapter[OAuthToken] + _storage_client_info: PydanticAdapter[OAuthClientInformationFull] + + def __init__(self, async_key_value: AsyncKeyValue, server_url: str): + self._server_url = server_url + self._key_value_store = async_key_value + self._storage_oauth_token = PydanticAdapter[OAuthToken]( + default_collection="mcp-oauth-token", + key_value=async_key_value, + pydantic_model=OAuthToken, + raise_on_validation_error=True, + ) + self._storage_client_info = PydanticAdapter[OAuthClientInformationFull]( + default_collection="mcp-oauth-client-info", + key_value=async_key_value, + pydantic_model=OAuthClientInformationFull, + raise_on_validation_error=True, + ) + + def _get_token_cache_key(self) -> str: + return f"{self._server_url}/tokens" + + def _get_client_info_cache_key(self) -> str: + return f"{self._server_url}/client_info" + + async def clear(self) -> None: + await self._storage_oauth_token.delete(key=self._get_token_cache_key()) + await self._storage_client_info.delete(key=self._get_client_info_cache_key()) + + @override + async def get_tokens(self) -> OAuthToken | None: + return await self._storage_oauth_token.get(key=self._get_token_cache_key()) + + @override + async def set_tokens(self, tokens: OAuthToken) -> None: + await self._storage_oauth_token.put( + key=self._get_token_cache_key(), + value=tokens, + ttl=tokens.expires_in, + ) + + @override + async def get_client_info(self) -> OAuthClientInformationFull | None: + return await self._storage_client_info.get( + key=self._get_client_info_cache_key() + ) + + @override + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + ttl: int | None = None + + if client_info.client_secret_expires_at: + ttl = client_info.client_secret_expires_at - int(time.time()) + + await self._storage_client_info.put( + key=self._get_client_info_cache_key(), + value=client_info, + ttl=ttl, + ) + + class OAuth(OAuthClientProvider): """ OAuth client provider for MCP servers with browser-based authentication. @@ -252,7 +147,7 @@ class OAuth(OAuthClientProvider): mcp_url: str, scopes: str | list[str] | None = None, client_name: str = "FastMCP Client", - token_storage_cache_dir: Path | None = None, + token_storage: AsyncKeyValue | None = None, additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, ): @@ -264,7 +159,7 @@ class OAuth(OAuthClientProvider): scopes: OAuth scopes to request. Can be a space-separated string or a list of strings. client_name: Name for this client during registration - token_storage_cache_dir: Directory for FileTokenStorage + token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided additional_client_metadata: Extra fields for OAuthClientMetadata callback_port: Fixed port for OAuth callback (default: random available port) """ @@ -294,8 +189,10 @@ class OAuth(OAuthClientProvider): ) # Create server-specific token storage - storage = FileTokenStorage( - server_url=server_base_url, cache_dir=token_storage_cache_dir + token_storage = token_storage or MemoryStore() + + self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( + async_key_value=token_storage, server_url=server_base_url ) # Store server_base_url for use in callback_handler @@ -305,7 +202,7 @@ class OAuth(OAuthClientProvider): super().__init__( server_url=server_base_url, client_metadata=client_metadata, - storage=storage, + storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, ) @@ -399,23 +296,7 @@ class OAuth(OAuthClientProvider): # Clear cached state and retry once self._initialized = False - - # Try to clear storage if it supports it - if hasattr(self.context.storage, "clear"): - try: - self.context.storage.clear() - except Exception as e: - logger.warning(f"Failed to clear OAuth storage cache: {e}") - # Can't retry without clearing cache, re-raise original error - raise ClientNotFoundError( - "OAuth client not found and cache could not be cleared" - ) from e - else: - logger.warning( - "Storage does not support clear() - cannot retry with fresh credentials" - ) - # Can't retry without clearing cache, re-raise original error - raise + await self.token_storage_adapter.clear() gen = super().async_auth_flow(request) response = None diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index c6da794e8..cf1166a80 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -12,12 +12,21 @@ from dataclasses import dataclass from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import HTMLResponse from starlette.routing import Route from uvicorn import Config, Server from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.ui import ( + HELPER_TEXT_STYLES, + INFO_BOX_STYLES, + STATUS_MESSAGE_STYLES, + create_info_box, + create_logo, + create_page, + create_secure_html_response, + create_status_message, +) logger = get_logger(__name__) @@ -29,155 +38,41 @@ def create_callback_html( server_url: str | None = None, ) -> str: """Create a styled HTML response for OAuth callbacks.""" - logo_url = "https://gofastmcp.com/assets/brand/blue-logo.png" - # Build the main status message - if is_success: - status_title = "Authentication successful" - status_icon = "✓" - icon_bg = "#10b98120" - else: - status_title = "Authentication failed" - status_icon = "✕" - icon_bg = "#ef444420" + status_title = ( + "Authentication successful" if is_success else "Authentication failed" + ) # Add detail info box for both success and error cases detail_info = "" if is_success and server_url: - detail_info = f""" -
- Connected to: {server_url} -
- """ + detail_info = create_info_box( + f"Connected to: {server_url}", centered=True + ) elif not is_success: - detail_info = f""" -
- {message} -
- """ + detail_info = create_info_box(message, is_error=True, centered=True) - return f""" - - - - - - {title} - - - + # Build the page content + content = f"""
- -
- {status_icon} -
{status_title}
-
+ {create_logo()} + {create_status_message(status_title, is_success=is_success)} {detail_info}
You can safely close this tab now.
- - """ + # Additional styles needed for this page + additional_styles = STATUS_MESSAGE_STYLES + INFO_BOX_STYLES + HELPER_TEXT_STYLES + + return create_page( + content=content, + title=title, + additional_styles=additional_styles, + ) + @dataclass class CallbackResponse: @@ -221,32 +116,34 @@ def create_oauth_callback_server( if callback_response.error: error_desc = callback_response.error_description or "Unknown error" + # Create user-friendly error messages + if callback_response.error == "access_denied": + user_message = "Access was denied by the authorization server." + else: + user_message = f"Authorization failed: {error_desc}" + # Resolve future with exception if provided if response_future and not response_future.done(): - response_future.set_exception( - RuntimeError( - f"OAuth error: {callback_response.error} - {error_desc}" - ) - ) + response_future.set_exception(RuntimeError(user_message)) - return HTMLResponse( + return create_secure_html_response( create_callback_html( - f"FastMCP OAuth Error: {callback_response.error}
{error_desc}", + user_message, is_success=False, ), status_code=400, ) if not callback_response.code: + user_message = "No authorization code was received from the server." + # Resolve future with exception if provided if response_future and not response_future.done(): - response_future.set_exception( - RuntimeError("OAuth callback missing authorization code") - ) + response_future.set_exception(RuntimeError(user_message)) - return HTMLResponse( + return create_secure_html_response( create_callback_html( - "FastMCP OAuth Error: No authorization code received", + user_message, is_success=False, ), status_code=400, @@ -254,17 +151,17 @@ def create_oauth_callback_server( # Check for missing state parameter (indicates OAuth flow issue) if callback_response.state is None: + user_message = ( + "The OAuth server did not return the expected state parameter." + ) + # Resolve future with exception if provided if response_future and not response_future.done(): - response_future.set_exception( - RuntimeError( - "OAuth server did not return state parameter - authentication failed" - ) - ) + response_future.set_exception(RuntimeError(user_message)) - return HTMLResponse( + return create_secure_html_response( create_callback_html( - "FastMCP OAuth Error: Authentication failed
The OAuth server did not return the expected state parameter", + user_message, is_success=False, ), status_code=400, @@ -276,7 +173,7 @@ def create_oauth_callback_server( (callback_response.code, callback_response.state) ) - return HTMLResponse( + return create_secure_html_response( create_callback_html("", is_success=True, server_url=server_url) ) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 1016d35e0..3664c21d3 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -8,7 +8,7 @@ import sys import warnings from collections.abc import AsyncIterator from pathlib import Path -from typing import Any, Literal, TypeVar, cast, overload +from typing import Any, Literal, TextIO, TypeVar, cast, overload import anyio import httpx @@ -313,6 +313,7 @@ class StdioTransport(ClientTransport): env: dict[str, str] | None = None, cwd: str | None = None, keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): """ Initialize a Stdio transport. @@ -326,6 +327,11 @@ class StdioTransport(ClientTransport): Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. + log_file: Optional path or file-like object where subprocess stderr will + be written. Can be a Path or TextIO object. Defaults to sys.stderr + if not provided. When a Path is provided, the file will be created + if it doesn't exist, or appended to if it does. When set, server + errors will be written to this file instead of appearing in the console. """ self.command = command self.args = args @@ -334,6 +340,7 @@ class StdioTransport(ClientTransport): if keep_alive is None: keep_alive = True self.keep_alive = keep_alive + self.log_file = log_file self._session: ClientSession | None = None self._connect_task: asyncio.Task | None = None @@ -368,6 +375,7 @@ class StdioTransport(ClientTransport): args=self.args, env=self.env, cwd=self.cwd, + log_file=self.log_file, session_kwargs=session_kwargs, ready_event=self._ready_event, stop_event=self._stop_event, @@ -421,6 +429,7 @@ async def _stdio_transport_connect_task( args: list[str], env: dict[str, str] | None, cwd: str | None, + log_file: Path | TextIO | None, session_kwargs: SessionKwargs, ready_event: anyio.Event, stop_event: anyio.Event, @@ -438,7 +447,19 @@ async def _stdio_transport_connect_task( env=env, cwd=cwd, ) - transport = await stack.enter_async_context(stdio_client(server_params)) + # Handle log_file: Path needs to be opened, TextIO used as-is + if log_file is None: + log_file_handle = sys.stderr + elif isinstance(log_file, Path): + log_file_handle = open(log_file, "a") + stack.callback(log_file_handle.close) + else: + # Must be TextIO - use it directly + log_file_handle = log_file + + transport = await stack.enter_async_context( + stdio_client(server_params, errlog=log_file_handle) + ) read_stream, write_stream = transport session_future.set_result( await stack.enter_async_context( @@ -471,6 +492,7 @@ class PythonStdioTransport(StdioTransport): cwd: str | None = None, python_cmd: str = sys.executable, keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): """ Initialize a Python transport. @@ -485,6 +507,11 @@ class PythonStdioTransport(StdioTransport): Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. + log_file: Optional path or file-like object where subprocess stderr will + be written. Can be a Path or TextIO object. Defaults to sys.stderr + if not provided. When a Path is provided, the file will be created + if it doesn't exist, or appended to if it does. When set, server + errors will be written to this file instead of appearing in the console. """ script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -502,6 +529,7 @@ class PythonStdioTransport(StdioTransport): env=env, cwd=cwd, keep_alive=keep_alive, + log_file=log_file, ) self.script_path = script_path @@ -516,6 +544,7 @@ class FastMCPStdioTransport(StdioTransport): env: dict[str, str] | None = None, cwd: str | None = None, keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -529,6 +558,7 @@ class FastMCPStdioTransport(StdioTransport): env=env, cwd=cwd, keep_alive=keep_alive, + log_file=log_file, ) self.script_path = script_path @@ -544,6 +574,7 @@ class NodeStdioTransport(StdioTransport): cwd: str | None = None, node_cmd: str = "node", keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): """ Initialize a Node transport. @@ -558,6 +589,11 @@ class NodeStdioTransport(StdioTransport): Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. + log_file: Optional path or file-like object where subprocess stderr will + be written. Can be a Path or TextIO object. Defaults to sys.stderr + if not provided. When a Path is provided, the file will be created + if it doesn't exist, or appended to if it does. When set, server + errors will be written to this file instead of appearing in the console. """ script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -570,7 +606,12 @@ class NodeStdioTransport(StdioTransport): full_args.extend(args) super().__init__( - command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive + command=node_cmd, + args=full_args, + env=env, + cwd=cwd, + keep_alive=keep_alive, + log_file=log_file, ) self.script_path = script_path diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index b63dff339..b23f96420 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -41,7 +41,7 @@ class ComponentService: return tool # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._tool_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): tool_key = key.removeprefix(f"{mounted.prefix}_") @@ -70,7 +70,7 @@ class ComponentService: return tool # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._tool_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): tool_key = key.removeprefix(f"{mounted.prefix}_") @@ -103,7 +103,7 @@ class ComponentService: return template # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._resource_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if has_resource_prefix( key, @@ -146,7 +146,7 @@ class ComponentService: return template # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._resource_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if has_resource_prefix( key, @@ -185,7 +185,7 @@ class ComponentService: return prompt # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._prompt_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): prompt_key = key.removeprefix(f"{mounted.prefix}_") @@ -213,7 +213,7 @@ class ComponentService: return prompt # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._prompt_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): prompt_key = key.removeprefix(f"{mounted.prefix}_") diff --git a/src/fastmcp/contrib/mcp_mixin/README.md b/src/fastmcp/contrib/mcp_mixin/README.md index 0742d7b6a..39c3a2352 100644 --- a/src/fastmcp/contrib/mcp_mixin/README.md +++ b/src/fastmcp/contrib/mcp_mixin/README.md @@ -11,12 +11,15 @@ Tools: * [enable/disable](https://gofastmcp.com/servers/tools#disabling-tools) * [annotations](https://gofastmcp.com/servers/tools#annotations-2) * [excluded arguments](https://gofastmcp.com/servers/tools#excluding-arguments) +* [meta](https://gofastmcp.com/servers/tools#param-meta) Prompts: * [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts) +* [meta](https://gofastmcp.com/servers/prompts#param-meta) Resources: * [enable/disable](https://gofastmcp.com/servers/resources#disabling-resources) +* [meta](https://gofastmcp.com/servers/resources#param-meta) ## Usage @@ -78,7 +81,16 @@ class MyComponent(MCPMixin): if delete_all: return "99 records deleted. I bet you're not a tool :)" return "Tool executed, but you might be a tool!" - + + # example tool w/ meta + @mcp_tool( + name="data_tool", + description="Fetches user data from database", + meta={"version": "2.0", "category": "database", "author": "dev-team"} + ) + def data_tool_method(self, user_id: int): + return f"Fetching data for user {user_id}" + @mcp_resource(uri="component://data") def resource_method(self): return {"data": "some data"} @@ -88,6 +100,15 @@ class MyComponent(MCPMixin): def resource_method(self): return {"data": "some data"} + # example resource w/meta and title + @mcp_resource( + uri="component://config", + title="Data resource Title, + meta={"internal": True, "cache_ttl": 3600, "priority": "high"} + ) + def config_resource_method(self): + return {"config": "data"} + # prompt @mcp_prompt(name="A prompt") def prompt_method(self, name): @@ -98,6 +119,16 @@ class MyComponent(MCPMixin): def prompt_method(self, name): return f"What's up {name}?" + # example prompt w/title and meta + @mcp_prompt( + name="analysis_prompt", + title="Data Analysis Prompt", + description="Analyzes data patterns", + meta={"complexity": "high", "domain": "analytics", "requires_context": True} + ) + def analysis_prompt_method(self, dataset: str): + return f"Analyze the patterns in {dataset}" + mcp_server = FastMCP() component = MyComponent() diff --git a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py index 8e11e6342..5688fa125 100644 --- a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py +++ b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any -from mcp.types import ToolAnnotations +from mcp.types import Annotations, ToolAnnotations from fastmcp.prompts.prompt import Prompt from fastmcp.resources.resource import Resource @@ -29,6 +29,7 @@ def mcp_tool( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, serializer: Callable[[Any], str] | None = None, + meta: dict[str, Any] | None = None, enabled: bool | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP tool for later registration.""" @@ -41,6 +42,7 @@ def mcp_tool( "annotations": annotations, "exclude_args": exclude_args, "serializer": serializer, + "meta": meta, "enabled": enabled, } call_args = {k: v for k, v in call_args.items() if v is not None} @@ -54,9 +56,12 @@ def mcp_resource( uri: str, *, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, + annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, enabled: bool | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP resource for later registration.""" @@ -65,9 +70,12 @@ def mcp_resource( call_args = { "uri": uri, "name": name or get_fn_name(func), + "title": title, "description": description, "mime_type": mime_type, "tags": tags, + "annotations": annotations, + "meta": meta, "enabled": enabled, } call_args = {k: v for k, v in call_args.items() if v is not None} @@ -81,8 +89,10 @@ def mcp_resource( def mcp_prompt( name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, + meta: dict[str, Any] | None = None, enabled: bool | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP prompt for later registration.""" @@ -90,8 +100,10 @@ def mcp_prompt( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args = { "name": name or get_fn_name(func), + "title": title, "description": description, "tags": tags, + "meta": meta, "enabled": enabled, } @@ -151,7 +163,6 @@ class MCPMixin: tool = Tool.from_function( fn=method, name=registration_info.get("name"), - title=registration_info.get("title"), description=registration_info.get("description"), tags=registration_info.get("tags"), annotations=registration_info.get("annotations"), @@ -195,6 +206,7 @@ class MCPMixin: fn=method, uri=registration_info["uri"], name=registration_info.get("name"), + title=registration_info.get("title"), description=registration_info.get("description"), mime_type=registration_info.get("mime_type"), tags=registration_info.get("tags"), diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 91c5cf31f..2785d04df 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -4,7 +4,6 @@ from __future__ import annotations as _annotations import inspect import json -from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Sequence from typing import Any @@ -62,7 +61,7 @@ class PromptArgument(FastMCPBaseModel): ) -class Prompt(FastMCPComponent, ABC): +class Prompt(FastMCPComponent): """A prompt template that can be rendered with parameters.""" arguments: list[PromptArgument] | None = Field( @@ -139,13 +138,16 @@ class Prompt(FastMCPComponent, ABC): meta=meta, ) - @abstractmethod async def render( self, arguments: dict[str, Any] | None = None, ) -> list[PromptMessage]: - """Render the prompt with arguments.""" - raise NotImplementedError("Prompt.render() must be implemented by subclasses") + """Render the prompt with arguments. + + This method is not implemented in the base Prompt class and must be + implemented by subclasses. + """ + raise NotImplementedError("Subclasses must implement render()") class FunctionPrompt(Prompt): diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index ab1a944e2..1563a1831 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations as _annotations import warnings from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any +from typing import Any from mcp import GetPromptResult @@ -12,9 +12,6 @@ from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult from fastmcp.settings import DuplicateBehavior from fastmcp.utilities.logging import get_logger -if TYPE_CHECKING: - from fastmcp.server.server import MountedServer - logger = get_logger(__name__) @@ -27,7 +24,6 @@ class PromptManager: mask_error_details: bool | None = None, ): self._prompts: dict[str, Prompt] = {} - self._mounted_servers: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided @@ -42,52 +38,6 @@ class PromptManager: self.duplicate_behavior = duplicate_behavior - def mount(self, server: MountedServer) -> None: - """Adds a mounted server as a source for prompts.""" - self._mounted_servers.append(server) - - async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]: - """ - The single, consolidated recursive method for fetching prompts. The 'via_server' - parameter determines the communication path. - - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests - """ - all_prompts: dict[str, Prompt] = {} - - for mounted in self._mounted_servers: - try: - if via_server: - # Use the server-to-server filtered path - child_results = await mounted.server._list_prompts() - else: - # Use the manager-to-manager unfiltered path - child_results = await mounted.server._prompt_manager.list_prompts() - - # The combination logic is the same for both paths - child_dict = {p.key: p for p in child_results} - if mounted.prefix: - for prompt in child_dict.values(): - prefixed_prompt = prompt.model_copy( - key=f"{mounted.prefix}_{prompt.key}" - ) - all_prompts[prefixed_prompt.key] = prefixed_prompt - else: - all_prompts.update(child_dict) - except Exception as e: - # Skip failed mounts silently, matches existing behavior - logger.warning( - f"Failed to get prompts from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local prompts, which always take precedence - all_prompts.update(self._prompts) - return all_prompts - async def has_prompt(self, key: str) -> bool: """Check if a prompt exists.""" prompts = await self.get_prompts() @@ -102,16 +52,9 @@ class PromptManager: async def get_prompts(self) -> dict[str, Prompt]: """ - Gets the complete, unfiltered inventory of all prompts. + Gets the complete, unfiltered inventory of local prompts. """ - return await self._load_prompts(via_server=False) - - async def list_prompts(self) -> list[Prompt]: - """ - Lists all prompts, applying protocol filtering. - """ - prompts_dict = await self._load_prompts(via_server=True) - return list(prompts_dict.values()) + return dict(self._prompts) def add_prompt_from_fn( self, @@ -160,44 +103,16 @@ class PromptManager: Internal API for servers: Finds and renders a prompt, respecting the filtered protocol path. """ - # 1. Check local prompts first. The server will have already applied its filter. - if name in self._prompts: - prompt = await self.get_prompt(name) - if not prompt: - raise NotFoundError(f"Unknown prompt: {name}") - - try: - messages = await prompt.render(arguments) - return GetPromptResult( - description=prompt.description, messages=messages - ) - - # Pass through PromptErrors as-is - except PromptError as e: - logger.exception(f"Error rendering prompt {name!r}") - raise e - - # Handle other exceptions - except Exception as e: - logger.exception(f"Error rendering prompt {name!r}") - if self.mask_error_details: - # Mask internal details - raise PromptError(f"Error rendering prompt {name!r}") from e - else: - # Include original error details - raise PromptError(f"Error rendering prompt {name!r}: {e}") from e - - # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._mounted_servers): - prompt_key = name - if mounted.prefix: - if name.startswith(f"{mounted.prefix}_"): - prompt_key = name.removeprefix(f"{mounted.prefix}_") - else: - continue - try: - return await mounted.server._get_prompt(prompt_key, arguments) - except NotFoundError: - continue - - raise NotFoundError(f"Unknown prompt: {name}") + prompt = await self.get_prompt(name) + try: + messages = await prompt.render(arguments) + return GetPromptResult(description=prompt.description, messages=messages) + except PromptError as e: + logger.exception(f"Error rendering prompt {name!r}") + raise e + except Exception as e: + logger.exception(f"Error rendering prompt {name!r}") + if self.mask_error_details: + raise PromptError(f"Error rendering prompt {name!r}") from e + else: + raise PromptError(f"Error rendering prompt {name!r}: {e}") from e diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 3067fb2c7..d7f9d7177 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -2,7 +2,6 @@ from __future__ import annotations -import abc import inspect from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any @@ -31,7 +30,7 @@ if TYPE_CHECKING: pass -class Resource(FastMCPComponent, abc.ABC): +class Resource(FastMCPComponent): """Base class for all resources.""" model_config = ConfigDict(validate_default=True) @@ -111,10 +110,13 @@ class Resource(FastMCPComponent, abc.ABC): raise ValueError("Either name or uri must be provided") return self - @abc.abstractmethod async def read(self) -> str | bytes: - """Read the resource content.""" - pass + """Read the resource content. + + This method is not implemented in the base Resource class and must be + implemented by subclasses. + """ + raise NotImplementedError("Subclasses must implement read()") def to_mcp_resource( self, diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index c646c71aa..07331ae18 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import inspect import warnings from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import Any from pydantic import AnyUrl @@ -19,9 +19,6 @@ from fastmcp.resources.template import ( from fastmcp.settings import DuplicateBehavior from fastmcp.utilities.logging import get_logger -if TYPE_CHECKING: - from fastmcp.server.server import MountedServer - logger = get_logger(__name__) @@ -43,7 +40,6 @@ class ResourceManager: """ self._resources: dict[str, Resource] = {} self._templates: dict[str, ResourceTemplate] = {} - self._mounted_servers: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided @@ -57,137 +53,13 @@ class ResourceManager: ) self.duplicate_behavior = duplicate_behavior - def mount(self, server: MountedServer) -> None: - """Adds a mounted server as a source for resources and templates.""" - self._mounted_servers.append(server) - async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, keyed by URI.""" - return await self._load_resources(via_server=False) + return dict(self._resources) async def get_resource_templates(self) -> dict[str, ResourceTemplate]: """Get all registered templates, keyed by URI template.""" - return await self._load_resource_templates(via_server=False) - - async def _load_resources(self, *, via_server: bool = False) -> dict[str, Resource]: - """ - The single, consolidated recursive method for fetching resources. The 'via_server' - parameter determines the communication path. - - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests - """ - all_resources: dict[str, Resource] = {} - - for mounted in self._mounted_servers: - try: - if via_server: - # Use the server-to-server filtered path - child_resources_list = await mounted.server._list_resources() - child_resources = { - resource.key: resource for resource in child_resources_list - } - else: - # Use the manager-to-manager unfiltered path - child_resources = ( - await mounted.server._resource_manager.get_resources() - ) - - # Apply prefix if needed - if mounted.prefix: - from fastmcp.server.server import add_resource_prefix - - for uri, resource in child_resources.items(): - prefixed_uri = add_resource_prefix( - uri, mounted.prefix, mounted.resource_prefix_format - ) - # Create a copy of the resource with the prefixed key and name - prefixed_resource = resource.model_copy( - update={"name": f"{mounted.prefix}_{resource.name}"}, - key=prefixed_uri, - ) - all_resources[prefixed_uri] = prefixed_resource - else: - all_resources.update(child_resources) - except Exception as e: - # Skip failed mounts silently, matches existing behavior - logger.warning( - f"Failed to get resources from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local resources, which always take precedence - all_resources.update(self._resources) - return all_resources - - async def _load_resource_templates( - self, *, via_server: bool = False - ) -> dict[str, ResourceTemplate]: - """ - The single, consolidated recursive method for fetching templates. The 'via_server' - parameter determines the communication path. - - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests - """ - all_templates: dict[str, ResourceTemplate] = {} - - for mounted in self._mounted_servers: - try: - if via_server: - # Use the server-to-server filtered path - child_templates = await mounted.server._list_resource_templates() - else: - # Use the manager-to-manager unfiltered path - child_templates = ( - await mounted.server._resource_manager.list_resource_templates() - ) - child_dict = {template.key: template for template in child_templates} - - # Apply prefix if needed - if mounted.prefix: - from fastmcp.server.server import add_resource_prefix - - for uri_template, template in child_dict.items(): - prefixed_uri_template = add_resource_prefix( - uri_template, mounted.prefix, mounted.resource_prefix_format - ) - # Create a copy of the template with the prefixed key and name - prefixed_template = template.model_copy( - update={"name": f"{mounted.prefix}_{template.name}"}, - key=prefixed_uri_template, - ) - all_templates[prefixed_uri_template] = prefixed_template - else: - all_templates.update(child_dict) - except Exception as e: - # Skip failed mounts silently, matches existing behavior - logger.warning( - f"Failed to get templates from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local templates, which always take precedence - all_templates.update(self._templates) - return all_templates - - async def list_resources(self) -> list[Resource]: - """ - Lists all resources, applying protocol filtering. - """ - resources_dict = await self._load_resources(via_server=True) - return list(resources_dict.values()) - - async def list_resource_templates(self) -> list[ResourceTemplate]: - """ - Lists all templates, applying protocol filtering. - """ - templates_dict = await self._load_resource_templates(via_server=True) - return list(templates_dict.values()) + return dict(self._templates) def add_resource_or_template_from_fn( self, @@ -381,12 +253,12 @@ class ResourceManager: uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) - # First check concrete resources (local and mounted) + # First check concrete resources resources = await self.get_resources() if resource := resources.get(uri_str): return resource - # Then check templates (local and mounted) - use the utility function to match against storage keys + # Then check templates templates = await self.get_resource_templates() for storage_key, template in templates.items(): # Try to match against the storage key (which might be a custom key) @@ -424,9 +296,6 @@ class ResourceManager: # 1. Check local resources first. The server will have already applied its filter. if uri_str in self._resources: resource = await self.get_resource(uri_str) - if not resource: - raise NotFoundError(f"Resource {uri_str!r} not found") - try: return await resource.read() @@ -471,32 +340,4 @@ class ResourceManager: f"Error reading resource from template {uri_str!r}: {e}" ) from e - # 2. Check mounted servers using the filtered protocol path. - from fastmcp.server.server import has_resource_prefix, remove_resource_prefix - - for mounted in reversed(self._mounted_servers): - key = uri_str - try: - if mounted.prefix: - if has_resource_prefix( - key, - mounted.prefix, - mounted.resource_prefix_format, - ): - key = remove_resource_prefix( - key, - mounted.prefix, - mounted.resource_prefix_format, - ) - else: - continue - - try: - result = await mounted.server._read_resource(key) - return result[0].content - except NotFoundError: - continue - except NotFoundError: - continue - raise NotFoundError(f"Resource {uri_str!r} not found.") diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 8400aa68a..2781f3516 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -18,16 +18,22 @@ production use with enterprise identity providers. from __future__ import annotations +import base64 import hashlib +import hmac +import json import secrets import time from base64 import urlsafe_b64encode from typing import TYPE_CHECKING, Any, Final -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse import httpx from authlib.common.security import generate_token from authlib.integrations.httpx_client import AsyncOAuth2Client +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler from mcp.server.auth.json_response import PydanticJSONResponse @@ -45,16 +51,26 @@ from mcp.server.auth.settings import ( RevocationOptions, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from pydantic import AnyHttpUrl, AnyUrl, SecretStr +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, SecretStr from starlette.requests import Request -from starlette.responses import RedirectResponse +from starlette.responses import HTMLResponse, RedirectResponse from starlette.routing import Route -import fastmcp from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier -from fastmcp.server.auth.redirect_validation import validate_redirect_uri +from fastmcp.server.auth.redirect_validation import ( + validate_redirect_uri, +) from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import JSONFileStorage, KVStorage +from fastmcp.utilities.ui import ( + BUTTON_STYLES, + DETAIL_BOX_STYLES, + INFO_BOX_STYLES, + TOOLTIP_STYLES, + create_detail_box, + create_logo, + create_page, + create_secure_html_response, +) if TYPE_CHECKING: pass @@ -62,6 +78,62 @@ if TYPE_CHECKING: logger = get_logger(__name__) +# ------------------------------------------------------------------------- +# Constants +# ------------------------------------------------------------------------- + +# Default token expiration times +DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour +DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes + +# HTTP client timeout +HTTP_TIMEOUT_SECONDS: Final[int] = 30 + + +# ------------------------------------------------------------------------- +# Pydantic Models +# ------------------------------------------------------------------------- + + +class OAuthTransaction(BaseModel): + """OAuth transaction state for consent flow. + + Stored server-side to track active authorization flows with client context. + Includes CSRF tokens for consent protection per MCP security best practices. + """ + + txn_id: str + client_id: str + client_redirect_uri: str + client_state: str + code_challenge: str | None + code_challenge_method: str + scopes: list[str] + created_at: float + resource: str | None = None + proxy_code_verifier: str | None = None + csrf_token: str | None = None + csrf_expires_at: float | None = None + + +class ClientCode(BaseModel): + """Client authorization code with PKCE and upstream tokens. + + Stored server-side after upstream IdP callback. Contains the upstream + tokens bound to the client's PKCE challenge for secure token exchange. + """ + + code: str + client_id: str + redirect_uri: str + code_challenge: str | None + code_challenge_method: str + scopes: list[str] + idp_tokens: dict[str, Any] + expires_at: float + created_at: float + + class ProxyDCRClient(OAuthClientInformationFull): """Client for DCR proxy with configurable redirect URI validation. @@ -88,21 +160,8 @@ class ProxyDCRClient(OAuthClientInformationFull): arise from accepting arbitrary redirect URIs. """ - def __init__( - self, - *args: Any, - allowed_redirect_uri_patterns: list[str] | None = None, - **kwargs: Any, - ): - """Initialize with allowed redirect URI patterns. - - Args: - allowed_redirect_uri_patterns: List of allowed redirect URI patterns with wildcard support. - If None, defaults to localhost-only patterns. - If empty list, allows all redirect URIs. - """ - super().__init__(*args, **kwargs) - self._allowed_redirect_uri_patterns = allowed_redirect_uri_patterns + allowed_redirect_uri_patterns: list[str] | None = Field(default=None) + client_name: str | None = Field(default=None) def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: """Validate redirect URI against allowed patterns. @@ -114,7 +173,10 @@ class ProxyDCRClient(OAuthClientInformationFull): """ if redirect_uri is not None: # Validate against allowed patterns - if validate_redirect_uri(redirect_uri, self._allowed_redirect_uri_patterns): + if validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ): return redirect_uri # Fall back to normal validation if not in allowed patterns return super().validate_redirect_uri(redirect_uri) @@ -122,12 +184,110 @@ class ProxyDCRClient(OAuthClientInformationFull): return super().validate_redirect_uri(redirect_uri) -# Default token expiration times -DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour -DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes +# ------------------------------------------------------------------------- +# Helper Functions +# ------------------------------------------------------------------------- -# HTTP client timeout -HTTP_TIMEOUT_SECONDS: Final[int] = 30 + +def create_consent_html( + client_id: str, + redirect_uri: str, + scopes: list[str], + txn_id: str, + csrf_token: str, + client_name: str | None = None, + title: str = "Authorization Consent", +) -> str: + """Create a styled HTML consent page for OAuth authorization requests.""" + # Format scopes for display + scopes_display = ", ".join(scopes) if scopes else "None" + + # Build warning box with client name if available + client_display = client_name or client_id + warning_box = f""" +
+

{client_display} is requesting access to this FastMCP server.

+

Review the details below before approving.

+
+ """ + + # Build detail box with client information + detail_rows = [] + if client_name: + detail_rows.append(("Client Name", client_name)) + detail_rows.extend( + [ + ("Client ID", client_id), + ("Redirect URI", redirect_uri), + ("Requested Scopes", scopes_display), + ] + ) + detail_box = create_detail_box(detail_rows) + + # Build form with buttons + form = f""" +
+ + +
+ + +
+
+ """ + + # Build help link with tooltip + help_link = """ + + """ + + # Build the page content + content = f""" +
+ {create_logo()} +

Authorization Consent

+ {warning_box} + {detail_box} + {form} +
+ {help_link} + """ + + # Additional styles needed for this page + additional_styles = ( + INFO_BOX_STYLES + DETAIL_BOX_STYLES + BUTTON_STYLES + TOOLTIP_STYLES + ) + + # Need to allow form-action for form submission + csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; form-action *" + + return create_page( + content=content, + title=title, + additional_styles=additional_styles, + csp_policy=csp_policy, + ) + + +# ------------------------------------------------------------------------- +# Handler Classes +# ------------------------------------------------------------------------- class TokenHandler(_SDKTokenHandler): @@ -258,7 +418,6 @@ class OAuthProxy(OAuthProvider): State Management --------------- The proxy maintains minimal but crucial state: - - _clients: DCR registrations (all use ProxyDCRClient for flexibility) - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens - _access_tokens, _refresh_tokens: Token storage for revocation @@ -314,7 +473,7 @@ class OAuthProxy(OAuthProvider): # Extra parameters to forward to token endpoint extra_token_params: dict[str, str] | None = None, # Client storage - client_storage: KVStorage | None = None, + client_storage: AsyncKeyValue | None = None, ): """Initialize the OAuth proxy provider. @@ -348,9 +507,7 @@ class OAuthProxy(OAuthProvider): Example: {"audience": "https://api.example.com"} extra_token_params: Additional parameters to forward to the upstream token endpoint. Useful for provider-specific parameters during token exchange. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage in ~/.fastmcp/oauth-proxy-clients/ if not specified. - Pass any KVStorage implementation for custom storage backends. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ # Always enable DCR since we implement it locally for MCP clients client_registration_options = ClientRegistrationOptions( @@ -387,7 +544,25 @@ class OAuthProxy(OAuthProvider): self._redirect_path = ( redirect_path if redirect_path.startswith("/") else f"/{redirect_path}" ) - self._allowed_client_redirect_uris = allowed_client_redirect_uris + # Redirect URI validation (consent flow provides primary protection) + if allowed_client_redirect_uris is None: + logger.info( + "allowed_client_redirect_uris not specified; accepting all redirect URIs. " + "Consent flow provides protection against confused deputy attacks. " + "Configure allowed patterns for defense-in-depth." + ) + self._allowed_client_redirect_uris = None + elif ( + isinstance(allowed_client_redirect_uris, list) + and not allowed_client_redirect_uris + ): + logger.warning( + "allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. " + "This will block all OAuth clients." + ) + self._allowed_client_redirect_uris = [] + else: + self._allowed_client_redirect_uris = allowed_client_redirect_uris # PKCE configuration self._forward_pkce = forward_pkce @@ -399,11 +574,37 @@ class OAuthProxy(OAuthProvider): self._extra_authorize_params = extra_authorize_params or {} self._extra_token_params = extra_token_params or {} - # Initialize client storage (default to file-based if not provided) - if client_storage is None: - cache_dir = fastmcp.settings.home / "oauth-proxy-clients" - client_storage = JSONFileStorage(cache_dir) - self._client_storage = client_storage + self._client_storage: AsyncKeyValue = client_storage or MemoryStore() + + # Warn if using MemoryStore in production + if client_storage is None or isinstance(client_storage, MemoryStore): + logger.warning( + "Using in-memory storage - all OAuth state will be lost on restart. " + "For production, configure persistent storage (Redis, PostgreSQL, etc.)." + ) + + self._client_store = PydanticAdapter[ProxyDCRClient]( + key_value=self._client_storage, + pydantic_model=ProxyDCRClient, + default_collection="mcp-oauth-proxy-clients", + raise_on_validation_error=True, + ) + + # OAuth transaction storage for IdP callback forwarding + # Reuse client_storage with different collections for state management + self._transaction_store = PydanticAdapter[OAuthTransaction]( + key_value=self._client_storage, + pydantic_model=OAuthTransaction, + default_collection="mcp-oauth-transactions", + raise_on_validation_error=True, + ) + + self._code_store = PydanticAdapter[ClientCode]( + key_value=self._client_storage, + pydantic_model=ClientCode, + default_collection="mcp-authorization-codes", + raise_on_validation_error=True, + ) # Local state for token bookkeeping only (no client caching) self._access_tokens: dict[str, AccessToken] = {} @@ -413,12 +614,6 @@ class OAuthProxy(OAuthProvider): self._access_to_refresh: dict[str, str] = {} self._refresh_to_access: dict[str, str] = {} - # OAuth transaction storage for IdP callback forwarding - self._oauth_transactions: dict[ - str, dict[str, Any] - ] = {} # txn_id -> transaction_data - self._client_codes: dict[str, dict[str, Any]] = {} # client_code -> code_data - # Use the provided token validator self._token_validator = token_verifier @@ -457,19 +652,13 @@ class OAuthProxy(OAuthProvider): For unregistered clients, returns None (which will raise an error in the SDK). """ # Load from storage - data = await self._client_storage.get(client_id) - if not data: + if not (client := await self._client_store.get(key=client_id)): return None - if client_data := data.get("client", None): - return ProxyDCRClient( - allowed_redirect_uri_patterns=data.get( - "allowed_redirect_uri_patterns", self._allowed_client_redirect_uris - ), - **client_data, - ) + if client.allowed_redirect_uri_patterns is None: + client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris - return None + return client async def register_client(self, client_info: OAuthClientInformationFull) -> None: """Register a client locally @@ -481,7 +670,7 @@ class OAuthProxy(OAuthProvider): """ # Create a ProxyDCRClient with configured redirect URI validation - proxy_client = ProxyDCRClient( + proxy_client: ProxyDCRClient = ProxyDCRClient( client_id=client_info.client_id, client_secret=client_info.client_secret, redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], @@ -490,14 +679,13 @@ class OAuthProxy(OAuthProvider): scope=client_info.scope or self._default_scope_str, token_endpoint_auth_method="none", allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, + client_name=getattr(client_info, "client_name", None), ) - # Store as structured dict with all needed metadata - storage_data = { - "client": proxy_client.model_dump(mode="json"), - "allowed_redirect_uri_patterns": self._allowed_client_redirect_uris, - } - await self._client_storage.set(client_info.client_id, storage_data) + await self._client_store.put( + key=client_info.client_id, + value=proxy_client, + ) # Log redirect URIs to help users discover what patterns they might need if client_info.redirect_uris: @@ -523,13 +711,12 @@ class OAuthProxy(OAuthProvider): client: OAuthClientInformationFull, params: AuthorizationParams, ) -> str: - """Start OAuth transaction and redirect to upstream IdP. + """Start OAuth transaction and route through consent interstitial. - This implements the DCR-compliant proxy pattern: - 1. Store transaction with client details and PKCE challenge - 2. Generate proxy's own PKCE parameters if forwarding is enabled - 3. Use transaction ID as state for IdP - 4. Redirect to IdP with our fixed callback URL and proxy's PKCE + Flow: + 1. Store transaction with client details and PKCE (if forwarding) + 2. Return local /consent URL; browser visits consent first + 3. Consent handler redirects to upstream IdP if approved/already approved """ # Generate transaction ID for this authorization request txn_id = secrets.token_urlsafe(32) @@ -545,75 +732,31 @@ class OAuthProxy(OAuthProvider): ) # Store transaction data for IdP callback processing - transaction_data = { - "client_id": client.client_id, - "client_redirect_uri": str(params.redirect_uri), - "client_state": params.state, - "code_challenge": params.code_challenge, - "code_challenge_method": getattr(params, "code_challenge_method", "S256"), - "scopes": params.scopes or [], - "created_at": time.time(), - } + await self._transaction_store.put( + key=txn_id, + value=OAuthTransaction( + txn_id=txn_id, + client_id=client.client_id, + client_redirect_uri=str(params.redirect_uri), + client_state=params.state or "", + code_challenge=params.code_challenge, + code_challenge_method=getattr(params, "code_challenge_method", "S256"), + scopes=params.scopes or [], + created_at=time.time(), + resource=getattr(params, "resource", None), + proxy_code_verifier=proxy_code_verifier, + ), + ) - # Store proxy's PKCE verifier if we're forwarding - if proxy_code_verifier: - transaction_data["proxy_code_verifier"] = proxy_code_verifier - - self._oauth_transactions[txn_id] = transaction_data - - # Build query parameters for upstream IdP authorization request - # Use our fixed IdP callback and transaction ID as state - query_params: dict[str, Any] = { - "response_type": "code", - "client_id": self._upstream_client_id, - "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", - "state": txn_id, # Use txn_id as IdP state - } - - # Add scopes - use client scopes or fallback to required scopes - scopes_to_use = params.scopes or self.required_scopes or [] - - if scopes_to_use: - query_params["scope"] = " ".join(scopes_to_use) - - # Forward proxy's PKCE challenge to upstream if enabled - if proxy_code_challenge: - query_params["code_challenge"] = proxy_code_challenge - query_params["code_challenge_method"] = "S256" - logger.debug( - "Forwarding proxy PKCE challenge to upstream for transaction %s", - txn_id, - ) - - # Forward resource parameter if provided (RFC 8707) - if params.resource: - query_params["resource"] = params.resource - logger.debug( - "Forwarding resource indicator '%s' to upstream for transaction %s", - params.resource, - txn_id, - ) - - # Add any extra authorization parameters configured for this proxy - if self._extra_authorize_params: - query_params.update(self._extra_authorize_params) - logger.debug( - "Adding extra authorization parameters for transaction %s: %s", - txn_id, - list(self._extra_authorize_params.keys()), - ) - - # Build the upstream authorization URL - separator = "&" if "?" in self._upstream_authorization_endpoint else "?" - upstream_url = f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" + consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}" logger.debug( - "Starting OAuth transaction %s for client %s, redirecting to IdP (PKCE forwarding: %s)", + "Starting OAuth transaction %s for client %s, redirecting to consent page (PKCE forwarding: %s)", txn_id, client.client_id, "enabled" if proxy_code_challenge else "disabled", ) - return upstream_url + return consent_url # ------------------------------------------------------------------------- # Authorization Code Handling @@ -630,22 +773,22 @@ class OAuthProxy(OAuthProvider): with PKCE challenge for validation. """ # Look up client code data - code_data = self._client_codes.get(authorization_code) - if not code_data: + code_model = await self._code_store.get(key=authorization_code) + if not code_model: logger.debug("Authorization code not found: %s", authorization_code) return None # Check if code expired - if time.time() > code_data["expires_at"]: + if time.time() > code_model.expires_at: logger.debug("Authorization code expired: %s", authorization_code) - self._client_codes.pop(authorization_code, None) + await self._code_store.delete(key=authorization_code) return None # Verify client ID matches - if code_data["client_id"] != client.client_id: + if code_model.client_id != client.client_id: logger.debug( "Authorization code client ID mismatch: %s vs %s", - code_data["client_id"], + code_model.client_id, client.client_id, ) return None @@ -654,11 +797,11 @@ class OAuthProxy(OAuthProvider): return AuthorizationCode( code=authorization_code, client_id=client.client_id, - redirect_uri=code_data["redirect_uri"], + redirect_uri=code_model.redirect_uri, redirect_uri_provided_explicitly=True, - scopes=code_data["scopes"], - expires_at=code_data["expires_at"], - code_challenge=code_data.get("code_challenge", ""), + scopes=code_model.scopes, + expires_at=code_model.expires_at, + code_challenge=code_model.code_challenge or "", ) async def exchange_authorization_code( @@ -672,8 +815,8 @@ class OAuthProxy(OAuthProvider): during the IdP callback exchange. PKCE validation is handled by the MCP framework. """ # Look up stored code data - code_data = self._client_codes.get(authorization_code.code) - if not code_data: + code_model = await self._code_store.get(key=authorization_code.code) + if not code_model: logger.error( "Authorization code not found in client codes: %s", authorization_code.code, @@ -681,10 +824,10 @@ class OAuthProxy(OAuthProvider): raise TokenError("invalid_grant", "Authorization code not found") # Get stored IdP tokens - idp_tokens = code_data["idp_tokens"] + idp_tokens = code_model.idp_tokens # Clean up client code (one-time use) - self._client_codes.pop(authorization_code.code, None) + await self._code_store.delete(key=authorization_code.code) # Extract token information for local tracking access_token_value = idp_tokens["access_token"] @@ -787,8 +930,7 @@ class OAuthProxy(OAuthProvider): ) # Handle refresh token rotation if new one provided - if "refresh_token" in token_response: - new_refresh_token = token_response["refresh_token"] + if new_refresh_token := token_response.get("refresh_token"): if new_refresh_token != refresh_token.token: # Remove old refresh token self._refresh_tokens.pop(refresh_token.token, None) @@ -934,8 +1076,18 @@ class OAuthProxy(OAuthProvider): ) ) + # Add consent endpoints + custom_routes.append( + Route(path="/consent", endpoint=self._show_consent_page, methods=["GET"]) + ) + custom_routes.append( + Route( + path="/consent/submit", endpoint=self._submit_consent, methods=["POST"] + ) + ) + logger.debug( - f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback)" + f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)" ) return custom_routes @@ -977,13 +1129,14 @@ class OAuthProxy(OAuthProvider): ) # Look up transaction data - transaction = self._oauth_transactions.get(txn_id) - if not transaction: + transaction_model = await self._transaction_store.get(key=txn_id) + if not transaction_model: logger.error("IdP callback with invalid transaction ID: %s", txn_id) return RedirectResponse( url="data:text/html,

OAuth Error

Invalid or expired transaction

", status_code=302, ) + transaction = transaction_model.model_dump() # Exchange IdP code for tokens (server-side) oauth_client = AsyncOAuth2Client( @@ -1047,19 +1200,23 @@ class OAuthProxy(OAuthProvider): code_expires_at = int(time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS) # Store client code with PKCE challenge and IdP tokens - self._client_codes[client_code] = { - "client_id": transaction["client_id"], - "redirect_uri": transaction["client_redirect_uri"], - "code_challenge": transaction["code_challenge"], - "code_challenge_method": transaction["code_challenge_method"], - "scopes": transaction["scopes"], - "idp_tokens": idp_tokens, - "expires_at": code_expires_at, - "created_at": time.time(), - } + await self._code_store.put( + key=client_code, + value=ClientCode( + code=client_code, + client_id=transaction["client_id"], + redirect_uri=transaction["client_redirect_uri"], + code_challenge=transaction["code_challenge"], + code_challenge_method=transaction["code_challenge_method"], + scopes=transaction["scopes"], + idp_tokens=idp_tokens, + expires_at=code_expires_at, + created_at=time.time(), + ), + ) # Clean up transaction - self._oauth_transactions.pop(txn_id, None) + await self._transaction_store.delete(key=txn_id) # Build client callback URL with our code and original state client_redirect_uri = transaction["client_redirect_uri"] @@ -1086,3 +1243,300 @@ class OAuthProxy(OAuthProvider): url="data:text/html,

OAuth Error

Internal server error during IdP callback

", status_code=302, ) + + # ------------------------------------------------------------------------- + # Consent Interstitial + # ------------------------------------------------------------------------- + + def _normalize_uri(self, uri: str) -> str: + """Normalize a URI to a canonical form for consent tracking.""" + parsed = urlparse(uri) + path = parsed.path or "" + normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}" + if normalized.endswith("/") and len(path) > 1: + normalized = normalized[:-1] + return normalized + + def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str: + """Create a stable key for consent tracking from client_id and redirect_uri.""" + normalized = self._normalize_uri(str(redirect_uri)) + return f"{client_id}:{normalized}" + + def _cookie_name(self, base_name: str) -> str: + """Return secure cookie name for HTTPS, fallback for HTTP development.""" + base_url_str = str(self.base_url) + if base_url_str.startswith("https://"): + return f"__Host-{base_name}" + logger.warning( + "Using non-secure cookies for development; deploy with HTTPS for production." + ) + return f"__{base_name}" + + def _sign_cookie(self, payload: str) -> str: + """Sign a cookie payload with HMAC-SHA256. + + Returns: base64(payload).base64(signature) + """ + # Use upstream client secret as signing key + key = self._upstream_client_secret.get_secret_value().encode() + signature = hmac.new(key, payload.encode(), hashlib.sha256).digest() + signature_b64 = base64.b64encode(signature).decode() + return f"{payload}.{signature_b64}" + + def _verify_cookie(self, signed_value: str) -> str | None: + """Verify and extract payload from signed cookie. + + Returns: payload if signature valid, None otherwise + """ + try: + if "." not in signed_value: + return None + payload, signature_b64 = signed_value.rsplit(".", 1) + + # Verify signature + key = self._upstream_client_secret.get_secret_value().encode() + expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest() + provided_sig = base64.b64decode(signature_b64.encode()) + + # Constant-time comparison + if not hmac.compare_digest(expected_sig, provided_sig): + return None + + return payload + except Exception: + return None + + def _decode_list_cookie(self, request: Request, base_name: str) -> list[str]: + """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid.""" + # Prefer secure name, but also check non-secure variant for dev + secure_name = self._cookie_name(base_name) + raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}") + if not raw: + return [] + try: + # Verify signature + payload = self._verify_cookie(raw) + if not payload: + logger.debug("Cookie signature verification failed for %s", secure_name) + return [] + + # Decode payload + data = base64.b64decode(payload.encode()) + value = json.loads(data.decode()) + if isinstance(value, list): + return [str(x) for x in value] + except Exception: + logger.debug("Failed to decode cookie %s; treating as empty", secure_name) + return [] + + def _encode_list_cookie(self, values: list[str]) -> str: + """Encode values to base64 and sign with HMAC. + + Returns: signed cookie value (payload.signature) + """ + payload = json.dumps(values, separators=(",", ":")).encode() + payload_b64 = base64.b64encode(payload).decode() + return self._sign_cookie(payload_b64) + + def _set_list_cookie( + self, + response: HTMLResponse | RedirectResponse, + base_name: str, + value_b64: str, + max_age: int, + ) -> None: + name = self._cookie_name(base_name) + secure = str(self.base_url).startswith("https://") + response.set_cookie( + name, + value_b64, + max_age=max_age, + secure=secure, + httponly=True, + samesite="lax", + path="/", + ) + + def _build_upstream_authorize_url( + self, txn_id: str, transaction: dict[str, Any] + ) -> str: + """Construct the upstream IdP authorization URL using stored transaction data.""" + query_params: dict[str, Any] = { + "response_type": "code", + "client_id": self._upstream_client_id, + "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", + "state": txn_id, + } + + scopes_to_use = transaction.get("scopes") or self.required_scopes or [] + if scopes_to_use: + query_params["scope"] = " ".join(scopes_to_use) + + # If PKCE forwarding was enabled, include the proxy challenge + proxy_code_verifier = transaction.get("proxy_code_verifier") + if proxy_code_verifier: + challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() + proxy_code_challenge = ( + urlsafe_b64encode(challenge_bytes).decode().rstrip("=") + ) + query_params["code_challenge"] = proxy_code_challenge + query_params["code_challenge_method"] = "S256" + + # Forward resource indicator if present in transaction + if resource := transaction.get("resource"): + query_params["resource"] = resource + + # Extra configured parameters + if self._extra_authorize_params: + query_params.update(self._extra_authorize_params) + + separator = "&" if "?" in self._upstream_authorization_endpoint else "?" + return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" + + async def _show_consent_page( + self, request: Request + ) -> HTMLResponse | RedirectResponse: + """Display consent page or auto-approve/deny based on cookies.""" + txn_id = request.query_params.get("txn_id") + if not txn_id: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn_model = await self._transaction_store.get(key=txn_id) + if not txn_model: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn = txn_model.model_dump() + client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) + + approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) + denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) + + if client_key in approved: + upstream_url = self._build_upstream_authorize_url(txn_id, txn) + return RedirectResponse(url=upstream_url, status_code=302) + + if client_key in denied: + callback_params = { + "error": "access_denied", + "state": txn.get("client_state") or "", + } + sep = "&" if "?" in txn["client_redirect_uri"] else "?" + return RedirectResponse( + url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}", + status_code=302, + ) + + # Need consent: issue CSRF token and show HTML + csrf_token = secrets.token_urlsafe(32) + csrf_expires_at = time.time() + 15 * 60 + + # Update transaction with CSRF token + txn_model.csrf_token = csrf_token + txn_model.csrf_expires_at = csrf_expires_at + await self._transaction_store.put(key=txn_id, value=txn_model) + + # Update dict for use in HTML generation + txn["csrf_token"] = csrf_token + txn["csrf_expires_at"] = csrf_expires_at + + # Load client to get client_name if available + client = await self.get_client(txn["client_id"]) + client_name = getattr(client, "client_name", None) if client else None + + html = create_consent_html( + client_id=txn["client_id"], + redirect_uri=txn["client_redirect_uri"], + scopes=txn.get("scopes") or [], + txn_id=txn_id, + csrf_token=csrf_token, + client_name=client_name, + ) + response = create_secure_html_response(html) + # Store CSRF in cookie with short lifetime + self._set_list_cookie( + response, + "MCP_CONSENT_STATE", + self._encode_list_cookie([csrf_token]), + max_age=15 * 60, + ) + return response + + async def _submit_consent( + self, request: Request + ) -> RedirectResponse | HTMLResponse: + """Handle consent approval/denial, set cookies, and redirect appropriately.""" + form = await request.form() + txn_id = str(form.get("txn_id", "")) + action = str(form.get("action", "")) + csrf_token = str(form.get("csrf_token", "")) + + if not txn_id: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn_model = await self._transaction_store.get(key=txn_id) + if not txn_model: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn = txn_model.model_dump() + expected_csrf = txn.get("csrf_token") + expires_at = float(txn.get("csrf_expires_at") or 0) + + if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at: + return create_secure_html_response( + "

Error

Invalid or expired consent token

", status_code=400 + ) + + client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) + + if action == "approve": + approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) + if client_key not in approved: + approved.add(client_key) + approved_b64 = self._encode_list_cookie(sorted(approved)) + + upstream_url = self._build_upstream_authorize_url(txn_id, txn) + response = RedirectResponse(url=upstream_url, status_code=302) + self._set_list_cookie( + response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600 + ) + # Clear CSRF cookie by setting empty short-lived value + self._set_list_cookie( + response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 + ) + return response + + elif action == "deny": + denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) + if client_key not in denied: + denied.add(client_key) + denied_b64 = self._encode_list_cookie(sorted(denied)) + + callback_params = { + "error": "access_denied", + "state": txn.get("client_state") or "", + } + sep = "&" if "?" in txn["client_redirect_uri"] else "?" + client_callback_url = ( + f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}" + ) + response = RedirectResponse(url=client_callback_url, status_code=302) + self._set_list_cookie( + response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600 + ) + self._set_list_cookie( + response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 + ) + return response + + else: + return create_secure_html_response( + "

Error

Invalid action

", status_code=400 + ) diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index e7f24dcc9..589e0e2d3 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -12,6 +12,7 @@ This implementation is based on: from collections.abc import Sequence import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, BaseModel, model_validator from typing_extensions import Self @@ -19,7 +20,6 @@ from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import KVStorage logger = get_logger(__name__) @@ -213,7 +213,7 @@ class OIDCProxy(OAuthProxy): redirect_path: str | None = None, # Client configuration allowed_client_redirect_uris: list[str] | None = None, - client_storage: KVStorage | None = None, + client_storage: AsyncKeyValue | None = None, # Token validation configuration token_endpoint_auth_method: str | None = None, ) -> None: @@ -236,8 +236,7 @@ class OIDCProxy(OAuthProxy): If None (default), only localhost redirect URIs are allowed. If empty list, all redirect URIs are allowed (not recommended for production). These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage if not specified. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided token_endpoint_auth_method: Token endpoint authentication method for upstream server. Common values: "client_secret_basic", "client_secret_post", "none". If None, authlib will use its default (typically "client_secret_basic"). diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 512a70068..24d3020b6 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -21,13 +21,14 @@ Example: ``` """ +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import KVStorage from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) @@ -38,7 +39,7 @@ class Auth0ProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTH0_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -92,7 +93,7 @@ class Auth0Provider(OIDCProxy): required_scopes: list[str] | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, - client_storage: KVStorage | None = None, + client_storage: AsyncKeyValue | None = None, ) -> None: """Initialize Auth0 OAuth provider. @@ -106,8 +107,7 @@ class Auth0Provider(OIDCProxy): redirect_path: Redirect path configured in Auth0 application allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage if not specified. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = Auth0ProviderSettings.model_validate( { diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 6d3f04ced..dccdefb57 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -30,6 +30,7 @@ from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -42,7 +43,7 @@ class AWSCognitoProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AWS_COGNITO_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index cd6a2ded9..723d79152 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,17 +6,23 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations -import httpx +from typing import TYPE_CHECKING + +from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import KVStorage from fastmcp.utilities.types import NotSet, NotSetT +if TYPE_CHECKING: + from mcp.server.auth.provider import AuthorizationParams + from mcp.shared.auth import OAuthClientInformationFull + logger = get_logger(__name__) @@ -25,94 +31,29 @@ class AzureProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AZURE_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) client_id: str | None = None client_secret: SecretStr | None = None tenant_id: str | None = None + identifier_uri: str | None = None base_url: str | None = None redirect_path: str | None = None required_scopes: list[str] | None = None - timeout_seconds: int | None = None + additional_authorize_scopes: list[str] | None = None allowed_client_redirect_uris: list[str] | None = None @field_validator("required_scopes", mode="before") @classmethod - def _parse_scopes(cls, v): + def _parse_scopes(cls, v: object) -> list[str] | None: return parse_scopes(v) - -class AzureTokenVerifier(TokenVerifier): - """Token verifier for Azure OAuth tokens. - - Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API - to get user information and validate the token. - """ - - def __init__( - self, - *, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - ): - """Initialize the Azure token verifier. - - Args: - required_scopes: Required OAuth scopes - timeout_seconds: HTTP request timeout - """ - super().__init__(required_scopes=required_scopes) - self.timeout_seconds = timeout_seconds - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify Azure OAuth token by calling Microsoft Graph API.""" - try: - async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: - # Use Microsoft Graph API to validate token and get user info - response = await client.get( - "https://graph.microsoft.com/v1.0/me", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Azure-OAuth", - }, - ) - - if response.status_code != 200: - logger.debug( - "Azure token verification failed: %d - %s", - response.status_code, - response.text[:200], - ) - return None - - user_data = response.json() - - # Create AccessToken with Azure user info - return AccessToken( - token=token, - client_id=str(user_data.get("id", "unknown")), - scopes=self.required_scopes or [], - expires_at=None, - claims={ - "sub": user_data.get("id"), - "email": user_data.get("mail") - or user_data.get("userPrincipalName"), - "name": user_data.get("displayName"), - "given_name": user_data.get("givenName"), - "family_name": user_data.get("surname"), - "job_title": user_data.get("jobTitle"), - "office_location": user_data.get("officeLocation"), - }, - ) - - except httpx.RequestError as e: - logger.debug("Failed to verify Azure token: %s", e) - return None - except Exception as e: - logger.debug("Azure token verification error: %s", e) - return None + @field_validator("additional_authorize_scopes", mode="before") + @classmethod + def _parse_additional_authorize_scopes(cls, v: object) -> list[str] | None: + return parse_scopes(v) class AzureProvider(OAuthProxy): @@ -123,16 +64,17 @@ class AzureProvider(OAuthProxy): Microsoft accounts depending on the tenant configuration. Features: - - Transparent OAuth proxy to Azure/Microsoft identity platform - - Automatic token validation via Microsoft Graph API - - User information extraction - - Support for different tenant configurations (common, organizations, consumers) + - OAuth proxy to Azure/Microsoft identity platform + - JWT validation using tenant issuer and JWKS + - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" - Setup Requirements: - 1. Register an application in Azure Portal (portal.azure.com) - 2. Configure redirect URI as: http://localhost:8000/auth/callback - 3. Note your Application (client) ID and create a client secret - 4. Optionally note your Directory (tenant) ID for single-tenant apps + Setup: + 1. Create an App registration in Azure Portal + 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) + 3. Add an Application ID URI. Either use the default (api://{client_id}) or set a custom one. + 4. Add a custom scope. + 5. Create a client secret. + 6. Get Application (client) ID, Directory (tenant) ID, and client secret Example: ```python @@ -142,8 +84,10 @@ class AzureProvider(OAuthProxy): auth = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", - tenant_id="your-tenant-id", # Required: your Azure tenant ID from Azure Portal - base_url="http://localhost:8000" + tenant_id="your-tenant-id", + required_scopes=["your-scope"], + base_url="http://localhost:8000", + # identifier_uri defaults to api://{client_id} ) mcp = FastMCP("My App", auth=auth) @@ -156,27 +100,33 @@ class AzureProvider(OAuthProxy): client_id: str | NotSetT = NotSet, client_secret: str | NotSetT = NotSet, tenant_id: str | NotSetT = NotSet, + identifier_uri: str | None | NotSetT = NotSet, base_url: str | NotSetT = NotSet, redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | None | NotSetT = NotSet, - timeout_seconds: int | NotSetT = NotSet, + additional_authorize_scopes: list[str] | None | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, - client_storage: KVStorage | None = None, - ): + client_storage: AsyncKeyValue | None = None, + ) -> None: """Initialize Azure OAuth provider. Args: client_id: Azure application (client) ID client_secret: Azure client secret tenant_id: Azure tenant ID (your specific tenant ID, "organizations", or "consumers") + identifier_uri: Optional Application ID URI for your API. (defaults to api://{client_id}) + Used only to prefix scopes in authorization requests. Tokens are always validated + against your app's client ID. base_url: Public URL of your FastMCP server (for OAuth callbacks) redirect_path: Redirect path configured in Azure (defaults to "/auth/callback") - required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"]) - timeout_seconds: HTTP request timeout for Azure API calls + required_scopes: Required scopes. These are validated on tokens and used as defaults + when the client does not request specific scopes. + additional_authorize_scopes: Additional scopes to include in the authorization request + without prefixing. Use this to request upstream scopes such as Microsoft Graph + permissions. These are not used for token validation. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage if not specified. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = AzureProviderSettings.model_validate( { @@ -185,10 +135,11 @@ class AzureProvider(OAuthProxy): "client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id, + "identifier_uri": identifier_uri, "base_url": base_url, "redirect_path": redirect_path, "required_scopes": required_scopes, - "timeout_seconds": timeout_seconds, + "additional_authorize_scopes": additional_authorize_scopes, "allowed_client_redirect_uris": allowed_client_redirect_uris, }.items() if v is not NotSet @@ -197,45 +148,48 @@ class AzureProvider(OAuthProxy): # Validate required settings if not settings.client_id: - raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID" - ) + msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID" + raise ValueError(msg) if not settings.client_secret: - raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET" - ) + msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET" + raise ValueError(msg) # Validate tenant_id is provided if not settings.tenant_id: - raise ValueError( - "tenant_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. " - "Use your Azure tenant ID (found in Azure Portal), 'organizations', or 'consumers'" + msg = ( + "tenant_id is required - set via parameter or " + "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. Use your Azure tenant ID " + "(found in Azure Portal), 'organizations', or 'consumers'" ) + raise ValueError(msg) + + if not settings.required_scopes: + raise ValueError("required_scopes is required") # Apply defaults + self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}" + self.additional_authorize_scopes = settings.additional_authorize_scopes or [] tenant_id_final = settings.tenant_id - timeout_seconds_final = settings.timeout_seconds or 10 - # Default scopes for Azure - User.Read gives us access to user info via Graph API - scopes_final = settings.required_scopes or [ - "User.Read", - "email", - "openid", - "profile", - ] - allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + # Always validate tokens against the app's API client ID using JWT + issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0" + jwks_uri = ( + f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys" + ) + + token_verifier = JWTVerifier( + jwks_uri=jwks_uri, + issuer=issuer, + audience=settings.client_id, + algorithm="RS256", + required_scopes=settings.required_scopes, + ) # Extract secret string from SecretStr client_secret_str = ( settings.client_secret.get_secret_value() if settings.client_secret else "" ) - # Create Azure token verifier - token_verifier = AzureTokenVerifier( - required_scopes=scopes_final, - timeout_seconds=timeout_seconds_final, - ) - # Build Azure OAuth endpoints with tenant authorization_endpoint = ( f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize" @@ -254,12 +208,65 @@ class AzureProvider(OAuthProxy): base_url=settings.base_url, redirect_path=settings.redirect_path, issuer_url=settings.base_url, - allowed_client_redirect_uris=allowed_client_redirect_uris_final, + allowed_client_redirect_uris=settings.allowed_client_redirect_uris, client_storage=client_storage, ) logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s", + "Initialized Azure OAuth provider for client %s with tenant %s%s", settings.client_id, tenant_id_final, + f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", ) + + async def authorize( + self, + client: OAuthClientInformationFull, + params: AuthorizationParams, + ) -> str: + """Start OAuth transaction and redirect to Azure AD. + + Override parent's authorize method to filter out the 'resource' parameter + which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use + scopes to determine the resource/audience instead of a separate parameter. + + Args: + client: OAuth client information + params: Authorization parameters from the client + + Returns: + Authorization URL to redirect the user to Azure AD + """ + # Clear the resource parameter that Azure AD v2.0 doesn't support + # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators) + # but Azure AD v2.0 uses scopes instead to determine the audience + params_to_use = params + if hasattr(params, "resource"): + original_resource = getattr(params, "resource", None) + if original_resource is not None: + params_to_use = params.model_copy(update={"resource": None}) + if original_resource: + logger.debug( + "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", + original_resource, + ) + original_scopes = params_to_use.scopes or self.required_scopes + prefixed_scopes = ( + self._add_prefix_to_scopes(original_scopes) + if self.identifier_uri + else original_scopes + ) + + final_scopes = list(prefixed_scopes) + if self.additional_authorize_scopes: + final_scopes.extend(self.additional_authorize_scopes) + + modified_params = params_to_use.model_copy(update={"scopes": final_scopes}) + + auth_url = await super().authorize(client, modified_params) + separator = "&" if "?" in auth_url else "?" + return f"{auth_url}{separator}prompt=select_account" + + def _add_prefix_to_scopes(self, scopes: list[str]) -> list[str]: + """Add Application ID URI prefix for authorization request.""" + return [f"{self.identifier_uri}/{scope}" for scope in scopes] diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py index 03ae1d007..43163c6f8 100644 --- a/src/fastmcp/server/auth/providers/descope.py +++ b/src/fastmcp/server/auth/providers/descope.py @@ -15,6 +15,7 @@ from starlette.routing import Route from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -24,7 +25,7 @@ logger = get_logger(__name__) class DescopeProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index e08e2f97c..0846bd03f 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -22,15 +22,16 @@ Example: from __future__ import annotations import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import KVStorage from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) @@ -41,7 +42,7 @@ class GitHubProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GITHUB_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -202,7 +203,7 @@ class GitHubProvider(OAuthProxy): required_scopes: list[str] | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, - client_storage: KVStorage | None = None, + client_storage: AsyncKeyValue | None = None, ): """Initialize GitHub OAuth provider. @@ -215,8 +216,7 @@ class GitHubProvider(OAuthProxy): timeout_seconds: HTTP request timeout for GitHub API calls allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage if not specified. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = GitHubProviderSettings.model_validate( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index c6fbeb6cd..71cb29472 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -24,15 +24,16 @@ from __future__ import annotations import time import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import KVStorage from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) @@ -43,7 +44,7 @@ class GoogleProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -218,7 +219,7 @@ class GoogleProvider(OAuthProxy): required_scopes: list[str] | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, - client_storage: KVStorage | None = None, + client_storage: AsyncKeyValue | None = None, ): """Initialize Google OAuth provider. @@ -234,8 +235,7 @@ class GoogleProvider(OAuthProxy): timeout_seconds: HTTP request timeout for Google API calls allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage if not specified. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = GoogleProviderSettings.model_validate( diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 5eccb82b3..c33d122ef 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -16,6 +16,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import TypedDict from fastmcp.server.auth import AccessToken, TokenVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -143,7 +144,7 @@ class JWTVerifierSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_JWT_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index a0c568515..1aefaf052 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -15,6 +15,7 @@ from starlette.routing import Route from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -24,7 +25,7 @@ logger = get_logger(__name__) class ScalekitProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py new file mode 100644 index 000000000..40019d688 --- /dev/null +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -0,0 +1,172 @@ +"""Supabase authentication provider for FastMCP. + +This module provides SupabaseProvider - a complete authentication solution that integrates +with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. +""" + +from __future__ import annotations + +import httpx +from pydantic import AnyHttpUrl, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class SupabaseProviderSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_SUPABASE_", + env_file=ENV_FILE, + extra="ignore", + ) + + project_url: AnyHttpUrl + base_url: AnyHttpUrl + required_scopes: list[str] | None = None + + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + + +class SupabaseProvider(RemoteAuthProvider): + """Supabase metadata provider for DCR (Dynamic Client Registration). + + This provider implements Supabase Auth integration using metadata forwarding. + This approach allows Supabase to handle the OAuth flow directly while FastMCP acts + as a resource server, verifying JWTs issued by Supabase Auth. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Supabase Project Setup: + - Create a Supabase project at https://supabase.com + - Note your project URL (e.g., "https://abc123.supabase.co") + - For projects created after May 1st, 2025, asymmetric RS256 keys are used by default + - For older projects, consider migrating to asymmetric keys for better security + + 2. JWT Verification: + - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json + - JWTs are issued by {project_url}/auth/v1 + - Tokens are cached for up to 10 minutes by Supabase's edge servers + + For detailed setup instructions, see: + https://supabase.com/docs/guides/auth/jwts + + Example: + ```python + from fastmcp.server.auth.providers.supabase import SupabaseProvider + + # Create Supabase metadata provider (JWT verifier created automatically) + supabase_auth = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://your-fastmcp-server.com", + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=supabase_auth) + ``` + """ + + def __init__( + self, + *, + project_url: AnyHttpUrl | str | NotSetT = NotSet, + base_url: AnyHttpUrl | str | NotSetT = NotSet, + required_scopes: list[str] | None | NotSetT = NotSet, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Supabase metadata provider. + + Args: + project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co") + base_url: Public URL of this FastMCP server + required_scopes: Optional list of scopes to require for all requests + token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase + """ + settings = SupabaseProviderSettings.model_validate( + { + k: v + for k, v in { + "project_url": project_url, + "base_url": base_url, + "required_scopes": required_scopes, + }.items() + if v is not NotSet + } + ) + + self.project_url = str(settings.project_url).rstrip("/") + self.base_url = str(settings.base_url).rstrip("/") + + # Create default JWT verifier if none provided + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json", + issuer=f"{self.project_url}/auth/v1", + algorithm="ES256", # Supabase uses ES256 for asymmetric keys + required_scopes=settings.required_scopes, + ) + + # Initialize RemoteAuthProvider with Supabase as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")], + base_url=self.base_url, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Supabase authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Supabase's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Supabase OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Supabase metadata: {e}", + }, + status_code=500, + ) + + # Add Supabase authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index b0c5f1817..ae8814a92 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -11,6 +11,7 @@ Choose based on your WorkOS setup and authentication requirements. from __future__ import annotations import httpx +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.responses import JSONResponse @@ -19,9 +20,9 @@ from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.storage import KVStorage from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) @@ -32,7 +33,7 @@ class WorkOSProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_WORKOS_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) @@ -168,7 +169,7 @@ class WorkOSProvider(OAuthProxy): required_scopes: list[str] | None | NotSetT = NotSet, timeout_seconds: int | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, - client_storage: KVStorage | None = None, + client_storage: AsyncKeyValue | None = None, ): """Initialize WorkOS OAuth provider. @@ -182,8 +183,7 @@ class WorkOSProvider(OAuthProxy): timeout_seconds: HTTP request timeout for WorkOS API calls allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage implementation for OAuth client registrations. - Defaults to file-based storage if not specified. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = WorkOSProviderSettings.model_validate( @@ -262,7 +262,7 @@ class WorkOSProvider(OAuthProxy): class AuthKitProviderSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_", - env_file=".env", + env_file=ENV_FILE, extra="ignore", ) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 7e46d0f8d..c1b39b805 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -205,7 +205,7 @@ class Context: """ if self.fastmcp is None: raise ValueError("Context is not available outside of a request") - return await self.fastmcp._mcp_read_resource(uri) + return await self.fastmcp._read_resource_mcp(uri) async def log( self, diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index eb9e87c2a..4251b3b80 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -1,5 +1,12 @@ -from typing import Any +from __future__ import annotations +import weakref +from contextlib import AsyncExitStack +from typing import TYPE_CHECKING, Any + +import anyio +import mcp.types +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.server.lowlevel.server import ( LifespanResultT, NotificationOptions, @@ -9,11 +16,82 @@ from mcp.server.lowlevel.server import ( Server as _Server, ) from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.server.stdio import stdio_server as stdio_server +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder + +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from fastmcp.server.server import FastMCP + +logger = get_logger(__name__) + + +class MiddlewareServerSession(ServerSession): + """ServerSession that routes initialization requests through FastMCP middleware.""" + + def __init__(self, fastmcp: FastMCP, *args, **kwargs): + super().__init__(*args, **kwargs) + self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) + + @property + def fastmcp(self) -> FastMCP: + """Get the FastMCP instance.""" + fastmcp = self._fastmcp_ref() + if fastmcp is None: + raise RuntimeError("FastMCP instance is no longer available") + return fastmcp + + async def _received_request( + self, + responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult], + ): + """ + Override the _received_request method to route initialization requests + through FastMCP middleware. + + These are not handled by routes that FastMCP typically overrides and + require special handling. + """ + import fastmcp.server.context + from fastmcp.server.middleware.middleware import MiddlewareContext + + if isinstance(responder.request.root, mcp.types.InitializeRequest): + + async def call_original_handler( + ctx: MiddlewareContext, + ) -> None: + return await super(MiddlewareServerSession, self)._received_request( + responder + ) + + async with fastmcp.server.context.Context( + fastmcp=self.fastmcp + ) as fastmcp_ctx: + # Create the middleware context. + mw_context = MiddlewareContext( + message=responder.request.root, + source="client", + type="request", + method="initialize", + fastmcp_context=fastmcp_ctx, + ) + + return await self.fastmcp._apply_middleware( + mw_context, call_original_handler + ) + else: + return await super()._received_request(responder) class LowLevelServer(_Server[LifespanResultT, RequestT]): - def __init__(self, *args: Any, **kwargs: Any): + def __init__(self, fastmcp: FastMCP, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) + # Store a weak reference to FastMCP to avoid circular references + self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) + # FastMCP servers support notifications for all components self.notification_options = NotificationOptions( prompts_changed=True, @@ -21,6 +99,14 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): tools_changed=True, ) + @property + def fastmcp(self) -> FastMCP: + """Get the FastMCP instance.""" + fastmcp = self._fastmcp_ref() + if fastmcp is None: + raise RuntimeError("FastMCP instance is no longer available") + return fastmcp + def create_initialization_options( self, notification_options: NotificationOptions | None = None, @@ -35,3 +121,36 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): experimental_capabilities=experimental_capabilities, **kwargs, ) + + async def run( + self, + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], + initialization_options: InitializationOptions, + raise_exceptions: bool = False, + stateless: bool = False, + ): + """ + Overrides the run method to use the MiddlewareServerSession. + """ + async with AsyncExitStack() as stack: + lifespan_context = await stack.enter_async_context(self.lifespan(self)) + session = await stack.enter_async_context( + MiddlewareServerSession( + self.fastmcp, + read_stream, + write_stream, + initialization_options, + stateless=stateless, + ) + ) + + async with anyio.create_task_group() as tg: + async for message in session.incoming_messages: + tg.start_soon( + self._handle_message, + message, + session, + lifespan_context, + raise_exceptions, + ) diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py index fe2a46cfc..593ce3bbf 100644 --- a/src/fastmcp/server/middleware/logging.py +++ b/src/fastmcp/server/middleware/logging.py @@ -2,6 +2,7 @@ import json import logging +import time from collections.abc import Callable from logging import Logger from typing import Any @@ -52,14 +53,14 @@ class BaseLoggingMiddleware(Middleware): else: return " ".join([f"{k}={v}" for k, v in message.items()]) - def _get_timestamp_from_context(self, context: MiddlewareContext[Any]) -> str: - """Get a timestamp from the context.""" - return context.timestamp.isoformat() - def _create_before_message( - self, context: MiddlewareContext[Any], event: str + self, context: MiddlewareContext[Any] ) -> dict[str, str | int]: - message = self._create_base_message(context, event) + message = { + "event": context.type + "_start", + "method": context.method or "unknown", + "source": context.source, + } if ( self.include_payloads @@ -85,57 +86,61 @@ class BaseLoggingMiddleware(Middleware): return message - def _create_after_message( - self, context: MiddlewareContext[Any], event: str - ) -> dict[str, str | int]: - return self._create_base_message(context, event) - - def _create_base_message( + def _create_error_message( self, context: MiddlewareContext[Any], - event: str, - ) -> dict[str, str | int]: - """Format a message for logging.""" - - parts: dict[str, str | int] = { - "event": event, - "timestamp": self._get_timestamp_from_context(context), + start_time: float, + error: Exception, + ) -> dict[str, str | int | float]: + duration_ms: float = _get_duration_ms(start_time) + message = { + "event": context.type + "_error", "method": context.method or "unknown", - "type": context.type, "source": context.source, + "duration_ms": duration_ms, + "error": str(object=error), } + return message - return parts + def _create_after_message( + self, + context: MiddlewareContext[Any], + start_time: float, + ) -> dict[str, str | int | float]: + duration_ms: float = _get_duration_ms(start_time) + message = { + "event": context.type + "_success", + "method": context.method or "unknown", + "source": context.source, + "duration_ms": duration_ms, + } + return message + + def _log_message( + self, message: dict[str, str | int | float], log_level: int | None = None + ): + self.logger.log(log_level or self.log_level, self._format_message(message)) async def on_message( self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] ) -> Any: - """Log all messages.""" + """Log messages for configured methods.""" if self.methods and context.method not in self.methods: return await call_next(context) - request_start_log_message = self._create_before_message( - context, "request_start" - ) - - formatted_message = self._format_message(request_start_log_message) - self.logger.log(self.log_level, f"Processing message: {formatted_message}") + self._log_message(self._create_before_message(context)) + start_time = time.perf_counter() try: result = await call_next(context) - request_success_log_message = self._create_after_message( - context, "request_success" - ) - - formatted_message = self._format_message(request_success_log_message) - self.logger.log(self.log_level, f"Completed message: {formatted_message}") + self._log_message(self._create_after_message(context, start_time)) return result except Exception as e: - self.logger.log( - logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}" + self._log_message( + self._create_error_message(context, start_time, e), logging.ERROR ) raise @@ -184,7 +189,7 @@ class LoggingMiddleware(BaseLoggingMiddleware): payload_serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ - self.logger: Logger = logger or logging.getLogger("fastmcp.requests") + self.logger: Logger = logger or logging.getLogger("fastmcp.middleware.logging") self.log_level = log_level self.include_payloads: bool = include_payloads self.include_payload_length: bool = include_payload_length @@ -234,7 +239,9 @@ class StructuredLoggingMiddleware(BaseLoggingMiddleware): payload_serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ - self.logger: Logger = logger or logging.getLogger("fastmcp.structured") + self.logger: Logger = logger or logging.getLogger( + "fastmcp.middleware.structured_logging" + ) self.log_level: int = log_level self.include_payloads: bool = include_payloads self.include_payload_length: bool = include_payload_length @@ -243,3 +250,7 @@ class StructuredLoggingMiddleware(BaseLoggingMiddleware): self.payload_serializer: Callable[[Any], str] | None = payload_serializer self.max_payload_length: int | None = None self.structured_logging: bool = True + + +def _get_duration_ms(start_time: float, /) -> float: + return round(number=(time.perf_counter() - start_time) * 1000, ndigits=2) diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 8b262d4f5..0b78e4866 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -99,6 +99,8 @@ class Middleware: handler = call_next match context.method: + case "initialize": + handler = partial(self.on_initialize, call_next=handler) case "tools/call": handler = partial(self.on_call_tool, call_next=handler) case "resources/read": @@ -145,6 +147,13 @@ class Middleware: ) -> Any: return await call_next(context) + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequestParams], + call_next: CallNext[mt.InitializeRequestParams, None], + ) -> None: + return await call_next(context) + async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 489561166..81290e5cd 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -195,8 +195,9 @@ class FastMCP(Generic[LifespanResultT]): self._has_lifespan = True # Generate random ID if no name provided self._mcp_server = LowLevelServer[LifespanResultT]( + fastmcp=self, name=name or self.generate_name(), - version=version, + version=version or fastmcp.__version__, instructions=instructions, lifespan=_lifespan_wrapper(self, lifespan), ) @@ -386,13 +387,13 @@ class FastMCP(Generic[LifespanResultT]): def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" - self._mcp_server.list_tools()(self._mcp_list_tools) - self._mcp_server.list_resources()(self._mcp_list_resources) - self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates) - self._mcp_server.list_prompts()(self._mcp_list_prompts) - self._mcp_server.call_tool()(self._mcp_call_tool) - self._mcp_server.read_resource()(self._mcp_read_resource) - self._mcp_server.get_prompt()(self._mcp_get_prompt) + self._mcp_server.list_tools()(self._list_tools_mcp) + self._mcp_server.list_resources()(self._list_resources_mcp) + self._mcp_server.list_resource_templates()(self._list_resource_templates_mcp) + self._mcp_server.list_prompts()(self._list_prompts_mcp) + self._mcp_server.call_tool()(self._call_tool_mcp) + self._mcp_server.read_resource()(self._read_resource_mcp) + self._mcp_server.get_prompt()(self._get_prompt_mcp) async def _apply_middleware( self, @@ -409,8 +410,24 @@ class FastMCP(Generic[LifespanResultT]): self.middleware.append(middleware) async def get_tools(self) -> dict[str, Tool]: - """Get all registered tools, indexed by registered key.""" - return await self._tool_manager.get_tools() + """Get all tools (unfiltered), including mounted servers, indexed by key.""" + all_tools = dict(await self._tool_manager.get_tools()) + + for mounted in self._mounted_servers: + try: + child_tools = await mounted.server.get_tools() + for key, tool in child_tools.items(): + new_key = f"{mounted.prefix}_{key}" if mounted.prefix else key + all_tools[new_key] = tool.model_copy(key=new_key) + except Exception as e: + logger.warning( + f"Failed to get tools from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_tools async def get_tool(self, key: str) -> Tool: tools = await self.get_tools() @@ -419,8 +436,37 @@ class FastMCP(Generic[LifespanResultT]): return tools[key] async def get_resources(self) -> dict[str, Resource]: - """Get all registered resources, indexed by registered key.""" - return await self._resource_manager.get_resources() + """Get all resources (unfiltered), including mounted servers, indexed by key.""" + all_resources = dict(await self._resource_manager.get_resources()) + + for mounted in self._mounted_servers: + try: + child_resources = await mounted.server.get_resources() + for key, resource in child_resources.items(): + new_key = ( + add_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ) + if mounted.prefix + else key + ) + update = ( + {"name": f"{mounted.prefix}_{resource.name}"} + if mounted.prefix and resource.name + else {} + ) + all_resources[new_key] = resource.model_copy( + key=new_key, update=update + ) + except Exception as e: + logger.warning( + f"Failed to get resources from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_resources async def get_resource(self, key: str) -> Resource: resources = await self.get_resources() @@ -429,8 +475,37 @@ class FastMCP(Generic[LifespanResultT]): return resources[key] async def get_resource_templates(self) -> dict[str, ResourceTemplate]: - """Get all registered resource templates, indexed by registered key.""" - return await self._resource_manager.get_resource_templates() + """Get all resource templates (unfiltered), including mounted servers, indexed by key.""" + all_templates = dict(await self._resource_manager.get_resource_templates()) + + for mounted in self._mounted_servers: + try: + child_templates = await mounted.server.get_resource_templates() + for key, template in child_templates.items(): + new_key = ( + add_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ) + if mounted.prefix + else key + ) + update = ( + {"name": f"{mounted.prefix}_{template.name}"} + if mounted.prefix and template.name + else {} + ) + all_templates[new_key] = template.model_copy( + key=new_key, update=update + ) + except Exception as e: + logger.warning( + f"Failed to get resource templates from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_templates async def get_resource_template(self, key: str) -> ResourceTemplate: """Get a registered resource template by key.""" @@ -440,10 +515,24 @@ class FastMCP(Generic[LifespanResultT]): return templates[key] async def get_prompts(self) -> dict[str, Prompt]: - """ - List all available prompts. - """ - return await self._prompt_manager.get_prompts() + """Get all prompts (unfiltered), including mounted servers, indexed by key.""" + all_prompts = dict(await self._prompt_manager.get_prompts()) + + for mounted in self._mounted_servers: + try: + child_prompts = await mounted.server.get_prompts() + for key, prompt in child_prompts.items(): + new_key = f"{mounted.prefix}_{key}" if mounted.prefix else key + all_prompts[new_key] = prompt.model_copy(key=new_key) + except Exception as e: + logger.warning( + f"Failed to get prompts from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_prompts async def get_prompt(self, key: str) -> Prompt: prompts = await self.get_prompts() @@ -519,11 +608,15 @@ class FastMCP(Generic[LifespanResultT]): return routes - async def _mcp_list_tools(self) -> list[MCPTool]: + async def _list_tools_mcp(self) -> list[MCPTool]: + """ + List all available tools, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_tools") async with fastmcp.server.context.Context(fastmcp=self): - tools = await self._list_tools() + tools = await self._list_tools_middleware() return [ tool.to_mcp_tool( name=tool.key, @@ -532,24 +625,11 @@ class FastMCP(Generic[LifespanResultT]): for tool in tools ] - async def _list_tools(self) -> list[Tool]: + async def _list_tools_middleware(self) -> list[Tool]: """ - List all available tools, in the format expected by the low-level MCP - server. + List all available tools, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[mcp.types.ListToolsRequest], - ) -> list[Tool]: - tools = await self._tool_manager.list_tools() # type: ignore[reportPrivateUsage] - - mcp_tools: list[Tool] = [] - for tool in tools: - if self._should_enable_component(tool): - mcp_tools.append(tool) - - return mcp_tools - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -561,13 +641,62 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._list_tools) - async def _mcp_list_resources(self) -> list[MCPResource]: + async def _list_tools( + self, + context: MiddlewareContext[mcp.types.ListToolsRequest], + ) -> list[Tool]: + """ + List all available tools. + """ + # 1. Get local tools and filter them + local_tools = await self._tool_manager.get_tools() + filtered_local = [ + tool for tool in local_tools.values() if self._should_enable_component(tool) + ] + + # 2. Get tools from mounted servers + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_tools: dict[str, Tool] = {tool.key: tool for tool in filtered_local} + + for mounted in self._mounted_servers: + try: + child_tools = await mounted.server._list_tools_middleware() + for tool in child_tools: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(tool): + continue + + key = tool.key + if mounted.prefix: + key = f"{mounted.prefix}_{tool.key}" + tool = tool.model_copy(key=key) + # Later mounted servers override earlier ones + all_tools[key] = tool + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning( + f"Failed to list tools from mounted server {server_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_tools.values()) + + async def _list_resources_mcp(self) -> list[MCPResource]: + """ + List all available resources, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_resources") async with fastmcp.server.context.Context(fastmcp=self): - resources = await self._list_resources() + resources = await self._list_resources_middleware() return [ resource.to_mcp_resource( uri=resource.key, @@ -576,25 +705,11 @@ class FastMCP(Generic[LifespanResultT]): for resource in resources ] - async def _list_resources(self) -> list[Resource]: + async def _list_resources_middleware(self) -> list[Resource]: """ - List all available resources, in the format expected by the low-level MCP - server. - + List all available resources, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[dict[str, Any]], - ) -> list[Resource]: - resources = await self._resource_manager.list_resources() # type: ignore[reportPrivateUsage] - - mcp_resources: list[Resource] = [] - for resource in resources: - if self._should_enable_component(resource): - mcp_resources.append(resource) - - return mcp_resources - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -606,13 +721,71 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._list_resources) - async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: + async def _list_resources( + self, + context: MiddlewareContext[dict[str, Any]], + ) -> list[Resource]: + """ + List all available resources. + """ + # 1. Filter local resources + local_resources = await self._resource_manager.get_resources() + filtered_local = [ + resource + for resource in local_resources.values() + if self._should_enable_component(resource) + ] + + # 2. Get from mounted servers with resource prefix handling + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_resources: dict[str, Resource] = { + resource.key: resource for resource in filtered_local + } + + for mounted in self._mounted_servers: + try: + child_resources = await mounted.server._list_resources_middleware() + for resource in child_resources: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(resource): + continue + + key = resource.key + if mounted.prefix: + key = add_resource_prefix( + resource.key, + mounted.prefix, + mounted.resource_prefix_format, + ) + resource = resource.model_copy( + key=key, + update={"name": f"{mounted.prefix}_{resource.name}"}, + ) + # Later mounted servers override earlier ones + all_resources[key] = resource + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning(f"Failed to list resources from {server_name!r}: {e}") + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_resources.values()) + + async def _list_resource_templates_mcp(self) -> list[MCPResourceTemplate]: + """ + List all available resource templates, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_resource_templates") async with fastmcp.server.context.Context(fastmcp=self): - templates = await self._list_resource_templates() + templates = await self._list_resource_templates_middleware() return [ template.to_mcp_template( uriTemplate=template.key, @@ -621,25 +794,12 @@ class FastMCP(Generic[LifespanResultT]): for template in templates ] - async def _list_resource_templates(self) -> list[ResourceTemplate]: + async def _list_resource_templates_middleware(self) -> list[ResourceTemplate]: """ - List all available resource templates, in the format expected by the low-level MCP - server. + List all available resource templates, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[dict[str, Any]], - ) -> list[ResourceTemplate]: - templates = await self._resource_manager.list_resource_templates() - - mcp_templates: list[ResourceTemplate] = [] - for template in templates: - if self._should_enable_component(template): - mcp_templates.append(template) - - return mcp_templates - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -651,13 +811,77 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware( + mw_context, self._list_resource_templates + ) - async def _mcp_list_prompts(self) -> list[MCPPrompt]: + async def _list_resource_templates( + self, + context: MiddlewareContext[dict[str, Any]], + ) -> list[ResourceTemplate]: + """ + List all available resource templates. + """ + # 1. Filter local templates + local_templates = await self._resource_manager.get_resource_templates() + filtered_local = [ + template + for template in local_templates.values() + if self._should_enable_component(template) + ] + + # 2. Get from mounted servers with resource prefix handling + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_templates: dict[str, ResourceTemplate] = { + template.key: template for template in filtered_local + } + + for mounted in self._mounted_servers: + try: + child_templates = ( + await mounted.server._list_resource_templates_middleware() + ) + for template in child_templates: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(template): + continue + + key = template.key + if mounted.prefix: + key = add_resource_prefix( + template.key, + mounted.prefix, + mounted.resource_prefix_format, + ) + template = template.model_copy( + key=key, + update={"name": f"{mounted.prefix}_{template.name}"}, + ) + # Later mounted servers override earlier ones + all_templates[key] = template + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning( + f"Failed to list resource templates from {server_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_templates.values()) + + async def _list_prompts_mcp(self) -> list[MCPPrompt]: + """ + List all available prompts, in the format expected by the low-level MCP + server. + """ logger.debug(f"[{self.name}] Handler called: list_prompts") async with fastmcp.server.context.Context(fastmcp=self): - prompts = await self._list_prompts() + prompts = await self._list_prompts_middleware() return [ prompt.to_mcp_prompt( name=prompt.key, @@ -666,25 +890,12 @@ class FastMCP(Generic[LifespanResultT]): for prompt in prompts ] - async def _list_prompts(self) -> list[Prompt]: + async def _list_prompts_middleware(self) -> list[Prompt]: """ - List all available prompts, in the format expected by the low-level MCP - server. + List all available prompts, applying MCP middleware. """ - async def _handler( - context: MiddlewareContext[mcp.types.ListPromptsRequest], - ) -> list[Prompt]: - prompts = await self._prompt_manager.list_prompts() # type: ignore[reportPrivateUsage] - - mcp_prompts: list[Prompt] = [] - for prompt in prompts: - if self._should_enable_component(prompt): - mcp_prompts.append(prompt) - - return mcp_prompts - async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( @@ -696,9 +907,58 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._list_prompts) - async def _mcp_call_tool( + async def _list_prompts( + self, + context: MiddlewareContext[mcp.types.ListPromptsRequest], + ) -> list[Prompt]: + """ + List all available prompts. + """ + # 1. Filter local prompts + local_prompts = await self._prompt_manager.get_prompts() + filtered_local = [ + prompt + for prompt in local_prompts.values() + if self._should_enable_component(prompt) + ] + + # 2. Get from mounted servers + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_prompts: dict[str, Prompt] = { + prompt.key: prompt for prompt in filtered_local + } + + for mounted in self._mounted_servers: + try: + child_prompts = await mounted.server._list_prompts_middleware() + for prompt in child_prompts: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(prompt): + continue + + key = prompt.key + if mounted.prefix: + key = f"{mounted.prefix}_{prompt.key}" + prompt = prompt.model_copy(key=key) + # Later mounted servers override earlier ones + all_prompts[key] = prompt + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning( + f"Failed to list prompts from mounted server {server_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_prompts.values()) + + async def _call_tool_mcp( self, key: str, arguments: dict[str, Any] ) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]]: """ @@ -719,29 +979,22 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - result = await self._call_tool(key, arguments) + result = await self._call_tool_middleware(key, arguments) return result.to_mcp_result() except DisabledError: raise NotFoundError(f"Unknown tool: {key}") except NotFoundError: raise NotFoundError(f"Unknown tool: {key}") - async def _call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult: + async def _call_tool_middleware( + self, + key: str, + arguments: dict[str, Any], + ) -> ToolResult: """ Applies this server's middleware and delegates the filtered call to the manager. """ - async def _handler( - context: MiddlewareContext[mcp.types.CallToolRequestParams], - ) -> ToolResult: - tool = await self._tool_manager.get_tool(context.message.name) - if not self._should_enable_component(tool): - raise NotFoundError(f"Unknown tool: {context.message.name!r}") - - return await self._tool_manager.call_tool( - key=context.message.name, arguments=context.message.arguments or {} - ) - mw_context = MiddlewareContext[CallToolRequestParams]( message=mcp.types.CallToolRequestParams(name=key, arguments=arguments), source="client", @@ -749,9 +1002,51 @@ class FastMCP(Generic[LifespanResultT]): method="tools/call", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._call_tool) - async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: + async def _call_tool( + self, + context: MiddlewareContext[mcp.types.CallToolRequestParams], + ) -> ToolResult: + """ + Call a tool + """ + tool_name = context.message.name + + # Try mounted servers in reverse order (later wins) + for mounted in reversed(self._mounted_servers): + try_name = tool_name + if mounted.prefix: + if not tool_name.startswith(f"{mounted.prefix}_"): + continue + try_name = tool_name[len(mounted.prefix) + 1 :] + + try: + # First, get the tool to check if parent's filter allows it + tool = await mounted.server._tool_manager.get_tool(try_name) + if not self._should_enable_component(tool): + # Parent filter blocks this tool, continue searching + continue + + return await mounted.server._call_tool_middleware( + try_name, context.message.arguments or {} + ) + except NotFoundError: + continue + + # Try local tools last (mounted servers override local) + try: + tool = await self._tool_manager.get_tool(tool_name) + if self._should_enable_component(tool): + return await self._tool_manager.call_tool( + key=tool_name, arguments=context.message.arguments or {} + ) + except NotFoundError: + pass + + raise NotFoundError(f"Unknown tool: {tool_name!r}") + + async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceContents]: """ Handle MCP 'readResource' requests. @@ -761,7 +1056,7 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - return await self._read_resource(uri) + return await self._read_resource_middleware(uri) except DisabledError: # convert to NotFoundError to avoid leaking resource presence raise NotFoundError(f"Unknown resource: {str(uri)!r}") @@ -769,26 +1064,14 @@ class FastMCP(Generic[LifespanResultT]): # standardize NotFound message raise NotFoundError(f"Unknown resource: {str(uri)!r}") - async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: + async def _read_resource_middleware( + self, + uri: AnyUrl | str, + ) -> list[ReadResourceContents]: """ Applies this server's middleware and delegates the filtered call to the manager. """ - async def _handler( - context: MiddlewareContext[mcp.types.ReadResourceRequestParams], - ) -> list[ReadResourceContents]: - resource = await self._resource_manager.get_resource(context.message.uri) - if not self._should_enable_component(resource): - raise NotFoundError(f"Unknown resource: {str(context.message.uri)!r}") - - content = await self._resource_manager.read_resource(context.message.uri) - return [ - ReadResourceContents( - content=content, - mime_type=resource.mime_type, - ) - ] - # Convert string URI to AnyUrl if needed if isinstance(uri, str): uri_param = AnyUrl(uri) @@ -802,9 +1085,57 @@ class FastMCP(Generic[LifespanResultT]): method="resources/read", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._read_resource) - async def _mcp_get_prompt( + async def _read_resource( + self, + context: MiddlewareContext[mcp.types.ReadResourceRequestParams], + ) -> list[ReadResourceContents]: + """ + Read a resource + """ + uri_str = str(context.message.uri) + + # Try mounted servers in reverse order (later wins) + for mounted in reversed(self._mounted_servers): + key = uri_str + if mounted.prefix: + if not has_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ): + continue + key = remove_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ) + + try: + # First, get the resource to check if parent's filter allows it + resource = await mounted.server._resource_manager.get_resource(key) + if not self._should_enable_component(resource): + # Parent filter blocks this resource, continue searching + continue + result = await mounted.server._read_resource_middleware(key) + return result + except NotFoundError: + continue + + # Try local resources last (mounted servers override local) + try: + resource = await self._resource_manager.get_resource(uri_str) + if self._should_enable_component(resource): + content = await self._resource_manager.read_resource(uri_str) + return [ + ReadResourceContents( + content=content, + mime_type=resource.mime_type, + ) + ] + except NotFoundError: + pass + + raise NotFoundError(f"Unknown resource: {uri_str!r}") + + async def _get_prompt_mcp( self, name: str, arguments: dict[str, Any] | None = None ) -> GetPromptResult: """ @@ -820,7 +1151,7 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - return await self._get_prompt(name, arguments) + return await self._get_prompt_middleware(name, arguments) except DisabledError: # convert to NotFoundError to avoid leaking prompt presence raise NotFoundError(f"Unknown prompt: {name}") @@ -828,24 +1159,13 @@ class FastMCP(Generic[LifespanResultT]): # standardize NotFound message raise NotFoundError(f"Unknown prompt: {name}") - async def _get_prompt( + async def _get_prompt_middleware( self, name: str, arguments: dict[str, Any] | None = None ) -> GetPromptResult: """ Applies this server's middleware and delegates the filtered call to the manager. """ - async def _handler( - context: MiddlewareContext[mcp.types.GetPromptRequestParams], - ) -> GetPromptResult: - prompt = await self._prompt_manager.get_prompt(context.message.name) - if not self._should_enable_component(prompt): - raise NotFoundError(f"Unknown prompt: {context.message.name!r}") - - return await self._prompt_manager.render_prompt( - name=context.message.name, arguments=context.message.arguments - ) - mw_context = MiddlewareContext( message=mcp.types.GetPromptRequestParams(name=name, arguments=arguments), source="client", @@ -853,7 +1173,45 @@ class FastMCP(Generic[LifespanResultT]): method="prompts/get", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, _handler) + return await self._apply_middleware(mw_context, self._get_prompt) + + async def _get_prompt( + self, + context: MiddlewareContext[mcp.types.GetPromptRequestParams], + ) -> GetPromptResult: + name = context.message.name + + # Try mounted servers in reverse order (later wins) + for mounted in reversed(self._mounted_servers): + try_name = name + if mounted.prefix: + if not name.startswith(f"{mounted.prefix}_"): + continue + try_name = name[len(mounted.prefix) + 1 :] + + try: + # First, get the prompt to check if parent's filter allows it + prompt = await mounted.server._prompt_manager.get_prompt(try_name) + if not self._should_enable_component(prompt): + # Parent filter blocks this prompt, continue searching + continue + return await mounted.server._get_prompt_middleware( + try_name, context.message.arguments + ) + except NotFoundError: + continue + + # Try local prompts last (mounted servers override local) + try: + prompt = await self._prompt_manager.get_prompt(name) + if self._should_enable_component(prompt): + return await self._prompt_manager.render_prompt( + name=name, arguments=context.message.arguments + ) + except NotFoundError: + pass + + raise NotFoundError(f"Unknown prompt: {name!r}") def add_tool(self, tool: Tool) -> Tool: """Add a tool to the server. @@ -1518,6 +1876,7 @@ class FastMCP(Generic[LifespanResultT]): path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, + json_response: bool | None = None, stateless_http: bool | None = None, ) -> None: """Run the server using HTTP transport. @@ -1530,6 +1889,7 @@ class FastMCP(Generic[LifespanResultT]): path: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) uvicorn_config: Additional configuration for the Uvicorn server middleware: A list of middleware to apply to the app + json_response: Whether to use JSON response format (defaults to settings.json_response) stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http) """ host = host or self._deprecated_settings.host @@ -1542,6 +1902,7 @@ class FastMCP(Generic[LifespanResultT]): path=path, transport=transport, middleware=middleware, + json_response=json_response, stateless_http=stateless_http, ) @@ -1855,9 +2216,6 @@ class FastMCP(Generic[LifespanResultT]): resource_prefix_format=self.resource_prefix_format, ) self._mounted_servers.append(mounted_server) - self._tool_manager.mount(mounted_server) - self._resource_manager.mount(mounted_server) - self._prompt_manager.mount(mounted_server) async def import_server( self, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 95e9f6290..ac8ce6df1 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import inspect +import os import warnings from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Literal @@ -19,10 +20,14 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) +ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") + LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] +TEN_MB_IN_BYTES = 1024 * 1024 * 10 + if TYPE_CHECKING: from fastmcp.server.auth.auth import AuthProvider @@ -82,7 +87,7 @@ class Settings(BaseSettings): model_config = ExtendedSettingsConfigDict( env_prefixes=["FASTMCP_", "FASTMCP_SERVER_"], - env_file=".env", + env_file=ENV_FILE, extra="ignore", env_nested_delimiter="__", nested_model_default_partial_update=True, diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index a40a9877c..cd74dc18e 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations import warnings from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import Any from mcp.types import ToolAnnotations @@ -16,9 +16,6 @@ from fastmcp.tools.tool_transform import ( ) from fastmcp.utilities.logging import get_logger -if TYPE_CHECKING: - from fastmcp.server.server import MountedServer - logger = get_logger(__name__) @@ -32,7 +29,6 @@ class ToolManager: transformations: dict[str, ToolTransformConfig] | None = None, ): self._tools: dict[str, Tool] = {} - self._mounted_servers: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details self.transformations = transformations or {} @@ -48,56 +44,12 @@ class ToolManager: self.duplicate_behavior = duplicate_behavior - def mount(self, server: MountedServer) -> None: - """Adds a mounted server as a source for tools.""" - self._mounted_servers.append(server) - - async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]: - """ - The single, consolidated recursive method for fetching tools. The 'via_server' - parameter determines the communication path. - - - via_server=False: Manager-to-manager path for complete, unfiltered inventory - - via_server=True: Server-to-server path for filtered MCP requests - """ - all_tools: dict[str, Tool] = {} - - for mounted in self._mounted_servers: - try: - if via_server: - # Use the server-to-server filtered path - child_results = await mounted.server._list_tools() - else: - # Use the manager-to-manager unfiltered path - child_results = await mounted.server._tool_manager.list_tools() - - # The combination logic is the same for both paths - child_dict = {t.key: t for t in child_results} - if mounted.prefix: - for tool in child_dict.values(): - prefixed_tool = tool.model_copy( - key=f"{mounted.prefix}_{tool.key}" - ) - all_tools[prefixed_tool.key] = prefixed_tool - else: - all_tools.update(child_dict) - except Exception as e: - # Skip failed mounts silently, matches existing behavior - logger.warning( - f"Failed to get tools from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local tools, which always take precedence - all_tools.update(self._tools) - + async def _load_tools(self) -> dict[str, Tool]: + """Return this manager's local tools with transformations applied.""" transformed_tools = apply_transformations_to_tools( - tools=all_tools, + tools=self._tools, transformations=self.transformations, ) - return transformed_tools async def has_tool(self, key: str) -> bool: @@ -114,25 +66,9 @@ class ToolManager: async def get_tools(self) -> dict[str, Tool]: """ - Gets the complete, unfiltered inventory of all tools. + Gets the complete, unfiltered inventory of local tools. """ - return await self._load_tools(via_server=False) - - async def list_tools(self) -> list[Tool]: - """ - Lists all tools, applying protocol filtering. - """ - tools_dict = await self._load_tools(via_server=True) - return list(tools_dict.values()) - - @property - def _tools_transformed(self) -> list[str]: - """Get the local tools.""" - - return [ - transformation.name or tool_name - for tool_name, transformation in self.transformations.items() - ] + return await self._load_tools() def add_tool_from_fn( self, @@ -214,41 +150,15 @@ class ToolManager: Internal API for servers: Finds and calls a tool, respecting the filtered protocol path. """ - # 1. Check local tools first. The server will have already applied its filter. - if key in self._tools or key in self._tools_transformed: - tool = await self.get_tool(key) - if not tool: - raise NotFoundError(f"Tool {key!r} not found") - - try: - return await tool.run(arguments) - - # raise ToolErrors as-is - except ToolError as e: - logger.exception(f"Error calling tool {key!r}") - raise e - - # Handle other exceptions - except Exception as e: - logger.exception(f"Error calling tool {key!r}") - if self.mask_error_details: - # Mask internal details - raise ToolError(f"Error calling tool {key!r}") from e - else: - # Include original error details - raise ToolError(f"Error calling tool {key!r}: {e}") from e - - # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._mounted_servers): - tool_key = key - if mounted.prefix: - if key.startswith(f"{mounted.prefix}_"): - tool_key = key.removeprefix(f"{mounted.prefix}_") - else: - continue - try: - return await mounted.server._call_tool(tool_key, arguments) - except NotFoundError: - continue - - raise NotFoundError(f"Tool {key!r} not found.") + tool = await self.get_tool(key) + try: + return await tool.run(arguments) + except ToolError as e: + logger.exception(f"Error calling tool {key!r}") + raise e + except Exception as e: + logger.exception(f"Error calling tool {key!r}") + if self.mask_error_details: + raise ToolError(f"Error calling tool {key!r}") from e + else: + raise ToolError(f"Error calling tool {key!r}: {e}") from e diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index f3303a8f4..f68921674 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -104,21 +104,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: Returns: FastMCPInfo dataclass containing the extracted information """ - # Get all the components using FastMCP2's direct methods - tools_dict = await mcp.get_tools() - prompts_dict = await mcp.get_prompts() - resources_dict = await mcp.get_resources() - templates_dict = await mcp.get_resource_templates() + # Get all components via middleware to respect filtering and preserve metadata + tools_list = await mcp._list_tools_middleware() + prompts_list = await mcp._list_prompts_middleware() + resources_list = await mcp._list_resources_middleware() + templates_list = await mcp._list_resource_templates_middleware() # Extract detailed tool information tool_infos = [] - for key, tool in tools_dict.items(): - # Convert to MCP tool to get input schema - mcp_tool = tool.to_mcp_tool(name=key) + for tool in tools_list: + mcp_tool = tool.to_mcp_tool(name=tool.key) tool_infos.append( ToolInfo( - key=key, - name=tool.name or key, + key=tool.key, + name=tool.name or tool.key, description=tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, output_schema=tool.output_schema, @@ -132,11 +131,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed prompt information prompt_infos = [] - for key, prompt in prompts_dict.items(): + for prompt in prompts_list: prompt_infos.append( PromptInfo( - key=key, - name=prompt.name or key, + key=prompt.key, + name=prompt.name or prompt.key, description=prompt.description, arguments=[arg.model_dump() for arg in prompt.arguments] if prompt.arguments @@ -150,11 +149,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed resource information resource_infos = [] - for key, resource in resources_dict.items(): + for resource in resources_list: resource_infos.append( ResourceInfo( - key=key, - uri=key, # For v2, key is the URI + key=resource.key, + uri=resource.key, name=resource.name, description=resource.description, mime_type=resource.mime_type, @@ -170,11 +169,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed template information template_infos = [] - for key, template in templates_dict.items(): + for template in templates_list: template_infos.append( TemplateInfo( - key=key, - uri_template=key, # For v2, key is the URI template + key=template.key, + uri_template=template.key, name=template.name, description=template.description, mime_type=template.mime_type, diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index 4d06418df..ec7f47023 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -47,25 +47,48 @@ def configure_logging( if logger is None: logger = logging.getLogger("fastmcp") - # Only configure the FastMCP logger namespace + formatter = logging.Formatter("%(message)s") + + # Don't propagate to the root logger + logger.propagate = False + logger.setLevel(level) + + # Configure the handler for normal logs handler = RichHandler( console=Console(stderr=True), - rich_tracebacks=enable_rich_tracebacks, **rich_kwargs, ) - formatter = logging.Formatter("%(message)s") handler.setFormatter(formatter) - logger.setLevel(level) + # filter to exclude tracebacks + handler.addFilter(lambda record: record.exc_info is None) + + # Configure the handler for tracebacks, for tracebacks we use a compressed format: + # no path or level name to maximize width available for the traceback + # suppress framework frames and limit the number of frames to 3 + + import mcp + import pydantic + + traceback_handler = RichHandler( + console=Console(stderr=True), + show_path=False, + show_level=False, + rich_tracebacks=enable_rich_tracebacks, + tracebacks_max_frames=3, + tracebacks_suppress=[fastmcp, mcp, pydantic], + **rich_kwargs, + ) + traceback_handler.setFormatter(formatter) + + traceback_handler.addFilter(lambda record: record.exc_info is not None) # Remove any existing handlers to avoid duplicates on reconfiguration for hdlr in logger.handlers[:]: logger.removeHandler(hdlr) logger.addHandler(handler) - - # Don't propagate to the root logger - logger.propagate = False + logger.addHandler(traceback_handler) @contextlib.contextmanager diff --git a/src/fastmcp/utilities/storage.py b/src/fastmcp/utilities/storage.py deleted file mode 100644 index 2eb21cead..000000000 --- a/src/fastmcp/utilities/storage.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Key-value storage utilities for persistent data management.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, Protocol - -import pydantic_core - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class KVStorage(Protocol): - """Protocol for key-value storage of JSON data.""" - - async def get(self, key: str) -> dict[str, Any] | None: - """Get a JSON dict by key.""" - ... - - async def set(self, key: str, value: dict[str, Any]) -> None: - """Store a JSON dict by key.""" - ... - - async def delete(self, key: str) -> None: - """Delete a value by key.""" - ... - - -class JSONFileStorage: - """File-based key-value storage for JSON data with automatic metadata tracking. - - Each key-value pair is stored as a separate JSON file on disk. - Keys are sanitized to be filesystem-safe. - - The storage automatically wraps all data with metadata: - - timestamp: Timestamp when the entry was last written - - Args: - cache_dir: Directory for storing JSON files - """ - - def __init__(self, cache_dir: Path): - """Initialize JSON file storage.""" - self.cache_dir = cache_dir - self.cache_dir.mkdir(exist_ok=True, parents=True) - - def _get_safe_key(self, key: str) -> str: - """Convert key to filesystem-safe string.""" - safe_key = key - - # Replace problematic characters with underscores - for char in [".", "/", "\\", ":", "*", "?", '"', "<", ">", "|", " "]: - safe_key = safe_key.replace(char, "_") - - # Compress multiple underscores into one - while "__" in safe_key: - safe_key = safe_key.replace("__", "_") - - # Strip leading and trailing underscores - safe_key = safe_key.strip("_") - - return safe_key - - def _get_file_path(self, key: str) -> Path: - """Get the file path for a given key.""" - safe_key = self._get_safe_key(key) - return self.cache_dir / f"{safe_key}.json" - - async def get(self, key: str) -> dict[str, Any] | None: - """Get a JSON dict from storage by key. - - Args: - key: The key to retrieve - - Returns: - The stored dict or None if not found - """ - path = self._get_file_path(key) - try: - wrapper = json.loads(path.read_text()) - - # Expect wrapped format with metadata - if not isinstance(wrapper, dict) or "data" not in wrapper: - logger.warning(f"Invalid storage format for key '{key}'") - return None - - logger.debug(f"Loaded data for key '{key}'") - return wrapper["data"] - - except FileNotFoundError: - logger.debug(f"No data found for key '{key}'") - return None - except json.JSONDecodeError as e: - logger.warning(f"Failed to load data for key '{key}': {e}") - return None - - async def set(self, key: str, value: dict[str, Any]) -> None: - """Store a JSON dict with metadata. - - Args: - key: The key to store under - value: The dict to store - """ - import time - - path = self._get_file_path(key) - current_time = time.time() - - # Create wrapper with metadata - wrapper = { - "data": value, - "timestamp": current_time, - } - - # Use pydantic_core for consistent JSON serialization - json_data = pydantic_core.to_json(wrapper, fallback=str) - path.write_bytes(json_data) - logger.debug(f"Saved data for key '{key}'") - - async def delete(self, key: str) -> None: - """Delete a value from storage. - - Args: - key: The key to delete - """ - path = self._get_file_path(key) - if path.exists(): - path.unlink() - logger.debug(f"Deleted data for key '{key}'") - - async def cleanup_old_entries( - self, - max_age_seconds: int = 30 * 24 * 60 * 60, # 30 days default - ) -> int: - """Remove entries older than the specified age. - - Uses the timestamp field to determine age. - - Args: - max_age_seconds: Maximum age in seconds (default 30 days) - - Returns: - Number of entries removed - """ - import time - - current_time = time.time() - removed_count = 0 - - for json_file in self.cache_dir.glob("*.json"): - try: - # Read the file and check timestamp - wrapper = json.loads(json_file.read_text()) - - # Check wrapped format - if not isinstance(wrapper, dict) or "data" not in wrapper: - continue # Invalid format, skip - - if "timestamp" not in wrapper: - continue # No timestamp field, skip - - entry_age = current_time - wrapper["timestamp"] - if entry_age > max_age_seconds: - json_file.unlink() - removed_count += 1 - logger.debug( - f"Removed old entry '{json_file.stem}' (age: {entry_age:.0f}s)" - ) - - except (json.JSONDecodeError, KeyError) as e: - logger.debug(f"Error reading {json_file.name}: {e}") - continue - - if removed_count > 0: - logger.info(f"Cleaned up {removed_count} old entries from storage") - - return removed_count - - -class InMemoryStorage: - """In-memory key-value storage for JSON data. - - Simple dict-based storage that doesn't persist across restarts. - Useful for testing or environments where file storage isn't available. - """ - - def __init__(self): - """Initialize in-memory storage.""" - self._data: dict[str, dict[str, Any]] = {} - - async def get(self, key: str) -> dict[str, Any] | None: - """Get a JSON dict from memory by key.""" - return self._data.get(key) - - async def set(self, key: str, value: dict[str, Any]) -> None: - """Store a JSON dict in memory.""" - self._data[key] = value - - async def delete(self, key: str) -> None: - """Delete a value from memory.""" - self._data.pop(key, None) diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 0bdea648c..a41ce6ccc 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -293,7 +293,7 @@ class Audio: class File: - """Helper class for returning audio from tools.""" + """Helper class for returning file data from tools.""" def __init__( self, diff --git a/src/fastmcp/utilities/ui.py b/src/fastmcp/utilities/ui.py new file mode 100644 index 000000000..0d5c3bafd --- /dev/null +++ b/src/fastmcp/utilities/ui.py @@ -0,0 +1,470 @@ +""" +Shared UI utilities for FastMCP HTML pages. + +This module provides reusable HTML/CSS components for OAuth callbacks, +consent pages, and other user-facing interfaces. +""" + +from __future__ import annotations + +from starlette.responses import HTMLResponse + +# FastMCP branding +FASTMCP_LOGO_URL = "https://gofastmcp.com/assets/brand/blue-logo.png" + +# Base CSS styles shared across all FastMCP pages +BASE_STYLES = """ + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + margin: 0; + padding: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #f9fafb; + color: #0a0a0a; + } + + .container { + background: #ffffff; + border: 1px solid #e5e7eb; + padding: 3rem 2.5rem; + border-radius: 1rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + text-align: center; + max-width: 36rem; + margin: 1rem; + width: 100%; + } + + @media (max-width: 640px) { + .container { + padding: 2rem 1.5rem; + margin: 0.5rem; + } + } + + .logo { + width: 64px; + height: auto; + margin-bottom: 1.5rem; + display: block; + margin-left: auto; + margin-right: auto; + } + + h1 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: #111827; + } +""" + +# Button styles +BUTTON_STYLES = """ + .button-group { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + justify-content: center; + } + + button { + padding: 0.75rem 2rem; + font-size: 0.9375rem; + font-weight: 500; + border-radius: 0.5rem; + border: none; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; + } + + button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + } + + .btn-approve, .btn-primary { + background: #10b981; + color: #ffffff; + min-width: 120px; + } + + .btn-deny, .btn-secondary { + background: #6b7280; + color: #ffffff; + min-width: 120px; + } +""" + +# Info box / message box styles +INFO_BOX_STYLES = """ + .info-box { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 0.875rem; + margin: 1.25rem 0; + font-size: 0.875rem; + color: #6b7280; + font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; + text-align: left; + } + + .info-box.centered { + text-align: center; + } + + .info-box.error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; + } + + .info-box strong { + color: #111827; + font-weight: 600; + } + + .warning-box { + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1.5rem; + text-align: left; + } + + .warning-box p { + margin-bottom: 0.5rem; + line-height: 1.5; + color: #92400e; + font-size: 0.9375rem; + } + + .warning-box p:last-child { + margin-bottom: 0; + } + + .warning-box strong { + font-weight: 600; + } +""" + +# Status message styles (for success/error indicators) +STATUS_MESSAGE_STYLES = """ + .status-message { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + } + + .status-icon { + font-size: 1.5rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + flex-shrink: 0; + } + + .status-icon.success { + background: #10b98120; + } + + .status-icon.error { + background: #ef444420; + } + + .message { + font-size: 1.125rem; + line-height: 1.75; + color: #111827; + font-weight: 600; + text-align: left; + } +""" + +# Detail box styles (for key-value pairs) +DETAIL_BOX_STYLES = """ + .detail-box { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1.5rem; + text-align: left; + } + + .detail-row { + display: flex; + padding: 0.5rem 0; + border-bottom: 1px solid #e5e7eb; + } + + .detail-row:last-child { + border-bottom: none; + } + + .detail-label { + font-weight: 600; + min-width: 140px; + color: #6b7280; + font-size: 0.875rem; + flex-shrink: 0; + } + + .detail-value { + flex: 1; + font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; + font-size: 0.75rem; + color: #111827; + word-break: break-all; + overflow-wrap: break-word; + } +""" + +# Helper text styles +HELPER_TEXT_STYLES = """ + .close-instruction, .help-text { + font-size: 0.875rem; + color: #6b7280; + margin-top: 1.5rem; + } +""" + +# Tooltip styles for hover help +TOOLTIP_STYLES = """ + .help-link-container { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + font-size: 0.875rem; + } + + .help-link { + color: #6b7280; + text-decoration: none; + cursor: help; + position: relative; + display: inline-block; + border-bottom: 1px dotted #9ca3af; + } + + @media (max-width: 640px) { + .help-link { + background: #ffffff; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + } + + .help-link:hover { + color: #111827; + border-bottom-color: #111827; + } + + .help-link:hover .tooltip { + opacity: 1; + visibility: visible; + } + + .tooltip { + position: absolute; + bottom: 100%; + right: 0; + left: auto; + margin-bottom: 0.5rem; + background: #1f2937; + color: #ffffff; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + line-height: 1.5; + width: 280px; + max-width: calc(100vw - 3rem); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + text-align: left; + } + + .tooltip::after { + content: ''; + position: absolute; + top: 100%; + right: 1rem; + border: 6px solid transparent; + border-top-color: #1f2937; + } + + .tooltip-link { + color: #60a5fa; + text-decoration: underline; + } +""" + + +def create_page( + content: str, + title: str = "FastMCP", + additional_styles: str = "", + csp_policy: str = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'", +) -> str: + """ + Create a complete HTML page with FastMCP styling. + + Args: + content: HTML content to place inside the page + title: Page title + additional_styles: Extra CSS to include + csp_policy: Content Security Policy header value + + Returns: + Complete HTML page as string + """ + return f""" + + + + + + {title} + + + + + {content} + + + """ + + +def create_logo() -> str: + """Create FastMCP logo HTML.""" + return f'' + + +def create_status_message(message: str, is_success: bool = True) -> str: + """ + Create a status message with icon. + + Args: + message: Status message text + is_success: True for success (✓), False for error (✕) + + Returns: + HTML for status message + """ + icon = "✓" if is_success else "✕" + icon_class = "success" if is_success else "error" + + return f""" +
+ {icon} +
{message}
+
+ """ + + +def create_info_box( + content: str, is_error: bool = False, centered: bool = False +) -> str: + """ + Create an info box. + + Args: + content: HTML content for the info box + is_error: True for error styling, False for normal + centered: True to center the text, False for left-aligned + + Returns: + HTML for info box + """ + classes = ["info-box"] + if is_error: + classes.append("error") + if centered: + classes.append("centered") + class_str = " ".join(classes) + return f'
{content}
' + + +def create_detail_box(rows: list[tuple[str, str]]) -> str: + """ + Create a detail box with key-value pairs. + + Args: + rows: List of (label, value) tuples + + Returns: + HTML for detail box + """ + rows_html = "\n".join( + f""" +
+
{label}:
+
{value}
+
+ """ + for label, value in rows + ) + + return f'
{rows_html}
' + + +def create_button_group(buttons: list[tuple[str, str, str]]) -> str: + """ + Create a group of buttons. + + Args: + buttons: List of (text, value, css_class) tuples + + Returns: + HTML for button group + """ + buttons_html = "\n".join( + f'' + for text, value, css_class in buttons + ) + + return f'
{buttons_html}
' + + +def create_secure_html_response(html: str, status_code: int = 200) -> HTMLResponse: + """ + Create an HTMLResponse with security headers. + + Adds X-Frame-Options: DENY to prevent clickjacking attacks per MCP security best practices. + + Args: + html: HTML content to return + status_code: HTTP status code + + Returns: + HTMLResponse with security headers + """ + return HTMLResponse( + content=html, + status_code=status_code, + headers={"X-Frame-Options": "DENY"}, + ) diff --git a/tests/client/auth/test_oauth_token_expiry.py b/tests/client/auth/test_oauth_token_expiry.py deleted file mode 100644 index 1ec17d38c..000000000 --- a/tests/client/auth/test_oauth_token_expiry.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Test OAuth token expiry handling with absolute timestamps.""" - -import json -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import pytest -from mcp.shared.auth import OAuthToken - -from fastmcp.client.auth.oauth import FileTokenStorage - - -@pytest.mark.asyncio -async def test_token_storage_with_expiry(tmp_path: Path): - """Test that tokens are stored with absolute expiry time and loaded correctly.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Create a token with 3600 seconds expiry - token = OAuthToken( - access_token="test_token", - token_type="Bearer", - expires_in=3600, - refresh_token="refresh_token", - ) - - # Save the token - await storage.set_tokens(token) - - # Check that the file contains the dataclass format - # JSONFileStorage wraps data in {"data": ..., "timestamp": ...} - token_file = storage._get_file_path("tokens") - wrapper = json.loads(token_file.read_text()) - - assert "data" in wrapper - assert "timestamp" in wrapper - data = wrapper["data"] - - assert "token_payload" in data - assert "expires_at" in data - assert data["expires_at"] is not None - # expires_at should be approximately now + 3600 seconds - expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) - expected = datetime.now(timezone.utc) + timedelta(seconds=3600) - assert abs((expires_at - expected).total_seconds()) < 2 - - # Load the token back - loaded_token = await storage.get_tokens() - assert loaded_token is not None - assert loaded_token.access_token == "test_token" - # expires_in should be recalculated to be approximately 3600 (minus loading time) - assert loaded_token.expires_in is not None - assert 3595 <= loaded_token.expires_in <= 3600 - - -@pytest.mark.asyncio -async def test_expired_token_returns_none(tmp_path: Path): - """Test that expired tokens return None when loaded.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Manually create an already-expired token file - token_file = storage._get_file_path("tokens") - past_expiry = datetime.now(timezone.utc) - timedelta( - seconds=10 - ) # Expired 10 seconds ago - - expired_token = { - "token_payload": { - "access_token": "test_token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "refresh_token", - }, - "expires_at": past_expiry.isoformat(), - } - token_file.write_text(json.dumps(expired_token, indent=2, default=str)) - - # Load the token - should return None since it's expired - loaded_token = await storage.get_tokens() - assert loaded_token is None - - -@pytest.mark.asyncio -async def test_token_without_expiry(tmp_path: Path): - """Test that tokens without expires_in are handled correctly.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Create a token without expires_in (perpetual token) - token = OAuthToken( - access_token="test_token", - token_type="Bearer", - expires_in=None, - refresh_token="refresh_token", - ) - - # Save the token - await storage.set_tokens(token) - - # Check that expires_at is None in the file - # JSONFileStorage wraps data in {"data": ..., "timestamp": ...} - token_file = storage._get_file_path("tokens") - wrapper = json.loads(token_file.read_text()) - data = wrapper["data"] - assert data["expires_at"] is None - - # Load the token back - should work since no expiry - loaded_token = await storage.get_tokens() - assert loaded_token is not None - assert loaded_token.access_token == "test_token" - assert loaded_token.expires_in is None - - -@pytest.mark.asyncio -async def test_invalid_format_returns_none(tmp_path: Path): - """Test that invalid token format returns None.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Manually write an invalid format token file (missing required fields) - token_file = storage._get_file_path("tokens") - invalid_token = { - "access_token": "invalid_token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "refresh_token", - } - token_file.write_text(json.dumps(invalid_token, indent=2)) - - # Try to load - should return None - loaded_token = await storage.get_tokens() - assert loaded_token is None - - -@pytest.mark.asyncio -async def test_token_expiry_recalculated_on_load(tmp_path: Path): - """Test that expires_in is correctly recalculated when loading tokens.""" - storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path) - - # Manually create a token file with a specific expires_at - token_file = storage._get_file_path("tokens") - future_expiry = datetime.now(timezone.utc) + timedelta( - seconds=1800 - ) # 30 minutes from now - - # JSONFileStorage expects wrapped format - stored_token = { - "data": { - "token_payload": { - "access_token": "test_token", - "token_type": "Bearer", - "expires_in": 3600, # Original value (will be recalculated) - "refresh_token": "refresh_token", - }, - "expires_at": future_expiry.isoformat(), - }, - "timestamp": datetime.now(timezone.utc).timestamp(), - } - token_file.write_text(json.dumps(stored_token, indent=2, default=str)) - - # Load the token - loaded_token = await storage.get_tokens() - assert loaded_token is not None - # expires_in should be recalculated to approximately 1800 seconds - assert loaded_token.expires_in is not None - assert 1795 <= loaded_token.expires_in <= 1800 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index b521a8cdc..009103233 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -9,6 +9,7 @@ from mcp import McpError from mcp.client.auth import OAuthClientProvider from pydantic import AnyUrl +import fastmcp from fastmcp.client import Client from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.transports import ( @@ -435,11 +436,8 @@ async def test_server_info_custom_version(): async with client: result = client.initialize_result assert result.serverInfo.name == "DefaultVersionServer" - # Should fall back to MCP library version - assert result.serverInfo.version is not None - assert ( - result.serverInfo.version != "1.2.3" - ) # Should be different from custom version + # Should fall back to FastMCP version + assert result.serverInfo.version == fastmcp.__version__ async def test_client_nested_context_manager(fastmcp_server): @@ -943,12 +941,7 @@ class TestInferTransport: transport = infer_transport(config) assert isinstance(transport, MCPConfigTransport) assert isinstance(transport.transport, FastMCPTransport) - assert ( - len( - cast(FastMCP, transport.transport.server)._tool_manager._mounted_servers - ) - == 2 - ) + assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2 def test_infer_fastmcp_server(self, fastmcp_server): """FastMCP server instances should infer to FastMCPTransport.""" diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index a9471d79b..39fd7650f 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -253,3 +253,112 @@ class TestKeepAlive: with pytest.raises(RuntimeError, match="Client failed to connect"): async with client: pass + + +class TestLogFile: + @pytest.fixture + def stdio_script_with_stderr(self, tmp_path): + script = inspect.cleandoc(''' + import sys + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def write_error(message: str) -> str: + """Writes a message to stderr and returns it""" + print(message, file=sys.stderr, flush=True) + return message + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stderr_script.py" + script_file.write_text(script) + return script_file + + async def test_log_file_parameter_accepted_by_stdio_transport(self, tmp_path): + """Test that log_file parameter can be set on StdioTransport""" + log_file_path = tmp_path / "errors.log" + transport = StdioTransport( + command="python", args=["script.py"], log_file=log_file_path + ) + assert transport.log_file == log_file_path + + async def test_log_file_parameter_accepted_by_python_stdio_transport( + self, tmp_path, stdio_script_with_stderr + ): + """Test that log_file parameter can be set on PythonStdioTransport""" + log_file_path = tmp_path / "errors.log" + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=log_file_path + ) + assert transport.log_file == log_file_path + + async def test_log_file_parameter_accepts_textio(self, tmp_path): + """Test that log_file parameter can accept a TextIO object""" + log_file_path = tmp_path / "errors.log" + with open(log_file_path, "w") as log_file: + transport = StdioTransport( + command="python", args=["script.py"], log_file=log_file + ) + assert transport.log_file == log_file + + async def test_log_file_captures_stderr_output_with_path( + self, tmp_path, stdio_script_with_stderr + ): + """Test that stderr output is written to the log_file when using Path""" + log_file_path = tmp_path / "errors.log" + + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=log_file_path + ) + client = Client(transport=transport) + + async with client: + await client.call_tool("write_error", {"message": "Test error message"}) + + # Need to wait a bit for stderr to flush + await asyncio.sleep(0.1) + + content = log_file_path.read_text() + assert "Test error message" in content + + async def test_log_file_captures_stderr_output_with_textio( + self, tmp_path, stdio_script_with_stderr + ): + """Test that stderr output is written to the log_file when using TextIO""" + log_file_path = tmp_path / "errors.log" + + with open(log_file_path, "w") as log_file: + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=log_file + ) + client = Client(transport=transport) + + async with client: + await client.call_tool( + "write_error", {"message": "Test error with TextIO"} + ) + + # Need to wait a bit for stderr to flush + await asyncio.sleep(0.1) + + content = log_file_path.read_text() + assert "Test error with TextIO" in content + + async def test_log_file_none_uses_default_behavior( + self, tmp_path, stdio_script_with_stderr + ): + """Test that log_file=None uses default stderr handling""" + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=None + ) + client = Client(transport=transport) + + async with client: + # Should work without error even without explicit log_file + result = await client.call_tool( + "write_error", {"message": "Default stderr"} + ) + assert result.data == "Default stderr" diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py index a39b293e3..04ad5eb69 100644 --- a/tests/contrib/test_mcp_mixin.py +++ b/tests/contrib/test_mcp_mixin.py @@ -253,3 +253,70 @@ class TestMCPMixin: assert f"cust{_DEFAULT_SEPARATOR_TOOL}tool_cust" not in tools assert f"cust{_DEFAULT_SEPARATOR_RESOURCE}res://cust" not in resources assert f"cust{_DEFAULT_SEPARATOR_PROMPT}prompt_cust" not in prompts + + async def test_tool_with_title_and_meta(self): + """Test that title (via annotations) and meta arguments are properly passed through.""" + from mcp.types import ToolAnnotations + + mcp = FastMCP() + + class MyToolWithMeta(MCPMixin): + @mcp_tool( + annotations=ToolAnnotations(title="My Tool Title"), + meta={"version": "1.0", "author": "test"}, + ) + def sample_tool(self): + pass + + instance = MyToolWithMeta() + instance.register_tools(mcp) + + registered_tools = await mcp.get_tools() + tool = registered_tools["sample_tool"] + + assert tool.annotations is not None + assert tool.annotations.title == "My Tool Title" + assert tool.meta == {"version": "1.0", "author": "test"} + + async def test_resource_with_meta(self): + """Test that meta argument is properly passed through for resources.""" + mcp = FastMCP() + + class MyResourceWithMeta(MCPMixin): + @mcp_resource( + uri="test://resource", + title="My Resource Title", + meta={"category": "data", "internal": True}, + ) + def sample_resource(self): + pass + + instance = MyResourceWithMeta() + instance.register_resources(mcp) + + registered_resources = await mcp.get_resources() + resource = registered_resources["test://resource"] + + assert resource.meta == {"category": "data", "internal": True} + assert resource.title == "My Resource Title" + + async def test_prompt_with_title_and_meta(self): + """Test that title and meta arguments are properly passed through for prompts.""" + mcp = FastMCP() + + class MyPromptWithMeta(MCPMixin): + @mcp_prompt( + title="My Prompt Title", + meta={"priority": "high", "category": "analysis"}, + ) + def sample_prompt(self): + pass + + instance = MyPromptWithMeta() + instance.register_prompts(mcp) + + prompts = await mcp.get_prompts() + prompt = prompts["sample_prompt"] + + assert prompt.title == "My Prompt Title" + assert prompt.meta == {"priority": "high", "category": "analysis"} diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index 1fbb14b8c..54d159fbd 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -11,6 +11,7 @@ with the following configuration: """ import os +import re from collections.abc import Generator from urllib.parse import parse_qs, urlparse @@ -84,6 +85,8 @@ def create_github_server_with_mock_callback( import secrets import time + from fastmcp.server.auth.oauth_proxy import ClientCode + # Generate a fake authorization code fake_code = secrets.token_urlsafe(32) @@ -94,17 +97,21 @@ def create_github_server_with_mock_callback( "expires_in": 3600, } - # Store the mock tokens in the proxy's client codes - auth._client_codes[fake_code] = { - "client_id": client.client_id, - "redirect_uri": str(params.redirect_uri), - "code_challenge": params.code_challenge, - "code_challenge_method": getattr(params, "code_challenge_method", "S256"), - "scopes": params.scopes or [], - "idp_tokens": mock_tokens, - "expires_at": int(time.time() + 300), # 5 minutes - "created_at": time.time(), - } + # Store the mock tokens in the proxy's code storage + await auth._code_store.put( + key=fake_code, + value=ClientCode( + code=fake_code, + client_id=client.client_id, + redirect_uri=str(params.redirect_uri), + code_challenge=params.code_challenge, + code_challenge_method=getattr(params, "code_challenge_method", "S256"), + scopes=params.scopes or [], + idp_tokens=mock_tokens, + expires_at=int(time.time() + 300), # 5 minutes + created_at=time.time(), + ), + ) # Return the redirect to the client's callback with the fake code callback_params = { @@ -204,11 +211,12 @@ async def test_github_oauth_credentials_available(): async def test_github_oauth_authorization_redirect(github_server: str): - """Test that GitHub OAuth authorization redirects to GitHub correctly. + """Test that GitHub OAuth authorization redirects to GitHub correctly through consent flow. Since HeadlessOAuth can't handle real GitHub redirects, we test that: 1. DCR client registration works - 2. Authorization endpoint redirects to GitHub with correct parameters + 2. Authorization endpoint redirects to consent page + 3. Consent approval redirects to GitHub with correct parameters """ # Extract base URL parsed = urlparse(github_server) @@ -235,7 +243,7 @@ async def test_github_oauth_authorization_redirect(github_server: str): client_id = client_info["client_id"] assert client_id is not None - # Step 2: Test authorization endpoint redirects to GitHub + # Step 2: Test authorization endpoint redirects to consent page auth_url = f"{base_url}/authorize" auth_params = { "response_type": "code", @@ -250,9 +258,44 @@ async def test_github_oauth_authorization_redirect(github_server: str): auth_url, params=auth_params, follow_redirects=False ) - # Should redirect to GitHub + # Should redirect to consent page (confused deputy protection) assert auth_response.status_code == 302 - redirect_location = auth_response.headers["location"] + consent_location = auth_response.headers["location"] + assert "/consent" in consent_location + + # Step 3: Visit consent page to get CSRF token + consent_response = await http_client.get( + consent_location, follow_redirects=False + ) + assert consent_response.status_code == 200 + + # Extract CSRF token from consent page HTML + csrf_match = re.search( + r'name="csrf_token"\s+value="([^"]+)"', consent_response.text + ) + assert csrf_match, "CSRF token not found in consent page" + csrf_token = csrf_match.group(1) + + # Extract txn_id from consent URL + txn_id_match = re.search(r"txn_id=([^&]+)", consent_location) + assert txn_id_match, "txn_id not found in consent URL" + txn_id = txn_id_match.group(1) + + # Step 4: Approve consent + approve_response = await http_client.post( + f"{base_url}/consent/submit", + data={ + "action": "approve", + "txn_id": txn_id, + "csrf_token": csrf_token, + }, + cookies=consent_response.cookies, + follow_redirects=False, + ) + + # Should redirect to GitHub + assert approve_response.status_code in (302, 303) + redirect_location = approve_response.headers["location"] # Parse redirect URL - should be GitHub redirect_parsed = urlparse(redirect_location) diff --git a/tests/integration_tests/test_github_mcp_remote.py b/tests/integration_tests/test_github_mcp_remote.py index fb122a2e6..6d8b3e2d5 100644 --- a/tests/integration_tests/test_github_mcp_remote.py +++ b/tests/integration_tests/test_github_mcp_remote.py @@ -34,84 +34,90 @@ def fixture_streamable_http_client() -> Client[StreamableHttpTransport]: ) -async def test_connect_disconnect( - streamable_http_client: Client[StreamableHttpTransport], -): - async with streamable_http_client: - assert streamable_http_client.is_connected() is True - await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access) - assert streamable_http_client.is_connected() is False +@pytest.mark.flaky(retries=2, delay=1) +class TestGithubMCPRemote: + async def test_connect_disconnect( + self, + streamable_http_client: Client[StreamableHttpTransport], + ): + async with streamable_http_client: + assert streamable_http_client.is_connected() is True + await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access) + assert streamable_http_client.is_connected() is False + async def test_ping(self, streamable_http_client: Client[StreamableHttpTransport]): + """Test pinging the server.""" + async with streamable_http_client: + assert streamable_http_client.is_connected() is True + result = await streamable_http_client.ping() + assert result is True -async def test_ping(streamable_http_client: Client[StreamableHttpTransport]): - """Test pinging the server.""" - async with streamable_http_client: - assert streamable_http_client.is_connected() is True - result = await streamable_http_client.ping() - assert result is True + async def test_list_tools( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test listing the MCP tools""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + tools = await streamable_http_client.list_tools() + assert isinstance(tools, list) + assert len(tools) > 0 # Ensure the tools list is non-empty + for tool in tools: + assert isinstance(tool, Tool) + assert len(tool.name) > 0 + assert tool.description is not None and len(tool.description) > 0 + assert isinstance(tool.inputSchema, dict) + assert len(tool.inputSchema) > 0 + async def test_list_resources( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test listing the MCP resources""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + resources = await streamable_http_client.list_resources() + assert isinstance(resources, list) + assert len(resources) == 0 -async def test_list_tools(streamable_http_client: Client[StreamableHttpTransport]): - """Test listing the MCP tools""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - tools = await streamable_http_client.list_tools() - assert isinstance(tools, list) - assert len(tools) > 0 # Ensure the tools list is non-empty - for tool in tools: - assert isinstance(tool, Tool) - assert len(tool.name) > 0 - assert tool.description is not None and len(tool.description) > 0 - assert isinstance(tool.inputSchema, dict) - assert len(tool.inputSchema) > 0 + async def test_list_prompts( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test listing the MCP prompts""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + prompts = await streamable_http_client.list_prompts() + # there is at least one prompt (as of July 2025) + assert len(prompts) >= 1 + async def test_call_tool_ko( + self, streamable_http_client: Client[StreamableHttpTransport] + ): + """Test calling a non-existing tool""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + with pytest.raises(McpError, match="tool not found"): + await streamable_http_client.call_tool("foo") -async def test_list_resources(streamable_http_client: Client[StreamableHttpTransport]): - """Test listing the MCP resources""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - resources = await streamable_http_client.list_resources() - assert isinstance(resources, list) - assert len(resources) == 0 + async def test_call_tool_list_commits( + self, + streamable_http_client: Client[StreamableHttpTransport], + ): + """Test calling a list_commit tool""" + async with streamable_http_client: + assert streamable_http_client.is_connected() + result = await streamable_http_client.call_tool( + "list_commits", {"owner": "jlowin", "repo": "fastmcp"} + ) - -async def test_list_prompts(streamable_http_client: Client[StreamableHttpTransport]): - """Test listing the MCP prompts""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - prompts = await streamable_http_client.list_prompts() - # there is at least one prompt (as of July 2025) - assert len(prompts) >= 1 - - -async def test_call_tool_ko(streamable_http_client: Client[StreamableHttpTransport]): - """Test calling a non-existing tool""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - with pytest.raises(McpError, match="tool not found"): - await streamable_http_client.call_tool("foo") - - -async def test_call_tool_list_commits( - streamable_http_client: Client[StreamableHttpTransport], -): - """Test calling a list_commit tool""" - async with streamable_http_client: - assert streamable_http_client.is_connected() - result = await streamable_http_client.call_tool( - "list_commits", {"owner": "jlowin", "repo": "fastmcp"} - ) - - # at this time, the github server does not support structured content - assert result.structured_content is None - assert isinstance(result.content, list) - assert len(result.content) == 1 - commits = json.loads(result.content[0].text) # type: ignore[attr-defined] - for commit in commits: - assert isinstance(commit, dict) - assert "sha" in commit - assert "commit" in commit - assert "author" in commit["commit"] - assert len(commit["commit"]["author"]["date"]) > 0 - assert len(commit["commit"]["author"]["name"]) > 0 - assert len(commit["commit"]["author"]["email"]) > 0 + # at this time, the github server does not support structured content + assert result.structured_content is None + assert isinstance(result.content, list) + assert len(result.content) == 1 + commits = json.loads(result.content[0].text) # type: ignore[attr-defined] + for commit in commits: + assert isinstance(commit, dict) + assert "sha" in commit + assert "commit" in commit + assert "author" in commit["commit"] + assert len(commit["commit"]["author"]["date"]) > 0 + assert len(commit["commit"]["author"]["name"]) > 0 + assert len(commit["commit"]["author"]["email"]) > 0 diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 1e165eea9..3e30be1d8 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -85,14 +85,15 @@ class TestResourceValidation: ) assert resource.mime_type == "application/json" - async def test_resource_read_abstract(self): - """Test that Resource.read() is abstract.""" + async def test_resource_read_not_implemented(self): + """Test that Resource.read() raises NotImplementedError.""" class ConcreteResource(Resource): pass - with pytest.raises(TypeError, match="abstract method"): - ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + resource = ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + with pytest.raises(NotImplementedError, match="Subclasses must implement read"): + await resource.read() def test_resource_meta_parameter(self): """Test that meta parameter is properly handled.""" diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 2c08df8d2..970a5c3b3 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -2,11 +2,15 @@ import os from unittest.mock import patch -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse import pytest +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.jwt import JWTVerifier class TestAzureProvider: @@ -95,6 +99,7 @@ class TestAzureProvider: client_id="test_client", client_secret="test_secret", tenant_id="test-tenant", + required_scopes=["User.Read"], ) # Check defaults @@ -109,6 +114,7 @@ class TestAzureProvider: client_secret="test_secret", tenant_id="my-tenant-id", base_url="https://myserver.com", + required_scopes=["User.Read"], ) # Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant @@ -131,6 +137,7 @@ class TestAzureProvider: client_id="test_client", client_secret="test_secret", tenant_id="organizations", + required_scopes=["User.Read"], ) parsed = urlparse(provider1._upstream_authorization_endpoint) assert "/organizations/" in parsed.path @@ -140,6 +147,7 @@ class TestAzureProvider: client_id="test_client", client_secret="test_secret", tenant_id="consumers", + required_scopes=["User.Read"], ) parsed = urlparse(provider2._upstream_authorization_endpoint) assert "/consumers/" in parsed.path @@ -162,3 +170,134 @@ class TestAzureProvider: # Provider should initialize successfully with these scopes assert provider is not None + + def test_init_does_not_require_api_client_id_anymore(self): + """API client ID is no longer required; audience is client_id.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + required_scopes=["User.Read"], + ) + assert provider is not None + + def test_init_with_custom_audience_uses_jwt_verifier(self): + """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="my-tenant", + identifier_uri="api://my-api", + required_scopes=[".default"], + ) + + assert provider._token_validator is not None + assert isinstance(provider._token_validator, JWTVerifier) + verifier = provider._token_validator + assert verifier.jwks_uri is not None + assert verifier.jwks_uri.startswith( + "https://login.microsoftonline.com/my-tenant/discovery/v2.0/keys" + ) + assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0" + assert verifier.audience == "test_client" + + @pytest.mark.asyncio + async def test_authorize_filters_resource_and_prefixes_scopes_with_audience(self): + """authorize() should drop resource and prefix non-openid scopes with audience.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="common", + identifier_uri="api://my-api", + required_scopes=["read", "write"], + base_url="https://srv.example", + ) + + await provider.register_client( + OAuthClientInformationFull( + client_id="dummy", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + ) + + client = OAuthClientInformationFull( + client_id="dummy", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "profile"], + state="abc", + code_challenge="xyz", + resource="https://should.be.ignored", + ) + + url = await provider.authorize(client, params) + + # Extract transaction ID from consent redirect + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert "txn_id" in qs, "Should redirect to consent page with transaction ID" + txn_id = qs["txn_id"][0] + + # Verify transaction contains correct parameters (resource filtered, scopes prefixed) + transaction = await provider._transaction_store.get(key=txn_id) + assert transaction is not None + assert "api://my-api/read" in transaction.scopes + assert "api://my-api/profile" in transaction.scopes + # Azure provider filters resource parameter (not stored in transaction) + assert transaction.resource is None + + @pytest.mark.asyncio + async def test_authorize_appends_unprefixed_additional_scopes(self): + """authorize() should append additional_authorize_scopes without prefixing them.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="common", + identifier_uri="api://my-api", + required_scopes=["read"], + base_url="https://srv.example", + additional_authorize_scopes=["Mail.Read", "User.Read"], + ) + + await provider.register_client( + OAuthClientInformationFull( + client_id="dummy", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + ) + + client = OAuthClientInformationFull( + client_id="dummy", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read"], + state="abc", + code_challenge="xyz", + ) + + url = await provider.authorize(client, params) + + # Extract transaction ID from consent redirect + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert "txn_id" in qs, "Should redirect to consent page with transaction ID" + txn_id = qs["txn_id"][0] + + # Verify transaction contains correct scopes (prefixed + unprefixed additional) + transaction = await provider._transaction_store.get(key=txn_id) + assert transaction is not None + assert "api://my-api/read" in transaction.scopes + assert "Mail.Read" in transaction.scopes + assert "User.Read" in transaction.scopes diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py new file mode 100644 index 000000000..973f29679 --- /dev/null +++ b/tests/server/auth/providers/test_supabase.py @@ -0,0 +1,165 @@ +"""Tests for Supabase Auth provider.""" + +import os +from collections.abc import Generator +from unittest.mock import patch + +import httpx +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process + + +class TestSupabaseProvider: + """Test Supabase Auth provider functionality.""" + + def test_init_with_explicit_params(self): + """Test SupabaseProvider initialization with explicit parameters.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + + assert provider.project_url == "https://abc123.supabase.co" + assert str(provider.base_url) == "https://myserver.com/" + + @pytest.mark.parametrize( + "scopes_env", + [ + "openid,email", + '["openid", "email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): + """Test SupabaseProvider initialization from environment variables.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL": "https://env123.supabase.co", + "FASTMCP_SERVER_AUTH_SUPABASE_BASE_URL": "https://envserver.com", + }, + ): + provider = SupabaseProvider() + + assert provider.project_url == "https://env123.supabase.co" + assert str(provider.base_url) == "https://envserver.com/" + + def test_environment_variable_loading(self): + """Test that environment variables are loaded correctly.""" + provider = SupabaseProvider( + project_url="https://env123.supabase.co", + base_url="http://env-server.com", + ) + + assert provider.project_url == "https://env123.supabase.co" + assert str(provider.base_url) == "http://env-server.com/" + + def test_project_url_normalization(self): + """Test that project_url handles trailing slashes correctly.""" + # Without trailing slash + provider1 = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + assert provider1.project_url == "https://abc123.supabase.co" + + # With trailing slash - should be stripped + provider2 = SupabaseProvider( + project_url="https://abc123.supabase.co/", + base_url="https://myserver.com", + ) + assert provider2.project_url == "https://abc123.supabase.co" + + def test_jwt_verifier_configured_correctly(self): + """Test that JWT verifier is configured correctly.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + + # Check that JWT verifier uses the correct endpoints + assert ( + provider.token_verifier.jwks_uri # type: ignore[attr-defined] + == "https://abc123.supabase.co/auth/v1/.well-known/jwks.json" + ) + assert ( + provider.token_verifier.issuer == "https://abc123.supabase.co/auth/v1" # type: ignore[attr-defined] + ) + assert provider.token_verifier.algorithm == "ES256" # type: ignore[attr-defined] + + def test_jwt_verifier_with_required_scopes(self): + """Test that JWT verifier respects required_scopes.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + required_scopes=["openid", "email"], + ) + + assert provider.token_verifier.required_scopes == ["openid", "email"] # type: ignore[attr-defined] + + def test_authorization_servers_configured(self): + """Test that authorization servers list is configured correctly.""" + provider = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://myserver.com", + ) + + assert len(provider.authorization_servers) == 1 + assert ( + str(provider.authorization_servers[0]) + == "https://abc123.supabase.co/auth/v1" + ) + + +def run_mcp_server(host: str, port: int) -> None: + mcp = FastMCP( + auth=SupabaseProvider( + project_url="https://test123.supabase.co", + base_url="http://localhost:4321", + ) + ) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + mcp.run(host=host, port=port, transport="http") + + +@pytest.fixture +def mcp_server_url() -> Generator[str]: + with run_server_in_process(run_mcp_server) as url: + yield f"{url}/mcp" + + +@pytest.fixture() +def client_with_headless_oauth( + mcp_server_url: str, +) -> Generator[Client, None, None]: + """Client with headless OAuth that bypasses browser interaction.""" + client = Client( + transport=StreamableHttpTransport(mcp_server_url), + auth=HeadlessOAuth(mcp_url=mcp_server_url), + ) + yield client + + +class TestSupabaseProviderIntegration: + async def test_unauthorized_access(self, mcp_server_url: str): + with pytest.raises(httpx.HTTPStatusError) as exc_info: + async with Client(mcp_server_url) as client: + tools = await client.list_tools() # noqa: F841 + + assert isinstance(exc_info.value, httpx.HTTPStatusError) + assert exc_info.value.response.status_code == 401 + assert "tools" not in locals() + + # async def test_authorized_access(self, client_with_headless_oauth: Client): + # async with client_with_headless_oauth: + # tools = await client_with_headless_oauth.list_tools() + # assert tools is not None + # assert len(tools) > 0 + # assert "add" in tools diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py new file mode 100644 index 000000000..41eff87ca --- /dev/null +++ b/tests/server/auth/test_oauth_consent_flow.py @@ -0,0 +1,657 @@ +"""Tests for OAuth Proxy consent flow with server-side storage. + +This test suite verifies: +1. OAuth transactions are stored in server-side storage (not in-memory) +2. Authorization codes are stored in server-side storage +3. Consent flow redirects correctly through /consent endpoint +4. CSRF protection works with cookies +5. State persists across storage backends +6. Security headers (X-Frame-Options) are set correctly +7. Cookie signing and tampering detection +8. Auto-approve behavior with valid cookies +""" + +import re +import secrets +import time +from urllib.parse import parse_qs, urlparse + +import pytest +from key_value.aio.stores.memory import MemoryStore +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from fastmcp.server.auth.auth import TokenVerifier +from fastmcp.server.auth.oauth_proxy import OAuthProxy + + +class MockTokenVerifier(TokenVerifier): + """Mock token verifier for testing.""" + + def __init__(self): + self.required_scopes = ["read", "write"] + + async def verify_token(self, token: str): + """Mock token verification.""" + from fastmcp.server.auth.auth import AccessToken + + return AccessToken( + token=token, + client_id="mock-client", + scopes=self.required_scopes, + expires_at=int(time.time() + 3600), + ) + + +class _Verifier(TokenVerifier): + """Minimal token verifier for security tests.""" + + def __init__(self): + self.required_scopes = ["read"] + + async def verify_token(self, token: str): + from fastmcp.server.auth.auth import AccessToken + + return AccessToken( + token=token, client_id="c", scopes=self.required_scopes, expires_at=None + ) + + +@pytest.fixture +def storage(): + """Create a fresh in-memory storage for each test.""" + return MemoryStore() + + +@pytest.fixture +def oauth_proxy_with_storage(storage): + """Create OAuth proxy with explicit storage backend.""" + return OAuthProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="test-upstream-client", + upstream_client_secret="test-upstream-secret", + token_verifier=MockTokenVerifier(), + base_url="https://myserver.com", + redirect_path="/auth/callback", + client_storage=storage, # Use our test storage + ) + + +@pytest.fixture +def oauth_proxy_https(): + """OAuthProxy configured with HTTPS base_url for __Host- cookies.""" + return OAuthProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="client-id", + upstream_client_secret="client-secret", + token_verifier=_Verifier(), + base_url="https://myserver.example", + client_storage=MemoryStore(), + ) + + +async def _start_flow( + proxy: OAuthProxy, client_id: str, redirect: str +) -> tuple[str, str]: + """Register client and start auth; returns (txn_id, consent_url).""" + await proxy.register_client( + OAuthClientInformationFull( + client_id=client_id, + client_secret="s", + redirect_uris=[AnyUrl(redirect)], + ) + ) + params = AuthorizationParams( + redirect_uri=AnyUrl(redirect), + redirect_uri_provided_explicitly=True, + state="client-state-xyz", + code_challenge="challenge", + code_challenge_method="S256", + scopes=["read"], + ) + consent_url = await proxy.authorize( + OAuthClientInformationFull( + client_id=client_id, + client_secret="s", + redirect_uris=[AnyUrl(redirect)], + ), + params, + ) + qs = parse_qs(urlparse(consent_url).query) + return qs["txn_id"][0], consent_url + + +def _extract_csrf(html: str) -> str | None: + """Extract CSRF token from HTML form.""" + m = re.search(r"name=\"csrf_token\"\s+value=\"([^\"]+)\"", html) + return m.group(1) if m else None + + +class TestServerSideStorage: + """Tests verifying OAuth state is stored in AsyncKeyValue storage.""" + + async def test_transaction_stored_in_storage_not_memory( + self, oauth_proxy_with_storage, storage + ): + """Verify OAuth transactions are stored in AsyncKeyValue, not in-memory dict.""" + # Register client + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:54321/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + # Start authorization flow + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state-123", + code_challenge="challenge-abc", + code_challenge_method="S256", + scopes=["read", "write"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + + # Extract transaction ID from consent redirect + parsed = urlparse(redirect_url) + assert "/consent" in parsed.path, "Should redirect to consent page" + + query_params = parse_qs(parsed.query) + txn_id = query_params["txn_id"][0] + + # Verify transaction is NOT in the old in-memory dict + # (the attribute should not exist or should be empty) + assert ( + not hasattr(oauth_proxy_with_storage, "_oauth_transactions") + or len(getattr(oauth_proxy_with_storage, "_oauth_transactions", {})) == 0 + ) + + # Verify transaction IS in storage backend + transaction = await storage.get(collection="mcp-oauth-transactions", key=txn_id) + assert transaction is not None, "Transaction should be in storage" + + # Verify transaction has expected structure + assert transaction["client_id"] == "test-client" + assert transaction["client_redirect_uri"] == "http://localhost:54321/callback" + assert transaction["client_state"] == "client-state-123" + assert transaction["code_challenge"] == "challenge-abc" + assert transaction["scopes"] == ["read", "write"] + + async def test_authorization_code_stored_in_storage( + self, oauth_proxy_with_storage, storage + ): + """Verify authorization codes are stored in AsyncKeyValue storage.""" + # Register client + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:54321/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + # Create a test app with OAuth routes + app = Starlette(routes=oauth_proxy_with_storage.get_routes()) + + with TestClient(app) as test_client: + # Start authorization flow + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="challenge-xyz", + code_challenge_method="S256", + scopes=["read"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + + # Extract txn_id from consent redirect + parsed = urlparse(redirect_url) + query_params = parse_qs(parsed.query) + txn_id = query_params["txn_id"][0] + + # Simulate consent approval + # First, get the consent page to establish CSRF cookie + consent_response = test_client.get( + f"/consent?txn_id={txn_id}", follow_redirects=False + ) + + # Extract CSRF token from response (it's in the HTML form) + csrf_token = None + if consent_response.status_code == 200: + # For this test, we'll generate a CSRF token manually + # In production, this comes from the consent page HTML + csrf_token = secrets.token_urlsafe(32) + + # Approve consent with CSRF token + approval_response = test_client.post( + "/consent", + data={"action": "approve", "txn": txn_id, "csrf_token": csrf_token}, + cookies=consent_response.cookies, + follow_redirects=False, + ) + + # After approval, authorization code should be in storage + # The code is returned in the redirect URL + if approval_response.status_code in (302, 303): + location = approval_response.headers.get("location", "") + callback_params = parse_qs(urlparse(location).query) + + if "code" in callback_params: + auth_code = callback_params["code"][0] + + # Verify code is NOT in old in-memory dict + assert ( + not hasattr(oauth_proxy_with_storage, "_client_codes") + or len(getattr(oauth_proxy_with_storage, "_client_codes", {})) + == 0 + ) + + # Verify code IS in storage + code_data = await storage.get( + collection="mcp-authorization-codes", key=auth_code + ) + assert code_data is not None, ( + "Authorization code should be in storage" + ) + assert code_data["client_id"] == "test-client" + assert code_data["scopes"] == ["read"] + + async def test_storage_collections_are_isolated(self, oauth_proxy_with_storage): + """Verify that transactions, codes, and clients use separate collections.""" + # Register a client + client = OAuthClientInformationFull( + client_id="isolation-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + # Start authorization to create transaction + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="test-state", + code_challenge="test-challenge", + code_challenge_method="S256", + scopes=["read"], + ) + + await oauth_proxy_with_storage.authorize(client, params) + + # Get all collections from storage + storage = oauth_proxy_with_storage._client_storage + + # Verify client is in client collection + client_data = await storage.get( + collection="mcp-oauth-proxy-clients", key="isolation-test-client" + ) + assert client_data is not None + + # Verify we can list transactions separately + # (This tests that collections are properly namespaced) + transactions = await storage.keys(collection="mcp-oauth-transactions") + + assert len(transactions) > 0, "Should have at least one transaction" + + # Verify transaction keys don't collide with client keys + for txn_key in transactions: + assert txn_key != "isolation-test-client" + + +class TestConsentFlowRedirects: + """Tests for consent flow redirect behavior.""" + + async def test_authorize_redirects_to_consent_page(self, oauth_proxy_with_storage): + """Verify authorize() redirects to /consent instead of upstream.""" + client = OAuthClientInformationFull( + client_id="consent-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:8080/callback"), + redirect_uri_provided_explicitly=True, + state="test-state", + code_challenge="", + scopes=["read"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + + # Should redirect to consent page, not upstream + assert "/consent" in redirect_url + assert "github.com" not in redirect_url + assert "?txn_id=" in redirect_url + + async def test_consent_page_contains_transaction_id(self, oauth_proxy_with_storage): + """Verify consent page receives and displays transaction ID.""" + client = OAuthClientInformationFull( + client_id="txn-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:9090/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:9090/callback"), + redirect_uri_provided_explicitly=True, + state="test-state", + code_challenge="test-challenge", + scopes=["read", "write"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + + # Extract txn_id parameter + parsed = urlparse(redirect_url) + query = parse_qs(parsed.query) + + assert "txn_id" in query + txn_id = query["txn_id"][0] + assert len(txn_id) > 0 + + # Create test client + app = Starlette(routes=oauth_proxy_with_storage.get_routes()) + + with TestClient(app) as test_client: + # Request consent page + response = test_client.get( + f"/consent?txn_id={txn_id}", follow_redirects=False + ) + + assert response.status_code == 200 + # Consent page should contain transaction reference + assert txn_id.encode() in response.content or b"consent" in response.content + + +class TestCSRFProtection: + """Tests for CSRF protection in consent flow.""" + + async def test_consent_requires_csrf_token(self, oauth_proxy_with_storage): + """Verify consent submission requires valid CSRF token.""" + client = OAuthClientInformationFull( + client_id="csrf-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:7070/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:7070/callback"), + redirect_uri_provided_explicitly=True, + state="test-state", + code_challenge="", + scopes=["read"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + parsed = urlparse(redirect_url) + query = parse_qs(parsed.query) + txn_id = query["txn_id"][0] + + app = Starlette(routes=oauth_proxy_with_storage.get_routes()) + + with TestClient(app) as test_client: + # Try to submit consent WITHOUT CSRF token + response = test_client.post( + "/consent/submit", + data={"action": "approve", "txn_id": txn_id}, + # No CSRF token! + follow_redirects=False, + ) + + # Should reject or require CSRF + # (Implementation may vary - checking for error response) + assert response.status_code in ( + 400, + 403, + 302, + ) # Error or redirect to error + + async def test_consent_cookie_established_on_page_visit( + self, oauth_proxy_with_storage + ): + """Verify consent page establishes CSRF cookie.""" + client = OAuthClientInformationFull( + client_id="cookie-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:6060/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:6060/callback"), + redirect_uri_provided_explicitly=True, + state="test-state", + code_challenge="", + scopes=["read"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + parsed = urlparse(redirect_url) + query = parse_qs(parsed.query) + txn_id = query["txn_id"][0] + + app = Starlette(routes=oauth_proxy_with_storage.get_routes()) + + with TestClient(app) as test_client: + # Visit consent page + response = test_client.get( + f"/consent?txn_id={txn_id}", follow_redirects=False + ) + + # Should set cookies for CSRF protection + assert response.status_code == 200 + # Cookie may be set via Set-Cookie header + cookies = response.cookies + # Look for any CSRF-related cookie (implementation dependent) + assert len(cookies) > 0 or "csrf" in response.text.lower(), ( + "Consent page should establish CSRF protection" + ) + + +class TestStoragePersistence: + """Tests for state persistence across storage backends.""" + + async def test_transaction_persists_after_retrieval(self, oauth_proxy_with_storage): + """Verify transaction can be retrieved multiple times (until deleted).""" + client = OAuthClientInformationFull( + client_id="persist-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:5050/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:5050/callback"), + redirect_uri_provided_explicitly=True, + state="persist-state", + code_challenge="persist-challenge", + code_challenge_method="S256", + scopes=["read"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + parsed = urlparse(redirect_url) + query = parse_qs(parsed.query) + txn_id = query["txn_id"][0] + + storage = oauth_proxy_with_storage._client_storage + + # Retrieve transaction multiple times + txn1 = await storage.get(collection="mcp-oauth-transactions", key=txn_id) + assert txn1 is not None + + txn2 = await storage.get(collection="mcp-oauth-transactions", key=txn_id) + assert txn2 is not None + + # Should be the same data + assert txn1["client_id"] == txn2["client_id"] + assert txn1["client_state"] == txn2["client_state"] + + async def test_storage_uses_pydantic_adapter(self, oauth_proxy_with_storage): + """Verify that PydanticAdapter serializes/deserializes correctly.""" + from fastmcp.server.auth.oauth_proxy import OAuthTransaction + + client = OAuthClientInformationFull( + client_id="pydantic-test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:4040/callback")], + ) + await oauth_proxy_with_storage.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:4040/callback"), + redirect_uri_provided_explicitly=True, + state="pydantic-state", + code_challenge="pydantic-challenge", + code_challenge_method="S256", + scopes=["read", "write"], + ) + + redirect_url = await oauth_proxy_with_storage.authorize(client, params) + parsed = urlparse(redirect_url) + query = parse_qs(parsed.query) + txn_id = query["txn_id"][0] + + # Retrieve using PydanticAdapter (which is what the proxy uses) + transaction_store = oauth_proxy_with_storage._transaction_store + txn_model = await transaction_store.get(key=txn_id) + + # Should be a Pydantic model instance + assert isinstance(txn_model, OAuthTransaction) + assert txn_model.client_id == "pydantic-test-client" + assert txn_model.client_state == "pydantic-state" + assert txn_model.code_challenge == "pydantic-challenge" + assert txn_model.scopes == ["read", "write"] + + +class TestConsentSecurity: + """Tests for consent page security features.""" + + async def test_consent_sets_xfo_header(self, oauth_proxy_https): + """Verify consent page sets X-Frame-Options header to prevent clickjacking.""" + txn_id, _ = await _start_flow( + oauth_proxy_https, "client-a", "http://localhost:5001/callback" + ) + app = Starlette(routes=oauth_proxy_https.get_routes()) + with TestClient(app) as c: + r = c.get(f"/consent?txn_id={txn_id}") + assert r.status_code == 200 + assert r.headers.get("X-Frame-Options") == "DENY" + + async def test_deny_sets_cookie_and_redirects_with_error(self, oauth_proxy_https): + """Verify denying consent sets signed cookie and redirects with error.""" + client_redirect = "http://localhost:5002/callback" + txn_id, _ = await _start_flow(oauth_proxy_https, "client-b", client_redirect) + app = Starlette(routes=oauth_proxy_https.get_routes()) + with TestClient(app) as c: + consent = c.get(f"/consent?txn_id={txn_id}") + csrf = _extract_csrf(consent.text) + assert csrf + # Persist consent page cookies on client instance to avoid per-request deprecation + for k, v in consent.cookies.items(): + c.cookies.set(k, v) + r = c.post( + "/consent/submit", + data={"action": "deny", "txn_id": txn_id, "csrf_token": csrf}, + follow_redirects=False, + ) + assert r.status_code in (302, 303) + loc = r.headers.get("location", "") + parsed = urlparse(loc) + assert parsed.scheme == "http" and parsed.netloc.startswith("localhost") + q = parse_qs(parsed.query) + assert q.get("error") == ["access_denied"] + assert q.get("state") == ["client-state-xyz"] + # Signed denied cookie should be set + assert "MCP_DENIED_CLIENTS" in ";\n".join( + r.headers.get("set-cookie", "").splitlines() + ) + + async def test_approve_sets_cookie_and_redirects_to_upstream( + self, oauth_proxy_https + ): + """Verify approving consent sets signed cookie and redirects to upstream.""" + txn_id, _ = await _start_flow( + oauth_proxy_https, "client-c", "http://localhost:5003/callback" + ) + app = Starlette(routes=oauth_proxy_https.get_routes()) + with TestClient(app) as c: + consent = c.get(f"/consent?txn_id={txn_id}") + csrf = _extract_csrf(consent.text) + assert csrf + for k, v in consent.cookies.items(): + c.cookies.set(k, v) + r = c.post( + "/consent/submit", + data={"action": "approve", "txn_id": txn_id, "csrf_token": csrf}, + follow_redirects=False, + ) + assert r.status_code in (302, 303) + loc = r.headers.get("location", "") + assert loc.startswith("https://github.com/login/oauth/authorize") + assert f"state={txn_id}" in loc + # Signed approved cookie should be set with __Host- prefix for HTTPS + set_cookie = ";\n".join(r.headers.get("set-cookie", "").splitlines()) + assert "__Host-MCP_APPROVED_CLIENTS" in set_cookie + + async def test_tampered_cookie_is_ignored(self, oauth_proxy_https): + """Verify tampered approval cookie is ignored and consent page shown.""" + txn_id, _ = await _start_flow( + oauth_proxy_https, "client-d", "http://localhost:5004/callback" + ) + app = Starlette(routes=oauth_proxy_https.get_routes()) + with TestClient(app) as c: + # Create a tampered cookie (invalid signature) + # Value format: payload.signature; using wrong signature to force failure + tampered_value = "W10=.invalidsig" + c.cookies.set("__Host-MCP_APPROVED_CLIENTS", tampered_value) + r = c.get(f"/consent?txn_id={txn_id}", follow_redirects=False) + # Should not auto-redirect to upstream; should show consent page + assert r.status_code == 200 + # httpx returns a URL object; compare path or stringify + assert urlparse(str(r.request.url)).path == "/consent" + + async def test_autoapprove_cookie_skips_consent(self, oauth_proxy_https): + """Verify valid approval cookie auto-approves and redirects to upstream.""" + client_id = "client-e" + redirect = "http://localhost:5005/callback" + txn_id, _ = await _start_flow(oauth_proxy_https, client_id, redirect) + app = Starlette(routes=oauth_proxy_https.get_routes()) + with TestClient(app) as c: + # Approve once to set approved cookie + consent = c.get(f"/consent?txn_id={txn_id}") + csrf = _extract_csrf(consent.text) + for k, v in consent.cookies.items(): + c.cookies.set(k, v) + r = c.post( + "/consent/submit", + data={"action": "approve", "txn_id": txn_id, "csrf_token": csrf}, + follow_redirects=False, + ) + # Extract approved cookie value + set_cookie = ";\n".join(r.headers.get("set-cookie", "").splitlines()) + m = re.search(r"__Host-MCP_APPROVED_CLIENTS=([^;]+)", set_cookie) + assert m, "approved cookie should be set" + approved_cookie = m.group(1) + + # Start a new flow for the same client and redirect + new_txn, _ = await _start_flow(oauth_proxy_https, client_id, redirect) + # Should auto-redirect to upstream when visiting consent due to cookie + c.cookies.set("__Host-MCP_APPROVED_CLIENTS", approved_cookie) + r2 = c.get(f"/consent?txn_id={new_txn}", follow_redirects=False) + assert r2.status_code in (302, 303) + assert r2.headers.get("location", "").startswith( + "https://github.com/login/oauth/authorize" + ) diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index c171547eb..13ceb5185 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -441,13 +441,16 @@ class TestOAuthProxyAuthorization: """Tests for OAuth proxy authorization flow.""" async def test_authorize_creates_transaction(self, oauth_proxy): - """Test that authorize creates transaction and returns upstream URL.""" + """Test that authorize creates transaction and redirects to consent.""" client = OAuthClientInformationFull( client_id="test-client", client_secret="test-secret", redirect_uris=[AnyUrl("http://localhost:54321/callback")], ) + # Register client first (required for consent flow) + await oauth_proxy.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:54321/callback"), redirect_uri_provided_explicitly=True, @@ -463,18 +466,18 @@ class TestOAuthProxyAuthorization: parsed = urlparse(redirect_url) query_params = parse_qs(parsed.query) - # Verify upstream URL structure - assert "github.com/login/oauth/authorize" in redirect_url - assert query_params["client_id"][0] == "test-client-id" - assert query_params["response_type"][0] == "code" - assert "state" in query_params # Transaction ID + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params - # Verify transaction was stored - txn_id = query_params["state"][0] - assert txn_id in oauth_proxy._oauth_transactions - transaction = oauth_proxy._oauth_transactions[txn_id] - assert transaction["client_id"] == "test-client" - assert transaction["code_challenge"] == "challenge-abc" + # Verify transaction was stored with correct data + txn_id = query_params["txn_id"][0] + transaction = await oauth_proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.client_id == "test-client" + assert transaction.code_challenge == "challenge-abc" + assert transaction.client_state == "client-state-123" + assert transaction.scopes == ["read", "write"] class TestOAuthProxyPKCE: @@ -512,6 +515,9 @@ class TestOAuthProxyPKCE: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy_with_pkce.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -523,16 +529,19 @@ class TestOAuthProxyPKCE: redirect_url = await proxy_with_pkce.authorize(client, params) query_params = parse_qs(urlparse(redirect_url).query) - # Proxy should forward its own PKCE - assert "code_challenge" in query_params - assert query_params["code_challenge"][0] != "client_challenge" - assert query_params["code_challenge_method"] == ["S256"] + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params # Transaction should store both challenges - txn_id = query_params["state"][0] - transaction = proxy_with_pkce._oauth_transactions[txn_id] - assert transaction["code_challenge"] == "client_challenge" # Client's - assert "proxy_code_verifier" in transaction # Proxy's verifier + txn_id = query_params["txn_id"][0] + transaction = await proxy_with_pkce._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.code_challenge == "client_challenge" # Client's + assert transaction.proxy_code_verifier is not None # Proxy's verifier + # Proxy code challenge is computed from verifier when building upstream URL + # Just verify the verifier exists and is different from client's challenge + assert len(transaction.proxy_code_verifier) > 0 async def test_pkce_forwarding_disabled(self, proxy_without_pkce): """Test that PKCE is not forwarded when disabled.""" @@ -542,6 +551,9 @@ class TestOAuthProxyPKCE: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy_without_pkce.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -553,15 +565,16 @@ class TestOAuthProxyPKCE: redirect_url = await proxy_without_pkce.authorize(client, params) query_params = parse_qs(urlparse(redirect_url).query) - # No PKCE forwarded to upstream - assert "code_challenge" not in query_params - assert "code_challenge_method" not in query_params + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params - # Client's challenge still stored - txn_id = query_params["state"][0] - transaction = proxy_without_pkce._oauth_transactions[txn_id] - assert transaction["code_challenge"] == "client_challenge" - assert "proxy_code_verifier" not in transaction + # Client's challenge still stored, but no proxy PKCE + txn_id = query_params["txn_id"][0] + transaction = await proxy_without_pkce._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.code_challenge == "client_challenge" + assert transaction.proxy_code_verifier is None # No proxy PKCE when disabled class TestOAuthProxyTokenEndpointAuth: @@ -682,6 +695,9 @@ class TestOAuthProxyE2E: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy.register_client(client_info) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -690,28 +706,22 @@ class TestOAuthProxyE2E: scopes=["read"], ) - # Get authorization URL + # Get authorization URL (now returns consent redirect) auth_url = await proxy.authorize(client_info, params) - # Verify mock provider was called - assert mock_oauth_provider.authorize_endpoint in auth_url - - # Verify state is present (transaction ID) + # Should redirect to consent page + assert "/consent" in auth_url query_params = parse_qs(urlparse(auth_url).query) - assert "state" in query_params + assert "txn_id" in query_params - # Simulate authorization callback - async with httpx.AsyncClient() as http_client: - # This would normally redirect, but our mock returns the code - response = await http_client.get(auth_url, follow_redirects=False) - - # Extract code from redirect location - location = response.headers.get("location", "") - callback_params = parse_qs(urlparse(location).query) - auth_code = callback_params.get("code", [None])[0] - - assert auth_code is not None - assert mock_oauth_provider.authorize_called + # Verify transaction was created with correct configuration + txn_id = query_params["txn_id"][0] + transaction = await proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.client_id == "test-client" + assert transaction.scopes == ["read"] + # Transaction ID itself is used as upstream state parameter + assert transaction.txn_id == txn_id @pytest.mark.asyncio async def test_token_refresh_with_mock_provider(self, mock_oauth_provider): @@ -790,6 +800,9 @@ class TestOAuthProxyE2E: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -803,14 +816,20 @@ class TestOAuthProxyE2E: auth_url = await proxy.authorize(client, params) query_params = parse_qs(urlparse(auth_url).query) - # Verify PKCE was forwarded (proxy's challenge, not client's) - assert "code_challenge" in query_params - assert query_params["code_challenge"][0] != "client_challenge_value" + # Should redirect to consent page + assert "/consent" in auth_url + assert "txn_id" in query_params - # Transaction should have proxy's verifier - txn_id = query_params["state"][0] - transaction = proxy._oauth_transactions[txn_id] - assert "proxy_code_verifier" in transaction + # Transaction should have proxy's PKCE verifier (different from client's) + txn_id = query_params["txn_id"][0] + transaction = await proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert ( + transaction.code_challenge == "client_challenge_value" + ) # Client's challenge + assert transaction.proxy_code_verifier is not None # Proxy generated its own + # Proxy code challenge is computed from verifier when needed + assert len(transaction.proxy_code_verifier) > 0 class TestParameterForwarding: @@ -850,6 +869,9 @@ class TestParameterForwarding: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy_without_extra_params.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -862,9 +884,17 @@ class TestParameterForwarding: redirect_url = await proxy_without_extra_params.authorize(client, params) query_params = parse_qs(urlparse(redirect_url).query) - # Resource parameter should be forwarded to upstream - assert "resource" in query_params - assert query_params["resource"][0] == "https://api.example.com/v1" + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Resource parameter should be stored in transaction for upstream forwarding + txn_id = query_params["txn_id"][0] + transaction = await proxy_without_extra_params._transaction_store.get( + key=txn_id + ) + assert transaction is not None + assert transaction.resource == "https://api.example.com/v1" async def test_extra_authorize_params(self, proxy_with_extra_params): """Test that extra authorization parameters are included.""" @@ -874,6 +904,9 @@ class TestParameterForwarding: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy_with_extra_params.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -885,9 +918,19 @@ class TestParameterForwarding: redirect_url = await proxy_with_extra_params.authorize(client, params) query_params = parse_qs(urlparse(redirect_url).query) - # Extra audience parameter should be included - assert "audience" in query_params - assert query_params["audience"][0] == "https://api.example.com" + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Extra audience parameter is configured at proxy level (not per-transaction) + txn_id = query_params["txn_id"][0] + transaction = await proxy_with_extra_params._transaction_store.get(key=txn_id) + assert transaction is not None + # Verify proxy has extra params configured + assert ( + proxy_with_extra_params._extra_authorize_params.get("audience") + == "https://api.example.com" + ) async def test_resource_and_extra_params_together(self, proxy_with_extra_params): """Test that both resource and extra params can be used together.""" @@ -897,6 +940,9 @@ class TestParameterForwarding: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy_with_extra_params.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -909,11 +955,19 @@ class TestParameterForwarding: redirect_url = await proxy_with_extra_params.authorize(client, params) query_params = parse_qs(urlparse(redirect_url).query) - # Both resource and audience should be present - assert "resource" in query_params - assert query_params["resource"][0] == "https://resource.example.com" - assert "audience" in query_params - assert query_params["audience"][0] == "https://api.example.com" + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Resource stored in transaction, extra params configured at proxy level + txn_id = query_params["txn_id"][0] + transaction = await proxy_with_extra_params._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.resource == "https://resource.example.com" + assert ( + proxy_with_extra_params._extra_authorize_params.get("audience") + == "https://api.example.com" + ) async def test_no_extra_params_when_not_configured( self, proxy_without_extra_params @@ -964,6 +1018,9 @@ class TestParameterForwarding: redirect_uris=[AnyUrl("http://localhost:12345/callback")], ) + # Register client first + await proxy.register_client(client) + params = AuthorizationParams( redirect_uri=AnyUrl("http://localhost:12345/callback"), redirect_uri_provided_explicitly=True, @@ -975,10 +1032,20 @@ class TestParameterForwarding: redirect_url = await proxy.authorize(client, params) query_params = parse_qs(urlparse(redirect_url).query) - # All extra parameters should be included - assert query_params["audience"][0] == "https://api.example.com" - assert query_params["prompt"][0] == "consent" - assert query_params["max_age"][0] == "3600" + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # All extra parameters configured at proxy level + txn_id = query_params["txn_id"][0] + transaction = await proxy._transaction_store.get(key=txn_id) + assert transaction is not None + # Verify proxy has all extra params configured + assert ( + proxy._extra_authorize_params.get("audience") == "https://api.example.com" + ) + assert proxy._extra_authorize_params.get("prompt") == "consent" + assert proxy._extra_authorize_params.get("max_age") == "3600" @pytest.mark.asyncio async def test_token_endpoint_invalid_client_error(self, jwt_verifier): diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 8a2ab8564..a4185538f 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -176,7 +176,7 @@ class TestOAuthProxyRedirectValidation: "new-client" ) # Use the client ID we registered assert isinstance(registered, ProxyDCRClient) - assert registered._allowed_redirect_uri_patterns == custom_patterns + assert registered.allowed_redirect_uri_patterns == custom_patterns @pytest.mark.asyncio async def test_proxy_unregistered_client_returns_none(self): diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/test_oauth_proxy_storage.py index 6ceef86aa..629708427 100644 --- a/tests/server/auth/test_oauth_proxy_storage.py +++ b/tests/server/auth/test_oauth_proxy_storage.py @@ -1,14 +1,18 @@ """Tests for OAuth proxy with persistent storage.""" +from collections.abc import AsyncGenerator from pathlib import Path from unittest.mock import AsyncMock, Mock import pytest +from diskcache.core import tempfile +from inline_snapshot import snapshot +from key_value.aio.stores.disk import MultiDiskStore +from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.storage import InMemoryStorage, JSONFileStorage class TestOAuthProxyStorage: @@ -23,14 +27,17 @@ class TestOAuthProxyStorage: return verifier @pytest.fixture - def temp_storage(self, tmp_path: Path) -> JSONFileStorage: + async def temp_storage(self) -> AsyncGenerator[MultiDiskStore, None]: """Create file-based storage for testing.""" - return JSONFileStorage(tmp_path / "oauth-clients") + with tempfile.TemporaryDirectory() as temp_dir: + disk_store = MultiDiskStore(base_directory=Path(temp_dir)) + yield disk_store + await disk_store.close() @pytest.fixture - def memory_storage(self) -> InMemoryStorage: + def memory_storage(self) -> MemoryStore: """Create in-memory storage for testing.""" - return InMemoryStorage() + return MemoryStore() def create_proxy(self, jwt_verifier, storage=None) -> OAuthProxy: """Create an OAuth proxy with specified storage.""" @@ -48,7 +55,7 @@ class TestOAuthProxyStorage: async def test_default_storage_is_file_based(self, jwt_verifier): """Test that proxy defaults to file-based storage.""" proxy = self.create_proxy(jwt_verifier, storage=None) - assert isinstance(proxy._client_storage, JSONFileStorage) + assert isinstance(proxy._client_storage, MemoryStore) async def test_register_and_get_client(self, jwt_verifier, temp_storage): """Test registering and retrieving a client.""" @@ -132,7 +139,7 @@ class TestOAuthProxyStorage: async def test_in_memory_storage_option(self, jwt_verifier): """Test using in-memory storage explicitly.""" - storage = InMemoryStorage() + storage = MemoryStore() proxy = self.create_proxy(jwt_verifier, storage=storage) client_info = OAuthClientInformationFull( @@ -151,7 +158,7 @@ class TestOAuthProxyStorage: assert client2 is not None # But new storage instance won't have it - proxy3 = self.create_proxy(jwt_verifier, storage=InMemoryStorage()) + proxy3 = self.create_proxy(jwt_verifier, storage=MemoryStore()) client3 = await proxy3.get_client("memory-client") assert client3 is None @@ -167,47 +174,31 @@ class TestOAuthProxyStorage: await proxy.register_client(client_info) # Check raw storage data - raw_data = await temp_storage.get("structured-client") + raw_data = await temp_storage.get( + collection="mcp-oauth-proxy-clients", key="structured-client" + ) assert raw_data is not None - assert "client" in raw_data - assert "allowed_redirect_uri_patterns" in raw_data - - async def test_cleanup_old_clients(self, jwt_verifier, temp_storage): - """Test cleanup of old clients using storage's cleanup method.""" - import json - import time - - proxy = self.create_proxy(jwt_verifier, storage=temp_storage) - - # Register some clients - client1 = OAuthClientInformationFull( - client_id="old-client", - client_secret="secret1", - redirect_uris=[AnyUrl("http://localhost:8080/callback")], + assert raw_data == snapshot( + { + "redirect_uris": ["http://localhost:8080/callback"], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "read write", + "client_name": None, + "client_uri": None, + "logo_uri": None, + "contacts": None, + "tos_uri": None, + "policy_uri": None, + "jwks_uri": None, + "jwks": None, + "software_id": None, + "software_version": None, + "client_id": "structured-client", + "client_secret": "secret", + "client_id_issued_at": None, + "client_secret_expires_at": None, + "allowed_redirect_uri_patterns": None, + } ) - await proxy.register_client(client1) - - client2 = OAuthClientInformationFull( - client_id="recent-client", - client_secret="secret2", - redirect_uris=[AnyUrl("http://localhost:9090/callback")], - ) - await proxy.register_client(client2) - - # Manually make the first client old by modifying the file directly - old_client_path = temp_storage._get_file_path("old-client") - wrapper = json.loads(old_client_path.read_text()) - wrapper["timestamp"] = time.time() - (35 * 24 * 60 * 60) # 35 days old - old_client_path.write_text(json.dumps(wrapper)) - - # Run cleanup directly on storage - removed_count = await temp_storage.cleanup_old_entries( - max_age_seconds=30 * 24 * 60 * 60 - ) - assert removed_count == 1 - - # Old client should be gone - assert await proxy.get_client("old-client") is None - - # Recent client should still exist - assert await proxy.get_client("recent-client") is not None diff --git a/tests/server/http/test_custom_routes.py b/tests/server/http/test_custom_routes.py index c43444756..d0a757be4 100644 --- a/tests/server/http/test_custom_routes.py +++ b/tests/server/http/test_custom_routes.py @@ -19,7 +19,7 @@ class TestCustomRoutes: return server - def test_custom_routes_via_server_http_app(self, server_with_custom_route): + def test_custom_routes_apply_filtering_http_app(self, server_with_custom_route): """Test that custom routes are included when using server.http_app().""" # Get the app via server.http_app() app = server_with_custom_route.http_app() diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py new file mode 100644 index 000000000..06776e16e --- /dev/null +++ b/tests/server/middleware/test_initialization_middleware.py @@ -0,0 +1,251 @@ +"""Tests for middleware support during initialization.""" + +from typing import Any + +import mcp.types as mt + +from fastmcp import Client, FastMCP +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext + + +class InitializationMiddleware(Middleware): + """Middleware that captures initialization details.""" + + def __init__(self): + super().__init__() + self.initialized = False + self.client_info = None + self.session_data = {} + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + """Capture initialization details and store session data.""" + self.initialized = True + + # Extract client info from the initialize params + if hasattr(context.message, "params") and hasattr( + context.message.params, "clientInfo" + ): + self.client_info = context.message.params.clientInfo + + # Store data in the context state for cross-request access + if context.fastmcp_context: + context.fastmcp_context.set_state("client_initialized", True) + if self.client_info: + context.fastmcp_context.set_state( + "client_name", getattr(self.client_info, "name", "unknown") + ) + + return await call_next(context) + + +class ClientDetectionMiddleware(Middleware): + """Middleware that detects specific clients and modifies behavior. + + This demonstrates storing data in the middleware instance itself + for cross-request access, since context state is request-scoped. + """ + + def __init__(self): + super().__init__() + self.is_test_client = False + self.tools_modified = False + self.initialization_called = False + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + """Detect test client during initialization.""" + self.initialization_called = True + + # For testing purposes, always set it to true + # Store in instance variable for cross-request access + self.is_test_client = True + + return await call_next(context) + + async def on_list_tools( + self, + context: MiddlewareContext[mt.ListToolsRequest], + call_next: CallNext[mt.ListToolsRequest, list], + ) -> list: + """Modify tools based on client detection.""" + tools = await call_next(context) + + # Use the instance variable set during initialization + if self.is_test_client: + # Add a special annotation to tools for test clients + for tool in tools: + if not hasattr(tool, "annotations"): + tool.annotations = mt.ToolAnnotations() + if tool.annotations is None: + tool.annotations = mt.ToolAnnotations() + # Mark as read-only for test clients + tool.annotations.readOnlyHint = True + self.tools_modified = True + + return tools + + +async def test_simple_initialization_hook(): + """Test that the on_initialize hook is called.""" + server = FastMCP("TestServer") + + class SimpleInitMiddleware(Middleware): + def __init__(self): + super().__init__() + self.called = False + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + self.called = True + return await call_next(context) + + middleware = SimpleInitMiddleware() + server.add_middleware(middleware) + + # Connect client + async with Client(server): + # Middleware should have been called + assert middleware.called is True, "on_initialize was not called" + + +async def test_middleware_receives_initialization(): + """Test that middleware can intercept initialization requests.""" + server = FastMCP("TestServer") + middleware = InitializationMiddleware() + server.add_middleware(middleware) + + @server.tool + def test_tool(x: int) -> str: + return f"Result: {x}" + + # Connect client + async with Client(server) as client: + # Middleware should have been called during initialization + assert middleware.initialized is True + + # Test that the tool still works + result = await client.call_tool("test_tool", {"x": 42}) + assert result.content[0].text == "Result: 42" # type: ignore[attr-defined] + + +async def test_client_detection_middleware(): + """Test middleware that detects specific clients and modifies behavior.""" + server = FastMCP("TestServer") + middleware = ClientDetectionMiddleware() + server.add_middleware(middleware) + + @server.tool + def example_tool() -> str: + return "example" + + # Connect with a client + async with Client(server) as client: + # Middleware should have been called during initialization + assert middleware.initialization_called is True + assert middleware.is_test_client is True + + # List tools to trigger modification + tools = await client.list_tools() + assert len(tools) == 1 + assert middleware.tools_modified is True + + # Check that the tool has the modified annotation + tool = tools[0] + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + + +async def test_multiple_middleware_initialization(): + """Test that multiple middleware can handle initialization.""" + server = FastMCP("TestServer") + + init_mw = InitializationMiddleware() + detect_mw = ClientDetectionMiddleware() + + server.add_middleware(init_mw) + server.add_middleware(detect_mw) + + @server.tool + def test_tool() -> str: + return "test" + + async with Client(server) as client: + # Both middleware should have processed initialization + assert init_mw.initialized is True + assert detect_mw.initialization_called is True + assert detect_mw.is_test_client is True + + # List tools to check detection worked + await client.list_tools() + assert detect_mw.tools_modified is True + + +async def test_initialization_middleware_with_state_sharing(): + """Test that state set during initialization is available in later requests.""" + server = FastMCP("TestServer") + + class StateTrackingMiddleware(Middleware): + def __init__(self): + super().__init__() + self.init_state = {} + self.tool_state = {} + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, None], + ) -> None: + # Store some state during initialization + if context.fastmcp_context: + context.fastmcp_context.set_state("init_timestamp", "2024-01-01") + context.fastmcp_context.set_state("client_id", "test-123") + self.init_state["timestamp"] = "2024-01-01" + self.init_state["client_id"] = "test-123" + + return await call_next(context) + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, Any], + ) -> Any: + # Try to access state from initialization + if context.fastmcp_context: + timestamp = context.fastmcp_context.get_state("init_timestamp") + client_id = context.fastmcp_context.get_state("client_id") + self.tool_state["timestamp"] = timestamp + self.tool_state["client_id"] = client_id + + return await call_next(context) + + middleware = StateTrackingMiddleware() + server.add_middleware(middleware) + + @server.tool + def test_tool() -> str: + return "success" + + async with Client(server) as client: + # Initialization should have set state + assert middleware.init_state["timestamp"] == "2024-01-01" + assert middleware.init_state["client_id"] == "test-123" + + # Call a tool - state should be accessible + result = await client.call_tool("test_tool", {}) + assert result.content[0].text == "success" # type: ignore[attr-defined] + + # State should have been accessible during tool call + # Note: State is request-scoped, so it won't persist across requests + # This test shows the pattern, but actual cross-request state would need + # external storage (Redis, DB, etc.) + # The middleware.tool_state might be None if state doesn't persist diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index 2bbeda9fa..f63165a66 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -1,11 +1,10 @@ """Tests for logging middleware.""" import datetime -import json import logging -import re +from collections.abc import Generator from typing import Any, Literal, TypeVar -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import mcp import mcp.types @@ -28,15 +27,15 @@ FIXED_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc) T = TypeVar("T") -def remove_line_numbers(logs: str) -> str: - """Remove line numbers from log messages.""" - trimmed_logs = "" - lines = logs.split("\n") - for line in lines: - # Match only the first `:\d+ ` - line = re.sub(pattern=r":\d+ ", repl=":LINE_NUMBER ", string=line, count=1) - trimmed_logs += line + "\n" - return trimmed_logs +def get_log_lines( + caplog: pytest.LogCaptureFixture, module: str | None = None +) -> list[str]: + """Get log lines from a caplog fixture.""" + return [ + record.message + for record in caplog.records + if (module or "logging") in record.name + ] def new_mock_context( @@ -55,6 +54,17 @@ def new_mock_context( return context +@pytest.fixture(autouse=True) +def mock_duration_ms() -> Generator[float, None]: + """Mock duration_ms.""" + patched = patch( + "fastmcp.server.middleware.logging._get_duration_ms", return_value=0.02 + ) + patched.start() + yield + patched.stop() + + @pytest.fixture def mock_context(): """Create a mock middleware context.""" @@ -81,15 +91,14 @@ class TestStructuredLoggingMiddleware: def test_init_default(self): """Test default initialization.""" - middleware = LoggingMiddleware() + middleware = StructuredLoggingMiddleware() - assert middleware.logger.name == "fastmcp.requests" + assert middleware.logger.name == "fastmcp.middleware.structured_logging" assert middleware.log_level == logging.INFO assert middleware.include_payloads is False - assert middleware.max_payload_length == 1000 assert middleware.include_payload_length is False assert middleware.estimate_payload_tokens is False - assert middleware.structured_logging is False + assert middleware.structured_logging is True def test_init_custom(self): """Test custom initialization.""" @@ -112,14 +121,12 @@ class TestStructuredLoggingMiddleware: """Test message formatting without payloads.""" middleware = StructuredLoggingMiddleware() - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", } ) @@ -130,14 +137,12 @@ class TestStructuredLoggingMiddleware: """Test message formatting with payloads.""" middleware = StructuredLoggingMiddleware(include_payloads=True) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}', "payload_type": "CallToolRequest", @@ -147,14 +152,12 @@ class TestStructuredLoggingMiddleware: def test_calculate_response_size(self, mock_context: MiddlewareContext[Any]): """Test response size calculation.""" middleware = StructuredLoggingMiddleware(include_payload_length=True) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", "payload_length": 98, } @@ -167,14 +170,12 @@ class TestStructuredLoggingMiddleware: middleware = StructuredLoggingMiddleware( include_payload_length=True, estimate_payload_tokens=True ) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) assert message == snapshot( { - "event": "test_event", - "timestamp": "2023-01-01T00:00:00+00:00", + "event": "request_start", "source": "client", - "type": "request", "method": "test_method", "payload_tokens": 24, "payload_length": 98, @@ -195,11 +196,13 @@ class TestStructuredLoggingMiddleware: assert result == "test_result" assert mock_call_next.called - assert remove_line_numbers(caplog.text) == snapshot("""\ -INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "test_method", "type": "request", "source": "client"} -INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": "request_success", "timestamp": "2023-01-01T00:00:00+00:00", "method": "test_method", "type": "request", "source": "client"} -""") + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] + ) async def test_on_message_failure( self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture @@ -212,8 +215,12 @@ INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": with pytest.raises(ValueError): await middleware.on_message(mock_context, mock_call_next) - assert "Processing message:" in caplog.text - assert "Failed message: test_method - test error" in caplog.text + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client"}', + '{"event": "request_error", "method": "test_method", "source": "client", "duration_ms": 0.02, "error": "test error"}', + ] + ) class TestLoggingMiddleware: @@ -222,7 +229,7 @@ class TestLoggingMiddleware: def test_init_default(self): """Test default initialization.""" middleware = LoggingMiddleware() - assert middleware.logger.name == "fastmcp.requests" + assert middleware.logger.name == "fastmcp.middleware.logging" assert middleware.log_level == logging.INFO assert middleware.include_payloads is False assert middleware.include_payload_length is False @@ -231,11 +238,11 @@ class TestLoggingMiddleware: def test_format_message(self, mock_context: MiddlewareContext[Any]): """Test message formatting.""" middleware = LoggingMiddleware() - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) formatted = middleware._format_message(message) assert formatted == snapshot( - "event=test_event timestamp=2023-01-01T00:00:00+00:00 method=test_method type=request source=client" + "event=request_start method=test_method source=client" ) def test_create_before_message_long_payload( @@ -244,12 +251,13 @@ class TestLoggingMiddleware: """Test message formatting with long payload truncation.""" middleware = LoggingMiddleware(include_payloads=True, max_payload_length=10) - message = middleware._create_before_message(mock_context, "test_event") + message = middleware._create_before_message(mock_context) formatted = middleware._format_message(message) - assert "payload=" in formatted - assert "..." in formatted + assert formatted == snapshot( + 'event=request_start method=test_method source=client payload={"method":... payload_type=CallToolRequest' + ) async def test_on_message_failure( self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture @@ -263,18 +271,12 @@ class TestLoggingMiddleware: await middleware.on_message(mock_context, mock_call_next) # Check that we have structured JSON logs - log_lines = [record.message for record in caplog.records] - assert len(log_lines) == 2 # start and error entries - - # Extract JSON from "Processing message: {JSON}" - start_message = log_lines[0] - assert start_message.startswith("Processing message: ") - start_json = start_message[len("Processing message: ") :] - start_entry = json.loads(start_json) - assert start_entry["event"] == "request_start" - - # Error messages have different format - check the second log entry - assert "Failed message:" in log_lines[1] + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client"}', + '{"event": "request_error", "method": "test_method", "source": "client", "duration_ms": 0.02, "error": "test error"}', + ] + ) async def test_on_message_with_pydantic_types_in_payload( self, @@ -299,37 +301,11 @@ class TestLoggingMiddleware: assert result == "test_result" - log_lines = [record.message for record in caplog.records] - - assert len(log_lines) == 2 - - # Extract JSON from log messages - start_message = log_lines[0] - assert start_message.startswith("Processing message: ") - start_json = start_message[len("Processing message: ") :] - assert json.loads(start_json) == snapshot( - { - "event": "request_start", - "timestamp": "2023-01-01T00:00:00+00:00", - "source": "client", - "type": "request", - "method": "test_method", - "payload": '{"method":"resources/read","params":{"_meta":null,"uri":"test://example/1"}}', - "payload_type": "ReadResourceRequest", - } - ) - - success_message = log_lines[1] - assert success_message.startswith("Completed message: ") - success_json = success_message[len("Completed message: ") :] - assert json.loads(success_json) == snapshot( - { - "event": "request_success", - "timestamp": "2023-01-01T00:00:00+00:00", - "source": "client", - "type": "request", - "method": "test_method", - } + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] ) async def test_on_message_with_resource_template_in_payload( @@ -354,23 +330,11 @@ class TestLoggingMiddleware: assert result == "test_result" - log_lines = [record.message for record in caplog.records] - assert len(log_lines) == 2 - - # Extract JSON from log message - start_message = log_lines[0] - assert start_message.startswith("Processing message: ") - start_json = start_message[len("Processing message: ") :] - assert json.loads(start_json) == snapshot( - { - "event": "request_start", - "timestamp": "2023-01-01T00:00:00+00:00", - "source": "client", - "type": "request", - "method": "test_method", - "payload": '{"name":"tmpl","title":null,"description":null,"tags":[],"meta":null,"enabled":true,"uri_template":"tmpl://{id}","mime_type":"text/plain","parameters":{"id":{"type":"string"}},"annotations":null}', - "payload_type": "ResourceTemplate", - } + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"tags\\":[],\\"meta\\":null,\\"enabled\\":true,\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] ) async def test_on_message_with_nonserializable_payload_falls_back_to_str( @@ -399,23 +363,11 @@ class TestLoggingMiddleware: assert result == "test_result" - log_lines = [record.message for record in caplog.records] - assert len(log_lines) >= 2 - - # Extract JSON from log message - start_message = log_lines[0] - assert start_message.startswith("Processing message: ") - start_json = start_message[len("Processing message: ") :] - assert json.loads(start_json) == snapshot( - { - "event": "request_start", - "timestamp": "2023-01-01T00:00:00+00:00", - "source": "client", - "type": "request", - "method": "test_method", - "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"obj":"NON_SERIALIZABLE"}}}', - "payload_type": "CallToolRequest", - } + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"tools/call\\",\\"params\\":{\\"_meta\\":null,\\"name\\":\\"test_method\\",\\"arguments\\":{\\"obj\\":\\"NON_SERIALIZABLE\\"}}}", "payload_type": "CallToolRequest"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] ) async def test_on_message_with_custom_serializer_applied( @@ -446,23 +398,11 @@ class TestLoggingMiddleware: assert result == "test_result" - log_lines = [record.message for record in caplog.records] - assert len(log_lines) >= 2 - - # Extract JSON from log message - start_message = log_lines[0] - assert start_message.startswith("Processing message: ") - start_json = start_message[len("Processing message: ") :] - assert json.loads(start_json) == snapshot( - { - "event": "request_start", - "timestamp": "2023-01-01T00:00:00+00:00", - "source": "client", - "type": "request", - "method": "test_method", - "payload": "CUSTOM_PAYLOAD", - "payload_type": "CallToolRequest", - } + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "test_method", "source": "client", "payload": "CUSTOM_PAYLOAD", "payload_type": "CallToolRequest"}', + '{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}', + ] ) @@ -545,9 +485,6 @@ class TestLoggingMiddlewareIntegration: ): """Test that logging middleware captures successful operations.""" logging_middleware = LoggingMiddleware(methods=["tools/call"]) - logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(logging_middleware) @@ -563,16 +500,14 @@ class TestLoggingMiddlewareIntegration: ) # Should have processing and completion logs for both operations - assert remove_line_numbers(caplog.text) == snapshot("""\ -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type ListToolsRequest -INFO mcp.server.lowlevel.server:server.py:LINE_NUMBER Processing request of type CallToolRequest -INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client - -""") + assert get_log_lines(caplog) == snapshot( + [ + "event=request_start method=tools/call source=client", + "event=request_success method=tools/call source=client duration_ms=0.02", + "event=request_start method=tools/call source=client", + "event=request_success method=tools/call source=client duration_ms=0.02", + ] + ) async def test_logging_middleware_logs_failures( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -591,8 +526,9 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques log_text = caplog.text # Should have processing and failure logs - assert "Processing message:" in log_text - assert "Failed message: tools/call" in log_text + assert log_text.splitlines()[-1] == snapshot( + "ERROR fastmcp.middleware.logging:logging.py:122 event=request_error method=tools/call source=client duration_ms=0.02 error=Error calling tool 'operation_with_error': Operation failed intentionally" + ) async def test_logging_middleware_with_payloads( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -602,32 +538,18 @@ INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=reques middleware = LoggingMiddleware( include_payloads=True, max_payload_length=500, methods=["tools/call"] ) - middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(middleware) with caplog_for_fastmcp(caplog): async with Client(logging_server) as client: await client.call_tool("simple_operation", {"data": "payload_test"}) - log_text = caplog.text - - # Remove client IDs from log text for consistent snapshots - import re - - log_text = re.sub(r"\[Client-[^\]]+\]", "[Client-XXXX]", log_text) - - assert remove_line_numbers(log_text) == snapshot("""\ -DEBUG fastmcp.fastmcp.client.transports:transports.py:LINE_NUMBER Inferred transport: -DEBUG fastmcp.fastmcp.client.client:client.py:LINE_NUMBER [Client-XXXX] called call_tool: simple_operation -DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] Handler called: list_tools -DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] Handler called: call_tool simple_operation with {'data': 'payload_test'} -INFO fastmcp.requests:logging.py:LINE_NUMBER Processing message: event=request_start timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams -INFO fastmcp.requests:logging.py:LINE_NUMBER Completed message: event=request_success timestamp=2023-01-01T00:00:00+00:00 method=tools/call type=request source=client -DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] Handler called: list_tools - -""") + assert get_log_lines(caplog) == snapshot( + [ + 'event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams', + "event=request_success method=tools/call source=client duration_ms=0.02", + ] + ) async def test_structured_logging_middleware_produces_json( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -637,9 +559,6 @@ DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] logging_middleware = StructuredLoggingMiddleware( include_payloads=True, methods=["tools/call"] ) - logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(logging_middleware) @@ -649,30 +568,12 @@ DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] name="simple_operation", arguments={"data": "json_test"} ) - # Extract JSON log entries - log_lines = [ - record.message - for record in caplog.records - if record.name == "fastmcp.structured" - ] - - assert len(log_lines) >= 2 # Should have start and success entries - - # Remove client IDs from log text for consistent snapshots - import re - - log_text = re.sub(r"\[Client-[^\]]+\]", "[Client-XXXX]", caplog.text) - - assert remove_line_numbers(log_text) == snapshot("""\ -DEBUG fastmcp.fastmcp.client.transports:transports.py:LINE_NUMBER Inferred transport: -DEBUG fastmcp.fastmcp.client.client:client.py:LINE_NUMBER [Client-XXXX] called call_tool: simple_operation -DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] Handler called: list_tools -DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] Handler called: call_tool simple_operation with {'data': 'json_test'} -INFO fastmcp.structured:logging.py:LINE_NUMBER Processing message: {"event": "request_start", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"} -INFO fastmcp.structured:logging.py:LINE_NUMBER Completed message: {"event": "request_success", "timestamp": "2023-01-01T00:00:00+00:00", "method": "tools/call", "type": "request", "source": "client"} -DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] Handler called: list_tools - -""") + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}', + '{"event": "request_success", "method": "tools/call", "source": "client", "duration_ms": 0.02}', + ] + ) async def test_structured_logging_middleware_handles_errors( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture @@ -680,9 +581,6 @@ DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] """Test structured logging of errors with JSON format.""" logging_middleware = StructuredLoggingMiddleware(methods=["tools/call"]) - logging_middleware._get_timestamp_from_context = ( # ty: ignore[invalid-assignment] - lambda _: FIXED_DATE.isoformat() - ) logging_server.add_middleware(logging_middleware) @@ -694,19 +592,13 @@ DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] "operation_with_error", {"should_fail": True} ) - # Verify that the structured logging middleware properly logs errors - logs = caplog.text - - # The key assertion: structured logging middleware logged the error in JSON format - assert re.search( - r"fastmcp\.structured.*Failed message: tools/call.*Operation failed intentionally", - logs, + assert get_log_lines(caplog) == snapshot( + [ + '{"event": "request_start", "method": "tools/call", "source": "client"}', + '{"event": "request_error", "method": "tools/call", "source": "client", "duration_ms": 0.02, "error": "Error calling tool \'operation_with_error\': Operation failed intentionally"}', + ] ) - # Verify the error contains expected error type and message - assert "ValueError" in logs - assert "Operation failed intentionally" in logs - async def test_logging_middleware_with_different_operations( self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture ): @@ -731,16 +623,18 @@ DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] await client.get_prompt("test_prompt") await client.list_resources() - log_text = caplog.text - - # Should have logs for all different operation types - # Note: Different operations may have different method names - processing_count = log_text.count("Processing message:") - completion_count = log_text.count("Completed message:") - - # Should have processed all 4 operations - assert processing_count == 4 - assert completion_count == 4 + assert get_log_lines(caplog) == snapshot( + [ + "event=request_start method=tools/call source=client", + "event=request_success method=tools/call source=client duration_ms=0.02", + "event=request_start method=resources/read source=client", + "event=request_success method=resources/read source=client duration_ms=0.02", + "event=request_start method=prompts/get source=client", + "event=request_success method=prompts/get source=client duration_ms=0.02", + "event=request_start method=resources/list source=client", + "event=request_success method=resources/list source=client duration_ms=0.02", + ] + ) async def test_logging_middleware_custom_configuration( self, logging_server: FastMCP @@ -770,5 +664,7 @@ DEBUG fastmcp.fastmcp.server.server:server.py:LINE_NUMBER [LoggingTestServer] # Check that our custom logger captured the logs log_output = log_buffer.getvalue() - assert "Processing message:" in log_output - assert "payload=" in log_output + assert log_output == snapshot("""\ +event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams +event=request_success method=tools/call source=client duration_ms=0.02 +""") diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 8469df424..d93b6a202 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -293,6 +293,17 @@ class TestMiddlewareHooks: result = list_prompts_calls[0].result assert isinstance(result, list) + async def test_initialize( + self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware + ): + async with Client(mcp_server) as client: + await client.ping() + + assert recording_middleware.assert_called(at_least=1) + assert recording_middleware.assert_called(hook="on_message", at_least=1) + assert recording_middleware.assert_called(hook="on_request", at_least=1) + assert recording_middleware.assert_called(hook="on_initialize", at_least=1) + async def test_list_tools_filtering_middleware(self): """Test that middleware can filter tools.""" diff --git a/tests/server/middleware/test_rate_limiting.py b/tests/server/middleware/test_rate_limiting.py index 0a4e1d67b..1f17d7095 100644 --- a/tests/server/middleware/test_rate_limiting.py +++ b/tests/server/middleware/test_rate_limiting.py @@ -306,9 +306,10 @@ class TestRateLimitingMiddlewareIntegration: async def test_rate_limiting_blocks_rapid_requests(self, rate_limit_server): """Test that rate limiting blocks rapid successive requests.""" - # Very restrictive rate limit (accounting for extra list_tools calls per tool call) + # Very restrictive rate limit (accounting for initialization and list_tools calls) + # Requests: 1 initialize + 1 list_tools + 4 call_tools = 6 total before limit rate_limit_server.add_middleware( - RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=5) + RateLimitingMiddleware(max_requests_per_second=10.0, burst_capacity=6) ) async with Client(rate_limit_server) as client: @@ -356,7 +357,7 @@ class TestRateLimitingMiddlewareIntegration: """Test sliding window rate limiting implementation.""" rate_limit_server.add_middleware( SlidingWindowRateLimitingMiddleware( - max_requests=5, # Accounting for extra list_tools calls + max_requests=6, # 1 init + 1 list_tools + 3 calls + 1 to fail window_minutes=1, # 1-minute window ) ) @@ -374,7 +375,7 @@ class TestRateLimitingMiddlewareIntegration: async def test_rate_limiting_with_different_operations(self, rate_limit_server): """Test that rate limiting applies to all types of operations.""" rate_limit_server.add_middleware( - RateLimitingMiddleware(max_requests_per_second=9.0, burst_capacity=4) + RateLimitingMiddleware(max_requests_per_second=9.0, burst_capacity=5) ) async with Client(rate_limit_server) as client: @@ -395,8 +396,8 @@ class TestRateLimitingMiddlewareIntegration: rate_limit_server.add_middleware( RateLimitingMiddleware( - max_requests_per_second=6.0, # Accounting for extra list_tools calls - burst_capacity=3, + max_requests_per_second=6.0, # Accounting for initialization and list_tools calls + burst_capacity=4, get_client_id=get_client_id, ) ) @@ -416,8 +417,8 @@ class TestRateLimitingMiddlewareIntegration: rate_limit_server.add_middleware( RateLimitingMiddleware( max_requests_per_second=6.0, - burst_capacity=4, - global_limit=True, # Accounting for extra list_tools calls + burst_capacity=5, # 1 init + 2 list_tools + 2 calls before limit + global_limit=True, # Accounting for initialization and list_tools calls ) ) @@ -435,7 +436,7 @@ class TestRateLimitingMiddlewareIntegration: rate_limit_server.add_middleware( RateLimitingMiddleware( max_requests_per_second=10.0, # 10 per second = 1 every 100ms - burst_capacity=3, + burst_capacity=4, ) ) diff --git a/tests/server/openapi/test_advanced_behavior.py b/tests/server/openapi/test_advanced_behavior.py index 81cd34faf..159c47900 100644 --- a/tests/server/openapi/test_advanced_behavior.py +++ b/tests/server/openapi/test_advanced_behavior.py @@ -102,7 +102,8 @@ class TestTagTransfer: ): """Test that tags from OpenAPI routes are correctly transferred to Tools.""" # Get internal tools directly (not the public API which returns MCP.Content) - tools = await fastmcp_openapi_server._tool_manager.list_tools() + tools_dict = await fastmcp_openapi_server._tool_manager.get_tools() + tools = list(tools_dict.values()) # Find the create_user and update_user_name tools create_user_tool = next( @@ -201,7 +202,8 @@ class TestReprMethods: async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI): """Test that OpenAPITool's __repr__ method works without recursion errors.""" - tools = await fastmcp_openapi_server._tool_manager.list_tools() + tools_dict = await fastmcp_openapi_server._tool_manager.get_tools() + tools = list(tools_dict.values()) tool = next(iter(tools)) # Verify repr doesn't cause recursion and contains expected elements @@ -276,7 +278,8 @@ class TestEnumHandling: ) # Get the tools from the server - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) # Find the read_item tool read_item_tool = next((t for t in tools if t.name == "read_item_items"), None) diff --git a/tests/server/openapi/test_configuration.py b/tests/server/openapi/test_configuration.py index 8000ac35e..38e2c6f05 100644 --- a/tests/server/openapi/test_configuration.py +++ b/tests/server/openapi/test_configuration.py @@ -65,8 +65,8 @@ class TestRouteMapWildcard: ) # All operations should be mapped to tools - tools = await mcp._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await mcp._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} # Check that all 4 operations became tools expected_tools = {"getUsers", "createUser", "getPosts", "createPost"} @@ -382,8 +382,8 @@ class TestMCPNames: ) # Check tools use custom names - tools = await server._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await server._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} assert "admin_create_user" in tool_names # Check resource templates use custom names @@ -412,7 +412,8 @@ class TestMCPNames: route_maps=GET_ROUTE_MAPS, ) - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) tool_names = {tool.name for tool in tools} templates_dict = await server._resource_manager.get_resource_templates() @@ -468,8 +469,8 @@ class TestMCPNames: # Check all component types all_names = [] - tools = await server._tool_manager.list_tools() - all_names.extend(tool.name for tool in tools) + tools_dict = await server._tool_manager.get_tools() + all_names.extend(tool.name for tool in tools_dict.values()) resources_dict = await server._resource_manager.get_resources() all_names.extend(resource.name for resource in resources_dict.values()) @@ -501,8 +502,8 @@ class TestMCPNames: mcp_names=mcp_names, ) - tools = await server._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await server._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} assert "openapi_user_list" in tool_names async def test_mcp_names_with_from_fastapi_classmethod(self): @@ -533,8 +534,8 @@ class TestMCPNames: mcp_names=mcp_names, ) - tools = await server._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await server._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} assert "fastapi_create_user" in tool_names assert "fastapi_user_list" in tool_names @@ -636,7 +637,8 @@ class TestRouteMapMCPTags: ) # Get the POST tool - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) create_user_tool = next((t for t in tools if "create_user" in t.name), None) assert create_user_tool is not None, "create_user tool not found" @@ -752,7 +754,8 @@ class TestRouteMapMCPTags: ) # Check tool tags - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) create_tool = next((t for t in tools if "create_user" in t.name), None) assert create_tool is not None assert "write-operation" in create_tool.tags diff --git a/tests/server/openapi/test_description_propagation.py b/tests/server/openapi/test_description_propagation.py index 04bf21c01..a851a19a8 100644 --- a/tests/server/openapi/test_description_propagation.py +++ b/tests/server/openapi/test_description_propagation.py @@ -567,7 +567,8 @@ class TestFastAPIDescriptionPropagation: print(f" Template: {name}, Name attribute: {template.name}") print("\nDEBUG - Tools created:") - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) for tool in tools: print(f" Tool: {tool.name}") diff --git a/tests/server/openapi/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py index a9ec8348d..db782b938 100644 --- a/tests/server/openapi/test_openapi_path_parameters.py +++ b/tests/server/openapi/test_openapi_path_parameters.py @@ -163,7 +163,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): mcp = FastMCP.from_openapi(array_path_spec, client=mock_client) # Call the tool with a single value - await mcp._mcp_call_tool("test_operation", {"days": ["monday"]}) + await mcp._call_tool_mcp("test_operation", {"days": ["monday"]}) # Check the request was made correctly mock_client.request.assert_called_with( @@ -177,7 +177,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): mock_client.request.reset_mock() # Call the tool with multiple values - await mcp._mcp_call_tool("test_operation", {"days": ["monday", "tuesday"]}) + await mcp._call_tool_mcp("test_operation", {"days": ["monday", "tuesday"]}) # Check the request was made correctly mock_client.request.assert_called_with( diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py index 561353084..fd82a7f9b 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -173,15 +173,15 @@ class TestTools: async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server): assert ( - await proxy_server._mcp_list_tools() - == await fastmcp_server._mcp_list_tools() + await proxy_server._list_tools_mcp() + == await fastmcp_server._list_tools_mcp() ) async def test_call_tool_result_same_as_original( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): - result = await fastmcp_server._mcp_call_tool("greet", {"name": "Alice"}) - proxy_result = await proxy_server._mcp_call_tool("greet", {"name": "Alice"}) + result = await fastmcp_server._call_tool_mcp("greet", {"name": "Alice"}) + proxy_result = await proxy_server._call_tool_mcp("greet", {"name": "Alice"}) assert result == proxy_result @@ -267,8 +267,8 @@ class TestResources: async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server): assert ( - await proxy_server._mcp_list_resources() - == await fastmcp_server._mcp_list_resources() + await proxy_server._list_resources_mcp() + == await fastmcp_server._list_resources_mcp() ) async def test_read_resource(self, proxy_server: FastMCPProxy): @@ -367,8 +367,8 @@ class TestResourceTemplates: async def test_list_resource_templates_same_as_original( self, fastmcp_server, proxy_server ): - result = await fastmcp_server._mcp_list_resource_templates() - proxy_result = await proxy_server._mcp_list_resource_templates() + result = await fastmcp_server._list_resource_templates_mcp() + proxy_result = await proxy_server._list_resource_templates_mcp() assert proxy_result == result @pytest.mark.parametrize("id", [1, 2, 3]) diff --git a/tests/server/test_file_server.py b/tests/server/test_file_server.py index c10b44519..592803d78 100644 --- a/tests/server/test_file_server.py +++ b/tests/server/test_file_server.py @@ -74,7 +74,7 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: async def test_list_resources(mcp: FastMCP): - resources = await mcp._mcp_list_resources() + resources = await mcp._list_resources_mcp() assert len(resources) == 4 assert [str(r.uri) for r in resources] == [ @@ -86,7 +86,7 @@ async def test_list_resources(mcp: FastMCP): async def test_read_resource_dir(mcp: FastMCP): - res_iter = await mcp._mcp_read_resource("dir://test_dir") + res_iter = await mcp._read_resource_mcp("dir://test_dir") res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] @@ -102,7 +102,7 @@ async def test_read_resource_dir(mcp: FastMCP): async def test_read_resource_file(mcp: FastMCP): - res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") + res_iter = await mcp._read_resource_mcp("file://test_dir/example.py") res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] @@ -110,17 +110,17 @@ async def test_read_resource_file(mcp: FastMCP): async def test_delete_file(mcp: FastMCP, test_dir: Path): - await mcp._mcp_call_tool( + await mcp._call_tool_mcp( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) assert not (test_dir / "example.py").exists() async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): - await mcp._mcp_call_tool( + await mcp._call_tool_mcp( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) - res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") + res_iter = await mcp._read_resource_mcp("file://test_dir/example.py") res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 8fde83446..e3fd3abd1 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -329,18 +329,15 @@ class TestMultipleServerMount: record.message for record in caplog.records if record.levelname == "WARNING" ] assert any( - "Failed to get tools from server: 'unreachable_proxy', mounted at: 'unreachable'" - in msg + "Failed to list tools from mounted server 'unreachable_proxy'" in msg for msg in warning_messages ) assert any( - "Failed to get resources from server: 'unreachable_proxy', mounted at: 'unreachable'" - in msg + "Failed to list resources from 'unreachable_proxy'" in msg for msg in warning_messages ) assert any( - "Failed to get prompts from server: 'unreachable_proxy', mounted at: 'unreachable'" - in msg + "Failed to list prompts from mounted server 'unreachable_proxy'" in msg for msg in warning_messages ) @@ -871,7 +868,7 @@ class TestAsProxyKwarg: sub = FastMCP("Sub") mcp.mount(sub, "sub") - assert mcp._tool_manager._mounted_servers[0].server is sub + assert mcp._mounted_servers[0].server is sub async def test_as_proxy_false(self): mcp = FastMCP("Main") @@ -879,7 +876,7 @@ class TestAsProxyKwarg: mcp.mount(sub, "sub", as_proxy=False) - assert mcp._tool_manager._mounted_servers[0].server is sub + assert mcp._mounted_servers[0].server is sub async def test_as_proxy_true(self): mcp = FastMCP("Main") @@ -887,8 +884,8 @@ class TestAsProxyKwarg: mcp.mount(sub, "sub", as_proxy=True) - assert mcp._tool_manager._mounted_servers[0].server is not sub - assert isinstance(mcp._tool_manager._mounted_servers[0].server, FastMCPProxy) + assert mcp._mounted_servers[0].server is not sub + assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy) async def test_as_proxy_defaults_true_if_lifespan(self): @asynccontextmanager @@ -900,8 +897,8 @@ class TestAsProxyKwarg: mcp.mount(sub, "sub") - assert mcp._tool_manager._mounted_servers[0].server is not sub - assert isinstance(mcp._tool_manager._mounted_servers[0].server, FastMCPProxy) + assert mcp._mounted_servers[0].server is not sub + assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy) async def test_as_proxy_ignored_for_proxy_mounts_default(self): mcp = FastMCP("Main") @@ -910,7 +907,7 @@ class TestAsProxyKwarg: mcp.mount(sub_proxy, "sub") - assert mcp._tool_manager._mounted_servers[0].server is sub_proxy + assert mcp._mounted_servers[0].server is sub_proxy async def test_as_proxy_ignored_for_proxy_mounts_false(self): mcp = FastMCP("Main") @@ -919,7 +916,7 @@ class TestAsProxyKwarg: mcp.mount(sub_proxy, "sub", as_proxy=False) - assert mcp._tool_manager._mounted_servers[0].server is sub_proxy + assert mcp._mounted_servers[0].server is sub_proxy async def test_as_proxy_ignored_for_proxy_mounts_true(self): mcp = FastMCP("Main") @@ -928,7 +925,7 @@ class TestAsProxyKwarg: mcp.mount(sub_proxy, "sub", as_proxy=True) - assert mcp._tool_manager._mounted_servers[0].server is sub_proxy + assert mcp._mounted_servers[0].server is sub_proxy async def test_as_proxy_mounts_still_have_live_link(self): mcp = FastMCP("Main") @@ -1024,6 +1021,101 @@ class TestResourceNamePrefixing: assert template.name == "prefix_user_template" +class TestParentTagFiltering: + """Test that parent server tag filters apply recursively to mounted servers.""" + + async def test_parent_include_tags_filters_mounted_tools(self): + """Test that parent include_tags filters out non-matching mounted tools.""" + parent = FastMCP("Parent", include_tags={"allowed"}) + mounted = FastMCP("Mounted") + + @mounted.tool(tags={"allowed"}) + def allowed_tool() -> str: + return "allowed" + + @mounted.tool(tags={"blocked"}) + def blocked_tool() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "allowed_tool" in tool_names + assert "blocked_tool" not in tool_names + + # Verify execution also respects filters + result = await client.call_tool("allowed_tool", {}) + assert result.data == "allowed" + + with pytest.raises(Exception, match="Unknown tool"): + await client.call_tool("blocked_tool", {}) + + async def test_parent_exclude_tags_filters_mounted_tools(self): + """Test that parent exclude_tags filters out matching mounted tools.""" + parent = FastMCP("Parent", exclude_tags={"blocked"}) + mounted = FastMCP("Mounted") + + @mounted.tool(tags={"production"}) + def production_tool() -> str: + return "production" + + @mounted.tool(tags={"blocked"}) + def blocked_tool() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "production_tool" in tool_names + assert "blocked_tool" not in tool_names + + async def test_parent_filters_apply_to_mounted_resources(self): + """Test that parent tag filters apply to mounted resources.""" + parent = FastMCP("Parent", include_tags={"allowed"}) + mounted = FastMCP("Mounted") + + @mounted.resource("resource://allowed", tags={"allowed"}) + def allowed_resource() -> str: + return "allowed" + + @mounted.resource("resource://blocked", tags={"blocked"}) + def blocked_resource() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + resources = await client.list_resources() + resource_uris = {str(r.uri) for r in resources} + assert "resource://allowed" in resource_uris + assert "resource://blocked" not in resource_uris + + async def test_parent_filters_apply_to_mounted_prompts(self): + """Test that parent tag filters apply to mounted prompts.""" + parent = FastMCP("Parent", exclude_tags={"blocked"}) + mounted = FastMCP("Mounted") + + @mounted.prompt(tags={"allowed"}) + def allowed_prompt() -> str: + return "allowed" + + @mounted.prompt(tags={"blocked"}) + def blocked_prompt() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + prompts = await client.list_prompts() + prompt_names = {p.name for p in prompts} + assert "allowed_prompt" in prompt_names + assert "blocked_prompt" not in prompt_names + + class TestCustomRouteForwarding: """Test that custom HTTP routes from mounted servers are forwarded.""" diff --git a/tests/server/test_server.py b/tests/server/test_server.py index af0b9dcd8..1b712be81 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -76,7 +76,7 @@ class TestTools: def fn(x: int) -> int: return x + 1 - mcp_tools = await mcp._mcp_list_tools() + mcp_tools = await mcp._list_tools_mcp() assert len(mcp_tools) == 1 assert mcp_tools[0].name == "fn" @@ -89,7 +89,7 @@ class TestTools: def fn(x: int) -> int: return x + 1 - mcp_tools = await mcp._mcp_list_tools() + mcp_tools = await mcp._list_tools_mcp() assert len(mcp_tools) == 1 assert mcp_tools[0].name == "custom_name" @@ -110,7 +110,7 @@ class TestTools: assert "adder" not in mcp_tools with pytest.raises(NotFoundError, match="Unknown tool: adder"): - await mcp._mcp_call_tool("adder", {"a": 1, "b": 2}) + await mcp._call_tool_mcp("adder", {"a": 1, "b": 2}) async def test_add_tool_at_init(self): def f(x: int) -> int: @@ -136,7 +136,7 @@ class TestToolDecorator: mcp = FastMCP() with pytest.raises(NotFoundError, match="Unknown tool: add"): - await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) + await mcp._call_tool_mcp("add", {"x": 1, "y": 2}) async def test_tool_decorator(self): mcp = FastMCP() @@ -185,7 +185,7 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - tools = await mcp._mcp_list_tools() + tools = await mcp._list_tools_mcp() assert len(tools) == 1 tool = tools[0] assert tool.description == "Add two numbers" @@ -307,10 +307,11 @@ class TestToolDecorator: def sample_tool(x: int) -> int: return x * 2 - # Verify the tags were set correctly - tools = await mcp._tool_manager.list_tools() - assert len(tools) == 1 - assert tools[0].tags == {"example", "test-tag"} + # Verify the tags were set correctly (local inventory) + tools_dict = await mcp._tool_manager.get_tools() + assert len(tools_dict) == 1 + only_tool = next(iter(tools_dict.values())) + assert only_tool.tags == {"example", "test-tag"} async def test_add_tool_with_custom_name(self): """Test adding a tool with a custom name using server.add_tool().""" diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index d0f03876c..cbe47465b 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -47,7 +47,7 @@ async def test_tool_annotations_in_mcp_protocol(): return message # Check via MCP protocol - mcp_tools = await mcp._mcp_list_tools() + mcp_tools = await mcp._list_tools_mcp() assert len(mcp_tools) == 1 assert mcp_tools[0].annotations is not None assert mcp_tools[0].annotations.title == "Echo Tool" diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py index 977d4abab..a35289fc1 100644 --- a/tests/server/test_tool_transformation.py +++ b/tests/server/test_tool_transformation.py @@ -29,14 +29,14 @@ async def test_transformed_tool_filtering(): """Echo back the message provided.""" return message - tools = list(await mcp._list_tools()) + tools = list(await mcp._list_tools_middleware()) assert len(tools) == 0 mcp.add_tool_transformation( "echo", ToolTransformConfig(name="echo_transformed", tags={"enabled_tools"}) ) - tools = list(await mcp._list_tools()) + tools = list(await mcp._list_tools_middleware()) assert len(tools) == 1 diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 10d0b7972..e39e0a7b8 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -280,8 +280,8 @@ class TestListTools: tool_manager.add_tool_transformation( "add", ToolTransformConfig(name="add_transformed") ) - tools = await tool_manager.list_tools() - tools_by_name = {tool.name: tool for tool in tools} + tools_dict = await tool_manager.get_tools() + tools_by_name = {tool.name: tool for tool in tools_dict.values()} assert "add_transformed" in tools_by_name assert "add" not in tools_by_name @@ -303,8 +303,8 @@ class TestListTools: name="add_transformed", description=None, tags={"enabled_tools"} ), ) - tools = await tool_manager.list_tools() - tools_by_name = {tool.name: tool for tool in tools} + tools_dict = await tool_manager.get_tools() + tools_by_name = {tool.name: tool for tool in tools_dict.values()} assert "add_transformed" in tools_by_name assert "add" not in tools_by_name assert tools_by_name["add_transformed"].description is None @@ -1027,12 +1027,12 @@ class TestMountedComponentsRaiseOnLoadError: # Create a failing mounted server by corrupting it parent_mcp.mount(child_mcp, prefix="child") - # Corrupt the child server to make it fail during tool loading - child_mcp._tool_manager._mounted_servers.append("invalid") # type: ignore + # Corrupt the parent's mounted servers to make it fail during loading + parent_mcp._mounted_servers.append("invalid") # type: ignore - # Should not raise, just warn - tools = await parent_mcp._tool_manager.list_tools() - assert isinstance(tools, list) # Should return empty list, not raise + # Should not raise, just warn; use server middleware path now + tools = await parent_mcp._list_tools_middleware() + assert isinstance(tools, list) # Should return list, not raise async def test_mounted_components_raise_on_load_error_true(self): """Test that when enabled, mounted component load errors are raised.""" @@ -1041,8 +1041,8 @@ class TestMountedComponentsRaiseOnLoadError: # Create a failing mounted server parent_mcp.mount(child_mcp, prefix="child") - # Corrupt the child server to make it fail during tool loading - child_mcp._tool_manager._mounted_servers.append("invalid") # type: ignore + # Corrupt the parent's mounted servers to make it fail during loading + parent_mcp._mounted_servers.append("invalid") # type: ignore # Use temporary settings context manager with temporary_settings(mounted_components_raise_on_load_error=True): @@ -1050,4 +1050,4 @@ class TestMountedComponentsRaiseOnLoadError: with pytest.raises( AttributeError, match="'str' object has no attribute 'server'" ): - await parent_mcp._tool_manager.list_tools() + await parent_mcp._list_tools_middleware() diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index e75439ea9..730e297b6 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -90,7 +90,7 @@ class TestGetFastMCPInfo: assert info.fastmcp_version == fastmcp.__version__ assert info.mcp_version == importlib.metadata.version("mcp") assert info.server_generation == 2 # v2 server - assert info.version is None + assert info.version == fastmcp.__version__ assert info.tools == [] assert info.prompts == [] assert info.resources == [] @@ -269,6 +269,198 @@ class TestGetFastMCPInfo: assert info.resources[0].uri == str(resources[0].uri) assert info.prompts[0].name == prompts[0].name + async def test_inspect_respects_tag_filtering(self): + """Test that inspect omits components filtered out by include_tags/exclude_tags. + + Regression test for Issue #2032: inspect command was showing components + that were filtered out by tag rules, causing confusion when those + components weren't actually available to clients. + """ + # Create server with include_tags that will filter out untagged components + mcp = FastMCP( + "FilteredServer", + include_tags={"fetch", "analyze", "create"}, + ) + + # Add tools with and without matching tags + @mcp.tool(tags={"fetch"}) + def tagged_tool() -> str: + """Tool with matching tag - should be visible.""" + return "visible" + + @mcp.tool + def untagged_tool() -> str: + """Tool without tags - should be filtered out.""" + return "hidden" + + # Add resources with and without matching tags + @mcp.resource("resource://tagged", tags={"analyze"}) + def tagged_resource() -> str: + """Resource with matching tag - should be visible.""" + return "visible resource" + + @mcp.resource("resource://untagged") + def untagged_resource() -> str: + """Resource without tags - should be filtered out.""" + return "hidden resource" + + # Add templates with and without matching tags + @mcp.resource("resource://tagged/{id}", tags={"create"}) + def tagged_template(id: str) -> str: + """Template with matching tag - should be visible.""" + return f"visible template {id}" + + @mcp.resource("resource://untagged/{id}") + def untagged_template(id: str) -> str: + """Template without tags - should be filtered out.""" + return f"hidden template {id}" + + # Add prompts with and without matching tags + @mcp.prompt(tags={"fetch"}) + def tagged_prompt() -> list: + """Prompt with matching tag - should be visible.""" + return [{"role": "user", "content": "visible prompt"}] + + @mcp.prompt + def untagged_prompt() -> list: + """Prompt without tags - should be filtered out.""" + return [{"role": "user", "content": "hidden prompt"}] + + # Get inspect info + info = await inspect_fastmcp(mcp) + + # Verify only tagged components are visible + assert len(info.tools) == 1 + assert info.tools[0].name == "tagged_tool" + + assert len(info.resources) == 1 + assert info.resources[0].uri == "resource://tagged" + + assert len(info.templates) == 1 + assert info.templates[0].uri_template == "resource://tagged/{id}" + + assert len(info.prompts) == 1 + assert info.prompts[0].name == "tagged_prompt" + + # Verify this matches what a client would see + async with Client(mcp) as client: + tools = await client.list_tools() + resources = await client.list_resources() + templates = await client.list_resource_templates() + prompts = await client.list_prompts() + + assert len(info.tools) == len(tools) + assert len(info.resources) == len(resources) + assert len(info.templates) == len(templates) + assert len(info.prompts) == len(prompts) + + async def test_inspect_respects_tag_filtering_with_mounted_servers(self): + """Test that inspect applies tag filtering to mounted servers. + + Verifies that when a parent server has tag filters, those filters + are respected when inspecting components from mounted servers. + """ + # Create a mounted server with various tagged and untagged components + mounted = FastMCP("MountedServer") + + @mounted.tool(tags={"allowed"}) + def allowed_tool() -> str: + return "allowed" + + @mounted.tool(tags={"blocked"}) + def blocked_tool() -> str: + return "blocked" + + @mounted.tool + def untagged_tool() -> str: + return "untagged" + + @mounted.resource("resource://allowed", tags={"allowed"}) + def allowed_resource() -> str: + return "allowed resource" + + @mounted.resource("resource://blocked", tags={"blocked"}) + def blocked_resource() -> str: + return "blocked resource" + + @mounted.prompt(tags={"allowed"}) + def allowed_prompt() -> list: + return [{"role": "user", "content": "allowed"}] + + @mounted.prompt(tags={"blocked"}) + def blocked_prompt() -> list: + return [{"role": "user", "content": "blocked"}] + + # Create parent server with tag filtering + parent = FastMCP("ParentServer", include_tags={"allowed"}) + parent.mount(mounted) + + # Get inspect info + info = await inspect_fastmcp(parent) + + # Only components with "allowed" tag should be visible + tool_names = [t.name for t in info.tools] + assert "allowed_tool" in tool_names + assert "blocked_tool" not in tool_names + assert "untagged_tool" not in tool_names + + resource_uris = [r.uri for r in info.resources] + assert "resource://allowed" in resource_uris + assert "resource://blocked" not in resource_uris + + prompt_names = [p.name for p in info.prompts] + assert "allowed_prompt" in prompt_names + assert "blocked_prompt" not in prompt_names + + # Verify this matches what a client would see + async with Client(parent) as client: + tools = await client.list_tools() + resources = await client.list_resources() + prompts = await client.list_prompts() + + assert len(info.tools) == len(tools) + assert len(info.resources) == len(resources) + assert len(info.prompts) == len(prompts) + + async def test_inspect_parent_filters_override_mounted_server_filters(self): + """Test that parent server tag filters apply to mounted servers. + + Even if a mounted server has no tag filters of its own, + the parent server's filters should still apply. + """ + # Create mounted server with NO tag filters (allows everything) + mounted = FastMCP("MountedServer") + + @mounted.tool(tags={"production"}) + def production_tool() -> str: + return "production" + + @mounted.tool(tags={"development"}) + def development_tool() -> str: + return "development" + + @mounted.tool + def untagged_tool() -> str: + return "untagged" + + # Create parent with exclude_tags - should filter mounted components + parent = FastMCP("ParentServer", exclude_tags={"development"}) + parent.mount(mounted) + + # Get inspect info + info = await inspect_fastmcp(parent) + + # Only production and untagged should be visible + tool_names = [t.name for t in info.tools] + assert "production_tool" in tool_names + assert "untagged_tool" in tool_names + assert "development_tool" not in tool_names + + # Verify this matches what a client would see + async with Client(parent) as client: + tools = await client.list_tools() + assert len(info.tools) == len(tools) + class TestFastMCP1xCompatibility: """Tests for FastMCP 1.x compatibility.""" @@ -405,7 +597,7 @@ class TestFastMCP1xCompatibility: assert info1x.server_generation == 1 # v1 assert info2x.server_generation == 2 # v2 assert info1x.version is None - assert info2x.version is None + assert info2x.version == fastmcp.__version__ # No templates added in these tests assert len(info1x.templates) == 0 diff --git a/tests/utilities/test_storage.py b/tests/utilities/test_storage.py deleted file mode 100644 index 1c53b6637..000000000 --- a/tests/utilities/test_storage.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Tests for KVStorage implementations.""" - -from pathlib import Path - -import pytest - -from fastmcp.utilities.storage import InMemoryStorage, JSONFileStorage - - -class TestJSONFileStorage: - """Tests for file-based JSON storage.""" - - @pytest.fixture - def temp_storage(self, tmp_path: Path) -> JSONFileStorage: - """Create a JSONFileStorage with temp directory.""" - return JSONFileStorage(tmp_path / "storage") - - async def test_basic_get_set_delete(self, temp_storage: JSONFileStorage): - """Test basic storage operations.""" - # Initially empty - assert await temp_storage.get("key1") is None - - # Set a value - data = {"name": "test", "value": 123} - await temp_storage.set("key1", data) - - # Get it back - loaded = await temp_storage.get("key1") - assert loaded == data - - # Delete it - await temp_storage.delete("key1") - assert await temp_storage.get("key1") is None - - async def test_special_characters_in_keys(self, temp_storage: JSONFileStorage): - """Test that special characters in keys are handled safely.""" - key = "user/123:test.json?query=value" - data = {"test": "data"} - - await temp_storage.set(key, data) - loaded = await temp_storage.get(key) - assert loaded == data - - # Verify the file was created with safe name - files = list(temp_storage.cache_dir.glob("*.json")) - assert len(files) == 1 - assert "/" not in files[0].name - assert ":" not in files[0].name - assert "?" not in files[0].name - - async def test_multiple_keys(self, temp_storage: JSONFileStorage): - """Test storing multiple keys.""" - data1 = {"id": 1} - data2 = {"id": 2} - data3 = {"id": 3} - - await temp_storage.set("key1", data1) - await temp_storage.set("key2", data2) - await temp_storage.set("key3", data3) - - assert await temp_storage.get("key1") == data1 - assert await temp_storage.get("key2") == data2 - assert await temp_storage.get("key3") == data3 - - # Delete one - await temp_storage.delete("key2") - assert await temp_storage.get("key1") == data1 - assert await temp_storage.get("key2") is None - assert await temp_storage.get("key3") == data3 - - async def test_overwrite_existing(self, temp_storage: JSONFileStorage): - """Test overwriting existing values.""" - await temp_storage.set("key", {"version": 1}) - await temp_storage.set("key", {"version": 2}) - - loaded = await temp_storage.get("key") - assert loaded == {"version": 2} - - async def test_persistence_across_instances(self, tmp_path: Path): - """Test that data persists across storage instances.""" - storage_dir = tmp_path / "persistent" - - # First instance - storage1 = JSONFileStorage(storage_dir) - data = {"persistent": True, "value": 42} - await storage1.set("mykey", data) - - # New instance, same directory - storage2 = JSONFileStorage(storage_dir) - loaded = await storage2.get("mykey") - assert loaded == data - - async def test_delete_nonexistent(self, temp_storage: JSONFileStorage): - """Test deleting non-existent key doesn't error.""" - # Should not raise - await temp_storage.delete("nonexistent") - - -class TestInMemoryStorage: - """Tests for in-memory storage.""" - - @pytest.fixture - def memory_storage(self) -> InMemoryStorage: - """Create an InMemoryStorage instance.""" - return InMemoryStorage() - - async def test_basic_operations(self, memory_storage: InMemoryStorage): - """Test basic storage operations.""" - # Initially empty - assert await memory_storage.get("key1") is None - - # Set and get - data = {"name": "test", "value": 123} - await memory_storage.set("key1", data) - assert await memory_storage.get("key1") == data - - # Delete - await memory_storage.delete("key1") - assert await memory_storage.get("key1") is None - - async def test_no_persistence(self): - """Test that data doesn't persist across instances.""" - storage1 = InMemoryStorage() - await storage1.set("key", {"value": 1}) - - storage2 = InMemoryStorage() - assert await storage2.get("key") is None - - async def test_isolation_between_keys(self, memory_storage: InMemoryStorage): - """Test that keys are isolated from each other.""" - data1 = {"id": 1, "nested": {"value": "a"}} - data2 = {"id": 2, "nested": {"value": "b"}} - - await memory_storage.set("key1", data1) - await memory_storage.set("key2", data2) - - # Modify retrieved data shouldn't affect stored - retrieved = await memory_storage.get("key1") - if retrieved: - retrieved["modified"] = True - - # Original should be unchanged - assert await memory_storage.get("key1") == data1 diff --git a/uv.lock b/uv.lock index 243457b91..72c90e253 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -69,6 +69,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "cachetools" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -400,6 +409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" }, ] +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -526,6 +544,7 @@ dependencies = [ { name = "mcp" }, { name = "openapi-core" }, { name = "openapi-pydantic" }, + { name = "py-key-value-aio", extra = ["disk", "memory"] }, { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, { name = "python-dotenv" }, @@ -558,6 +577,7 @@ dev = [ { name = "pytest-flakefinder" }, { name = "pytest-httpx" }, { name = "pytest-report" }, + { name = "pytest-retry" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -574,6 +594,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-core", specifier = ">=0.19.5" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, + { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.1" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -601,6 +622,7 @@ dev = [ { name = "pytest-flakefinder" }, { name = "pytest-httpx", specifier = ">=0.35.0" }, { name = "pytest-report", specifier = ">=0.2.1" }, + { name = "pytest-retry", specifier = ">=1.7.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff" }, @@ -1173,6 +1195,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + [[package]] name = "pdbpp" version = "0.11.7" @@ -1277,6 +1308,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/bf/7237a1d41b4afc33a8c0f71c991d95a6bb6719cd5ccab8d1628b72fbe03c/py_key_value_aio-0.2.1.tar.gz", hash = "sha256:79c8c835451b61d4abd863c65d33870612f3a80dc312120b2d1445269764d625", size = 19440, upload-time = "2025-10-09T03:26:28.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/82/41b5574270fbed7171d34a9b7c9b1b18fd86c31421e45dc935ba354eb42f/py_key_value_aio-0.2.1-py3-none-any.whl", hash = "sha256:5f0bc1bb3f886578a88ed2b61858658142db35c59dd3ccd9ec727184c540288a", size = 41564, upload-time = "2025-10-09T03:26:26.174Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/7c76aa82e5e41c6ad5e0e43bcc2072b48d84e03439dbb25b3e184773b553/py_key_value_shared-0.2.0.tar.gz", hash = "sha256:ee6d9a9101b54f228876c61b2f2f83a951c9c52233d8271599532c069fa26052", size = 6285, upload-time = "2025-09-29T02:27:46.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f8/6c6cf5abcb78d103006ea1bec6137c9859611ffc50093684b5130c5642c1/py_key_value_shared-0.2.0-py3-none-any.whl", hash = "sha256:84cb4f6b6bed97a32feebc512ce1e333097ce5768c7198abcd7d4bd3c5f1de06", size = 10437, upload-time = "2025-09-29T02:27:45.281Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -1588,6 +1652,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/3b/82/e141da085de0b6dac3f047ae009e136bcedbcfca4ada082a55359d6f735e/pytest-report-0.2.1.tar.gz", hash = "sha256:d382e8db4c52a815d39dae5f21ee5edc0da3ae8ec19a22e55e9be5c60714a39d", size = 3517, upload-time = "2016-05-11T02:08:04.665Z" } +[[package]] +name = "pytest-retry" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ff/3266c8a73b9b93c4b14160a7e2b31d1e1088e28ed29f4c2d93ae34093bfd/pytest_retry-1.7.0-py3-none-any.whl", hash = "sha256:a2dac85b79a4e2375943f1429479c65beb6c69553e7dae6b8332be47a60954f4", size = 13775, upload-time = "2025-01-19T01:56:11.199Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -2068,11 +2144,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]]