diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb0672f..787a62d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v5.0.0 hooks: - id: trailing-whitespace + exclude: \.md$ - id: check-yaml - id: check-added-large-files diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc0a68..d8d542e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] + +🚀 FastApiMCP now supports MCP Authorization! + +You can now add MCP-compliant OAuth configuration in a FastAPI-native way, using your existing FastAPI `Depends()` that we all know and love. + +### Added +- 🎉 Support for Authentication / Authorization compliant to [MCP 2025-03-26 Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization), using OAuth 2.1. (#10) +- 🎉 Support passing http headers to tool calls (#82) + ## [0.3.0] 🚀 FastApiMCP now works with ASGI-transport by default. diff --git a/README.md b/README.md index 4080493..9904f5b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

fastapi-to-mcp

FastAPI-MCP

-

A zero-configuration tool for automatically exposing FastAPI endpoints as Model Context Protocol (MCP) tools.

+

Expose your FastAPI endpoints as Model Context Protocol (MCP) tools, with Auth!

[![PyPI version](https://img.shields.io/pypi/v/fastapi-mcp?color=%2334D058&label=pypi%20package)](https://pypi.org/project/fastapi-mcp/) @@ -16,11 +16,20 @@ ## Features -- **Zero configuration** - Just point it at your FastAPI app and it works, with automatic discovery of endpoints and conversion to MCP tools -- **Schema & docs preservation** - Keep the same request/response models and preserve documentation of all your endpoints -- **Flexible deployment** - Mount your MCP server to the same FastAPI application, or deploy separately -- **Custom endpoint exposure** - Control which endpoints become MCP tools using operation IDs and tags -- **ASGI transport** - Uses FastAPI's ASGI interface directly by default for efficient communication +- **Authentication** built in, using your existing FastAPI dependencies! + +- **FastAPI-native:** Not just another OpenAPI -> MCP converter + +- **Zero/Minimal configuration** required - just point it at your FastAPI app and it works + +- **Preserving schemas** of your request models and response models + +- **Preserve documentation** of all your endpoints, just as it is in Swagger + +- **Flexible deployment** - Mount your MCP server to the same app, or deploy separately + +- **ASGI transport** - Uses FastAPI's ASGI interface directly for efficient communication + ## Installation @@ -52,9 +61,7 @@ mcp = FastApiMCP(app) mcp.mount() ``` -That's it! Your auto-generated MCP server is now available at `https://app.base.url/mcp`. - -> **Note on `base_url`**: While `base_url` is optional, it is highly recommended to provide it explicitly. The `base_url` tells the MCP server where to send API requests when tools are called. Without it, the library will attempt to determine the URL automatically, which may not work correctly in deployed environments where the internal and external URLs differ. +That's it! Your auto-generated MCP server is now available at `https://app.base.url/mcp`. ## Documentation, Examples and Advanced Usage @@ -63,10 +70,23 @@ FastAPI-MCP provides comprehensive documentation in the `docs` folder: - [FAQ](docs/00_FAQ.md) - Frequently asked questions about usage, development and support - [Tool Naming](docs/01_tool_naming.md) - Best practices for naming your MCP tools using operation IDs - [Connecting to MCP Server](docs/02_connecting_to_the_mcp_server.md) - How to connect various MCP clients like Cursor and Claude Desktop -- [Advanced Usage](docs/03_advanced_usage.md) - Advanced features like custom schemas, endpoint filtering, and separate deployment +- [Authentication and Authorization](docs/03_authentication_and_authorization.md) - How to authenticate and authorize your MCP tools +- [Advanced Usage](docs/04_advanced_usage.md) - Advanced features like custom schemas, endpoint filtering, and separate deployment Check out the [examples directory](examples) for code samples demonstrating these features in action. +## FastAPI-first Approach + +FastAPI-MCP is designed as a native extension of FastAPI, not just a converter that generates MCP tools from your API. This approach offers several key advantages: + +- **Native dependencies**: Secure your MCP endpoints using familiar FastAPI `Depends()` for authentication and authorization + +- **ASGI transport**: Communicates directly with your FastAPI app using its ASGI interface, eliminating the need for HTTP calls from the MCP to your API + +- **Unified infrastructure**: Your FastAPI app doesn't need to run separately from the MCP server (though [separate deployment](docs/04_advanced_usage.md#deploying-separately-from-original-fastapi-app) is also supported) + +This design philosophy ensures minimum friction when adding MCP capabilities to your existing FastAPI services. + ## Development and Contributing Thank you for considering contributing to FastAPI-MCP! We encourage the community to post Issues and create Pull Requests. diff --git a/docs/02_connecting_to_the_mcp_server.md b/docs/02_connecting_to_the_mcp_server.md index 777c58b..2b245e4 100644 --- a/docs/02_connecting_to_the_mcp_server.md +++ b/docs/02_connecting_to_the_mcp_server.md @@ -2,42 +2,35 @@ ## Connecting to the MCP Server using SSE -Once your FastAPI app with MCP integration is running, you can connect to it with any MCP client supporting SSE, such as Cursor: +Once your FastAPI app with MCP integration is running, you can connect to it with any MCP client supporting SSE. -1. Run your application. -2. In Cursor -> Settings -> MCP, use the URL of your MCP server endpoint (e.g., `http://localhost:8000/mcp`) as sse. -3. Cursor will discover all available tools and resources automatically. +All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the following config format: -## Connecting to the MCP Server using [mcp-proxy stdio](https://github.com/sparfenyuk/mcp-proxy?tab=readme-ov-file#1-stdio-to-sse) - -If your MCP client does not support SSE, for example Claude Desktop: - -1. Run your application. -2. Install [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy?tab=readme-ov-file#installing-via-pypi), for example: `uv tool install mcp-proxy`. -3. Add in Claude Desktop MCP config file (`claude_desktop_config.json`): - -On Windows: ```json { "mcpServers": { - "my-api-mcp-proxy": { - "command": "mcp-proxy", - "args": ["http://127.0.0.1:8000/mcp"] + "fastapi-mcp": { + "url": "http://localhost:8000/mcp" } } } ``` -On MacOS: + +## Connecting to the MCP Server using [mcp-remote](https://www.npmjs.com/package/mcp-remote) + +If you want to support authentication, or your MCP client does not support SSE, we recommend using `mcp-remote` as a bridge. + ```json { "mcpServers": { - "my-api-mcp-proxy": { - "command": "/Full/Path/To/Your/Executable/mcp-proxy", - "args": ["http://127.0.0.1:8000/mcp"] + "fastapi-mcp": { + "command": "npx", + "args": [ + "mcp-remote", + "http://localhost:8000/mcp", + "8080" // Optional port number. Necessary if you want your OAuth to work and you don't have dynamic client registration. + ] } } } ``` -Find the path to mcp-proxy by running in Terminal: `which mcp-proxy`. - -4. Claude Desktop will discover all available tools and resources automatically. diff --git a/docs/03_authentication_and_authorization.md b/docs/03_authentication_and_authorization.md new file mode 100644 index 0000000..eaca933 --- /dev/null +++ b/docs/03_authentication_and_authorization.md @@ -0,0 +1,223 @@ +# Authentication and Authorization + +FastAPI-MCP supports authentication and authorization using your existing FastAPI dependencies. + +It also supports the full OAuth 2 flow, compliant with [MCP Spec 2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). + +It's worth noting that most MCP clients currently do not support the latest MCP spec, so for our examples we might use a bridge client such as `npx mcp-remote`. We recommend you use it as well, and we'll show our examples using it. + +## Basic Token Passthrough + +If you just want to be able to pass a valid authorization header, without supporting a full authentication flow, you don't need to do anything special. + +You just need to make sure your MCP client is sending it: + +```json +{ + "mcpServers": { + "remote-example": { + "command": "npx", + "args": [ + "mcp-remote", + "http://localhost:8000/mcp", + "--header", + "Authorization:${AUTH_HEADER}" + ] + }, + "env": { + "AUTH_HEADER": "Bearer " + } + } +} +``` + +This is enough to pass the authorization header to your FastAPI endpoints. + +Optionally, if you want your MCP server to reject requests without an authorization header, you can add a dependency: + +```python +from fastapi import Depends +from fastapi_mcp import FastApiMCP, AuthConfig + +mcp = FastApiMCP( + app, + name="Protected MCP", + auth_config=AuthConfig( + dependencies=[Depends(verify_auth)], + ), +) +mcp.mount() +``` + +## OAuth Flow + +FastAPI-MCP supports the full OAuth 2 flow, compliant with [MCP Spec 2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). + +It would look something like this: + +```python +from fastapi import Depends +from fastapi_mcp import FastApiMCP, AuthConfig + +mcp = FastApiMCP( + app, + name="MCP With OAuth", + auth_config=AuthConfig( + issuer=f"https://auth.example.com/", + authorize_url=f"https://auth.example.com/authorize", + oauth_metadata_url=f"https://auth.example.com/.well-known/oauth-authorization-server", + audience="my-audience", + client_id="my-client-id", + client_secret="my-client-secret", + dependencies=[Depends(verify_auth)], + setup_proxies=True, + ), +) + +mcp.mount() +``` + +And you can call it like: + +```json +{ + "mcpServers": { + "fastapi-mcp": { + "command": "npx", + "args": [ + "mcp-remote", + "http://localhost:8000/mcp", + "8080" // Optional port number. Necessary if you want your OAuth to work and you don't have dynamic client registration. + ] + } + } +} +``` + +You can use it with any OAuth provider that supports the OAuth 2 spec. See explanation on [AuthConfig](#authconfig-explained) for more details. + +## Custom OAuth Metadata + +If you already have a properly configured OAuth server that works with MCP clients, or if you want full control over the metadata, you can provide your own OAuth metadata directly: + +```python +from fastapi import Depends +from fastapi_mcp import FastApiMCP, AuthConfig + +mcp = FastApiMCP( + app, + name="MCP With Custom OAuth", + auth_config=AuthConfig( + # Provide your own complete OAuth metadata + custom_oauth_metadata={ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "scopes_supported": ["openid", "profile", "email"], + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": ["S256"] + }, + + # Your auth checking dependency + dependencies=[Depends(verify_auth)], + ), +) + +mcp.mount() +``` + +This approach gives you complete control over the OAuth metadata and is useful when: +- You have a fully MCP-compliant OAuth server already configured +- You need to customize the OAuth flow beyond what the proxy approach offers +- You're using a custom or specialized OAuth implementation + +For this to work, you have to make sure mcp-remote is running [on a fixed port](#add-a-fixed-port-to-mcp-remote), for example `8080`, and then configure the callback URL to `http://127.0.0.1:8080/oauth/callback` in your OAuth provider. + +## Working Example with Auth0 + +For a complete working example of OAuth integration with Auth0, check out the [auth_example_auth0.py](/examples/08_auth_example_auth0.py) in the examples folder. This example demonstrates the simple case of using Auth0 as an OAuth provider, with a working example of the OAuth flow. + +For it to work, you need an .env file in the root of the project with the following variables: + +``` +AUTH0_DOMAIN=your-tenant.auth0.com +AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/ +AUTH0_CLIENT_ID=your-client-id +AUTH0_CLIENT_SECRET=your-client-secret +``` + +You also need to make sure to configure callback URLs properly in your Auth0 dashboard. + +## AuthConfig Explained + +### `setup_proxies=True` + +Most OAuth providers need some adaptation to work with MCP clients. This is where `setup_proxies=True` comes in - it creates proxy endpoints that make your OAuth provider compatible with MCP clients: + +```python +mcp = FastApiMCP( + app, + auth_config=AuthConfig( + # Your OAuth provider information + issuer="https://auth.example.com", + authorize_url="https://auth.example.com/authorize", + oauth_metadata_url="https://auth.example.com/.well-known/oauth-authorization-server", + + # Credentials registered with your OAuth provider + client_id="your-client-id", + client_secret="your-client-secret", + + # Recommended, since some clients don't specify them + audience="your-api-audience", + default_scope="openid profile email", + + # Your auth checking dependency + dependencies=[Depends(verify_auth)], + + # Create compatibility proxies - usually needed! + setup_proxies=True, + ), +) +``` + +You also need to make sure to configure callback URLs properly in your OAuth provider. With mcp-remote for example, you have to [use a fixed port](#add-a-fixed-port-to-mcp-remote). + +### Why Use Proxies? + +Proxies solve several problems: + +1. **Missing registration endpoints**: + The MCP spec expects OAuth providers to support [dynamic client registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591), but many don't. + Furthermore, dynamic client registration is probably overkill for most use cases. + The `setup_fake_dynamic_registration` option (True by default) creates a compatible endpoint that just returns a static client ID and secret. + +2. **Scope handling**: + Some MCP clients don't properly request scopes, so our proxy adds the necessary scopes for you. + +3. **Audience requirements**: + Some OAuth providers require an audience parameter that MCP clients don't always provide. The proxy adds this automatically. + +### Add a fixed port to mcp-remote + +```json +{ + "mcpServers": { + "example": { + "command": "npx", + "args": [ + "mcp-remote", + "http://localhost:8000/mcp", + "8080" + ] + } + } +} +``` + +Normally, mcp-remote will start on a random port, making it impossible to configure the OAuth provider's callback URL properly. + + +You have to make sure mcp-remote is running on a fixed port, for example `8080`, and then configure the callback URL to `http://127.0.0.1:8080/oauth/callback` in your OAuth provider. \ No newline at end of file diff --git a/docs/03_advanced_usage.md b/docs/04_advanced_usage.md similarity index 79% rename from docs/03_advanced_usage.md rename to docs/04_advanced_usage.md index 8d72ee1..c4dd1ab 100644 --- a/docs/03_advanced_usage.md +++ b/docs/04_advanced_usage.md @@ -1,4 +1,4 @@ -# FastAPI-MCP Advanced Usage +## Advanced Usage FastAPI-MCP provides several ways to customize and control how your MCP server is created and configured. Here are some advanced usage patterns: @@ -13,7 +13,6 @@ app = FastAPI() mcp = FastApiMCP( app, name="My API MCP", - base_url="http://localhost:8000", describe_all_responses=True, # Include all possible response schemas in tool descriptions describe_full_response_schema=True # Include full JSON schema in tool descriptions ) @@ -89,10 +88,7 @@ api_app = FastAPI() mcp_app = FastAPI() # Create MCP server from the API app -mcp = FastApiMCP( - api_app, - base_url="http://api-host:8001", # The URL where the API app will be running -) +mcp = FastApiMCP(api_app) # Mount the MCP server to the separate app mcp.mount(mcp_app) @@ -125,3 +121,32 @@ async def new_endpoint(): # Refresh the MCP server to include the new endpoint mcp.setup_server() ``` + +### Communication with the FastAPI App + +FastAPI-MCP uses ASGI transport by default, which means it communicates directly with your FastAPI app without making HTTP requests. This is more efficient and doesn't require a base URL. + +It's not even necessary that the FastAPI server will run. See the examples folder for more. + +If you need to specify a custom base URL or use a different transport method, you can provide your own `httpx.AsyncClient`: + +```python +import httpx +from fastapi import FastAPI +from fastapi_mcp import FastApiMCP + +app = FastAPI() + +# Use a custom HTTP client with a specific base URL +custom_client = httpx.AsyncClient( + base_url="https://api.example.com", + timeout=30.0 +) + +mcp = FastApiMCP( + app, + http_client=custom_client +) + +mcp.mount() +``` diff --git a/examples/08_auth_example_auth0.py b/examples/08_auth_example_auth0.py new file mode 100644 index 0000000..a91b548 --- /dev/null +++ b/examples/08_auth_example_auth0.py @@ -0,0 +1,137 @@ +from fastapi import FastAPI, Depends, HTTPException, Request, status +from pydantic_settings import BaseSettings +from typing import Any +import logging + +from fastapi_mcp import FastApiMCP, AuthConfig + +from examples.shared.auth import fetch_jwks_public_key +from examples.shared.setup import setup_logging + + +setup_logging() +logger = logging.getLogger(__name__) + + +class Settings(BaseSettings): + """ + For this to work, you need an .env file in the root of the project with the following variables: + AUTH0_DOMAIN=your-tenant.auth0.com + AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/ + AUTH0_CLIENT_ID=your-client-id + AUTH0_CLIENT_SECRET=your-client-secret + """ + + auth0_domain: str # Auth0 domain, e.g. "your-tenant.auth0.com" + auth0_audience: str # Audience, e.g. "https://your-tenant.auth0.com/api/v2/" + auth0_client_id: str + auth0_client_secret: str + + @property + def auth0_jwks_url(self): + return f"https://{self.auth0_domain}/.well-known/jwks.json" + + @property + def auth0_oauth_metadata_url(self): + return f"https://{self.auth0_domain}/.well-known/openid-configuration" + + class Config: + env_file = ".env" + + +settings = Settings() # type: ignore + + +async def lifespan(app: FastAPI): + app.state.jwks_public_key = await fetch_jwks_public_key(settings.auth0_jwks_url) + logger.info(f"Auth0 client ID in settings: {settings.auth0_client_id}") + logger.info(f"Auth0 domain in settings: {settings.auth0_domain}") + logger.info(f"Auth0 audience in settings: {settings.auth0_audience}") + yield + + +async def verify_auth(request: Request) -> dict[str, Any]: + try: + import jwt + + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header") + + token = auth_header.split(" ")[1] + + header = jwt.get_unverified_header(token) + + # Check if this is a JWE token (encrypted token) + if header.get("alg") == "dir" and header.get("enc") == "A256GCM": + raise ValueError( + "Token is encrypted, offline validation not possible. " + "This is usually due to not specifying the audience when requesting the token." + ) + + # Otherwise, it's a JWT, we can validate it offline + if header.get("alg") in ["RS256", "HS256"]: + claims = jwt.decode( + token, + app.state.jwks_public_key, + algorithms=["RS256", "HS256"], + audience=settings.auth0_audience, + issuer=f"https://{settings.auth0_domain}/", + options={"verify_signature": True}, + ) + return claims + + except Exception as e: + logger.error(f"Auth error: {str(e)}") + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") + + +async def get_current_user_id(claims: dict = Depends(verify_auth)) -> str: + user_id = claims.get("sub") + + if not user_id: + logger.error("No user ID found in token") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") + + return user_id + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/api/public", operation_id="public") +async def public(): + return {"message": "This is a public route"} + + +@app.get("/api/protected", operation_id="protected") +async def protected(user_id: str = Depends(get_current_user_id)): + return {"message": f"Hello, {user_id}!", "user_id": user_id} + + +# Set up FastAPI-MCP with Auth0 auth +mcp = FastApiMCP( + app, + name="MCP With Auth0", + description="Example of FastAPI-MCP with Auth0 authentication", + auth_config=AuthConfig( + issuer=f"https://{settings.auth0_domain}/", + authorize_url=f"https://{settings.auth0_domain}/authorize", + oauth_metadata_url=settings.auth0_oauth_metadata_url, + audience=settings.auth0_audience, + client_id=settings.auth0_client_id, + client_secret=settings.auth0_client_secret, + dependencies=[Depends(verify_auth)], + setup_proxies=True, + ), +) + +# Mount the MCP server +mcp.mount() + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/shared/auth.py b/examples/shared/auth.py new file mode 100644 index 0000000..1fb439e --- /dev/null +++ b/examples/shared/auth.py @@ -0,0 +1,50 @@ +from jwt.algorithms import RSAAlgorithm +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +import logging +import httpx + +from examples.shared.setup import setup_logging + +setup_logging() + +logger = logging.getLogger(__name__) + + +async def fetch_jwks_public_key(url: str) -> str: + """ + Fetch JWKS from a given URL and extract the primary public key in PEM format. + + Args: + url: The JWKS URL to fetch from + + Returns: + PEM-formatted public key as a string + """ + logger.info(f"Fetching JWKS from: {url}") + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + jwks_data = response.json() + + if not jwks_data or "keys" not in jwks_data or not jwks_data["keys"]: + logger.error("Invalid JWKS data format: missing or empty 'keys' array") + raise ValueError("Invalid JWKS data format: missing or empty 'keys' array") + + # Just use the first key in the set + jwk = jwks_data["keys"][0] + + # Convert JWK to PEM format + public_key = RSAAlgorithm.from_jwk(jwk) + if isinstance(public_key, RSAPublicKey): + pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + pem_str = pem.decode("utf-8") + logger.info("Successfully extracted public key from JWKS") + return pem_str + else: + logger.error("Invalid JWKS data format: expected RSA public key") + raise ValueError("Invalid JWKS data format: expected RSA public key") diff --git a/fastapi_mcp/__init__.py b/fastapi_mcp/__init__.py index 40327b2..f748712 100644 --- a/fastapi_mcp/__init__.py +++ b/fastapi_mcp/__init__.py @@ -13,8 +13,11 @@ __version__ = "0.0.0.dev0" # pragma: no cover from .server import FastApiMCP +from .types import AuthConfig, OAuthMetadata __all__ = [ "FastApiMCP", + "AuthConfig", + "OAuthMetadata", ] diff --git a/fastapi_mcp/auth/__init__.py b/fastapi_mcp/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi_mcp/auth/proxy.py b/fastapi_mcp/auth/proxy.py new file mode 100644 index 0000000..06e7590 --- /dev/null +++ b/fastapi_mcp/auth/proxy.py @@ -0,0 +1,264 @@ +from typing_extensions import Annotated, Doc +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.responses import RedirectResponse +import httpx +from typing import Optional +import logging +from urllib.parse import urlencode + +from fastapi_mcp.types import ( + ClientRegistrationRequest, + ClientRegistrationResponse, + AuthConfig, + OAuthMetadata, + OAuthMetadataDict, + StrHttpUrl, +) + + +logger = logging.getLogger(__name__) + + +def setup_oauth_custom_metadata( + app: Annotated[FastAPI, Doc("The FastAPI app instance")], + auth_config: Annotated[AuthConfig, Doc("The AuthConfig used")], + metadata: Annotated[OAuthMetadataDict, Doc("The custom metadata specified in AuthConfig")], + include_in_schema: Annotated[bool, Doc("Whether to include the metadata endpoint in your OpenAPI docs")] = False, +): + """ + Just serve the custom metadata provided to AuthConfig under the path specified in `metadata_path`. + """ + + auth_config = AuthConfig.model_validate(auth_config) + metadata = OAuthMetadata.model_validate(metadata) + + @app.get( + auth_config.metadata_path, + response_model=OAuthMetadata, + response_model_exclude_unset=True, + response_model_exclude_none=True, + include_in_schema=include_in_schema, + operation_id="oauth_custom_metadata", + ) + async def oauth_metadata_proxy(): + return metadata + + +def setup_oauth_metadata_proxy( + app: Annotated[FastAPI, Doc("The FastAPI app instance")], + metadata_url: Annotated[ + str, + Doc( + """ + The URL of the OAuth provider's metadata endpoint that you want to proxy. + """ + ), + ], + path: Annotated[ + str, + Doc( + """ + The path to mount the OAuth metadata endpoint at. + + Clients will usually expect this to be /.well-known/oauth-authorization-server + """ + ), + ] = "/.well-known/oauth-authorization-server", + authorize_path: Annotated[ + str, + Doc( + """ + The path to mount the authorize endpoint at. + + Clients will usually expect this to be /oauth/authorize + """ + ), + ] = "/oauth/authorize", + register_path: Annotated[ + Optional[str], + Doc( + """ + The path to mount the register endpoint at. + + Clients will usually expect this to be /oauth/register + """ + ), + ] = None, + include_in_schema: Annotated[bool, Doc("Whether to include the metadata endpoint in your OpenAPI docs")] = False, +): + """ + Proxy for your OAuth provider's Metadata endpoint, just adding our (fake) registration endpoint. + """ + + @app.get( + path, + response_model=OAuthMetadata, + response_model_exclude_unset=True, + response_model_exclude_none=True, + include_in_schema=include_in_schema, + operation_id="oauth_metadata_proxy", + ) + async def oauth_metadata_proxy(request: Request): + base_url = str(request.base_url).rstrip("/") + + # Fetch your OAuth provider's OpenID Connect metadata + async with httpx.AsyncClient() as client: + response = await client.get(metadata_url) + if response.status_code != 200: + logger.error( + f"Failed to fetch OAuth metadata from {metadata_url}: {response.status_code}. Response: {response.text}" + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to fetch OAuth metadata", + ) + + oauth_metadata = response.json() + + # Override the registration endpoint if provided + if register_path: + oauth_metadata["registration_endpoint"] = f"{base_url}{register_path}" + + # Replace your OAuth provider's authorize endpoint with our proxy + oauth_metadata["authorization_endpoint"] = f"{base_url}{authorize_path}" + + return OAuthMetadata.model_validate(oauth_metadata) + + +def setup_oauth_authorize_proxy( + app: Annotated[FastAPI, Doc("The FastAPI app instance")], + client_id: Annotated[ + str, + Doc( + """ + In case the client doesn't specify a client ID, this will be used as the default client ID on the + request to your OAuth provider. + """ + ), + ], + authorize_url: Annotated[ + Optional[StrHttpUrl], + Doc( + """ + The URL of your OAuth provider's authorization endpoint. + + Usually this is something like `https://app.example.com/oauth/authorize`. + """ + ), + ], + audience: Annotated[ + Optional[str], + Doc( + """ + Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the + audience when sending a request to your server. + + This may cause unexpected behavior from your OAuth provider, so this is a workaround. + + In case the client doesn't specify an audience, this will be used as the default audience on the + request to your OAuth provider. + """ + ), + ] = None, + default_scope: Annotated[ + str, + Doc( + """ + Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the + scope when sending a request to your server. + + This may cause unexpected behavior from your OAuth provider, so this is a workaround. + + Here is where you can optionally specify a default scope that will be sent to your OAuth provider in case + the client doesn't specify it. + """ + ), + ] = "openid profile email", + path: Annotated[str, Doc("The path to mount the authorize endpoint at")] = "/oauth/authorize", + include_in_schema: Annotated[bool, Doc("Whether to include the authorize endpoint in your OpenAPI docs")] = False, +): + """ + Proxy for your OAuth provider's authorize endpoint that logs the requested scopes and adds + default scopes and the audience parameter if not provided. + """ + + @app.get( + path, + include_in_schema=include_in_schema, + ) + async def oauth_authorize_proxy( + response_type: str = "code", + client_id: Optional[str] = client_id, + redirect_uri: Optional[str] = None, + scope: str = "", + state: Optional[str] = None, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[str] = None, + audience: Optional[str] = audience, + ): + if not scope: + logger.warning("Client didn't provide any scopes! Using default scopes.") + scope = default_scope + + scopes = scope.split() + for required_scope in default_scope.split(): + if required_scope not in scopes: + scopes.append(required_scope) + + params = { + "response_type": response_type, + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(scopes), + "audience": audience, + } + + if state: + params["state"] = state + if code_challenge: + params["code_challenge"] = code_challenge + if code_challenge_method: + params["code_challenge_method"] = code_challenge_method + + auth_url = f"{authorize_url}?{urlencode(params)}" + + return RedirectResponse(url=auth_url) + + +def setup_oauth_fake_dynamic_register_endpoint( + app: Annotated[FastAPI, Doc("The FastAPI app instance")], + client_id: Annotated[str, Doc("The client ID of the pre-registered client")], + client_secret: Annotated[str, Doc("The client secret of the pre-registered client")], + path: Annotated[str, Doc("The path to mount the register endpoint at")] = "/oauth/register", + include_in_schema: Annotated[bool, Doc("Whether to include the register endpoint in your OpenAPI docs")] = False, +): + """ + A proxy for dynamic client registration endpoint. + + In MCP 2025-03-26 Spec, it is recommended to support OAuth Dynamic Client Registration (RFC 7591). + Furthermore, `npx mcp-remote` which is the current de-facto client that supports MCP's up-to-date spec, + requires this endpoint to be present. + + But, this is an overcomplication for most use cases. + + So instead of actually implementing dynamic client registration, we just echo back the pre-registered + client ID and secret. + + Use this if you don't need dynamic client registration, or if your OAuth provider doesn't support it. + """ + + @app.post( + path, + response_model=ClientRegistrationResponse, + include_in_schema=include_in_schema, + ) + async def oauth_register_proxy(request: ClientRegistrationRequest) -> ClientRegistrationResponse: + client_response = ClientRegistrationResponse( + client_name=request.client_name or "MCP Server", # Name doesn't really affect functionality + client_id=client_id, + client_secret=client_secret, + redirect_uris=request.redirect_uris, # Just echo back their requested URIs + grant_types=request.grant_types or ["authorization_code"], + token_endpoint_auth_method=request.token_endpoint_auth_method or "none", + ) + return client_response diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index 1a7b39e..5974c8c 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -1,15 +1,16 @@ import json import httpx -from typing import Dict, Optional, Any, List, Union +from typing import Dict, Optional, Any, List, Union, Callable, Awaitable, Iterable, Literal, Sequence from typing_extensions import Annotated, Doc -from fastapi import FastAPI, Request, APIRouter +from fastapi import FastAPI, Request, APIRouter, params from fastapi.openapi.utils import get_openapi from mcp.server.lowlevel.server import Server import mcp.types as types from fastapi_mcp.openapi.convert import convert_openapi_to_mcp_tools from fastapi_mcp.transport.sse import FastApiSseTransport +from fastapi_mcp.types import HTTPRequestInfo, AuthConfig import logging @@ -17,14 +18,72 @@ logger = logging.getLogger(__name__) +class LowlevelMCPServer(Server): + def call_tool(self): + """ + A near-direct copy of `mcp.server.lowlevel.server.Server.call_tool()`, except that it looks for + the original HTTP request info in the MCP message, and passes it to the tool call handler. + """ + + def decorator( + func: Callable[ + ..., + Awaitable[Iterable[types.TextContent | types.ImageContent | types.EmbeddedResource]], + ], + ): + logger.debug("Registering handler for CallToolRequest") + + async def handler(req: types.CallToolRequest): + try: + # Pull the original HTTP request info from the MCP message. It was injected in + # `FastApiSseTransport.handle_fastapi_post_message()` + if hasattr(req.params, "_http_request_info") and req.params._http_request_info is not None: + http_request_info = HTTPRequestInfo.model_validate(req.params._http_request_info) + results = await func(req.params.name, (req.params.arguments or {}), http_request_info) + else: + results = await func(req.params.name, (req.params.arguments or {})) + return types.ServerResult(types.CallToolResult(content=list(results), isError=False)) + except Exception as e: + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=str(e))], + isError=True, + ) + ) + + self.request_handlers[types.CallToolRequest] = handler + return func + + return decorator + + class FastApiMCP: + """ + Create an MCP server from a FastAPI app. + """ + def __init__( self, - fastapi: FastAPI, - name: Optional[str] = None, - description: Optional[str] = None, - describe_all_responses: bool = False, - describe_full_response_schema: bool = False, + fastapi: Annotated[ + FastAPI, + Doc("The FastAPI application to create an MCP server from"), + ], + name: Annotated[ + Optional[str], + Doc("Name for the MCP server (defaults to app.title)"), + ] = None, + description: Annotated[ + Optional[str], + Doc("Description for the MCP server (defaults to app.description)"), + ] = None, + describe_all_responses: Annotated[ + bool, + Doc("Whether to include all possible response schemas in tool descriptions"), + ] = False, + describe_full_response_schema: Annotated[ + bool, + Doc("Whether to include full json schema for responses in tool descriptions"), + ] = False, http_client: Annotated[ Optional[httpx.AsyncClient], Doc( @@ -34,25 +93,27 @@ def __init__( """ ), ] = None, - include_operations: Optional[List[str]] = None, - exclude_operations: Optional[List[str]] = None, - include_tags: Optional[List[str]] = None, - exclude_tags: Optional[List[str]] = None, + include_operations: Annotated[ + Optional[List[str]], + Doc("List of operation IDs to include as MCP tools. Cannot be used with exclude_operations."), + ] = None, + exclude_operations: Annotated[ + Optional[List[str]], + Doc("List of operation IDs to exclude from MCP tools. Cannot be used with include_operations."), + ] = None, + include_tags: Annotated[ + Optional[List[str]], + Doc("List of tags to include as MCP tools. Cannot be used with exclude_tags."), + ] = None, + exclude_tags: Annotated[ + Optional[List[str]], + Doc("List of tags to exclude from MCP tools. Cannot be used with include_tags."), + ] = None, + auth_config: Annotated[ + Optional[AuthConfig], + Doc("Configuration for MCP authentication"), + ] = None, ): - """ - Create an MCP server from a FastAPI app. - - Args: - fastapi: The FastAPI application - name: Name for the MCP server (defaults to app.title) - description: Description for the MCP server (defaults to app.description) - describe_all_responses: Whether to include all possible response schemas in tool descriptions - describe_full_response_schema: Whether to include full json schema for responses in tool descriptions - include_operations: List of operation IDs to include as MCP tools. Cannot be used with exclude_operations. - exclude_operations: List of operation IDs to exclude from MCP tools. Cannot be used with include_operations. - include_tags: List of tags to include as MCP tools. Cannot be used with exclude_tags. - exclude_tags: List of tags to exclude from MCP tools. Cannot be used with include_tags. - """ # Validate operation and tag filtering options if include_operations is not None and exclude_operations is not None: raise ValueError("Cannot specify both include_operations and exclude_operations") @@ -75,6 +136,10 @@ def __init__( self._exclude_operations = exclude_operations self._include_tags = include_tags self._exclude_tags = exclude_tags + self._auth_config = auth_config + + if self._auth_config: + self._auth_config = self._auth_config.model_validate(self._auth_config) self._http_client = http_client or httpx.AsyncClient( transport=httpx.ASGITransport(app=self.fastapi, raise_app_exceptions=False), @@ -85,7 +150,6 @@ def __init__( self.setup_server() def setup_server(self) -> None: - # Get OpenAPI schema from FastAPI app openapi_schema = get_openapi( title=self.fastapi.title, version=self.fastapi.version, @@ -94,7 +158,6 @@ def setup_server(self) -> None: routes=self.fastapi.routes, ) - # Convert OpenAPI schema to MCP tools all_tools, self.operation_map = convert_openapi_to_mcp_tools( openapi_schema, describe_all_responses=self._describe_all_responses, @@ -104,28 +167,123 @@ def setup_server(self) -> None: # Filter tools based on operation IDs and tags self.tools = self._filter_tools(all_tools, openapi_schema) - # Create the MCP lowlevel server - mcp_server: Server = Server(self.name, self.description) + mcp_server: LowlevelMCPServer = LowlevelMCPServer(self.name, self.description) - # Register handlers for tools @mcp_server.list_tools() async def handle_list_tools() -> List[types.Tool]: return self.tools - # Register the tool call handler @mcp_server.call_tool() async def handle_call_tool( - name: str, arguments: Dict[str, Any] + name: str, arguments: Dict[str, Any], http_request_info: Optional[HTTPRequestInfo] = None ) -> List[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: return await self._execute_api_tool( client=self._http_client, tool_name=name, arguments=arguments, operation_map=self.operation_map, + http_request_info=http_request_info, ) self.server = mcp_server + def _register_mcp_connection_endpoint_sse( + self, + router: FastAPI | APIRouter, + transport: FastApiSseTransport, + mount_path: str, + dependencies: Optional[Sequence[params.Depends]], + ): + @router.get(mount_path, include_in_schema=False, operation_id="mcp_connection", dependencies=dependencies) + async def handle_mcp_connection(request: Request): + async with transport.connect_sse(request.scope, request.receive, request._send) as (reader, writer): + await self.server.run( + reader, + writer, + self.server.create_initialization_options(notification_options=None, experimental_capabilities={}), + raise_exceptions=False, + ) + + def _register_mcp_messages_endpoint_sse( + self, + router: FastAPI | APIRouter, + transport: FastApiSseTransport, + mount_path: str, + dependencies: Optional[Sequence[params.Depends]], + ): + @router.post( + f"{mount_path}/messages/", + include_in_schema=False, + operation_id="mcp_messages", + dependencies=dependencies, + ) + async def handle_post_message(request: Request): + return await transport.handle_fastapi_post_message(request) + + def _register_mcp_endpoints_sse( + self, + router: FastAPI | APIRouter, + transport: FastApiSseTransport, + mount_path: str, + dependencies: Optional[Sequence[params.Depends]], + ): + self._register_mcp_connection_endpoint_sse(router, transport, mount_path, dependencies) + self._register_mcp_messages_endpoint_sse(router, transport, mount_path, dependencies) + + def _setup_auth_2025_03_26(self): + from fastapi_mcp.auth.proxy import ( + setup_oauth_custom_metadata, + setup_oauth_metadata_proxy, + setup_oauth_authorize_proxy, + setup_oauth_fake_dynamic_register_endpoint, + ) + + if self._auth_config: + if self._auth_config.custom_oauth_metadata: + setup_oauth_custom_metadata( + app=self.fastapi, + auth_config=self._auth_config, + metadata=self._auth_config.custom_oauth_metadata, + ) + + elif self._auth_config.setup_proxies: + assert self._auth_config.client_id is not None + + metadata_url = self._auth_config.oauth_metadata_url + if not metadata_url: + metadata_url = f"{self._auth_config.issuer}{self._auth_config.metadata_path}" + + setup_oauth_metadata_proxy( + app=self.fastapi, + metadata_url=metadata_url, + path=self._auth_config.metadata_path, + register_path="/oauth/register" if self._auth_config.setup_fake_dynamic_registration else None, + ) + setup_oauth_authorize_proxy( + app=self.fastapi, + client_id=self._auth_config.client_id, + authorize_url=self._auth_config.authorize_url, + audience=self._auth_config.audience, + ) + if self._auth_config.setup_fake_dynamic_registration: + assert self._auth_config.client_secret is not None + setup_oauth_fake_dynamic_register_endpoint( + app=self.fastapi, + client_id=self._auth_config.client_id, + client_secret=self._auth_config.client_secret, + ) + + def _setup_auth(self): + if self._auth_config: + if self._auth_config.version == "2025-03-26": + self._setup_auth_2025_03_26() + else: + raise ValueError( + f"Unsupported MCP spec version: {self._auth_config.version}. Please check your AuthConfig." + ) + else: + logger.info("No auth config provided, skipping auth setup") + def mount( self, router: Annotated[ @@ -141,10 +299,18 @@ def mount( str, Doc( """ - Path where the MCP server will be mounted + Path where the MCP server will be mounted. Defaults to '/mcp'. """ ), ] = "/mcp", + transport: Annotated[ + Literal["sse"], + Doc( + """ + The transport type for the MCP server. Currently only 'sse' is supported. + """ + ), + ] = "sse", ) -> None: """ Mount the MCP server to **any** FastAPI app or APIRouter. @@ -173,21 +339,14 @@ def mount( sse_transport = FastApiSseTransport(messages_path) - # Route for MCP connection - @router.get(mount_path, include_in_schema=False, operation_id="mcp_connection") - async def handle_mcp_connection(request: Request): - async with sse_transport.connect_sse(request.scope, request.receive, request._send) as (reader, writer): - await self.server.run( - reader, - writer, - self.server.create_initialization_options(notification_options=None, experimental_capabilities={}), - raise_exceptions=False, - ) + dependencies = self._auth_config.dependencies if self._auth_config else None - # Route for MCP messages - @router.post(f"{mount_path}/messages/", include_in_schema=False, operation_id="mcp_messages") - async def handle_post_message(request: Request): - return await sse_transport.handle_fastapi_post_message(request) + if transport == "sse": + self._register_mcp_endpoints_sse(router, sse_transport, mount_path, dependencies) + else: # pragma: no cover + raise ValueError(f"Invalid transport: {transport}") # pragma: no cover + + self._setup_auth() # HACK: If we got a router and not a FastAPI instance, we need to re-include the router so that # FastAPI will pick up the new routes we added. The problem with this approach is that we assume @@ -201,20 +360,18 @@ async def handle_post_message(request: Request): async def _execute_api_tool( self, - client: httpx.AsyncClient, - tool_name: str, - arguments: Dict[str, Any], - operation_map: Dict[str, Dict[str, Any]], + client: Annotated[httpx.AsyncClient, Doc("httpx client to use in API calls")], + tool_name: Annotated[str, Doc("The name of the tool to execute")], + arguments: Annotated[Dict[str, Any], Doc("The arguments for the tool")], + operation_map: Annotated[Dict[str, Dict[str, Any]], Doc("A mapping from tool names to operation details")], + http_request_info: Annotated[ + Optional[HTTPRequestInfo], + Doc("HTTP request info to forward to the actual API call"), + ] = None, ) -> List[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: """ Execute an MCP tool by making an HTTP request to the corresponding API endpoint. - Args: - tool_name: The name of the tool to execute - arguments: The arguments for the tool - operation_map: A mapping from tool names to operation details - client: Optional HTTP client to use (primarily for testing) - Returns: The result as MCP content types """ @@ -250,6 +407,12 @@ async def _execute_api_tool( raise ValueError(f"Parameter name is None for parameter: {param}") headers[param_name] = arguments.pop(param_name) + if http_request_info and http_request_info.headers: + if "Authorization" in http_request_info.headers: + headers["Authorization"] = http_request_info.headers["Authorization"] + elif "authorization" in http_request_info.headers: + headers["Authorization"] = http_request_info.headers["authorization"] + body = arguments if arguments else None try: diff --git a/fastapi_mcp/transport/sse.py b/fastapi_mcp/transport/sse.py index 8b2ed9b..c39f9cc 100644 --- a/fastapi_mcp/transport/sse.py +++ b/fastapi_mcp/transport/sse.py @@ -8,6 +8,7 @@ from pydantic import ValidationError from mcp.server.sse import SseServerTransport from mcp.types import JSONRPCMessage, JSONRPCError, ErrorData +from fastapi_mcp.types import HTTPRequestInfo logger = logging.getLogger(__name__) @@ -33,7 +34,7 @@ async def handle_fastapi_post_message(self, request: Request) -> Response: when using the original implementation. """ - logger.debug("Handling POST message with FastAPI patterns") + logger.debug("Handling POST message SSE") session_id_param = request.query_params.get("session_id") if session_id_param is None: @@ -57,6 +58,20 @@ async def handle_fastapi_post_message(self, request: Request) -> Response: try: message = JSONRPCMessage.model_validate_json(body) + + # HACK to inject the HTTP request info into the MCP message, + # so we can use it for auth. + # It is then used in our custom `LowlevelMCPServer.call_tool()` decorator. + if hasattr(message.root, "params") and message.root.params is not None: + message.root.params["_http_request_info"] = HTTPRequestInfo( + method=request.method, + path=request.url.path, + headers=dict(request.headers), + cookies=request.cookies, + query_params=dict(request.query_params), + body=body.decode(), + ).model_dump(mode="json") + logger.debug(f"Validated client message: {message}") except ValidationError as err: logger.error(f"Failed to parse message: {err}") diff --git a/fastapi_mcp/types.py b/fastapi_mcp/types.py index 86f12cb..98c3f27 100644 --- a/fastapi_mcp/types.py +++ b/fastapi_mcp/types.py @@ -1,5 +1,387 @@ -from pydantic import BaseModel, ConfigDict # pragma: no cover +import time +from typing import Any, Dict, Annotated, Union, Optional, Sequence, Literal, List +from typing_extensions import Doc +from pydantic import ( + BaseModel, + ConfigDict, + HttpUrl, + field_validator, + model_validator, +) +from pydantic.main import IncEx +from fastapi import params -class BaseType(BaseModel): # pragma: no cover - model_config = ConfigDict(extra="forbid") # pragma: no cover +StrHttpUrl = Annotated[Union[str, HttpUrl], HttpUrl] + + +class BaseType(BaseModel): + model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) + + +class HTTPRequestInfo(BaseType): + method: str + path: str + headers: Dict[str, str] + cookies: Dict[str, str] + query_params: Dict[str, str] + body: Any + + +class OAuthMetadata(BaseType): + """OAuth 2.0 Server Metadata according to RFC 8414""" + + issuer: Annotated[ + StrHttpUrl, + Doc( + """ + The authorization server's issuer identifier, which is a URL that uses the https scheme. + """ + ), + ] + + authorization_endpoint: Annotated[ + StrHttpUrl, + Doc( + """ + URL of the authorization server's authorization endpoint. + """ + ), + ] + + token_endpoint: Annotated[ + StrHttpUrl, + Doc( + """ + URL of the authorization server's token endpoint. + """ + ), + ] + + scopes_supported: Annotated[ + List[str], + Doc( + """ + List of OAuth 2.0 scopes that the authorization server supports. + """ + ), + ] = ["openid", "profile", "email"] + + response_types_supported: Annotated[ + List[str], + Doc( + """ + List of the OAuth 2.0 response_type values that the authorization server supports. + """ + ), + ] = ["code"] + + grant_types_supported: Annotated[ + List[str], + Doc( + """ + List of the OAuth 2.0 grant type values that the authorization server supports. + """ + ), + ] = ["authorization_code", "client_credentials"] + + token_endpoint_auth_methods_supported: Annotated[ + List[str], + Doc( + """ + List of client authentication methods supported by the token endpoint. + """ + ), + ] = ["none"] + + code_challenge_methods_supported: Annotated[ + List[str], + Doc( + """ + List of PKCE code challenge methods supported by the authorization server. + """ + ), + ] = ["S256"] + + registration_endpoint: Annotated[ + Optional[StrHttpUrl], + Doc( + """ + URL of the authorization server's client registration endpoint. + """ + ), + ] = None + + @field_validator( + "scopes_supported", + "response_types_supported", + "grant_types_supported", + "token_endpoint_auth_methods_supported", + "code_challenge_methods_supported", + ) + @classmethod + def validate_non_empty_lists(cls, v, info): + if not v: + raise ValueError(f"{info.field_name} cannot be empty") + + return v + + @model_validator(mode="after") + def validate_endpoints_for_grant_types(self): + if "authorization_code" in self.grant_types_supported and not self.authorization_endpoint: + raise ValueError("authorization_endpoint is required when authorization_code grant type is supported") + return self + + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool = False, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = True, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + serialize_as_any: bool = False, + ) -> dict[str, Any]: + # Always exclude unset and None fields, since clients don't take it well when + # OAuth metadata fields are present but empty. + exclude_unset = True + exclude_none = True + return super().model_dump( + mode=mode, + include=include, + exclude=exclude, + context=context, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + round_trip=round_trip, + warnings=warnings, + serialize_as_any=serialize_as_any, + ) + + +OAuthMetadataDict = Annotated[Union[Dict[str, Any], OAuthMetadata], OAuthMetadata] + + +class AuthConfig(BaseType): + version: Annotated[ + Literal["2025-03-26"], + Doc( + """ + The MCP spec version to use for setting up authorization. + Currently only "2025-03-26" is supported. + """ + ), + ] = "2025-03-26" + + dependencies: Annotated[ + Optional[Sequence[params.Depends]], + Doc( + """ + FastAPI dependencies (using `Depends()`) that check for authentication or authorization + and raise 401 or 403 errors if the request is not authenticated or authorized. + + This is necessary to trigger the client to start an OAuth flow. + + Example: + ```python + # Your authentication dependency + async def authenticate_request(request: Request, token: str = Depends(oauth2_scheme)): + payload = verify_token(request, token) + if payload is None: + raise HTTPException(status_code=401, detail="Unauthorized") + return payload + + # Usage with FastAPI-MCP + mcp = FastApiMCP( + app, + auth_config=AuthConfig(dependencies=[Depends(authenticate_request)]), + ) + ``` + """ + ), + ] = None + + issuer: Annotated[ + Optional[str], + Doc( + """ + The issuer of the OAuth 2.0 server. + Required if you don't provide `custom_oauth_metadata`. + Usually it's either the base URL of your app, or the URL of the OAuth provider. + For example, for Auth0, this would be `https://your-tenant.auth0.com`. + """ + ), + ] = None + + oauth_metadata_url: Annotated[ + Optional[StrHttpUrl], + Doc( + """ + The full URL of the OAuth provider's metadata endpoint. + + If not provided, FastAPI-MCP will attempt to guess based on the `issuer` and `metadata_path`. + + Only relevant if `setup_proxies` is `True`. + + If this URL is wrong, the metadata proxy will not work. + """ + ), + ] = None + + authorize_url: Annotated[ + Optional[StrHttpUrl], + Doc( + """ + The URL of your OAuth provider's authorization endpoint. + + Usually this is something like `https://app.example.com/oauth/authorize`. + """ + ), + ] = None + + audience: Annotated[ + Optional[str], + Doc( + """ + Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the + audience when sending a request to your server. + + This may cause unexpected behavior from your OAuth provider, so this is a workaround. + + In case the client doesn't specify an audience, this will be used as the default audience on the + request to your OAuth provider. + """ + ), + ] = None + + default_scope: Annotated[ + str, + Doc( + """ + Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the + scope when sending a request to your server. + + This may cause unexpected behavior from your OAuth provider, so this is a workaround. + + Here is where you can optionally specify a default scope that will be sent to your OAuth provider in case + the client doesn't specify it. + """ + ), + ] = "openid profile email" + + client_id: Annotated[ + Optional[str], + Doc( + """ + In case the client doesn't specify a client ID, this will be used as the default client ID on the + request to your OAuth provider. + + This is mandatory only if you set `setup_proxies` to `True`. + """ + ), + ] = None + + client_secret: Annotated[ + Optional[str], + Doc( + """ + The client secret to use for the client ID. + + This is mandatory only if you set `setup_proxies` to `True` and `setup_fake_dynamic_registration` to `True`. + """ + ), + ] = None + + custom_oauth_metadata: Annotated[ + Optional[OAuthMetadataDict], + Doc( + """ + Custom OAuth metadata to register. + + If your OAuth flow works with MCP out-of-the-box, you should just use this option to provide the + metadata yourself. + + Otherwise, set `setup_proxies` to `True` to automatically setup MCP-compliant proxies around your + OAuth provider's endpoints. + """ + ), + ] = None + + setup_proxies: Annotated[ + bool, + Doc( + """ + Whether to automatically setup MCP-compliant proxies around your original OAuth provider's endpoints. + """ + ), + ] = False + + setup_fake_dynamic_registration: Annotated[ + bool, + Doc( + """ + Whether to automatically setup a fake dynamic client registration endpoint. + + In MCP 2025-03-26 Spec, it is recommended to support OAuth Dynamic Client Registration (RFC 7591). + Furthermore, `npx mcp-remote` which is the current de-facto client that supports MCP's up-to-date spec, + requires this endpoint to be present. + + For most cases, a fake dynamic registration endpoint is enough, thus you should set this to `True`. + + This is only used if `setup_proxies` is also `True`. + """ + ), + ] = True + + metadata_path: Annotated[ + str, + Doc( + """ + The path to mount the OAuth metadata endpoint at. + + Clients will usually expect this to be /.well-known/oauth-authorization-server + """ + ), + ] = "/.well-known/oauth-authorization-server" + + @model_validator(mode="after") + def validate_required_fields(self): + if self.custom_oauth_metadata is None and self.issuer is None: + raise ValueError("'issuer' is required when 'custom_oauth_metadata' is not provided") + + if self.setup_proxies: + if self.client_id is None: + raise ValueError("'client_id' is required when 'setup_proxies' is True") + + if self.setup_fake_dynamic_registration and not self.client_secret: + raise ValueError("'client_secret' is required when 'setup_fake_dynamic_registration' is True") + + if self.setup_fake_dynamic_registration and not self.setup_proxies: + raise ValueError("'setup_fake_dynamic_registration' can only be True when 'setup_proxies' is True") + + return self + + +class ClientRegistrationRequest(BaseType): + redirect_uris: List[str] + client_name: Optional[str] = None + grant_types: Optional[List[str]] = ["authorization_code"] + token_endpoint_auth_method: Optional[str] = "none" + + +class ClientRegistrationResponse(BaseType): + client_id: str + client_id_issued_at: int = int(time.time()) + client_secret: Optional[str] = None + client_secret_expires_at: int = 0 + redirect_uris: List[str] + grant_types: List[str] + token_endpoint_auth_method: str + client_name: str diff --git a/pyproject.toml b/pyproject.toml index b029533..3d30482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "fastapi-mcp" -version = "0.3.0" +version = "0.3.1" description = "Automatic MCP server generator for FastAPI applications - converts FastAPI endpoints to MCP tools for LLM integration" readme = "README.md" requires-python = ">=3.10" @@ -47,6 +47,8 @@ dev = [ "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", "pre-commit>=4.2.0", + "pyjwt>=2.10.1", + "cryptography>=44.0.2", ] [project.urls] diff --git a/tests/test_mcp_simple_app.py b/tests/test_mcp_simple_app.py index 0d146fd..64c14d4 100644 --- a/tests/test_mcp_simple_app.py +++ b/tests/test_mcp_simple_app.py @@ -234,3 +234,82 @@ async def test_call_tool_get_item_with_details(lowlevel_server_simple_app: Serve assert parsed_result.price == 10.0 assert parsed_result.tags == ["tag1", "tag2"] assert parsed_result.description == "Item 1 description" + + +@pytest.mark.asyncio +async def test_headers_passthrough_to_tool_handler(fastapi_mcp: FastApiMCP): + """Test that the original request's headers pass through to the MCP tool call handler.""" + from unittest.mock import patch, MagicMock + from fastapi_mcp.types import HTTPRequestInfo + + # Test with uppercase "Authorization" header + with patch.object(fastapi_mcp, "_request") as mock_request: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"result": "success"}' + mock_response.json.return_value = {"result": "success"} + mock_request.return_value = mock_response + + http_request_info = HTTPRequestInfo( + method="POST", + path="/test", + headers={"Authorization": "Bearer token123"}, + cookies={}, + query_params={}, + body=None, + ) + + try: + # Call the _execute_api_tool method directly + # We don't care if it succeeds, just that _request gets the right headers + await fastapi_mcp._execute_api_tool( + client=fastapi_mcp._http_client, + tool_name="get_item", + arguments={"item_id": 1}, + operation_map=fastapi_mcp.operation_map, + http_request_info=http_request_info, + ) + except Exception: + pass + + assert mock_request.called, "The _request method was not called" + + if mock_request.called: + headers_arg = mock_request.call_args[0][4] # headers are the 5th argument + assert "Authorization" in headers_arg + assert headers_arg["Authorization"] == "Bearer token123" + + # Test again with lowercase "authorization" header + with patch.object(fastapi_mcp, "_request") as mock_request: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"result": "success"}' + mock_response.json.return_value = {"result": "success"} + mock_request.return_value = mock_response + + http_request_info = HTTPRequestInfo( + method="POST", + path="/test", + headers={"authorization": "Bearer token456"}, + cookies={}, + query_params={}, + body=None, + ) + + try: + await fastapi_mcp._execute_api_tool( + client=fastapi_mcp._http_client, + tool_name="get_item", + arguments={"item_id": 1}, + operation_map=fastapi_mcp.operation_map, + http_request_info=http_request_info, + ) + except Exception: + pass + + assert mock_request.called, "The _request method was not called" + + if mock_request.called: + headers_arg = mock_request.call_args[0][4] # headers are the 5th argument + assert "Authorization" in headers_arg + assert headers_arg["Authorization"] == "Bearer token456" diff --git a/uv.lock b/uv.lock index e5aa4d4..26a60dc 100644 --- a/uv.lock +++ b/uv.lock @@ -34,6 +34,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -190,6 +247,51 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "44.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 }, + { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, + { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, + { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, + { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, + { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, + { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, + { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, + { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, + { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, + { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 }, + { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 }, + { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 }, + { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, + { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, + { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, + { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, + { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, + { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, + { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, + { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, + { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, + { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, + { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, + { url = "https://files.pythonhosted.org/packages/99/10/173be140714d2ebaea8b641ff801cbcb3ef23101a2981cbf08057876f89e/cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", size = 3396886 }, + { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 }, + { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 }, + { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 }, + { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 }, + { url = "https://files.pythonhosted.org/packages/99/ec/6e560908349843718db1a782673f36852952d52a55ab14e46c42c8a7690a/cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", size = 3131719 }, + { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, + { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, + { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, + { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -224,7 +326,7 @@ wheels = [ [[package]] name = "fastapi-mcp" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "fastapi" }, @@ -241,8 +343,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "cryptography" }, { name = "mypy" }, { name = "pre-commit" }, + { name = "pyjwt" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -266,8 +370,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "cryptography", specifier = ">=44.0.2" }, { name = "mypy", specifier = ">=1.15.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, @@ -496,6 +602,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -607,6 +722,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pytest" version = "8.3.5"