From 94bcd7397ac8cae72e1078900613cb31ceab1887 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 15 May 2025 22:59:21 +0100 Subject: [PATCH 01/19] initial commit --- README.md | 80 +++ examples/clients/simple-auth-client/README.md | 152 +++++ .../mcp_simple_auth_client/__init__.py | 1 + .../mcp_simple_auth_client/main.py | 379 +++++++++++++ .../clients/simple-auth-client/pyproject.toml | 52 ++ examples/clients/simple-auth-client/uv.lock | 535 ++++++++++++++++++ src/mcp/client/auth.py | 518 +++++++++++++++++ src/mcp/client/oauth_providers.py | 161 ++++++ src/mcp/client/sse.py | 97 +++- src/mcp/client/streamable_http.py | 78 ++- 10 files changed, 2044 insertions(+), 9 deletions(-) create mode 100644 examples/clients/simple-auth-client/README.md create mode 100644 examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py create mode 100644 examples/clients/simple-auth-client/mcp_simple_auth_client/main.py create mode 100644 examples/clients/simple-auth-client/pyproject.toml create mode 100644 examples/clients/simple-auth-client/uv.lock create mode 100644 src/mcp/client/auth.py create mode 100644 src/mcp/client/oauth_providers.py diff --git a/README.md b/README.md index 2611e25f0..5e48721d7 100644 --- a/README.md +++ b/README.md @@ -796,6 +796,86 @@ async def main(): tool_result = await session.call_tool("echo", {"message": "hello"}) ``` +### OAuth Authentication for Clients + +The SDK supports OAuth 2.0 client authentication for secure access to MCP servers that require authentication: + +```python +from mcp.client.auth import OAuthClientProvider, UnauthorizedError +from mcp.client.oauth_providers import InMemoryOAuthProvider +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientMetadata +from mcp import ClientSession + +# Create an OAuth provider +oauth_provider = InMemoryOAuthProvider( + redirect_url="http://localhost:8080/callback", + client_metadata=OAuthClientMetadata( + redirect_uris=["http://localhost:8080/callback"], + client_name="My MCP Client", + scope="tools resources", # Request specific scopes + ), +) + +async def main(): + # Connect with OAuth authentication + async with streamablehttp_client( + "https://example.com/mcp", + auth_provider=oauth_provider, + ) as (read_stream, write_stream, _): + # Create a session + async with ClientSession(read_stream, write_stream) as session: + # Initialize (this may trigger OAuth flow) + try: + await session.initialize() + # Use authenticated session + result = await session.call_tool("protected_tool", {"arg": "value"}) + except UnauthorizedError: + # Handle authorization required + print("Authorization required. Check your browser.") + +# Handle OAuth callback after user authorization +async def handle_callback(authorization_code: str): + from mcp.client.streamable_http import StreamableHTTPTransport + + # Create a transport instance to handle auth completion + transport = StreamableHTTPTransport( + url="https://example.com/mcp", + auth_provider=oauth_provider, + ) + + # Exchange authorization code for tokens + await transport.finish_auth(authorization_code) + print("Authorization successful!") +``` + +#### Custom OAuth Providers + +You can implement custom OAuth storage by creating your own provider: + +```python +from mcp.client.oauth_providers import InMemoryOAuthProvider + +class DatabaseOAuthProvider(InMemoryOAuthProvider): + async def save_tokens(self, tokens): + # Save to database + await db.save_tokens(self.client_id, tokens) + + async def tokens(self): + # Load from database + return await db.load_tokens(self.client_id) + + # Implement other methods as needed... +``` + +The OAuth client implementation supports: + +- Dynamic client registration +- Authorization code flow with PKCE +- Token refresh +- Multiple storage providers (in-memory and file-based included) +- Automatic token management and retry logic + ### MCP Primitives The MCP protocol defines three core primitives that servers can implement: diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md new file mode 100644 index 000000000..4a37758da --- /dev/null +++ b/examples/clients/simple-auth-client/README.md @@ -0,0 +1,152 @@ +# Simple OAuth Client Example + +This example demonstrates how to create an MCP client that connects to an OAuth-protected server using the Python SDK's OAuth client support. + +## Overview + +This client connects to the [simple-auth server](../../servers/simple-auth/) and demonstrates: +- OAuth 2.0 client authentication with PKCE +- Token storage and management +- Handling the OAuth authorization flow +- Making authenticated requests to protected tools + +## Prerequisites + +1. **Set up the simple-auth server first**: + - Follow the instructions in [../../servers/simple-auth/README.md](../../servers/simple-auth/README.md) + - Make sure the server is running on `http://localhost:8000` + +2. **Create a GitHub OAuth App** (if not done already): + - Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App + - Application name: "Simple MCP Auth Demo" + - Homepage URL: `http://localhost:8000` + - Authorization callback URL: `http://localhost:8080/callback` + - Note: The client uses port 8080 for its callback, while the server uses 8000 + +## Installation + +```bash +# Install dependencies with uv +uv install + +# Or install in development mode with dev dependencies +uv install --dev +``` + +## Running the Client + +```bash +# Basic usage - will start OAuth flow if not authenticated +uv run mcp-simple-auth-client + +# Specify custom server URL +uv run mcp-simple-auth-client --server-url http://localhost:8000 + +# Specify custom callback port (if port 8080 is in use) +uv run mcp-simple-auth-client --callback-port 8081 + +# Use file-based token storage instead of in-memory +uv run mcp-simple-auth-client --use-file-storage + +# Run with debug logging +uv run mcp-simple-auth-client --debug +``` + +## How It Works + +1. **First Run**: If no tokens are stored, the client will: + - Start a local HTTP server to handle the OAuth callback + - Open your default browser to the GitHub authorization page + - Wait for you to authorize the application + - Exchange the authorization code for tokens + - Save the tokens for future use + +2. **Subsequent Runs**: The client will: + - Load existing tokens from storage + - Use them to authenticate with the server + - Automatically refresh tokens if needed + +3. **Making Requests**: Once authenticated, the client can: + - Call the `get_user_profile` tool + - Display the GitHub user information + +## Example Output + +``` +$ uv run mcp-simple-auth-client +Starting OAuth client... +No existing tokens found. Starting OAuth flow... +Opening authorization URL in browser... +Starting callback server on http://localhost:8080... +Waiting for OAuth callback... +Authorization successful! +Connecting to MCP server... +Calling get_user_profile tool... + +GitHub User Profile: +{ + "login": "username", + "id": 12345, + "name": "John Doe", + "email": "john@example.com", + "bio": "Developer", + "public_repos": 42, + "followers": 100, + "following": 50 +} + +Done! +``` + +## OAuth Flow + +``` +Client Browser GitHub MCP Server + | | | | + |-- Opens auth URL ------>| | | + | |-- User authorizes ------>| | + | | |<-- Auth code ------------| + |<-- Callback ------------| | | + | | | | + |-- Exchange code for tokens ------------------------>| | + |<-- Access token -----------------------------------| | + | | | | + |-- Authenticated request -------------------------------------------------------->| + |<-- Protected resource -----------------------------------------------------------| +``` + +## Development + +```bash +# Run linting +uv run ruff check . + +# Run type checking +uv run pyright + +# Run with development dependencies +uv run --dev pytest +``` + +## Troubleshooting + +**Client fails to connect:** +- Make sure the server is running: `uv run mcp-simple-auth` in the server directory +- Check that the server URL is correct (default: http://localhost:8000) +- Verify OAuth configuration matches between client and server + +**OAuth flow fails:** +- Ensure GitHub OAuth app callback URL matches the client's callback URL +- Check that no other service is using the callback port (default: 8080) +- Make sure your browser allows opening localhost URLs + +**Token issues:** +- Delete stored tokens to restart the OAuth flow: `rm oauth_*.json` +- Check that the server is configured with valid GitHub OAuth credentials + +## Security Notes + +- Tokens are stored locally in files or memory +- The local callback server only runs during the OAuth flow +- Consider using HTTPS in production environments +- Implement proper token encryption for sensitive applications \ No newline at end of file diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py new file mode 100644 index 000000000..2baf91a34 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py @@ -0,0 +1 @@ +"""Simple OAuth client for MCP simple-auth server.""" \ No newline at end of file diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py new file mode 100644 index 000000000..2fb1d5116 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -0,0 +1,379 @@ +""" +Simple OAuth client for the MCP simple-auth server. + +This example demonstrates how to use the MCP Python SDK's OAuth client +to connect to an OAuth-protected server. +""" + +import asyncio +import json +import logging +import webbrowser + +import click +from mcp import ClientSession +from mcp.client.auth import UnauthorizedError +from mcp.client.oauth_providers import FileBasedOAuthProvider, InMemoryOAuthProvider +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientMetadata + + +class CLIOAuthProvider(InMemoryOAuthProvider): + """OAuth provider for CLI interactive sessions.""" + + def __init__(self, server_url: str): + client_metadata = OAuthClientMetadata( + redirect_uris=["http://localhost:8080/callback"], + client_name="MCP CLI Auth Client", + scope="user", + ) + super().__init__( + redirect_url="http://localhost:8080/callback", + client_metadata=client_metadata, + ) + self.server_url = server_url + + async def redirect_to_authorization(self, authorization_url: str) -> None: + """Open the authorization URL in the browser and prompt for the code.""" + print("\nšŸ” Starting OAuth authorization...") + print(f"Opening browser to: {authorization_url}") + + webbrowser.open(authorization_url) + + print("\nAfter authorizing, copy the 'code' parameter from the callback URL.") + print("Example: if redirected to 'http://localhost:8080/callback?code=abc123'") + print("Then paste: abc123") + + auth_code = input("\nPaste the authorization code here: ").strip() + if auth_code: + from mcp.client.streamable_http import StreamableHTTPTransport + + transport = StreamableHTTPTransport( + f"{self.server_url}/mcp", + auth_provider=self, + ) + try: + await transport.finish_auth(auth_code) + print("āœ… Authorization successful!") + except Exception as e: + print(f"āŒ Authorization failed: {e}") + raise + else: + raise Exception("No authorization code provided") + + +class InteractiveOAuthProvider(InMemoryOAuthProvider): + """OAuth provider that handles the authorization flow interactively.""" + + async def redirect_to_authorization(self, authorization_url: str) -> None: + """Open the authorization URL in the browser and prompt for the code.""" + print("\nStarting OAuth authorization flow...") + print(f"Opening browser to: {authorization_url}") + + # Open the browser + webbrowser.open(authorization_url) + + print( + "\nAfter authorizing the application, " + "you'll be redirected to a callback URL." + ) + print("Copy the 'code' parameter from the callback URL and paste it here.") + print( + "Example: if redirected to " + "'http://localhost:8080/callback?code=abc123&state=xyz'" + ) + print("Then copy and paste: abc123") + + +async def run_oauth_client( + server_url: str, use_file_storage: bool, debug: bool +) -> None: + """Run the OAuth client example.""" + if debug: + logging.basicConfig(level=logging.DEBUG) + logging.getLogger("mcp").setLevel(logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + + # Create OAuth client metadata + client_metadata = OAuthClientMetadata( + redirect_uris=["http://localhost:8080/callback"], + client_name="Simple MCP Auth Client", + scope="user", # Request the 'user' scope for GitHub profile access + ) + + # Choose storage provider + if use_file_storage: + print("Using file-based token storage...") + oauth_provider = FileBasedOAuthProvider( + redirect_url="http://localhost:8080/callback", + client_metadata=client_metadata, + ) + else: + print("Using in-memory token storage...") + oauth_provider = InteractiveOAuthProvider( + redirect_url="http://localhost:8080/callback", + client_metadata=client_metadata, + ) + + print("Starting OAuth client...") + + try: + # Check if we have existing tokens + existing_tokens = await oauth_provider.tokens() + if existing_tokens: + print("Found existing tokens. Attempting to connect...") + else: + print("No existing tokens found. Will start OAuth flow if needed...") + + # Connect to the MCP server with OAuth + async with streamablehttp_client( + f"{server_url}/mcp", + auth_provider=oauth_provider, + ) as (read_stream, write_stream, _): + print("Connecting to MCP server...") + + # Create a session + async with ClientSession(read_stream, write_stream) as session: + try: + # Initialize the connection (this may trigger OAuth flow) + await session.initialize() + print("Connected successfully!") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + # Call the get_user_profile tool + print("Calling get_user_profile tool...") + result = await session.call_tool("get_user_profile", {}) + + print("\nGitHub User Profile:") + if result.content: + # The result content should be a dict in JSON format + profile_data = result.content[0].text + if isinstance(profile_data, str): + # If it's a JSON string, parse it for pretty printing + try: + parsed_data = json.loads(profile_data) + print(json.dumps(parsed_data, indent=2)) + except json.JSONDecodeError: + print(profile_data) + else: + print(json.dumps(profile_data, indent=2)) + else: + print("No content received") + + except UnauthorizedError: + print("\nAuthorization required!") + print("Please complete the OAuth flow and run the command again.") + + # If we're using the interactive provider, we need to manually + # handle the callback + if isinstance(oauth_provider, InteractiveOAuthProvider): + auth_code = input( + "\nPaste the authorization code here: " + ).strip() + if auth_code: + # Create a transport to finish the auth + from mcp.client.streamable_http import ( + StreamableHTTPTransport, + ) + + transport = StreamableHTTPTransport( + f"{server_url}/mcp", + auth_provider=oauth_provider, + ) + try: + await transport.finish_auth(auth_code) + print( + "Authorization successful! " + "Please run the command again." + ) + except Exception as e: + print(f"Authorization failed: {e}") + else: + print("No authorization code provided.") + + except Exception as e: + print(f"Error during MCP operations: {e}") + if debug: + import traceback + + traceback.print_exc() + + except Exception as e: + print(f"Failed to connect: {e}") + if debug: + import traceback + + traceback.print_exc() + + print("Done!") + + +async def handle_command(session: ClientSession, command: str) -> None: + """Handle interactive commands.""" + parts = command.split() + if not parts: + return + + cmd = parts[0].lower() + + if cmd == "help": + print("Available commands:") + print(" help - Show this help") + print(" tools - List available tools") + print(" resources - List available resources") + print(" prompts - List available prompts") + print(" call [args] - Call a tool") + print(" read - Read a resource") + print(" exit - Exit the session") + + elif cmd == "tools": + tools = await session.list_tools() + if tools.tools: + print("Available tools:") + for tool in tools.tools: + print(f" {tool.name}: {tool.description}") + else: + print("No tools available") + + elif cmd == "resources": + resources = await session.list_resources() + if resources.resources: + print("Available resources:") + for resource in resources.resources: + print(f" {resource.uri}: {resource.name}") + else: + print("No resources available") + + elif cmd == "prompts": + prompts = await session.list_prompts() + if prompts.prompts: + print("Available prompts:") + for prompt in prompts.prompts: + print(f" {prompt.name}: {prompt.description}") + else: + print("No prompts available") + + elif cmd == "call" and len(parts) >= 2: + tool_name = parts[1] + try: + # Parse arguments as JSON if provided + args = {} + if len(parts) > 2: + args_str = " ".join(parts[2:]) + try: + args = json.loads(args_str) + except json.JSONDecodeError: + print(f"Invalid JSON arguments: {args_str}") + return + + result = await session.call_tool(tool_name, args) + print(f"Result from {tool_name}:") + if result.content: + for content in result.content: + if hasattr(content, "text"): + print(content.text) + else: + print(str(content)) + else: + print("No content received") + except Exception as e: + print(f"Error calling tool {tool_name}: {e}") + + elif cmd == "read" and len(parts) >= 2: + resource_uri = parts[1] + try: + result = await session.read_resource(resource_uri) + print(f"Resource content from {resource_uri}:") + if result.contents: + for content in result.contents: + if hasattr(content, "text"): + print(content.text) + else: + print(str(content)) + else: + print("No content received") + except Exception as e: + print(f"Error reading resource {resource_uri}: {e}") + + else: + print(f"Unknown command: {command}") + print("Type 'help' for available commands") + + +async def run_interactive_client(): + """Start an interactive MCP client session.""" + server_url = "http://localhost:3000" + oauth_provider = CLIOAuthProvider(server_url) + + print("šŸ”— Connecting to localhost:3000...") + + try: + async with streamablehttp_client( + f"{server_url}/mcp", auth_provider=oauth_provider + ) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + print("āœ… Connected!") + print("Type 'help' for available commands or 'exit' to quit.") + + # Interactive command loop + while True: + try: + command = input("mcp> ").strip() + if not command or command == "exit": + break + await handle_command(session, command) + except KeyboardInterrupt: + break + except Exception as e: + print(f"Error: {e}") + except Exception as e: + print(f"Failed to connect: {e}") + + print("šŸ‘‹ Session ended") + + +@click.group() +def app(): + """MCP Simple Auth Client CLI""" + pass + + +@app.command() +def client(): + """Start an interactive MCP client session.""" + asyncio.run(run_interactive_client()) + + +@app.command() +@click.option( + "--server-url", + default="http://localhost:8000", + help="URL of the MCP server (default: http://localhost:8000)", +) +@click.option( + "--use-file-storage", + is_flag=True, + help="Use file-based token storage instead of in-memory", +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug logging", +) +def oauth(server_url: str, use_file_storage: bool, debug: bool): + """Run OAuth client example.""" + asyncio.run(run_oauth_client(server_url, use_file_storage, debug)) + + +def main(): + """Entry point for the CLI.""" + app() + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml new file mode 100644 index 000000000..bef4ae2a8 --- /dev/null +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "mcp-simple-auth-client" +version = "0.1.0" +description = "A simple OAuth client for the MCP simple-auth server" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic" }] +keywords = ["mcp", "oauth", "client", "auth"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "click>=8.0.0", + "mcp>=1.0.0", +] + +[project.scripts] +mcp-simple-auth-client = "mcp_simple_auth_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client"] + +[tool.pyright] +include = ["mcp_simple_auth_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.uv] +dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] + +[tool.uv.sources] +mcp = { path = "../../../" } + +[[tool.uv.index]] +url = "https://pypi.org/simple" diff --git a/examples/clients/simple-auth-client/uv.lock b/examples/clients/simple-auth-client/uv.lock new file mode 100644 index 000000000..a62447fcb --- /dev/null +++ b/examples/clients/simple-auth-client/uv.lock @@ -0,0 +1,535 @@ +version = 1 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "click" +version = "8.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/62ca20172d4f87d93cf89665fbaedcd560ac48b465bd1d92bfc7ea6b0a41/click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d", size = 235857 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/58/1f37bf81e3c689cc74ffa42102fa8915b59085f54a6e4a80bc6265c0f6bf/click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", size = 102156 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "mcp" +source = { directory = "../../../" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx-sse", specifier = ">=0.4" }, + { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "starlette", specifier = ">=0.27" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, + { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-examples", specifier = ">=0.0.14" }, + { name = "pytest-flakefinder", specifier = ">=1.1.0" }, + { name = "pytest-pretty", specifier = ">=1.2.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.8.5" }, + { name = "trio", specifier = ">=0.26.2" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, + { name = "mkdocstrings-python", specifier = ">=1.12.2" }, +] + +[[package]] +name = "mcp-simple-auth-client" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0.0" }, + { name = "mcp", directory = "../../../" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, +] + +[[package]] +name = "pyright" +version = "1.1.400" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "ruff" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243 }, + { url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636 }, + { url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624 }, + { url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358 }, + { url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850 }, + { url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787 }, + { url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479 }, + { url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760 }, + { url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747 }, + { url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657 }, + { url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135 }, + { url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179 }, + { url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021 }, + { url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958 }, + { url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285 }, + { url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233 }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, +] diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py new file mode 100644 index 000000000..ef1ceb380 --- /dev/null +++ b/src/mcp/client/auth.py @@ -0,0 +1,518 @@ +""" +OAuth client implementation for MCP Python SDK. + +This module provides an end-to-end OAuth client to be used with MCP servers, +implementing the OAuth 2.0 authorization code flow with PKCE. +""" + +import base64 +import hashlib +import logging +import secrets +import string +from typing import Literal, Protocol, TypeVar, runtime_checkable +from urllib.parse import urlencode, urljoin, urlparse, urlunparse + +import httpx + +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, +) +from mcp.types import LATEST_PROTOCOL_VERSION + +# Type variable to represent implementation of OAuthClientProvider +T = TypeVar("T", bound="OAuthClientProvider") + +logger = logging.getLogger(__name__) + + +class UnauthorizedError(Exception): + """Raised when OAuth authorization fails or is required.""" + + def __init__(self, message: str = "Unauthorized"): + super().__init__(message) + self.message = message + + +@runtime_checkable +class OAuthClientProvider(Protocol): + """ + Protocol for OAuth client providers to be used with MCP servers. + + This provider relies upon a concept of an authorized "session," the exact + meaning of which is application-defined. Tokens, authorization codes, and + code verifiers should not cross different sessions. + """ + + @property + def redirect_url(self) -> str: + """The URL to redirect the user agent to after authorization.""" + ... + + @property + def client_metadata(self) -> OAuthClientMetadata: + """Metadata about this OAuth client.""" + ... + + async def client_information(self) -> OAuthClientInformationFull | None: + """ + Loads information about this OAuth client, as registered already with the + server, or returns None if the client is not registered with the server. + """ + ... + + async def save_client_information( + self, client_information: OAuthClientInformationFull + ) -> None: + """ + If implemented, this permits the OAuth client to dynamically register with + the server. Client information saved this way should later be read via + client_information(). + + This method is not required to be implemented if client information is + statically known (e.g., pre-registered). + """ + ... + + async def tokens(self) -> OAuthToken | None: + """ + Loads any existing OAuth tokens for the current session, or returns + None if there are no saved tokens. + """ + ... + + async def save_tokens(self, tokens: OAuthToken) -> None: + """ + Stores new OAuth tokens for the current session, after a successful + authorization. + """ + ... + + async def redirect_to_authorization(self, authorization_url: str) -> None: + """ + Invoked to redirect the user agent to the given URL + to begin the authorization flow. + """ + ... + + async def save_code_verifier(self, code_verifier: str) -> None: + """ + Saves a PKCE code verifier for the current session, before redirecting to + the authorization flow. + """ + ... + + async def code_verifier(self) -> str: + """ + Loads the PKCE code verifier for the current session, necessary to validate + the authorization result. + """ + ... + + +class AuthResult: + """Result of an OAuth authorization attempt.""" + + AUTHORIZED = "AUTHORIZED" + REDIRECT = "REDIRECT" + + +def _generate_code_verifier() -> str: + """Generate a cryptographically random code verifier for PKCE.""" + return "".join( + secrets.choice(string.ascii_letters + string.digits + "-._~") + for _ in range(128) + ) + + +def _generate_code_challenge(code_verifier: str) -> str: + """Generate a code challenge from a code verifier using SHA256.""" + digest = hashlib.sha256(code_verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +async def auth( + provider: OAuthClientProvider, + *, + server_url: str, + authorization_code: str | None = None, + scope: str | None = None, +) -> Literal["AUTHORIZED", "REDIRECT"]: + """ + Orchestrates the full auth flow with a server. + + This can be used as a single entry point for all authorization functionality, + instead of linking together the other lower-level functions in this module. + + Args: + provider: OAuth client provider implementation + server_url: URL of the MCP server + authorization_code: Optional authorization code from redirect + scope: Optional scope to request + + Returns: + AuthResult.AUTHORIZED if successful, AuthResult.REDIRECT if redirect needed + + Raises: + UnauthorizedError: If authorization fails + """ + metadata = await discover_oauth_metadata(server_url) + + # Handle client registration if needed + client_information = await provider.client_information() + if not client_information: + if authorization_code is not None: + raise ValueError( + "Existing OAuth client information is required " + "when exchanging an authorization code" + ) + + try: + save_client_info = provider.save_client_information + except AttributeError: + raise ValueError( + "OAuth client information must be saveable for dynamic registration" + ) + + full_information = await register_client( + server_url=server_url, + metadata=metadata, + client_metadata=provider.client_metadata, + ) + await save_client_info(full_information) + client_information = full_information + + # Exchange authorization code for tokens + if authorization_code is not None: + code_verifier = await provider.code_verifier() + tokens = await exchange_authorization( + server_url=server_url, + metadata=metadata, + client_information=client_information, + authorization_code=authorization_code, + code_verifier=code_verifier, + redirect_uri=provider.redirect_url, + ) + await provider.save_tokens(tokens) + return AuthResult.AUTHORIZED + + tokens = await provider.tokens() + + # Handle token refresh or new authorization + if tokens and tokens.refresh_token: + try: + # Attempt to refresh the token + new_tokens = await refresh_authorization( + server_url=server_url, + metadata=metadata, + client_information=client_information, + refresh_token=tokens.refresh_token, + ) + await provider.save_tokens(new_tokens) + return AuthResult.AUTHORIZED + except Exception as error: + # Log error but continue to start new authorization flow + logger.warning(f"Could not refresh OAuth tokens: {error}") + + # Start new authorization flow + authorization_url, code_verifier = await start_authorization( + server_url=server_url, + metadata=metadata, + client_information=client_information, + redirect_url=provider.redirect_url, + scope=scope or provider.client_metadata.scope, + ) + + await provider.save_code_verifier(code_verifier) + await provider.redirect_to_authorization(authorization_url) + return AuthResult.REDIRECT + + +async def discover_oauth_metadata( + server_url: str, + protocol_version: str = LATEST_PROTOCOL_VERSION, +) -> OAuthMetadata | None: + """ + Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. + + If the server returns a 404 for the well-known endpoint, this function will + return None. Any other errors will be thrown as exceptions. + + Args: + server_url: URL of the MCP server + protocol_version: MCP protocol version header + + Returns: + OAuth metadata if available, None if not supported + """ + url = urljoin(server_url, "/.well-known/oauth-authorization-server") + + headers = {"MCP-Protocol-Version": protocol_version} + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + except Exception: + # Try without MCP protocol version header for CORS issues + response = await client.get(url) + + if response.status_code == 404: + return None + + response.raise_for_status() + + try: + return OAuthMetadata.model_validate(response.json()) + except Exception as e: + raise ValueError(f"Invalid OAuth metadata: {e}") + + +async def start_authorization( + *, + server_url: str, + metadata: OAuthMetadata | None, + client_information: OAuthClientInformationFull, + redirect_url: str, + scope: str | None = None, +) -> tuple[str, str]: + """ + Begins the authorization flow with the given server, by generating a PKCE challenge + and constructing the authorization URL. + + Args: + server_url: URL of the MCP server + metadata: OAuth metadata (optional) + client_information: OAuth client information + redirect_url: Redirect URL for authorization + scope: Optional scope to request + + Returns: + Tuple of (authorization_url, code_verifier) + """ + response_type = "code" + code_challenge_method = "S256" + + if metadata: + authorization_url = str(metadata.authorization_endpoint) + + if response_type not in metadata.response_types_supported: + raise ValueError( + "Incompatible auth server: does not support response type" + f" {response_type}" + ) + + if ( + metadata.code_challenge_methods_supported is not None + and code_challenge_method not in metadata.code_challenge_methods_supported + ): + raise ValueError( + "Incompatible auth server: does not support code challenge method " + f"{code_challenge_method}" + ) + else: + authorization_url = urljoin(server_url, "/authorize") + + # Generate PKCE challenge + code_verifier = _generate_code_verifier() + code_challenge = _generate_code_challenge(code_verifier) + + # Build authorization URL with parameters + parsed = urlparse(authorization_url) + params = { + "response_type": response_type, + "client_id": client_information.client_id, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "redirect_uri": redirect_url, + } + + if scope: + params["scope"] = scope + + # Construct URL with query parameters + query = urlencode(params) + final_url = urlunparse( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + query, + parsed.fragment, + ) + ) + + return final_url, code_verifier + + +async def exchange_authorization( + *, + server_url: str, + metadata: OAuthMetadata | None, + client_information: OAuthClientInformationFull, + authorization_code: str, + code_verifier: str, + redirect_uri: str, +) -> OAuthToken: + """ + Exchanges an authorization code for an access token with the given server. + + Args: + server_url: URL of the MCP server + metadata: OAuth metadata (optional) + client_information: OAuth client information + authorization_code: Authorization code from redirect + code_verifier: PKCE code verifier + redirect_uri: Redirect URI used in authorization + + Returns: + OAuth tokens + """ + grant_type = "authorization_code" + + if metadata: + token_url = str(metadata.token_endpoint) + + if ( + metadata.grant_types_supported is not None + and grant_type not in metadata.grant_types_supported + ): + raise ValueError( + f"Incompatible auth server: does not support grant type {grant_type}" + ) + else: + token_url = urljoin(server_url, "/token") + + # Exchange code for tokens + data = { + "grant_type": grant_type, + "client_id": client_information.client_id, + "code": authorization_code, + "code_verifier": code_verifier, + "redirect_uri": redirect_uri, + } + + if client_information.client_secret: + data["client_secret"] = client_information.client_secret + + async with httpx.AsyncClient() as client: + response = await client.post( + token_url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if not response.is_success: + raise Exception(f"Token exchange failed: HTTP {response.status_code}") + + try: + return OAuthToken.model_validate(response.json()) + except Exception as e: + raise ValueError(f"Invalid token response: {e}") + + +async def refresh_authorization( + *, + server_url: str, + metadata: OAuthMetadata | None, + client_information: OAuthClientInformationFull, + refresh_token: str, +) -> OAuthToken: + """ + Exchange a refresh token for an updated access token. + + Args: + server_url: URL of the MCP server + metadata: OAuth metadata (optional) + client_information: OAuth client information + refresh_token: Refresh token to exchange + + Returns: + New OAuth tokens + """ + grant_type = "refresh_token" + + if metadata: + token_url = str(metadata.token_endpoint) + + if ( + metadata.grant_types_supported is not None + and grant_type not in metadata.grant_types_supported + ): + raise ValueError( + f"Incompatible auth server: does not support grant type {grant_type}" + ) + else: + token_url = urljoin(server_url, "/token") + + # Exchange refresh token + data = { + "grant_type": grant_type, + "client_id": client_information.client_id, + "refresh_token": refresh_token, + } + + if client_information.client_secret: + data["client_secret"] = client_information.client_secret + + async with httpx.AsyncClient() as client: + response = await client.post( + token_url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if not response.is_success: + raise Exception(f"Token refresh failed: HTTP {response.status_code}") + + try: + return OAuthToken.model_validate(response.json()) + except Exception as e: + raise ValueError(f"Invalid token response: {e}") + + +async def register_client( + *, + server_url: str, + metadata: OAuthMetadata | None, + client_metadata: OAuthClientMetadata, +) -> OAuthClientInformationFull: + """ + Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. + + Args: + server_url: URL of the MCP server + metadata: OAuth metadata (optional) + client_metadata: Client metadata for registration + + Returns: + Full client information after registration + """ + if metadata: + if not metadata.registration_endpoint: + raise ValueError( + "Incompatible auth server: does not support dynamic client registration" + ) + registration_url = str(metadata.registration_endpoint) + else: + registration_url = urljoin(server_url, "/register") + + async with httpx.AsyncClient() as client: + response = await client.post( + registration_url, + json=client_metadata.model_dump(), + headers={"Content-Type": "application/json"}, + ) + + if not response.is_success: + raise Exception( + f"Dynamic client registration failed: HTTP {response.status_code}" + ) + + try: + return OAuthClientInformationFull.model_validate(response.json()) + except Exception as e: + raise ValueError(f"Invalid client registration response: {e}") diff --git a/src/mcp/client/oauth_providers.py b/src/mcp/client/oauth_providers.py new file mode 100644 index 000000000..e92e5b632 --- /dev/null +++ b/src/mcp/client/oauth_providers.py @@ -0,0 +1,161 @@ +""" +Implementations of OAuthClientProvider for common use cases. +""" + +import json +import webbrowser + +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryOAuthProvider: + """ + A simple in-memory OAuth provider for development and testing. + + This provider stores all OAuth data in memory and will lose state + when the application restarts. For production use, implement a + persistent storage solution. + """ + + def __init__( + self, + redirect_url: str, + client_metadata: OAuthClientMetadata, + ): + self._redirect_url = redirect_url + self._client_metadata = client_metadata + self._client_information: OAuthClientInformationFull | None = None + self._tokens: OAuthToken | None = None + self._code_verifier: str | None = None + + @property + def redirect_url(self) -> str: + """The URL to redirect the user agent to after authorization.""" + return self._redirect_url + + @property + def client_metadata(self) -> OAuthClientMetadata: + """Metadata about this OAuth client.""" + return self._client_metadata + + async def client_information(self) -> OAuthClientInformationFull | None: + """ + Loads information about this OAuth client, as registered already with the + server, or returns None if the client is not registered with the server. + """ + return self._client_information + + async def save_client_information( + self, client_information: OAuthClientInformationFull + ) -> None: + """ + Saves client information after dynamic registration. + """ + self._client_information = client_information + + async def tokens(self) -> OAuthToken | None: + """ + Loads any existing OAuth tokens for the current session, or returns + None if there are no saved tokens. + """ + return self._tokens + + async def save_tokens(self, tokens: OAuthToken) -> None: + """ + Stores new OAuth tokens for the current session, after a successful + authorization. + """ + self._tokens = tokens + + async def redirect_to_authorization(self, authorization_url: str) -> None: + """ + Opens the authorization URL in the default web browser. + """ + print(f"Opening authorization URL: {authorization_url}") + webbrowser.open(authorization_url) + + async def save_code_verifier(self, code_verifier: str) -> None: + """ + Saves a PKCE code verifier for the current session. + """ + self._code_verifier = code_verifier + + async def code_verifier(self) -> str: + """ + Loads the PKCE code verifier for the current session. + """ + if self._code_verifier is None: + raise ValueError("No code verifier available") + return self._code_verifier + + +class FileBasedOAuthProvider(InMemoryOAuthProvider): + """ + OAuth provider that persists tokens and client information to files. + + This is suitable for development and simple applications where + file-based persistence is acceptable. + """ + + def __init__( + self, + redirect_url: str, + client_metadata: OAuthClientMetadata, + tokens_file: str = "oauth_tokens.json", + client_info_file: str = "oauth_client_info.json", + ): + super().__init__(redirect_url, client_metadata) + self._tokens_file = tokens_file + self._client_info_file = client_info_file + + # Load existing data on initialization + self._load_client_information() + self._load_tokens() + + def _load_tokens(self) -> None: + """Load tokens from file if it exists.""" + try: + with open(self._tokens_file) as f: + data = json.load(f) + self._tokens = OAuthToken.model_validate(data) + except (FileNotFoundError, json.JSONDecodeError): + self._tokens = None + + def _save_tokens_to_file(self) -> None: + """Save tokens to file.""" + if self._tokens: + with open(self._tokens_file, "w") as f: + json.dump(self._tokens.model_dump(), f, indent=2) + + def _load_client_information(self) -> None: + """Load client information from file if it exists.""" + try: + with open(self._client_info_file) as f: + data = json.load(f) + self._client_information = OAuthClientInformationFull.model_validate( + data + ) + except (FileNotFoundError, json.JSONDecodeError): + self._client_information = None + + def _save_client_information_to_file(self) -> None: + """Save client information to file.""" + if self._client_information: + with open(self._client_info_file, "w") as f: + json.dump(self._client_information.model_dump(), f, indent=2) + + async def save_tokens(self, tokens: OAuthToken) -> None: + """ + Stores new OAuth tokens and saves them to file. + """ + await super().save_tokens(tokens) + self._save_tokens_to_file() + + async def save_client_information( + self, client_information: OAuthClientInformationFull + ) -> None: + """ + Saves client information and saves it to file. + """ + await super().save_client_information(client_information) + self._save_client_information_to_file() diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 29195cbd9..9d66f58a5 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -10,6 +10,7 @@ from httpx_sse import aconnect_sse import mcp.types as types +from mcp.client.auth import OAuthClientProvider, UnauthorizedError, auth from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import SessionMessage @@ -20,18 +21,47 @@ def remove_request_params(url: str) -> str: return urljoin(url, urlparse(url).path) +async def finish_auth( + url: str, + auth_provider: OAuthClientProvider, + authorization_code: str, +) -> None: + """ + Call this method after the user has finished authorizing via their user agent + and is redirected back to the MCP client application. This will exchange the + authorization code for an access token, enabling the next connection attempt + to successfully auth. + """ + if not auth_provider: + raise UnauthorizedError("No auth provider") + + result = await auth( + auth_provider, server_url=url, authorization_code=authorization_code + ) + if result != "AUTHORIZED": + raise UnauthorizedError("Failed to authorize") + + @asynccontextmanager async def sse_client( url: str, headers: dict[str, Any] | None = None, timeout: float = 5, sse_read_timeout: float = 60 * 5, + auth_provider: OAuthClientProvider | None = None, ): """ Client transport for SSE. `sse_read_timeout` determines how long (in seconds) the client will wait for a new event before disconnecting. All other HTTP operations are controlled by `timeout`. + + Args: + url: SSE endpoint URL + headers: Optional HTTP headers + timeout: HTTP request timeout in seconds + sse_read_timeout: SSE read timeout in seconds + auth_provider: Optional OAuth client provider for authentication """ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] @@ -42,17 +72,59 @@ async def sse_client( read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + async def _auth_then_retry() -> None: + """Perform OAuth authentication flow.""" + if not auth_provider: + raise UnauthorizedError("No auth provider") + + result = await auth(auth_provider, server_url=url) + if result != "AUTHORIZED": + raise UnauthorizedError() + + async def _get_headers() -> dict[str, Any]: + """Get headers with OAuth authorization if available.""" + auth_headers = {} + if auth_provider: + tokens = await auth_provider.tokens() + if tokens: + auth_headers["Authorization"] = f"Bearer {tokens.access_token}" + + return {**(headers or {}), **auth_headers} + async with anyio.create_task_group() as tg: try: logger.info(f"Connecting to SSE endpoint: {remove_request_params(url)}") - async with create_mcp_http_client(headers=headers) as client: + async with create_mcp_http_client(headers=await _get_headers()) as client: async with aconnect_sse( client, "GET", url, timeout=httpx.Timeout(timeout, read=sse_read_timeout), ) as event_source: - event_source.response.raise_for_status() + # Handle OAuth authentication errors + if event_source.response.status_code == 401 and auth_provider: + try: + await _auth_then_retry() + # Retry connection with new auth headers + async with create_mcp_http_client( + headers=await _get_headers() + ) as retry_client: + async with aconnect_sse( + retry_client, + "GET", + url, + timeout=httpx.Timeout( + timeout, read=sse_read_timeout + ), + ) as retry_event_source: + retry_event_source.response.raise_for_status() + event_source = retry_event_source + except Exception as exc: + logger.error(f"Auth retry failed: {exc}") + raise + else: + event_source.response.raise_for_status() + logger.debug("SSE connection established") async def sse_reader( @@ -118,6 +190,7 @@ async def post_writer(endpoint_url: str): logger.debug( f"Sending client message: {session_message}" ) + post_headers = await _get_headers() response = await client.post( endpoint_url, json=session_message.message.model_dump( @@ -125,7 +198,27 @@ async def post_writer(endpoint_url: str): mode="json", exclude_none=True, ), + headers=post_headers, ) + + # Handle OAuth authentication errors + if response.status_code == 401 and auth_provider: + try: + await _auth_then_retry() + # Retry with new auth headers + retry_headers = await _get_headers() + response = await client.post( + endpoint_url, + json=session_message.message.model_dump( + by_alias=True, + mode="json", + exclude_none=True, + ), + headers=retry_headers, + ) + except Exception as exc: + logger.error(f"Auth retry failed: {exc}") + response.raise_for_status() logger.debug( "Client message sent successfully: " diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 3324dab5a..e877b9d2e 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -19,6 +19,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import EventSource, ServerSentEvent, aconnect_sse +from mcp.client.auth import OAuthClientProvider, UnauthorizedError, auth from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( @@ -83,6 +84,7 @@ def __init__( headers: dict[str, Any] | None = None, timeout: timedelta = timedelta(seconds=30), sse_read_timeout: timedelta = timedelta(seconds=60 * 5), + auth_provider: OAuthClientProvider | None = None, ) -> None: """Initialize the StreamableHTTP transport. @@ -96,6 +98,7 @@ def __init__( self.headers = headers or {} self.timeout = timeout self.sse_read_timeout = sse_read_timeout + self.auth_provider = auth_provider self.session_id: str | None = None self.request_headers = { ACCEPT: f"{JSON}, {SSE}", @@ -103,11 +106,19 @@ def __init__( **self.headers, } - def _update_headers_with_session( + async def _update_headers_with_session( self, base_headers: dict[str, str] ) -> dict[str, str]: - """Update headers with session ID if available.""" + """Update headers with session ID and auth if available.""" headers = base_headers.copy() + + # Add OAuth authorization header if available + if self.auth_provider: + tokens = await self.auth_provider.tokens() + if tokens: + headers["Authorization"] = f"Bearer {tokens.access_token}" + + # Add session ID if available if self.session_id: headers[MCP_SESSION_ID] = self.session_id return headers @@ -174,6 +185,15 @@ async def _handle_sse_event( logger.warning(f"Unknown SSE event: {sse.event}") return False + async def _auth_then_retry(self) -> None: + """Perform OAuth authentication flow.""" + if not self.auth_provider: + raise UnauthorizedError("No auth provider") + + result = await auth(self.auth_provider, server_url=self.url) + if result != "AUTHORIZED": + raise UnauthorizedError() + async def handle_get_stream( self, client: httpx.AsyncClient, @@ -184,7 +204,7 @@ async def handle_get_stream( if not self.session_id: return - headers = self._update_headers_with_session(self.request_headers) + headers = await self._update_headers_with_session(self.request_headers) async with aconnect_sse( client, @@ -206,7 +226,7 @@ async def handle_get_stream( async def _handle_resumption_request(self, ctx: RequestContext) -> None: """Handle a resumption request using GET with SSE.""" - headers = self._update_headers_with_session(ctx.headers) + headers = await self._update_headers_with_session(ctx.headers) if ctx.metadata and ctx.metadata.resumption_token: headers[LAST_EVENT_ID] = ctx.metadata.resumption_token else: @@ -241,7 +261,7 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" - headers = self._update_headers_with_session(ctx.headers) + headers = await self._update_headers_with_session(ctx.headers) message = ctx.session_message.message is_initialization = self._is_initialization_request(message) @@ -255,6 +275,21 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: logger.debug("Received 202 Accepted") return + if response.status_code == 401 and self.auth_provider: + # Need to authenticate + try: + await self._auth_then_retry() + # Retry the request after authentication + return await self._handle_post_request(ctx) + except Exception as exc: + logger.error(f"Auth retry failed: {exc}") + if isinstance(message.root, JSONRPCRequest): + await self._send_session_terminated_error( + ctx.read_stream_writer, + message.root.id, + ) + return + if response.status_code == 404: if isinstance(message.root, JSONRPCRequest): await self._send_session_terminated_error( @@ -405,7 +440,7 @@ async def terminate_session(self, client: httpx.AsyncClient) -> None: return try: - headers = self._update_headers_with_session(self.request_headers) + headers = await self._update_headers_with_session(self.request_headers) response = await client.delete(self.url, headers=headers) if response.status_code == 405: @@ -419,6 +454,24 @@ def get_session_id(self) -> str | None: """Get the current session ID.""" return self.session_id + async def finish_auth(self, authorization_code: str) -> None: + """ + Call this method after the user has finished authorizing via their user agent + and is redirected back to the MCP client application. This will exchange the + authorization code for an access token, enabling the next connection attempt + to successfully auth. + """ + if not self.auth_provider: + raise UnauthorizedError("No auth provider") + + result = await auth( + self.auth_provider, + server_url=self.url, + authorization_code=authorization_code, + ) + if result != "AUTHORIZED": + raise UnauthorizedError("Failed to authorize") + @asynccontextmanager async def streamablehttp_client( @@ -427,6 +480,7 @@ async def streamablehttp_client( timeout: timedelta = timedelta(seconds=30), sse_read_timeout: timedelta = timedelta(seconds=60 * 5), terminate_on_close: bool = True, + auth_provider: OAuthClientProvider | None = None, ) -> AsyncGenerator[ tuple[ MemoryObjectReceiveStream[SessionMessage | Exception], @@ -441,13 +495,23 @@ async def streamablehttp_client( `sse_read_timeout` determines how long (in seconds) the client will wait for a new event before disconnecting. All other HTTP operations are controlled by `timeout`. + Args: + url: StreamableHTTP endpoint URL + headers: Optional HTTP headers + timeout: HTTP request timeout + sse_read_timeout: SSE read timeout + terminate_on_close: Whether to terminate session on close + auth_provider: Optional OAuth client provider for authentication + Yields: Tuple containing: - read_stream: Stream for reading messages from the server - write_stream: Stream for sending messages to the server - get_session_id_callback: Function to retrieve the current session ID """ - transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout) + transport = StreamableHTTPTransport( + url, headers, timeout, sse_read_timeout, auth_provider + ) read_stream_writer, read_stream = anyio.create_memory_object_stream[ SessionMessage | Exception From 58a7fddecdf1f49b913c1c68e310b493c3f9ce73 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 10:14:09 +0100 Subject: [PATCH 02/19] remove --- examples/clients/simple-auth-client/README.md | 152 --------- .../mcp_simple_auth_client/main.py | 292 +++++++++++++----- 2 files changed, 214 insertions(+), 230 deletions(-) diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md index 4a37758da..e69de29bb 100644 --- a/examples/clients/simple-auth-client/README.md +++ b/examples/clients/simple-auth-client/README.md @@ -1,152 +0,0 @@ -# Simple OAuth Client Example - -This example demonstrates how to create an MCP client that connects to an OAuth-protected server using the Python SDK's OAuth client support. - -## Overview - -This client connects to the [simple-auth server](../../servers/simple-auth/) and demonstrates: -- OAuth 2.0 client authentication with PKCE -- Token storage and management -- Handling the OAuth authorization flow -- Making authenticated requests to protected tools - -## Prerequisites - -1. **Set up the simple-auth server first**: - - Follow the instructions in [../../servers/simple-auth/README.md](../../servers/simple-auth/README.md) - - Make sure the server is running on `http://localhost:8000` - -2. **Create a GitHub OAuth App** (if not done already): - - Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App - - Application name: "Simple MCP Auth Demo" - - Homepage URL: `http://localhost:8000` - - Authorization callback URL: `http://localhost:8080/callback` - - Note: The client uses port 8080 for its callback, while the server uses 8000 - -## Installation - -```bash -# Install dependencies with uv -uv install - -# Or install in development mode with dev dependencies -uv install --dev -``` - -## Running the Client - -```bash -# Basic usage - will start OAuth flow if not authenticated -uv run mcp-simple-auth-client - -# Specify custom server URL -uv run mcp-simple-auth-client --server-url http://localhost:8000 - -# Specify custom callback port (if port 8080 is in use) -uv run mcp-simple-auth-client --callback-port 8081 - -# Use file-based token storage instead of in-memory -uv run mcp-simple-auth-client --use-file-storage - -# Run with debug logging -uv run mcp-simple-auth-client --debug -``` - -## How It Works - -1. **First Run**: If no tokens are stored, the client will: - - Start a local HTTP server to handle the OAuth callback - - Open your default browser to the GitHub authorization page - - Wait for you to authorize the application - - Exchange the authorization code for tokens - - Save the tokens for future use - -2. **Subsequent Runs**: The client will: - - Load existing tokens from storage - - Use them to authenticate with the server - - Automatically refresh tokens if needed - -3. **Making Requests**: Once authenticated, the client can: - - Call the `get_user_profile` tool - - Display the GitHub user information - -## Example Output - -``` -$ uv run mcp-simple-auth-client -Starting OAuth client... -No existing tokens found. Starting OAuth flow... -Opening authorization URL in browser... -Starting callback server on http://localhost:8080... -Waiting for OAuth callback... -Authorization successful! -Connecting to MCP server... -Calling get_user_profile tool... - -GitHub User Profile: -{ - "login": "username", - "id": 12345, - "name": "John Doe", - "email": "john@example.com", - "bio": "Developer", - "public_repos": 42, - "followers": 100, - "following": 50 -} - -Done! -``` - -## OAuth Flow - -``` -Client Browser GitHub MCP Server - | | | | - |-- Opens auth URL ------>| | | - | |-- User authorizes ------>| | - | | |<-- Auth code ------------| - |<-- Callback ------------| | | - | | | | - |-- Exchange code for tokens ------------------------>| | - |<-- Access token -----------------------------------| | - | | | | - |-- Authenticated request -------------------------------------------------------->| - |<-- Protected resource -----------------------------------------------------------| -``` - -## Development - -```bash -# Run linting -uv run ruff check . - -# Run type checking -uv run pyright - -# Run with development dependencies -uv run --dev pytest -``` - -## Troubleshooting - -**Client fails to connect:** -- Make sure the server is running: `uv run mcp-simple-auth` in the server directory -- Check that the server URL is correct (default: http://localhost:8000) -- Verify OAuth configuration matches between client and server - -**OAuth flow fails:** -- Ensure GitHub OAuth app callback URL matches the client's callback URL -- Check that no other service is using the callback port (default: 8080) -- Make sure your browser allows opening localhost URLs - -**Token issues:** -- Delete stored tokens to restart the OAuth flow: `rm oauth_*.json` -- Check that the server is configured with valid GitHub OAuth credentials - -## Security Notes - -- Tokens are stored locally in files or memory -- The local callback server only runs during the OAuth flow -- Consider using HTTPS in production environments -- Implement proper token encryption for sensitive applications \ No newline at end of file diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 2fb1d5116..47c139d56 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -9,6 +9,7 @@ import json import logging import webbrowser +from typing import List, Optional import click from mcp import ClientSession @@ -16,55 +17,71 @@ from mcp.client.oauth_providers import FileBasedOAuthProvider, InMemoryOAuthProvider from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientMetadata - - -class CLIOAuthProvider(InMemoryOAuthProvider): - """OAuth provider for CLI interactive sessions.""" - - def __init__(self, server_url: str): - client_metadata = OAuthClientMetadata( - redirect_uris=["http://localhost:8080/callback"], - client_name="MCP CLI Auth Client", - scope="user", - ) +from pydantic import BaseModel, AnyHttpUrl + + +# Create a custom OAuth provider that handles JSON serialization properly +class WorkingOAuthProvider(InMemoryOAuthProvider): + """OAuth provider that properly handles JSON serialization of AnyHttpUrl.""" + + def __init__(self, redirect_url: str): + # Create a minimal metadata object that avoids serialization issues + from mcp.shared.auth import OAuthClientMetadata + + # Initialize with minimal metadata super().__init__( - redirect_url="http://localhost:8080/callback", - client_metadata=client_metadata, + redirect_url=redirect_url, + client_metadata=None, # Don't pass metadata initially ) - self.server_url = server_url - - async def redirect_to_authorization(self, authorization_url: str) -> None: - """Open the authorization URL in the browser and prompt for the code.""" - print("\nšŸ” Starting OAuth authorization...") - print(f"Opening browser to: {authorization_url}") - - webbrowser.open(authorization_url) - - print("\nAfter authorizing, copy the 'code' parameter from the callback URL.") - print("Example: if redirected to 'http://localhost:8080/callback?code=abc123'") - print("Then paste: abc123") - - auth_code = input("\nPaste the authorization code here: ").strip() - if auth_code: - from mcp.client.streamable_http import StreamableHTTPTransport - - transport = StreamableHTTPTransport( - f"{self.server_url}/mcp", - auth_provider=self, + + # Store the metadata for manual registration if needed + self._pending_metadata = { + "redirect_uris": [redirect_url], + "client_name": "MCP CLI Auth Client", + "scope": "user", + } + + async def register_client(self) -> str: + """Register the client manually with JSON-safe metadata.""" + import httpx + import json + + # Get the metadata endpoint from the auth server + async with httpx.AsyncClient() as client: + # Get authorization server metadata + response = await client.get(f"{self._base_url}/.well-known/oauth-authorization-server") + server_metadata = response.json() + + # Register the client with JSON-safe data + registration_response = await client.post( + server_metadata["registration_endpoint"], + json=self._pending_metadata, + headers={"Content-Type": "application/json"} ) - try: - await transport.finish_auth(auth_code) - print("āœ… Authorization successful!") - except Exception as e: - print(f"āŒ Authorization failed: {e}") - raise - else: - raise Exception("No authorization code provided") + + if registration_response.status_code == 201: + client_info = registration_response.json() + self._client_id = client_info["client_id"] + self._client_secret = client_info.get("client_secret") + return self._client_id + else: + raise Exception(f"Client registration failed: {registration_response.text}") + + async def _ensure_registered(self): + """Ensure the client is registered before proceeding.""" + if not hasattr(self, '_client_id') or not self._client_id: + await self.register_client() class InteractiveOAuthProvider(InMemoryOAuthProvider): """OAuth provider that handles the authorization flow interactively.""" + def __init__( + self, redirect_url: str, client_metadata: SimpleClientMetadata | None = None + ): + super().__init__(redirect_url=redirect_url, client_metadata=client_metadata) + self._authorization_code = None + async def redirect_to_authorization(self, authorization_url: str) -> None: """Open the authorization URL in the browser and prompt for the code.""" print("\nStarting OAuth authorization flow...") @@ -84,6 +101,56 @@ async def redirect_to_authorization(self, authorization_url: str) -> None: ) print("Then copy and paste: abc123") + # Wait for user input + auth_code = input("\nPaste the authorization code here: ").strip() + if not auth_code: + raise ValueError("No authorization code provided") + + # Store the code for the finish_auth method + self._authorization_code = auth_code + + async def finish_auth(self, authorization_code: str | None = None) -> str: + """Finish the OAuth authorization process.""" + # Use the stored code if not provided + code = authorization_code or self._authorization_code + if not code: + raise ValueError("No authorization code available") + + # Call parent's finish_auth method + return await super().finish_auth(code) + + +class CLIOAuthProvider(InteractiveOAuthProvider): + """OAuth provider for CLI interactive sessions.""" + + def __init__(self, server_url: str): + # Create client metadata with plain strings to avoid AnyHttpUrl serialization issues + client_metadata = SimpleClientMetadata( + redirect_uris=["http://localhost:8080/callback"], + client_name="MCP CLI Auth Client", + scope="user", + ) + + super().__init__( + redirect_url="http://localhost:8080/callback", + client_metadata=client_metadata, + ) + self.server_url = server_url + + async def redirect_to_authorization(self, authorization_url: str) -> None: + """Handle authorization redirect by opening browser and getting code manually.""" + print("\nšŸ” Starting OAuth authorization...") + print(f"Opening browser to: {authorization_url}") + + webbrowser.open(authorization_url) + + print("\nAfter authorizing, copy the 'code' parameter from the callback URL.") + print("Example: if redirected to 'http://localhost:8080/callback?code=abc123'") + print("Then paste: abc123") + + # This method should NOT try to finish auth - that's handled by the transport + # Just let the parent class or transport handle the auth code + async def run_oauth_client( server_url: str, use_file_storage: bool, debug: bool @@ -95,26 +162,18 @@ async def run_oauth_client( else: logging.basicConfig(level=logging.INFO) - # Create OAuth client metadata + # Create OAuth client metadata using the original class client_metadata = OAuthClientMetadata( - redirect_uris=["http://localhost:8080/callback"], + redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")], client_name="Simple MCP Auth Client", - scope="user", # Request the 'user' scope for GitHub profile access + scope="user", + ) + + print("Using file-based token storage with automatic OAuth handling...") + oauth_provider = FileBasedOAuthProvider( + redirect_url="http://localhost:8080/callback", + client_metadata=client_metadata, ) - - # Choose storage provider - if use_file_storage: - print("Using file-based token storage...") - oauth_provider = FileBasedOAuthProvider( - redirect_url="http://localhost:8080/callback", - client_metadata=client_metadata, - ) - else: - print("Using in-memory token storage...") - oauth_provider = InteractiveOAuthProvider( - redirect_url="http://localhost:8080/callback", - client_metadata=client_metadata, - ) print("Starting OAuth client...") @@ -306,33 +365,110 @@ async def handle_command(session: ClientSession, command: str) -> None: async def run_interactive_client(): """Start an interactive MCP client session.""" - server_url = "http://localhost:3000" - oauth_provider = CLIOAuthProvider(server_url) + server_url = "http://localhost:8000" + use_file_storage = False + debug = False + + if debug: + logging.basicConfig(level=logging.DEBUG) + logging.getLogger("mcp").setLevel(logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + + # Create OAuth client metadata using the original class + client_metadata = OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")], + client_name="MCP CLI Auth Client", + scope="user", + ) + + print("Using file-based token storage with automatic OAuth handling...") + oauth_provider = FileBasedOAuthProvider( + redirect_url="http://localhost:8080/callback", + client_metadata=client_metadata, + ) - print("šŸ”— Connecting to localhost:3000...") + print("šŸ”— Connecting to localhost:8000...") try: + # Check if we have existing tokens + existing_tokens = await oauth_provider.tokens() + if existing_tokens: + print("Found existing tokens. Attempting to connect...") + else: + print("No existing tokens found. Will start OAuth flow if needed...") + + # Connect to the MCP server with OAuth async with streamablehttp_client( - f"{server_url}/mcp", auth_provider=oauth_provider - ) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - print("āœ… Connected!") - print("Type 'help' for available commands or 'exit' to quit.") - - # Interactive command loop - while True: - try: - command = input("mcp> ").strip() - if not command or command == "exit": + f"{server_url}/mcp", + auth_provider=oauth_provider, + ) as (read_stream, write_stream, _): + print("Connecting to MCP server...") + + # Create a session + async with ClientSession(read_stream, write_stream) as session: + try: + # Initialize the connection (this may trigger OAuth flow) + await session.initialize() + print("āœ… Connected!") + print("Type 'help' for available commands or 'exit' to quit.") + + # Interactive command loop + while True: + try: + command = input("mcp> ").strip() + if not command or command == "exit": + break + await handle_command(session, command) + except KeyboardInterrupt: break - await handle_command(session, command) - except KeyboardInterrupt: - break - except Exception as e: - print(f"Error: {e}") + except Exception as e: + print(f"Error: {e}") + + except UnauthorizedError: + print("\nAuthorization required!") + print("Please complete the OAuth flow and run the command again.") + + # If we're using the interactive provider, we need to manually + # handle the callback + if isinstance(oauth_provider, InteractiveOAuthProvider): + auth_code = input( + "\nPaste the authorization code here: " + ).strip() + if auth_code: + # Create a transport to finish the auth + from mcp.client.streamable_http import ( + StreamableHTTPTransport, + ) + + transport = StreamableHTTPTransport( + f"{server_url}/mcp", + auth_provider=oauth_provider, + ) + try: + await transport.finish_auth(auth_code) + print( + "Authorization successful! " + "Please run the command again." + ) + except Exception as e: + print(f"Authorization failed: {e}") + else: + print("No authorization code provided.") + + except Exception as e: + print(f"Error during MCP operations: {e}") + if debug: + import traceback + + traceback.print_exc() + except Exception as e: print(f"Failed to connect: {e}") + if debug: + import traceback + + traceback.print_exc() print("šŸ‘‹ Session ended") From 23aa8c66775d37ce283c5e6555978fecb89d0aaf Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 10:14:18 +0100 Subject: [PATCH 03/19] remove --- .../mcp_simple_auth_client/main.py | 515 ------------------ 1 file changed, 515 deletions(-) diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 47c139d56..e69de29bb 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -1,515 +0,0 @@ -""" -Simple OAuth client for the MCP simple-auth server. - -This example demonstrates how to use the MCP Python SDK's OAuth client -to connect to an OAuth-protected server. -""" - -import asyncio -import json -import logging -import webbrowser -from typing import List, Optional - -import click -from mcp import ClientSession -from mcp.client.auth import UnauthorizedError -from mcp.client.oauth_providers import FileBasedOAuthProvider, InMemoryOAuthProvider -from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.auth import OAuthClientMetadata -from pydantic import BaseModel, AnyHttpUrl - - -# Create a custom OAuth provider that handles JSON serialization properly -class WorkingOAuthProvider(InMemoryOAuthProvider): - """OAuth provider that properly handles JSON serialization of AnyHttpUrl.""" - - def __init__(self, redirect_url: str): - # Create a minimal metadata object that avoids serialization issues - from mcp.shared.auth import OAuthClientMetadata - - # Initialize with minimal metadata - super().__init__( - redirect_url=redirect_url, - client_metadata=None, # Don't pass metadata initially - ) - - # Store the metadata for manual registration if needed - self._pending_metadata = { - "redirect_uris": [redirect_url], - "client_name": "MCP CLI Auth Client", - "scope": "user", - } - - async def register_client(self) -> str: - """Register the client manually with JSON-safe metadata.""" - import httpx - import json - - # Get the metadata endpoint from the auth server - async with httpx.AsyncClient() as client: - # Get authorization server metadata - response = await client.get(f"{self._base_url}/.well-known/oauth-authorization-server") - server_metadata = response.json() - - # Register the client with JSON-safe data - registration_response = await client.post( - server_metadata["registration_endpoint"], - json=self._pending_metadata, - headers={"Content-Type": "application/json"} - ) - - if registration_response.status_code == 201: - client_info = registration_response.json() - self._client_id = client_info["client_id"] - self._client_secret = client_info.get("client_secret") - return self._client_id - else: - raise Exception(f"Client registration failed: {registration_response.text}") - - async def _ensure_registered(self): - """Ensure the client is registered before proceeding.""" - if not hasattr(self, '_client_id') or not self._client_id: - await self.register_client() - - -class InteractiveOAuthProvider(InMemoryOAuthProvider): - """OAuth provider that handles the authorization flow interactively.""" - - def __init__( - self, redirect_url: str, client_metadata: SimpleClientMetadata | None = None - ): - super().__init__(redirect_url=redirect_url, client_metadata=client_metadata) - self._authorization_code = None - - async def redirect_to_authorization(self, authorization_url: str) -> None: - """Open the authorization URL in the browser and prompt for the code.""" - print("\nStarting OAuth authorization flow...") - print(f"Opening browser to: {authorization_url}") - - # Open the browser - webbrowser.open(authorization_url) - - print( - "\nAfter authorizing the application, " - "you'll be redirected to a callback URL." - ) - print("Copy the 'code' parameter from the callback URL and paste it here.") - print( - "Example: if redirected to " - "'http://localhost:8080/callback?code=abc123&state=xyz'" - ) - print("Then copy and paste: abc123") - - # Wait for user input - auth_code = input("\nPaste the authorization code here: ").strip() - if not auth_code: - raise ValueError("No authorization code provided") - - # Store the code for the finish_auth method - self._authorization_code = auth_code - - async def finish_auth(self, authorization_code: str | None = None) -> str: - """Finish the OAuth authorization process.""" - # Use the stored code if not provided - code = authorization_code or self._authorization_code - if not code: - raise ValueError("No authorization code available") - - # Call parent's finish_auth method - return await super().finish_auth(code) - - -class CLIOAuthProvider(InteractiveOAuthProvider): - """OAuth provider for CLI interactive sessions.""" - - def __init__(self, server_url: str): - # Create client metadata with plain strings to avoid AnyHttpUrl serialization issues - client_metadata = SimpleClientMetadata( - redirect_uris=["http://localhost:8080/callback"], - client_name="MCP CLI Auth Client", - scope="user", - ) - - super().__init__( - redirect_url="http://localhost:8080/callback", - client_metadata=client_metadata, - ) - self.server_url = server_url - - async def redirect_to_authorization(self, authorization_url: str) -> None: - """Handle authorization redirect by opening browser and getting code manually.""" - print("\nšŸ” Starting OAuth authorization...") - print(f"Opening browser to: {authorization_url}") - - webbrowser.open(authorization_url) - - print("\nAfter authorizing, copy the 'code' parameter from the callback URL.") - print("Example: if redirected to 'http://localhost:8080/callback?code=abc123'") - print("Then paste: abc123") - - # This method should NOT try to finish auth - that's handled by the transport - # Just let the parent class or transport handle the auth code - - -async def run_oauth_client( - server_url: str, use_file_storage: bool, debug: bool -) -> None: - """Run the OAuth client example.""" - if debug: - logging.basicConfig(level=logging.DEBUG) - logging.getLogger("mcp").setLevel(logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - # Create OAuth client metadata using the original class - client_metadata = OAuthClientMetadata( - redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")], - client_name="Simple MCP Auth Client", - scope="user", - ) - - print("Using file-based token storage with automatic OAuth handling...") - oauth_provider = FileBasedOAuthProvider( - redirect_url="http://localhost:8080/callback", - client_metadata=client_metadata, - ) - - print("Starting OAuth client...") - - try: - # Check if we have existing tokens - existing_tokens = await oauth_provider.tokens() - if existing_tokens: - print("Found existing tokens. Attempting to connect...") - else: - print("No existing tokens found. Will start OAuth flow if needed...") - - # Connect to the MCP server with OAuth - async with streamablehttp_client( - f"{server_url}/mcp", - auth_provider=oauth_provider, - ) as (read_stream, write_stream, _): - print("Connecting to MCP server...") - - # Create a session - async with ClientSession(read_stream, write_stream) as session: - try: - # Initialize the connection (this may trigger OAuth flow) - await session.initialize() - print("Connected successfully!") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - # Call the get_user_profile tool - print("Calling get_user_profile tool...") - result = await session.call_tool("get_user_profile", {}) - - print("\nGitHub User Profile:") - if result.content: - # The result content should be a dict in JSON format - profile_data = result.content[0].text - if isinstance(profile_data, str): - # If it's a JSON string, parse it for pretty printing - try: - parsed_data = json.loads(profile_data) - print(json.dumps(parsed_data, indent=2)) - except json.JSONDecodeError: - print(profile_data) - else: - print(json.dumps(profile_data, indent=2)) - else: - print("No content received") - - except UnauthorizedError: - print("\nAuthorization required!") - print("Please complete the OAuth flow and run the command again.") - - # If we're using the interactive provider, we need to manually - # handle the callback - if isinstance(oauth_provider, InteractiveOAuthProvider): - auth_code = input( - "\nPaste the authorization code here: " - ).strip() - if auth_code: - # Create a transport to finish the auth - from mcp.client.streamable_http import ( - StreamableHTTPTransport, - ) - - transport = StreamableHTTPTransport( - f"{server_url}/mcp", - auth_provider=oauth_provider, - ) - try: - await transport.finish_auth(auth_code) - print( - "Authorization successful! " - "Please run the command again." - ) - except Exception as e: - print(f"Authorization failed: {e}") - else: - print("No authorization code provided.") - - except Exception as e: - print(f"Error during MCP operations: {e}") - if debug: - import traceback - - traceback.print_exc() - - except Exception as e: - print(f"Failed to connect: {e}") - if debug: - import traceback - - traceback.print_exc() - - print("Done!") - - -async def handle_command(session: ClientSession, command: str) -> None: - """Handle interactive commands.""" - parts = command.split() - if not parts: - return - - cmd = parts[0].lower() - - if cmd == "help": - print("Available commands:") - print(" help - Show this help") - print(" tools - List available tools") - print(" resources - List available resources") - print(" prompts - List available prompts") - print(" call [args] - Call a tool") - print(" read - Read a resource") - print(" exit - Exit the session") - - elif cmd == "tools": - tools = await session.list_tools() - if tools.tools: - print("Available tools:") - for tool in tools.tools: - print(f" {tool.name}: {tool.description}") - else: - print("No tools available") - - elif cmd == "resources": - resources = await session.list_resources() - if resources.resources: - print("Available resources:") - for resource in resources.resources: - print(f" {resource.uri}: {resource.name}") - else: - print("No resources available") - - elif cmd == "prompts": - prompts = await session.list_prompts() - if prompts.prompts: - print("Available prompts:") - for prompt in prompts.prompts: - print(f" {prompt.name}: {prompt.description}") - else: - print("No prompts available") - - elif cmd == "call" and len(parts) >= 2: - tool_name = parts[1] - try: - # Parse arguments as JSON if provided - args = {} - if len(parts) > 2: - args_str = " ".join(parts[2:]) - try: - args = json.loads(args_str) - except json.JSONDecodeError: - print(f"Invalid JSON arguments: {args_str}") - return - - result = await session.call_tool(tool_name, args) - print(f"Result from {tool_name}:") - if result.content: - for content in result.content: - if hasattr(content, "text"): - print(content.text) - else: - print(str(content)) - else: - print("No content received") - except Exception as e: - print(f"Error calling tool {tool_name}: {e}") - - elif cmd == "read" and len(parts) >= 2: - resource_uri = parts[1] - try: - result = await session.read_resource(resource_uri) - print(f"Resource content from {resource_uri}:") - if result.contents: - for content in result.contents: - if hasattr(content, "text"): - print(content.text) - else: - print(str(content)) - else: - print("No content received") - except Exception as e: - print(f"Error reading resource {resource_uri}: {e}") - - else: - print(f"Unknown command: {command}") - print("Type 'help' for available commands") - - -async def run_interactive_client(): - """Start an interactive MCP client session.""" - server_url = "http://localhost:8000" - use_file_storage = False - debug = False - - if debug: - logging.basicConfig(level=logging.DEBUG) - logging.getLogger("mcp").setLevel(logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - # Create OAuth client metadata using the original class - client_metadata = OAuthClientMetadata( - redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")], - client_name="MCP CLI Auth Client", - scope="user", - ) - - print("Using file-based token storage with automatic OAuth handling...") - oauth_provider = FileBasedOAuthProvider( - redirect_url="http://localhost:8080/callback", - client_metadata=client_metadata, - ) - - print("šŸ”— Connecting to localhost:8000...") - - try: - # Check if we have existing tokens - existing_tokens = await oauth_provider.tokens() - if existing_tokens: - print("Found existing tokens. Attempting to connect...") - else: - print("No existing tokens found. Will start OAuth flow if needed...") - - # Connect to the MCP server with OAuth - async with streamablehttp_client( - f"{server_url}/mcp", - auth_provider=oauth_provider, - ) as (read_stream, write_stream, _): - print("Connecting to MCP server...") - - # Create a session - async with ClientSession(read_stream, write_stream) as session: - try: - # Initialize the connection (this may trigger OAuth flow) - await session.initialize() - print("āœ… Connected!") - print("Type 'help' for available commands or 'exit' to quit.") - - # Interactive command loop - while True: - try: - command = input("mcp> ").strip() - if not command or command == "exit": - break - await handle_command(session, command) - except KeyboardInterrupt: - break - except Exception as e: - print(f"Error: {e}") - - except UnauthorizedError: - print("\nAuthorization required!") - print("Please complete the OAuth flow and run the command again.") - - # If we're using the interactive provider, we need to manually - # handle the callback - if isinstance(oauth_provider, InteractiveOAuthProvider): - auth_code = input( - "\nPaste the authorization code here: " - ).strip() - if auth_code: - # Create a transport to finish the auth - from mcp.client.streamable_http import ( - StreamableHTTPTransport, - ) - - transport = StreamableHTTPTransport( - f"{server_url}/mcp", - auth_provider=oauth_provider, - ) - try: - await transport.finish_auth(auth_code) - print( - "Authorization successful! " - "Please run the command again." - ) - except Exception as e: - print(f"Authorization failed: {e}") - else: - print("No authorization code provided.") - - except Exception as e: - print(f"Error during MCP operations: {e}") - if debug: - import traceback - - traceback.print_exc() - - except Exception as e: - print(f"Failed to connect: {e}") - if debug: - import traceback - - traceback.print_exc() - - print("šŸ‘‹ Session ended") - - -@click.group() -def app(): - """MCP Simple Auth Client CLI""" - pass - - -@app.command() -def client(): - """Start an interactive MCP client session.""" - asyncio.run(run_interactive_client()) - - -@app.command() -@click.option( - "--server-url", - default="http://localhost:8000", - help="URL of the MCP server (default: http://localhost:8000)", -) -@click.option( - "--use-file-storage", - is_flag=True, - help="Use file-based token storage instead of in-memory", -) -@click.option( - "--debug", - is_flag=True, - help="Enable debug logging", -) -def oauth(server_url: str, use_file_storage: bool, debug: bool): - """Run OAuth client example.""" - asyncio.run(run_oauth_client(server_url, use_file_storage, debug)) - - -def main(): - """Entry point for the CLI.""" - app() - - -if __name__ == "__main__": - main() From b949581a0358eaa7f530b320d606f9d9c9745211 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 14:00:27 +0100 Subject: [PATCH 04/19] wrongly working solution --- examples/clients/simple-auth-client/README.md | 125 +++++ .../mcp_simple_auth_client/main.py | 452 ++++++++++++++++++ .../clients/simple-auth-client/pyproject.toml | 2 +- src/mcp/client/streamable_http.py | 12 + 4 files changed, 590 insertions(+), 1 deletion(-) diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md index e69de29bb..314ccab59 100644 --- a/examples/clients/simple-auth-client/README.md +++ b/examples/clients/simple-auth-client/README.md @@ -0,0 +1,125 @@ +# Simple Auth Client Example + +This example demonstrates how to use the MCP Python SDK to create a client that connects to an MCP server using OAuth authentication over streamable HTTP transport. + +## Features + +- OAuth 2.0 authentication with PKCE +- Streamable HTTP transport +- Interactive command-line interface +- Tool listing and execution + +## Prerequisites + +1. Python 3.9 or higher +2. An MCP server that supports OAuth authentication (like `mcp-simple-auth`) +3. uv for dependency management + +## Installation + +```bash +cd examples/clients/simple-auth-client +uv install +``` + +## Usage + +### 1. Start the Auth Server + +First, start the MCP auth server in another terminal: + +```bash +cd path/to/mcp-simple-auth +uv run mcp-simple-auth --transport streamable-http --port 3001 +``` + +### 2. Run the Client + +```bash +# Run the client +uv run mcp-simple-auth-client + +# Or with custom server URL +MCP_SERVER_URL=http://localhost:3001 uv run mcp-simple-auth-client +``` + +### 3. Authentication Flow + +1. The client will attempt to connect to the server +2. If authentication is required, the client will open your default browser +3. Complete the OAuth flow in the browser +4. Return to the client - it should now be connected + +### 4. Interactive Commands + +Once connected, you can use these commands: + +- `list` - List available tools from the server +- `call [args]` - Call a tool with optional JSON arguments +- `quit` - Exit the client + +### Example Session + +``` +=ļæ½ Simple MCP Auth Client +Connecting to: http://localhost:3001 + +Please visit the following URL to authorize the application: +http://localhost:3001/authorize?response_type=code&client_id=... + + Connected to MCP server at http://localhost:3001 +Session ID: abc123 + +<ļæ½ Interactive MCP Client +Commands: + list - List available tools + call [args] - Call a tool + quit - Exit the client + +mcp> list + +=ļæ½ Available tools: +1. echo + Description: Echo back the input text + +mcp> call echo {"text": "Hello, world!"} + +=' Tool 'echo' result: +Hello, world! + +mcp> quit +=K Goodbye! +``` + +## Configuration + +You can customize the client behavior with environment variables: + +- `MCP_SERVER_URL` - Server URL (default: http://localhost:3001) +- `AUTH_CODE` - Authorization code for completing OAuth flow + +## Implementation Details + +This example shows how to: + +1. **Create an OAuth provider** - Implement the `OAuthClientProvider` protocol +2. **Use streamable HTTP transport** - Connect using the `streamablehttp_client` context manager +3. **Handle authentication** - Manage OAuth flow including browser redirect +4. **Interactive tool usage** - List and call tools from the command line + +The key components are: + +- `SimpleOAuthProvider` - Minimal OAuth provider implementation +- `SimpleAuthClient` - Main client class that handles connection and tool operations +- Interactive loop for user commands + +## Error Handling + +The client handles common error scenarios: + +- Server connection failures +- Authentication errors +- Invalid tool calls +- Network timeouts + +All errors are displayed with helpful messages to guide debugging. \ No newline at end of file diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index e69de29bb..e2ce7c908 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +""" +Simple MCP client example with OAuth authentication support. + +This client connects to an MCP server using streamable HTTP transport with OAuth authentication. +It provides an interactive command-line interface to list tools and execute them. +""" + +import asyncio +import json +import os +import threading +import time +import webbrowser +from datetime import timedelta +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +from mcp.client.auth import ( + OAuthClientProvider, + discover_oauth_metadata, +) +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from pydantic import AnyHttpUrl + + +class CallbackHandler(BaseHTTPRequestHandler): + """Simple HTTP handler to capture OAuth callback.""" + + authorization_code = None + error = None + + def do_GET(self): + """Handle GET request from OAuth redirect.""" + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + if "code" in query_params: + CallbackHandler.authorization_code = query_params["code"][0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b""" + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + """) + elif "error" in query_params: + CallbackHandler.error = query_params["error"][0] + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + f""" + + +

Authorization Failed

+

Error: {query_params['error'][0]}

+

You can close this window and return to the terminal.

+ + + """.encode() + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + """Suppress default logging.""" + pass + + +class CallbackServer: + """Simple server to handle OAuth callbacks.""" + + def __init__(self, port=3000): + self.port = port + self.server = None + self.thread = None + + def start(self): + """Start the callback server in a background thread.""" + self.server = HTTPServer(("localhost", self.port), CallbackHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + print(f"šŸ–„ļø Started callback server on http://localhost:{self.port}") + + def stop(self): + """Stop the callback server.""" + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1) + + def wait_for_callback(self, timeout=300): + """Wait for OAuth callback with timeout.""" + start_time = time.time() + while time.time() - start_time < timeout: + if CallbackHandler.authorization_code: + return CallbackHandler.authorization_code + elif CallbackHandler.error: + raise Exception(f"OAuth error: {CallbackHandler.error}") + time.sleep(0.1) + raise Exception("Timeout waiting for OAuth callback") + + +class JsonSerializableOAuthClientMetadata(OAuthClientMetadata): + """OAuth client metadata that handles JSON serialization properly.""" + + def model_dump(self, **kwargs) -> dict[str, Any]: + """Override to ensure URLs are serialized as strings.""" + data = super().model_dump(**kwargs) + # Convert AnyHttpUrl objects to strings + if "redirect_uris" in data: + data["redirect_uris"] = [str(url) for url in data["redirect_uris"]] + + # Debug: print what we're sending + print(f"šŸ› Client metadata being sent: {json.dumps(data, indent=2)}") + return data + + +class SimpleOAuthProvider(OAuthClientProvider): + """Simple OAuth client provider for demonstration purposes.""" + + def __init__(self, server_url: str, callback_port: int = 3000): + self._callback_port = callback_port + self._redirect_uri = f"http://localhost:{callback_port}/callback" + self._server_url = server_url + self._callback_server = None + print(f"šŸ› OAuth provider initialized with redirect URI: {self._redirect_uri}") + # Store the raw data for easy serialization - scope will be updated dynamically + self._client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": [self._redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_post", # Use client secret + "scope": "read", # Default scope, will be updated + } + self._client_info: OAuthClientInformationFull | None = None + self._tokens: OAuthToken | None = None + self._code_verifier: str | None = None + self._authorization_code: str | None = None + self._metadata_discovered = False + + @property + def redirect_url(self) -> str: + return self._redirect_uri + + async def _discover_and_update_metadata(self): + """Discover server OAuth metadata and update client scope accordingly.""" + if self._metadata_discovered: + return + + try: + print("šŸ› Discovering OAuth metadata...") + metadata = await discover_oauth_metadata(self._server_url) + if metadata and metadata.scopes_supported: + scope = " ".join(metadata.scopes_supported) + self._client_metadata_dict["scope"] = scope + print(f"šŸ› Updated scope to: {scope}") + self._metadata_discovered = True + except Exception as e: + print(f"šŸ› Failed to discover metadata: {e}, using default scope") + self._metadata_discovered = True + + @property + def client_metadata(self) -> OAuthClientMetadata: + # Create a fresh instance each time using our custom serializable version + return JsonSerializableOAuthClientMetadata.model_validate( + self._client_metadata_dict + ) + + async def client_information(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def save_client_information( + self, client_information: OAuthClientInformationFull + ) -> None: + self._client_info = client_information + print(f"Saved client information: {client_information.client_id}") + + async def tokens(self) -> OAuthToken | None: + return self._tokens + + async def save_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + print( + f"Saved OAuth tokens, access token starts with: {tokens.access_token[:10]}..." + ) + + async def redirect_to_authorization(self, authorization_url: str) -> None: + # Start callback server + self._callback_server = CallbackServer(self._callback_port) + self._callback_server.start() + + print("\n🌐 Opening authorization URL in your default browser...") + print(f"URL: {authorization_url}") + webbrowser.open(authorization_url) + + print("ā³ Waiting for authorization callback...") + print("(Complete the authorization in your browser)") + + try: + # Wait for the callback with authorization code + authorization_code = self._callback_server.wait_for_callback(timeout=300) + print(f"āœ… Received authorization code: {authorization_code[:20]}...") + + # Store the authorization code so auth() can handle token exchange + self._authorization_code = authorization_code + print("šŸŽ‰ OAuth callback received successfully!") + + except Exception as e: + print(f"āŒ OAuth flow failed: {e}") + raise + finally: + # Always stop the callback server + if self._callback_server: + self._callback_server.stop() + self._callback_server = None + + async def save_code_verifier(self, code_verifier: str) -> None: + self._code_verifier = code_verifier + + async def code_verifier(self) -> str: + if self._code_verifier is None: + raise ValueError("No code verifier available") + return self._code_verifier + + +class SimpleAuthClient: + """Simple MCP client with auth support.""" + + def __init__(self, server_url: str): + self.server_url = server_url + # Extract base URL for auth server (remove /mcp endpoint for auth endpoints) + auth_server_url = server_url.replace("/mcp", "") + # Use default redirect URI - this is where the auth server will redirect the user + # The user will need to copy the authorization code from this callback URL + self.auth_provider = SimpleOAuthProvider(auth_server_url) + self.session: ClientSession | None = None + + async def connect(self): + """Connect to the MCP server.""" + print(f"šŸ”— Attempting to connect to {self.server_url}...") + + # The streamable HTTP transport will handle the OAuth flow automatically + # We just need to wait for it to complete successfully + try: + # Discover OAuth metadata first to set proper scopes + await self.auth_provider._discover_and_update_metadata() + + # Check if we already have tokens, if not do auth flow first + existing_tokens = await self.auth_provider.tokens() + if not existing_tokens: + print("šŸ” No existing tokens found, initiating OAuth flow...") + await self.auth_provider._discover_and_update_metadata() + + # Start the auth flow to get tokens + from mcp.client.auth import auth + + auth_result = await auth( + self.auth_provider, server_url=self.server_url.replace("/mcp", "") + ) + + if auth_result == "REDIRECT": + print("šŸ”„ Waiting for OAuth completion...") + # Wait for authorization code to be set by the redirect handler + timeout = 300 # 5 minutes + start_time = time.time() + while ( + not self.auth_provider._authorization_code + and time.time() - start_time < timeout + ): + await asyncio.sleep(0.1) + + if not self.auth_provider._authorization_code: + raise Exception("Timeout waiting for OAuth authorization") + + # Now exchange the authorization code for tokens + auth_result = await auth( + self.auth_provider, + server_url=self.server_url.replace("/mcp", ""), + authorization_code=self.auth_provider._authorization_code, + ) + + if auth_result != "AUTHORIZED": + raise Exception("Failed to authorize with server") + + # Verify we have tokens now + tokens = await self.auth_provider.tokens() + if not tokens: + raise Exception("OAuth completed but no tokens were saved") + + print( + f"āœ… OAuth authorization successful! Access token: {tokens.access_token[:20]}..." + ) + + # Create streamable HTTP transport with auth + stream_context = streamablehttp_client( + url=self.server_url, + auth_provider=self.auth_provider, + timeout=timedelta(seconds=60), # Longer timeout for OAuth flow + ) + + print("šŸ“” Opening transport connection...") + async with stream_context as (read_stream, write_stream, get_session_id): + print("šŸ¤ Initializing MCP session...") + async with ClientSession(read_stream, write_stream) as session: + self.session = session + print("⚔ Starting session initialization...") + await session.initialize() + print("✨ Session initialization complete!") + + print(f"\nāœ… Connected to MCP server at {self.server_url}") + session_id = get_session_id() + if session_id: + print(f"Session ID: {session_id}") + + # Run interactive loop + await self.interactive_loop() + + except Exception as e: + print(f"āŒ Failed to connect: {e}") + import traceback + + traceback.print_exc() + + async def list_tools(self): + """List available tools from the server.""" + if not self.session: + print("āŒ Not connected to server") + return + + try: + result = await self.session.list_tools() + if hasattr(result, "tools") and result.tools: + print("\nšŸ“‹ Available tools:") + for i, tool in enumerate(result.tools, 1): + print(f"{i}. {tool.name}") + if tool.description: + print(f" Description: {tool.description}") + print() + else: + print("No tools available") + except Exception as e: + print(f"āŒ Failed to list tools: {e}") + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): + """Call a specific tool.""" + if not self.session: + print("āŒ Not connected to server") + return + + try: + result = await self.session.call_tool(tool_name, arguments or {}) + print(f"\nšŸ”§ Tool '{tool_name}' result:") + if hasattr(result, "content"): + for content in result.content: + if hasattr(content, "text"): + print(content.text) + else: + print(content) + else: + print(result) + except Exception as e: + print(f"āŒ Failed to call tool '{tool_name}': {e}") + + async def interactive_loop(self): + """Run interactive command loop.""" + print("\nšŸŽÆ Interactive MCP Client") + print("Commands:") + print(" list - List available tools") + print(" call [args] - Call a tool") + print(" quit - Exit the client") + print() + + while True: + try: + command = input("mcp> ").strip() + + if not command: + continue + + if command == "quit": + break + + elif command == "list": + await self.list_tools() + + elif command.startswith("call "): + parts = command.split(maxsplit=2) + tool_name = parts[1] if len(parts) > 1 else "" + + if not tool_name: + print("āŒ Please specify a tool name") + continue + + # Parse arguments (simple JSON-like format) + arguments = {} + if len(parts) > 2: + import json + + try: + arguments = json.loads(parts[2]) + except json.JSONDecodeError: + print("āŒ Invalid arguments format (expected JSON)") + continue + + await self.call_tool(tool_name, arguments) + + else: + print( + "āŒ Unknown command. Try 'list', 'call ', or 'quit'" + ) + + except KeyboardInterrupt: + print("\n\nšŸ‘‹ Goodbye!") + break + except EOFError: + break + + +async def main(): + """Main entry point.""" + # Default server URL - can be overridden with environment variable + # Most MCP streamable HTTP servers use /mcp as the endpoint + server_url = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp") + + print("šŸš€ Simple MCP Auth Client") + print(f"Connecting to: {server_url}") + + # Start connection flow - OAuth will be handled automatically + client = SimpleAuthClient(server_url) + await client.connect() + + +def cli(): + """CLI entry point for uv script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml index bef4ae2a8..5ae7c6b9d 100644 --- a/examples/clients/simple-auth-client/pyproject.toml +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ ] [project.scripts] -mcp-simple-auth-client = "mcp_simple_auth_client.main:main" +mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" [build-system] requires = ["hatchling"] diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index e877b9d2e..1e83227d2 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -215,6 +215,12 @@ async def handle_get_stream( self.timeout.seconds, read=self.sse_read_timeout.seconds ), ) as event_source: + if event_source.response.status_code == 401 and self.auth_provider: + # Need to authenticate + await self._auth_then_retry() + # Re-attempt the GET stream after authentication + return await self.handle_get_stream(client, read_stream_writer) + event_source.response.raise_for_status() logger.debug("GET SSE connection established") @@ -246,6 +252,12 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: self.timeout.seconds, read=ctx.sse_read_timeout.seconds ), ) as event_source: + if event_source.response.status_code == 401 and self.auth_provider: + # Need to authenticate + await self._auth_then_retry() + # Re-attempt the resumption request after authentication + return await self._handle_resumption_request(ctx) + event_source.response.raise_for_status() logger.debug("Resumption GET SSE connection established") From c711cd7334260a726c2c245337c195f8e6f75cb3 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 21:59:26 +0100 Subject: [PATCH 05/19] auth httpx --- .../mcp_simple_auth_client/main.py | 100 ++-- src/mcp/client/oauth_auth.py | 499 ++++++++++++++++++ src/mcp/client/streamable_http.py | 77 +-- src/mcp/shared/_httpx_utils.py | 12 + 4 files changed, 558 insertions(+), 130 deletions(-) create mode 100644 src/mcp/client/oauth_auth.py diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index e2ce7c908..e8eb4c0f4 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -2,8 +2,8 @@ """ Simple MCP client example with OAuth authentication support. -This client connects to an MCP server using streamable HTTP transport with OAuth authentication. -It provides an interactive command-line interface to list tools and execute them. +This client connects to an MCP server using streamable HTTP transport with OAuth. + """ import asyncio @@ -21,16 +21,17 @@ OAuthClientProvider, discover_oauth_metadata, ) +from mcp.client.oauth_auth import OAuthAuth from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from pydantic import AnyHttpUrl class CallbackHandler(BaseHTTPRequestHandler): """Simple HTTP handler to capture OAuth callback.""" authorization_code = None + state = None error = None def do_GET(self): @@ -40,6 +41,7 @@ def do_GET(self): if "code" in query_params: CallbackHandler.authorization_code = query_params["code"][0] + CallbackHandler.state = query_params.get("state", [None])[0] self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() @@ -116,8 +118,11 @@ class JsonSerializableOAuthClientMetadata(OAuthClientMetadata): """OAuth client metadata that handles JSON serialization properly.""" def model_dump(self, **kwargs) -> dict[str, Any]: - """Override to ensure URLs are serialized as strings.""" + """Override to ensure URLs are serialized as strings and exclude null values.""" + # Exclude null values by default + kwargs.setdefault("exclude_none", True) data = super().model_dump(**kwargs) + # Convert AnyHttpUrl objects to strings if "redirect_uris" in data: data["redirect_uris"] = [str(url) for url in data["redirect_uris"]] @@ -193,9 +198,7 @@ async def tokens(self) -> OAuthToken | None: async def save_tokens(self, tokens: OAuthToken) -> None: self._tokens = tokens - print( - f"Saved OAuth tokens, access token starts with: {tokens.access_token[:10]}..." - ) + print(f"Saved OAuth tokens: {tokens.access_token[:10]}...") async def redirect_to_authorization(self, authorization_url: str) -> None: # Start callback server @@ -252,66 +255,41 @@ async def connect(self): """Connect to the MCP server.""" print(f"šŸ”— Attempting to connect to {self.server_url}...") - # The streamable HTTP transport will handle the OAuth flow automatically - # We just need to wait for it to complete successfully try: - # Discover OAuth metadata first to set proper scopes - await self.auth_provider._discover_and_update_metadata() - - # Check if we already have tokens, if not do auth flow first - existing_tokens = await self.auth_provider.tokens() - if not existing_tokens: - print("šŸ” No existing tokens found, initiating OAuth flow...") - await self.auth_provider._discover_and_update_metadata() - - # Start the auth flow to get tokens - from mcp.client.auth import auth - - auth_result = await auth( - self.auth_provider, server_url=self.server_url.replace("/mcp", "") - ) - - if auth_result == "REDIRECT": - print("šŸ”„ Waiting for OAuth completion...") - # Wait for authorization code to be set by the redirect handler - timeout = 300 # 5 minutes - start_time = time.time() - while ( - not self.auth_provider._authorization_code - and time.time() - start_time < timeout - ): - await asyncio.sleep(0.1) - - if not self.auth_provider._authorization_code: - raise Exception("Timeout waiting for OAuth authorization") - - # Now exchange the authorization code for tokens - auth_result = await auth( - self.auth_provider, - server_url=self.server_url.replace("/mcp", ""), - authorization_code=self.auth_provider._authorization_code, - ) - - if auth_result != "AUTHORIZED": - raise Exception("Failed to authorize with server") - - # Verify we have tokens now - tokens = await self.auth_provider.tokens() - if not tokens: - raise Exception("OAuth completed but no tokens were saved") + # Set up callback server + callback_server = CallbackServer(port=3000) + callback_server.start() + + async def callback_handler() -> tuple[str, str | None]: + """Wait for OAuth callback and return auth code and state.""" + print("ā³ Waiting for authorization callback...") + try: + auth_code = callback_server.wait_for_callback(timeout=300) + return auth_code, CallbackHandler.state + finally: + callback_server.stop() + + # Create OAuth authentication handler using the new interface + oauth_auth = OAuthAuth( + server_url=self.server_url.replace("/mcp", ""), + client_metadata=self.auth_provider.client_metadata, + storage=None, # Use in-memory storage + redirect_handler=None, # Use default (open browser) + callback_handler=callback_handler, + ) - print( - f"āœ… OAuth authorization successful! Access token: {tokens.access_token[:20]}..." - ) + # Initialize the auth handler and ensure we have tokens - # Create streamable HTTP transport with auth + # Create streamable HTTP transport with auth handler stream_context = streamablehttp_client( url=self.server_url, - auth_provider=self.auth_provider, - timeout=timedelta(seconds=60), # Longer timeout for OAuth flow + auth=oauth_auth, + timeout=timedelta(seconds=60), ) - print("šŸ“” Opening transport connection...") + print( + "šŸ“” Opening transport connection (HTTPX handles auth automatically)..." + ) async with stream_context as (read_stream, write_stream, get_session_id): print("šŸ¤ Initializing MCP session...") async with ClientSession(read_stream, write_stream) as session: @@ -365,7 +343,7 @@ async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = Non print(f"\nšŸ”§ Tool '{tool_name}' result:") if hasattr(result, "content"): for content in result.content: - if hasattr(content, "text"): + if content.type == "text": print(content.text) else: print(content) diff --git a/src/mcp/client/oauth_auth.py b/src/mcp/client/oauth_auth.py new file mode 100644 index 000000000..11639e4aa --- /dev/null +++ b/src/mcp/client/oauth_auth.py @@ -0,0 +1,499 @@ +""" +Production-ready OAuth2 Authentication implementation for HTTPX using anyio. + +This module provides a complete OAuth 2.0 authentication implementation +that handles authorization code flow with PKCE, automatic token refresh and proper error handling. +The callback server implementation should be handled by the calling code. +""" + +import base64 +import hashlib +import secrets +import string +import time +import webbrowser +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, Awaitable, Callable +from urllib.parse import urljoin + +import anyio +import httpx + +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, +) +from mcp.types import LATEST_PROTOCOL_VERSION + + +class TokenStorage(ABC): + """Abstract base class for token storage implementations.""" + + @abstractmethod + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + pass + + @abstractmethod + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + pass + + @abstractmethod + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + pass + + @abstractmethod + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + pass + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +async def discover_oauth_metadata(server_url: str) -> OAuthMetadata | None: + """ + Discovers OAuth metadata from the server's well-known endpoint. + + Args: + server_url: Base URL of the OAuth server + + Returns: + OAuthMetadata if found, None otherwise + """ + url = urljoin(server_url, "/.well-known/oauth-authorization-server") + headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + if response.status_code == 404: + return None + response.raise_for_status() + metadata_json = response.json() + print(f"OAuth metadata discovered: {metadata_json}") + return OAuthMetadata.model_validate(metadata_json) + except Exception: + # Try without MCP protocol version header for CORS issues + try: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + metadata_json = response.json() + print(f"OAuth metadata discovered (no MCP header): {metadata_json}") + return OAuthMetadata.model_validate(metadata_json) + except Exception as e: + print(f"Failed to discover OAuth metadata: {e}") + return None + + +async def register_oauth_client( + server_url: str, + client_metadata: OAuthClientMetadata, + metadata: OAuthMetadata | None = None, +) -> OAuthClientInformationFull: + """ + Registers an OAuth client with the server. + + Args: + server_url: Base URL of the OAuth server + client_metadata: Client metadata for registration + metadata: Optional OAuth metadata (will be discovered if not provided) + + Returns: + Registered client information + """ + if not metadata: + metadata = await discover_oauth_metadata(server_url) + + if metadata and metadata.registration_endpoint: + registration_url = str(metadata.registration_endpoint) + else: + registration_url = urljoin(server_url, "/register") + + # Prepare registration data and adjust scope based on server metadata + registration_data = client_metadata.model_dump(exclude_none=True) + + # If the server has supported scopes, use them instead of the requested scope + if metadata and metadata.scopes_supported: + # Use the first supported scope or "user" if available + if "user" in metadata.scopes_supported: + registration_data["scope"] = "user" + else: + registration_data["scope"] = metadata.scopes_supported[0] + print(f"Adjusted scope to server-supported: {registration_data['scope']}") + + print(f"Attempting registration at {registration_url}") + print(f"Registration data: {registration_data}") + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + registration_url, + json=registration_data, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code not in (200, 201): + print( + f"Registration failed with {response.status_code}: {response.text}" + ) + raise httpx.HTTPStatusError( + f"Registration failed: {response.status_code}", + request=response.request, + response=response, + ) + + response_data = response.json() + print(f"Registration successful: {response_data}") + return OAuthClientInformationFull.model_validate(response_data) + + except httpx.HTTPStatusError: + raise + except Exception as e: + print(f"Registration error: {e}") + raise + + +class ProductionOAuth2Auth(httpx.Auth): + """ + Production-ready OAuth2 Authentication for httpx using anyio. + Handles OAuth flow with automatic client registration and token storage. + """ + + def __init__( + self, + server_url: str, + client_metadata: OAuthClientMetadata, + storage: TokenStorage | None = None, + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, + timeout: float = 300.0, # 5 minutes timeout for OAuth flow + ): + """ + Initialize OAuth2 authentication. + + Args: + server_url: Base URL of the OAuth server + client_metadata: OAuth client metadata + storage: Token storage implementation (defaults to in-memory) + redirect_handler: Function to handle authorization URL (defaults to opening browser) + callback_handler: Function to wait for callback and return (auth_code, state) + timeout: Timeout for OAuth flow in seconds + """ + self.server_url = server_url + self.client_metadata = client_metadata + self.storage = storage or InMemoryTokenStorage() + self.redirect_handler = redirect_handler or self._default_redirect_handler + self.callback_handler = callback_handler + self.timeout = timeout + + # Cache for current tokens and metadata + self._current_tokens: OAuthToken | None = None + self._metadata: OAuthMetadata | None = None + self._client_info: OAuthClientInformationFull | None = None + self._token_expiry_time: float | None = None + + # PKCE parameters + self._code_verifier: str | None = None + self._code_challenge: str | None = None + + # Lock for thread safety during token operations + self._token_lock = anyio.Lock() + + async def _default_redirect_handler(self, authorization_url: str) -> None: + """Default redirect handler that opens the URL in a browser.""" + print(f"Opening browser for authorization: {authorization_url}") + webbrowser.open(authorization_url) + + def _generate_code_verifier(self) -> str: + """Generate a cryptographically random code verifier for PKCE.""" + return "".join( + secrets.choice(string.ascii_letters + string.digits + "-._~") + for _ in range(128) + ) + + def _generate_code_challenge(self, code_verifier: str) -> str: + """Generate a code challenge from a code verifier using SHA256.""" + digest = hashlib.sha256(code_verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """ + Handle authentication flow for requests. + + This method adds the Bearer token if available and handles 401 responses. + The actual OAuth flow initialization should be done before using this auth handler. + """ + + if not self._has_valid_token(): + await self.initialize() + await self.ensure_token() + # Add token to request if available + if self._current_tokens and self._current_tokens.access_token: + request.headers["Authorization"] = ( + f"Bearer {self._current_tokens.access_token}" + ) + + response = yield request + + # If we get a 401, we could attempt refresh or re-auth + # but due to the synchronous nature of this method, the calling code + # should handle token refresh/re-authentication at a higher level + if response.status_code == 401: + # Clear the token so next request will trigger re-auth + self._current_tokens = None + + def _has_valid_token(self) -> bool: + """Check if current token is valid.""" + if not self._current_tokens or not self._current_tokens.access_token: + return False + + # Check token expiry if available + if self._token_expiry_time and time.time() > self._token_expiry_time: + return False + + return True + + async def initialize(self) -> None: + """Initialize the auth handler by loading stored tokens and client info.""" + self._current_tokens = await self.storage.get_tokens() + self._client_info = await self.storage.get_client_info() + + async def _get_or_register_client(self) -> OAuthClientInformationFull: + """Get existing client info or register a new client.""" + if not self._client_info: + try: + self._client_info = await register_oauth_client( + self.server_url, self.client_metadata, self._metadata + ) + await self.storage.set_client_info(self._client_info) + print(f"Successfully registered client: {self._client_info.client_id}") + except Exception as e: + print(f"Client registration failed: {e}") + print("Using fallback client configuration for testing") + # Create a fallback client configuration for testing + # This allows us to test the OAuth flow even if registration isn't supported + self._client_info = OAuthClientInformationFull( + client_id="simple-auth-client", + client_secret=None, # Some servers don't require client secrets + **self.client_metadata.model_dump(exclude_none=True), + ) + await self.storage.set_client_info(self._client_info) + return self._client_info + + async def ensure_token(self) -> None: + """Ensure we have a valid access token, performing OAuth flow if needed.""" + async with self._token_lock: + # Check if we have a valid token + if self._has_valid_token(): + return + + # Try to refresh token first + if ( + self._current_tokens + and self._current_tokens.refresh_token + and await self._refresh_access_token() + ): + return + + # Perform full OAuth flow + await self._perform_oauth_flow() + + async def _perform_oauth_flow(self) -> None: + """Perform complete OAuth2 authorization code flow.""" + print("Starting OAuth2 authentication flow...") + + # Discover metadata if not already done + if not self._metadata: + self._metadata = await discover_oauth_metadata(self.server_url) + + # Get or register client + client_info = await self._get_or_register_client() + + # Generate PKCE parameters + self._code_verifier = self._generate_code_verifier() + self._code_challenge = self._generate_code_challenge(self._code_verifier) + + # Determine endpoints from metadata or use defaults + if self._metadata and self._metadata.authorization_endpoint: + auth_url_base = str(self._metadata.authorization_endpoint) + else: + auth_url_base = urljoin(self.server_url, "/authorize") + + # Build authorization URL + auth_params = { + "response_type": "code", + "client_id": client_info.client_id, + "redirect_uri": self.client_metadata.redirect_uris[0], + "state": secrets.token_urlsafe(32), + "code_challenge": self._code_challenge, + "code_challenge_method": "S256", + } + + if hasattr(client_info, "scope") and client_info.scope: + auth_params["scope"] = client_info.scope + elif self._metadata and self._metadata.scopes_supported: + # Use "user" if available, otherwise the first supported scope + if "user" in self._metadata.scopes_supported: + auth_params["scope"] = "user" + else: + auth_params["scope"] = self._metadata.scopes_supported[0] + elif self.client_metadata.scope: + auth_params["scope"] = self.client_metadata.scope + + from urllib.parse import urlencode + + auth_url = f"{auth_url_base}?{urlencode(auth_params)}" + + # Handle redirect (open browser or custom handler) + await self.redirect_handler(auth_url) + + # Wait for callback using the provided callback handler + if not self.callback_handler: + raise Exception("No callback handler provided for OAuth flow") + + auth_code, returned_state = await self.callback_handler() + + # Validate state parameter + if returned_state != auth_params["state"]: + raise Exception("State parameter mismatch - possible CSRF attack") + + if not auth_code: + raise Exception("No authorization code received") + + # Exchange code for token + await self._exchange_code_for_token(auth_code, client_info) + + async def _exchange_code_for_token( + self, auth_code: str, client_info: OAuthClientInformationFull + ) -> None: + """Exchange authorization code for access token.""" + # Determine token endpoint + if self._metadata and self._metadata.token_endpoint: + token_url = str(self._metadata.token_endpoint) + else: + token_url = urljoin(self.server_url, "/token") + + token_data = { + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": str(self.client_metadata.redirect_uris[0]), + "client_id": client_info.client_id, + "code_verifier": self._code_verifier, + } + + if client_info.client_secret: + token_data["client_secret"] = client_info.client_secret + + async with httpx.AsyncClient() as client: + response = await client.post( + token_url, + data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30.0, + ) + + if response.status_code != 200: + raise Exception( + f"Token exchange failed: {response.status_code} {response.text}" + ) + + # Parse and store tokens + token_response = OAuthToken.model_validate(response.json()) + + # Calculate expiry time if available + if token_response.expires_in: + self._token_expiry_time = time.time() + token_response.expires_in + else: + self._token_expiry_time = None + + # Store tokens in storage and cache + await self.storage.set_tokens(token_response) + self._current_tokens = token_response + + print("Successfully obtained access token!") + + async def _refresh_access_token(self) -> bool: + """Refresh the access token using refresh token.""" + if not self._current_tokens or not self._current_tokens.refresh_token: + return False + + # Get client info + client_info = await self._get_or_register_client() + + # Determine token endpoint + if self._metadata and self._metadata.token_endpoint: + token_url = str(self._metadata.token_endpoint) + else: + token_url = urljoin(self.server_url, "/token") + + refresh_data = { + "grant_type": "refresh_token", + "refresh_token": self._current_tokens.refresh_token, + "client_id": client_info.client_id, + } + + if client_info.client_secret: + refresh_data["client_secret"] = client_info.client_secret + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + token_url, + data=refresh_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30.0, + ) + + if response.status_code != 200: + print(f"Token refresh failed: {response.status_code}") + return False + + # Parse and store new tokens + token_response = OAuthToken.model_validate(response.json()) + + # Calculate expiry time if available + if token_response.expires_in: + self._token_expiry_time = time.time() + token_response.expires_in + else: + self._token_expiry_time = None + + # Store tokens in storage and cache + await self.storage.set_tokens(token_response) + self._current_tokens = token_response + + print("Successfully refreshed access token!") + return True + + except Exception as e: + print(f"Token refresh error: {e}") + return False + + +# Maintain compatibility with existing OAuthAuth class +OAuthAuth = ProductionOAuth2Auth diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 1e83227d2..fb6c99273 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -19,7 +19,6 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import EventSource, ServerSentEvent, aconnect_sse -from mcp.client.auth import OAuthClientProvider, UnauthorizedError, auth from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( @@ -84,7 +83,7 @@ def __init__( headers: dict[str, Any] | None = None, timeout: timedelta = timedelta(seconds=30), sse_read_timeout: timedelta = timedelta(seconds=60 * 5), - auth_provider: OAuthClientProvider | None = None, + auth: httpx.Auth | None = None, ) -> None: """Initialize the StreamableHTTP transport. @@ -93,12 +92,13 @@ def __init__( headers: Optional headers to include in requests. timeout: HTTP timeout for regular operations. sse_read_timeout: Timeout for SSE read operations. + auth: Optional HTTPX authentication handler. """ self.url = url self.headers = headers or {} self.timeout = timeout self.sse_read_timeout = sse_read_timeout - self.auth_provider = auth_provider + self.auth = auth self.session_id: str | None = None self.request_headers = { ACCEPT: f"{JSON}, {SSE}", @@ -109,15 +109,9 @@ def __init__( async def _update_headers_with_session( self, base_headers: dict[str, str] ) -> dict[str, str]: - """Update headers with session ID and auth if available.""" + """Update headers with session ID.""" headers = base_headers.copy() - # Add OAuth authorization header if available - if self.auth_provider: - tokens = await self.auth_provider.tokens() - if tokens: - headers["Authorization"] = f"Bearer {tokens.access_token}" - # Add session ID if available if self.session_id: headers[MCP_SESSION_ID] = self.session_id @@ -185,15 +179,6 @@ async def _handle_sse_event( logger.warning(f"Unknown SSE event: {sse.event}") return False - async def _auth_then_retry(self) -> None: - """Perform OAuth authentication flow.""" - if not self.auth_provider: - raise UnauthorizedError("No auth provider") - - result = await auth(self.auth_provider, server_url=self.url) - if result != "AUTHORIZED": - raise UnauthorizedError() - async def handle_get_stream( self, client: httpx.AsyncClient, @@ -215,12 +200,6 @@ async def handle_get_stream( self.timeout.seconds, read=self.sse_read_timeout.seconds ), ) as event_source: - if event_source.response.status_code == 401 and self.auth_provider: - # Need to authenticate - await self._auth_then_retry() - # Re-attempt the GET stream after authentication - return await self.handle_get_stream(client, read_stream_writer) - event_source.response.raise_for_status() logger.debug("GET SSE connection established") @@ -252,12 +231,6 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: self.timeout.seconds, read=ctx.sse_read_timeout.seconds ), ) as event_source: - if event_source.response.status_code == 401 and self.auth_provider: - # Need to authenticate - await self._auth_then_retry() - # Re-attempt the resumption request after authentication - return await self._handle_resumption_request(ctx) - event_source.response.raise_for_status() logger.debug("Resumption GET SSE connection established") @@ -287,21 +260,6 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: logger.debug("Received 202 Accepted") return - if response.status_code == 401 and self.auth_provider: - # Need to authenticate - try: - await self._auth_then_retry() - # Retry the request after authentication - return await self._handle_post_request(ctx) - except Exception as exc: - logger.error(f"Auth retry failed: {exc}") - if isinstance(message.root, JSONRPCRequest): - await self._send_session_terminated_error( - ctx.read_stream_writer, - message.root.id, - ) - return - if response.status_code == 404: if isinstance(message.root, JSONRPCRequest): await self._send_session_terminated_error( @@ -466,24 +424,6 @@ def get_session_id(self) -> str | None: """Get the current session ID.""" return self.session_id - async def finish_auth(self, authorization_code: str) -> None: - """ - Call this method after the user has finished authorizing via their user agent - and is redirected back to the MCP client application. This will exchange the - authorization code for an access token, enabling the next connection attempt - to successfully auth. - """ - if not self.auth_provider: - raise UnauthorizedError("No auth provider") - - result = await auth( - self.auth_provider, - server_url=self.url, - authorization_code=authorization_code, - ) - if result != "AUTHORIZED": - raise UnauthorizedError("Failed to authorize") - @asynccontextmanager async def streamablehttp_client( @@ -492,7 +432,7 @@ async def streamablehttp_client( timeout: timedelta = timedelta(seconds=30), sse_read_timeout: timedelta = timedelta(seconds=60 * 5), terminate_on_close: bool = True, - auth_provider: OAuthClientProvider | None = None, + auth: httpx.Auth | None = None, ) -> AsyncGenerator[ tuple[ MemoryObjectReceiveStream[SessionMessage | Exception], @@ -513,7 +453,7 @@ async def streamablehttp_client( timeout: HTTP request timeout sse_read_timeout: SSE read timeout terminate_on_close: Whether to terminate session on close - auth_provider: Optional OAuth client provider for authentication + auth: Optional HTTPX authentication handler Yields: Tuple containing: @@ -521,9 +461,7 @@ async def streamablehttp_client( - write_stream: Stream for sending messages to the server - get_session_id_callback: Function to retrieve the current session ID """ - transport = StreamableHTTPTransport( - url, headers, timeout, sse_read_timeout, auth_provider - ) + transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout, auth) read_stream_writer, read_stream = anyio.create_memory_object_stream[ SessionMessage | Exception @@ -541,6 +479,7 @@ async def streamablehttp_client( timeout=httpx.Timeout( transport.timeout.seconds, read=transport.sse_read_timeout.seconds ), + auth=transport.auth, ) as client: # Define callbacks that need access to tg def start_get_stream() -> None: diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 95080bde1..5240c970c 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -10,6 +10,7 @@ def create_mcp_http_client( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, ) -> httpx.AsyncClient: """Create a standardized httpx AsyncClient with MCP defaults. @@ -21,6 +22,7 @@ def create_mcp_http_client( headers: Optional headers to include with all requests. timeout: Request timeout as httpx.Timeout object. Defaults to 30 seconds if not specified. + auth: Optional authentication handler. Returns: Configured httpx.AsyncClient instance with MCP defaults. @@ -43,6 +45,12 @@ def create_mcp_http_client( timeout = httpx.Timeout(60.0, read=300.0) async with create_mcp_http_client(headers, timeout) as client: response = await client.get("/long-request") + + # With authentication + from httpx import BasicAuth + auth = BasicAuth(username="user", password="pass") + async with create_mcp_http_client(headers, timeout, auth) as client: + response = await client.get("/protected-endpoint") """ # Set MCP defaults kwargs: dict[str, Any] = { @@ -59,4 +67,8 @@ def create_mcp_http_client( if headers is not None: kwargs["headers"] = headers + # Handle authentication + if auth is not None: + kwargs["auth"] = auth + return httpx.AsyncClient(**kwargs) From ba05092fc32fadab17d124af19ccac2752292e8c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 23:15:35 +0100 Subject: [PATCH 06/19] clean up --- .../mcp_simple_auth_client/main.py | 148 +---- src/mcp/client/auth.py | 518 ------------------ src/mcp/client/oauth_auth.py | 4 +- src/mcp/client/oauth_providers.py | 161 ------ src/mcp/client/sse.py | 97 +--- 5 files changed, 18 insertions(+), 910 deletions(-) delete mode 100644 src/mcp/client/auth.py delete mode 100644 src/mcp/client/oauth_providers.py diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index e8eb4c0f4..270dcc9c2 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -7,24 +7,18 @@ """ import asyncio -import json import os import threading import time -import webbrowser from datetime import timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlparse -from mcp.client.auth import ( - OAuthClientProvider, - discover_oauth_metadata, -) from mcp.client.oauth_auth import OAuthAuth from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.auth import OAuthClientMetadata class CallbackHandler(BaseHTTPRequestHandler): @@ -114,141 +108,14 @@ def wait_for_callback(self, timeout=300): raise Exception("Timeout waiting for OAuth callback") -class JsonSerializableOAuthClientMetadata(OAuthClientMetadata): - """OAuth client metadata that handles JSON serialization properly.""" - - def model_dump(self, **kwargs) -> dict[str, Any]: - """Override to ensure URLs are serialized as strings and exclude null values.""" - # Exclude null values by default - kwargs.setdefault("exclude_none", True) - data = super().model_dump(**kwargs) - - # Convert AnyHttpUrl objects to strings - if "redirect_uris" in data: - data["redirect_uris"] = [str(url) for url in data["redirect_uris"]] - - # Debug: print what we're sending - print(f"šŸ› Client metadata being sent: {json.dumps(data, indent=2)}") - return data - - -class SimpleOAuthProvider(OAuthClientProvider): - """Simple OAuth client provider for demonstration purposes.""" - - def __init__(self, server_url: str, callback_port: int = 3000): - self._callback_port = callback_port - self._redirect_uri = f"http://localhost:{callback_port}/callback" - self._server_url = server_url - self._callback_server = None - print(f"šŸ› OAuth provider initialized with redirect URI: {self._redirect_uri}") - # Store the raw data for easy serialization - scope will be updated dynamically - self._client_metadata_dict = { - "client_name": "Simple Auth Client", - "redirect_uris": [self._redirect_uri], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "client_secret_post", # Use client secret - "scope": "read", # Default scope, will be updated - } - self._client_info: OAuthClientInformationFull | None = None - self._tokens: OAuthToken | None = None - self._code_verifier: str | None = None - self._authorization_code: str | None = None - self._metadata_discovered = False - - @property - def redirect_url(self) -> str: - return self._redirect_uri - - async def _discover_and_update_metadata(self): - """Discover server OAuth metadata and update client scope accordingly.""" - if self._metadata_discovered: - return - - try: - print("šŸ› Discovering OAuth metadata...") - metadata = await discover_oauth_metadata(self._server_url) - if metadata and metadata.scopes_supported: - scope = " ".join(metadata.scopes_supported) - self._client_metadata_dict["scope"] = scope - print(f"šŸ› Updated scope to: {scope}") - self._metadata_discovered = True - except Exception as e: - print(f"šŸ› Failed to discover metadata: {e}, using default scope") - self._metadata_discovered = True - - @property - def client_metadata(self) -> OAuthClientMetadata: - # Create a fresh instance each time using our custom serializable version - return JsonSerializableOAuthClientMetadata.model_validate( - self._client_metadata_dict - ) - - async def client_information(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def save_client_information( - self, client_information: OAuthClientInformationFull - ) -> None: - self._client_info = client_information - print(f"Saved client information: {client_information.client_id}") - - async def tokens(self) -> OAuthToken | None: - return self._tokens - - async def save_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - print(f"Saved OAuth tokens: {tokens.access_token[:10]}...") - - async def redirect_to_authorization(self, authorization_url: str) -> None: - # Start callback server - self._callback_server = CallbackServer(self._callback_port) - self._callback_server.start() - - print("\n🌐 Opening authorization URL in your default browser...") - print(f"URL: {authorization_url}") - webbrowser.open(authorization_url) - - print("ā³ Waiting for authorization callback...") - print("(Complete the authorization in your browser)") - - try: - # Wait for the callback with authorization code - authorization_code = self._callback_server.wait_for_callback(timeout=300) - print(f"āœ… Received authorization code: {authorization_code[:20]}...") - - # Store the authorization code so auth() can handle token exchange - self._authorization_code = authorization_code - print("šŸŽ‰ OAuth callback received successfully!") - - except Exception as e: - print(f"āŒ OAuth flow failed: {e}") - raise - finally: - # Always stop the callback server - if self._callback_server: - self._callback_server.stop() - self._callback_server = None - - async def save_code_verifier(self, code_verifier: str) -> None: - self._code_verifier = code_verifier - - async def code_verifier(self) -> str: - if self._code_verifier is None: - raise ValueError("No code verifier available") - return self._code_verifier - - class SimpleAuthClient: """Simple MCP client with auth support.""" def __init__(self, server_url: str): self.server_url = server_url # Extract base URL for auth server (remove /mcp endpoint for auth endpoints) - auth_server_url = server_url.replace("/mcp", "") # Use default redirect URI - this is where the auth server will redirect the user # The user will need to copy the authorization code from this callback URL - self.auth_provider = SimpleOAuthProvider(auth_server_url) self.session: ClientSession | None = None async def connect(self): @@ -269,10 +136,21 @@ async def callback_handler() -> tuple[str, str | None]: finally: callback_server.stop() + client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": ["http://localhost:3000/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_post", # Use client secret + "scope": "read", # Default scope, will be updated + } + # Create OAuth authentication handler using the new interface oauth_auth = OAuthAuth( server_url=self.server_url.replace("/mcp", ""), - client_metadata=self.auth_provider.client_metadata, + client_metadata=OAuthClientMetadata.model_validate( + client_metadata_dict + ), storage=None, # Use in-memory storage redirect_handler=None, # Use default (open browser) callback_handler=callback_handler, diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py deleted file mode 100644 index ef1ceb380..000000000 --- a/src/mcp/client/auth.py +++ /dev/null @@ -1,518 +0,0 @@ -""" -OAuth client implementation for MCP Python SDK. - -This module provides an end-to-end OAuth client to be used with MCP servers, -implementing the OAuth 2.0 authorization code flow with PKCE. -""" - -import base64 -import hashlib -import logging -import secrets -import string -from typing import Literal, Protocol, TypeVar, runtime_checkable -from urllib.parse import urlencode, urljoin, urlparse, urlunparse - -import httpx - -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthClientMetadata, - OAuthMetadata, - OAuthToken, -) -from mcp.types import LATEST_PROTOCOL_VERSION - -# Type variable to represent implementation of OAuthClientProvider -T = TypeVar("T", bound="OAuthClientProvider") - -logger = logging.getLogger(__name__) - - -class UnauthorizedError(Exception): - """Raised when OAuth authorization fails or is required.""" - - def __init__(self, message: str = "Unauthorized"): - super().__init__(message) - self.message = message - - -@runtime_checkable -class OAuthClientProvider(Protocol): - """ - Protocol for OAuth client providers to be used with MCP servers. - - This provider relies upon a concept of an authorized "session," the exact - meaning of which is application-defined. Tokens, authorization codes, and - code verifiers should not cross different sessions. - """ - - @property - def redirect_url(self) -> str: - """The URL to redirect the user agent to after authorization.""" - ... - - @property - def client_metadata(self) -> OAuthClientMetadata: - """Metadata about this OAuth client.""" - ... - - async def client_information(self) -> OAuthClientInformationFull | None: - """ - Loads information about this OAuth client, as registered already with the - server, or returns None if the client is not registered with the server. - """ - ... - - async def save_client_information( - self, client_information: OAuthClientInformationFull - ) -> None: - """ - If implemented, this permits the OAuth client to dynamically register with - the server. Client information saved this way should later be read via - client_information(). - - This method is not required to be implemented if client information is - statically known (e.g., pre-registered). - """ - ... - - async def tokens(self) -> OAuthToken | None: - """ - Loads any existing OAuth tokens for the current session, or returns - None if there are no saved tokens. - """ - ... - - async def save_tokens(self, tokens: OAuthToken) -> None: - """ - Stores new OAuth tokens for the current session, after a successful - authorization. - """ - ... - - async def redirect_to_authorization(self, authorization_url: str) -> None: - """ - Invoked to redirect the user agent to the given URL - to begin the authorization flow. - """ - ... - - async def save_code_verifier(self, code_verifier: str) -> None: - """ - Saves a PKCE code verifier for the current session, before redirecting to - the authorization flow. - """ - ... - - async def code_verifier(self) -> str: - """ - Loads the PKCE code verifier for the current session, necessary to validate - the authorization result. - """ - ... - - -class AuthResult: - """Result of an OAuth authorization attempt.""" - - AUTHORIZED = "AUTHORIZED" - REDIRECT = "REDIRECT" - - -def _generate_code_verifier() -> str: - """Generate a cryptographically random code verifier for PKCE.""" - return "".join( - secrets.choice(string.ascii_letters + string.digits + "-._~") - for _ in range(128) - ) - - -def _generate_code_challenge(code_verifier: str) -> str: - """Generate a code challenge from a code verifier using SHA256.""" - digest = hashlib.sha256(code_verifier.encode()).digest() - return base64.urlsafe_b64encode(digest).decode().rstrip("=") - - -async def auth( - provider: OAuthClientProvider, - *, - server_url: str, - authorization_code: str | None = None, - scope: str | None = None, -) -> Literal["AUTHORIZED", "REDIRECT"]: - """ - Orchestrates the full auth flow with a server. - - This can be used as a single entry point for all authorization functionality, - instead of linking together the other lower-level functions in this module. - - Args: - provider: OAuth client provider implementation - server_url: URL of the MCP server - authorization_code: Optional authorization code from redirect - scope: Optional scope to request - - Returns: - AuthResult.AUTHORIZED if successful, AuthResult.REDIRECT if redirect needed - - Raises: - UnauthorizedError: If authorization fails - """ - metadata = await discover_oauth_metadata(server_url) - - # Handle client registration if needed - client_information = await provider.client_information() - if not client_information: - if authorization_code is not None: - raise ValueError( - "Existing OAuth client information is required " - "when exchanging an authorization code" - ) - - try: - save_client_info = provider.save_client_information - except AttributeError: - raise ValueError( - "OAuth client information must be saveable for dynamic registration" - ) - - full_information = await register_client( - server_url=server_url, - metadata=metadata, - client_metadata=provider.client_metadata, - ) - await save_client_info(full_information) - client_information = full_information - - # Exchange authorization code for tokens - if authorization_code is not None: - code_verifier = await provider.code_verifier() - tokens = await exchange_authorization( - server_url=server_url, - metadata=metadata, - client_information=client_information, - authorization_code=authorization_code, - code_verifier=code_verifier, - redirect_uri=provider.redirect_url, - ) - await provider.save_tokens(tokens) - return AuthResult.AUTHORIZED - - tokens = await provider.tokens() - - # Handle token refresh or new authorization - if tokens and tokens.refresh_token: - try: - # Attempt to refresh the token - new_tokens = await refresh_authorization( - server_url=server_url, - metadata=metadata, - client_information=client_information, - refresh_token=tokens.refresh_token, - ) - await provider.save_tokens(new_tokens) - return AuthResult.AUTHORIZED - except Exception as error: - # Log error but continue to start new authorization flow - logger.warning(f"Could not refresh OAuth tokens: {error}") - - # Start new authorization flow - authorization_url, code_verifier = await start_authorization( - server_url=server_url, - metadata=metadata, - client_information=client_information, - redirect_url=provider.redirect_url, - scope=scope or provider.client_metadata.scope, - ) - - await provider.save_code_verifier(code_verifier) - await provider.redirect_to_authorization(authorization_url) - return AuthResult.REDIRECT - - -async def discover_oauth_metadata( - server_url: str, - protocol_version: str = LATEST_PROTOCOL_VERSION, -) -> OAuthMetadata | None: - """ - Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. - - If the server returns a 404 for the well-known endpoint, this function will - return None. Any other errors will be thrown as exceptions. - - Args: - server_url: URL of the MCP server - protocol_version: MCP protocol version header - - Returns: - OAuth metadata if available, None if not supported - """ - url = urljoin(server_url, "/.well-known/oauth-authorization-server") - - headers = {"MCP-Protocol-Version": protocol_version} - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - except Exception: - # Try without MCP protocol version header for CORS issues - response = await client.get(url) - - if response.status_code == 404: - return None - - response.raise_for_status() - - try: - return OAuthMetadata.model_validate(response.json()) - except Exception as e: - raise ValueError(f"Invalid OAuth metadata: {e}") - - -async def start_authorization( - *, - server_url: str, - metadata: OAuthMetadata | None, - client_information: OAuthClientInformationFull, - redirect_url: str, - scope: str | None = None, -) -> tuple[str, str]: - """ - Begins the authorization flow with the given server, by generating a PKCE challenge - and constructing the authorization URL. - - Args: - server_url: URL of the MCP server - metadata: OAuth metadata (optional) - client_information: OAuth client information - redirect_url: Redirect URL for authorization - scope: Optional scope to request - - Returns: - Tuple of (authorization_url, code_verifier) - """ - response_type = "code" - code_challenge_method = "S256" - - if metadata: - authorization_url = str(metadata.authorization_endpoint) - - if response_type not in metadata.response_types_supported: - raise ValueError( - "Incompatible auth server: does not support response type" - f" {response_type}" - ) - - if ( - metadata.code_challenge_methods_supported is not None - and code_challenge_method not in metadata.code_challenge_methods_supported - ): - raise ValueError( - "Incompatible auth server: does not support code challenge method " - f"{code_challenge_method}" - ) - else: - authorization_url = urljoin(server_url, "/authorize") - - # Generate PKCE challenge - code_verifier = _generate_code_verifier() - code_challenge = _generate_code_challenge(code_verifier) - - # Build authorization URL with parameters - parsed = urlparse(authorization_url) - params = { - "response_type": response_type, - "client_id": client_information.client_id, - "code_challenge": code_challenge, - "code_challenge_method": code_challenge_method, - "redirect_uri": redirect_url, - } - - if scope: - params["scope"] = scope - - # Construct URL with query parameters - query = urlencode(params) - final_url = urlunparse( - ( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.params, - query, - parsed.fragment, - ) - ) - - return final_url, code_verifier - - -async def exchange_authorization( - *, - server_url: str, - metadata: OAuthMetadata | None, - client_information: OAuthClientInformationFull, - authorization_code: str, - code_verifier: str, - redirect_uri: str, -) -> OAuthToken: - """ - Exchanges an authorization code for an access token with the given server. - - Args: - server_url: URL of the MCP server - metadata: OAuth metadata (optional) - client_information: OAuth client information - authorization_code: Authorization code from redirect - code_verifier: PKCE code verifier - redirect_uri: Redirect URI used in authorization - - Returns: - OAuth tokens - """ - grant_type = "authorization_code" - - if metadata: - token_url = str(metadata.token_endpoint) - - if ( - metadata.grant_types_supported is not None - and grant_type not in metadata.grant_types_supported - ): - raise ValueError( - f"Incompatible auth server: does not support grant type {grant_type}" - ) - else: - token_url = urljoin(server_url, "/token") - - # Exchange code for tokens - data = { - "grant_type": grant_type, - "client_id": client_information.client_id, - "code": authorization_code, - "code_verifier": code_verifier, - "redirect_uri": redirect_uri, - } - - if client_information.client_secret: - data["client_secret"] = client_information.client_secret - - async with httpx.AsyncClient() as client: - response = await client.post( - token_url, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - - if not response.is_success: - raise Exception(f"Token exchange failed: HTTP {response.status_code}") - - try: - return OAuthToken.model_validate(response.json()) - except Exception as e: - raise ValueError(f"Invalid token response: {e}") - - -async def refresh_authorization( - *, - server_url: str, - metadata: OAuthMetadata | None, - client_information: OAuthClientInformationFull, - refresh_token: str, -) -> OAuthToken: - """ - Exchange a refresh token for an updated access token. - - Args: - server_url: URL of the MCP server - metadata: OAuth metadata (optional) - client_information: OAuth client information - refresh_token: Refresh token to exchange - - Returns: - New OAuth tokens - """ - grant_type = "refresh_token" - - if metadata: - token_url = str(metadata.token_endpoint) - - if ( - metadata.grant_types_supported is not None - and grant_type not in metadata.grant_types_supported - ): - raise ValueError( - f"Incompatible auth server: does not support grant type {grant_type}" - ) - else: - token_url = urljoin(server_url, "/token") - - # Exchange refresh token - data = { - "grant_type": grant_type, - "client_id": client_information.client_id, - "refresh_token": refresh_token, - } - - if client_information.client_secret: - data["client_secret"] = client_information.client_secret - - async with httpx.AsyncClient() as client: - response = await client.post( - token_url, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - - if not response.is_success: - raise Exception(f"Token refresh failed: HTTP {response.status_code}") - - try: - return OAuthToken.model_validate(response.json()) - except Exception as e: - raise ValueError(f"Invalid token response: {e}") - - -async def register_client( - *, - server_url: str, - metadata: OAuthMetadata | None, - client_metadata: OAuthClientMetadata, -) -> OAuthClientInformationFull: - """ - Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. - - Args: - server_url: URL of the MCP server - metadata: OAuth metadata (optional) - client_metadata: Client metadata for registration - - Returns: - Full client information after registration - """ - if metadata: - if not metadata.registration_endpoint: - raise ValueError( - "Incompatible auth server: does not support dynamic client registration" - ) - registration_url = str(metadata.registration_endpoint) - else: - registration_url = urljoin(server_url, "/register") - - async with httpx.AsyncClient() as client: - response = await client.post( - registration_url, - json=client_metadata.model_dump(), - headers={"Content-Type": "application/json"}, - ) - - if not response.is_success: - raise Exception( - f"Dynamic client registration failed: HTTP {response.status_code}" - ) - - try: - return OAuthClientInformationFull.model_validate(response.json()) - except Exception as e: - raise ValueError(f"Invalid client registration response: {e}") diff --git a/src/mcp/client/oauth_auth.py b/src/mcp/client/oauth_auth.py index 11639e4aa..69b86c6e4 100644 --- a/src/mcp/client/oauth_auth.py +++ b/src/mcp/client/oauth_auth.py @@ -134,7 +134,9 @@ async def register_oauth_client( registration_url = urljoin(server_url, "/register") # Prepare registration data and adjust scope based on server metadata - registration_data = client_metadata.model_dump(exclude_none=True) + registration_data = client_metadata.model_dump( + by_alias=True, mode="json", exclude_none=True + ) # If the server has supported scopes, use them instead of the requested scope if metadata and metadata.scopes_supported: diff --git a/src/mcp/client/oauth_providers.py b/src/mcp/client/oauth_providers.py deleted file mode 100644 index e92e5b632..000000000 --- a/src/mcp/client/oauth_providers.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -Implementations of OAuthClientProvider for common use cases. -""" - -import json -import webbrowser - -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryOAuthProvider: - """ - A simple in-memory OAuth provider for development and testing. - - This provider stores all OAuth data in memory and will lose state - when the application restarts. For production use, implement a - persistent storage solution. - """ - - def __init__( - self, - redirect_url: str, - client_metadata: OAuthClientMetadata, - ): - self._redirect_url = redirect_url - self._client_metadata = client_metadata - self._client_information: OAuthClientInformationFull | None = None - self._tokens: OAuthToken | None = None - self._code_verifier: str | None = None - - @property - def redirect_url(self) -> str: - """The URL to redirect the user agent to after authorization.""" - return self._redirect_url - - @property - def client_metadata(self) -> OAuthClientMetadata: - """Metadata about this OAuth client.""" - return self._client_metadata - - async def client_information(self) -> OAuthClientInformationFull | None: - """ - Loads information about this OAuth client, as registered already with the - server, or returns None if the client is not registered with the server. - """ - return self._client_information - - async def save_client_information( - self, client_information: OAuthClientInformationFull - ) -> None: - """ - Saves client information after dynamic registration. - """ - self._client_information = client_information - - async def tokens(self) -> OAuthToken | None: - """ - Loads any existing OAuth tokens for the current session, or returns - None if there are no saved tokens. - """ - return self._tokens - - async def save_tokens(self, tokens: OAuthToken) -> None: - """ - Stores new OAuth tokens for the current session, after a successful - authorization. - """ - self._tokens = tokens - - async def redirect_to_authorization(self, authorization_url: str) -> None: - """ - Opens the authorization URL in the default web browser. - """ - print(f"Opening authorization URL: {authorization_url}") - webbrowser.open(authorization_url) - - async def save_code_verifier(self, code_verifier: str) -> None: - """ - Saves a PKCE code verifier for the current session. - """ - self._code_verifier = code_verifier - - async def code_verifier(self) -> str: - """ - Loads the PKCE code verifier for the current session. - """ - if self._code_verifier is None: - raise ValueError("No code verifier available") - return self._code_verifier - - -class FileBasedOAuthProvider(InMemoryOAuthProvider): - """ - OAuth provider that persists tokens and client information to files. - - This is suitable for development and simple applications where - file-based persistence is acceptable. - """ - - def __init__( - self, - redirect_url: str, - client_metadata: OAuthClientMetadata, - tokens_file: str = "oauth_tokens.json", - client_info_file: str = "oauth_client_info.json", - ): - super().__init__(redirect_url, client_metadata) - self._tokens_file = tokens_file - self._client_info_file = client_info_file - - # Load existing data on initialization - self._load_client_information() - self._load_tokens() - - def _load_tokens(self) -> None: - """Load tokens from file if it exists.""" - try: - with open(self._tokens_file) as f: - data = json.load(f) - self._tokens = OAuthToken.model_validate(data) - except (FileNotFoundError, json.JSONDecodeError): - self._tokens = None - - def _save_tokens_to_file(self) -> None: - """Save tokens to file.""" - if self._tokens: - with open(self._tokens_file, "w") as f: - json.dump(self._tokens.model_dump(), f, indent=2) - - def _load_client_information(self) -> None: - """Load client information from file if it exists.""" - try: - with open(self._client_info_file) as f: - data = json.load(f) - self._client_information = OAuthClientInformationFull.model_validate( - data - ) - except (FileNotFoundError, json.JSONDecodeError): - self._client_information = None - - def _save_client_information_to_file(self) -> None: - """Save client information to file.""" - if self._client_information: - with open(self._client_info_file, "w") as f: - json.dump(self._client_information.model_dump(), f, indent=2) - - async def save_tokens(self, tokens: OAuthToken) -> None: - """ - Stores new OAuth tokens and saves them to file. - """ - await super().save_tokens(tokens) - self._save_tokens_to_file() - - async def save_client_information( - self, client_information: OAuthClientInformationFull - ) -> None: - """ - Saves client information and saves it to file. - """ - await super().save_client_information(client_information) - self._save_client_information_to_file() diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 9d66f58a5..29195cbd9 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -10,7 +10,6 @@ from httpx_sse import aconnect_sse import mcp.types as types -from mcp.client.auth import OAuthClientProvider, UnauthorizedError, auth from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import SessionMessage @@ -21,47 +20,18 @@ def remove_request_params(url: str) -> str: return urljoin(url, urlparse(url).path) -async def finish_auth( - url: str, - auth_provider: OAuthClientProvider, - authorization_code: str, -) -> None: - """ - Call this method after the user has finished authorizing via their user agent - and is redirected back to the MCP client application. This will exchange the - authorization code for an access token, enabling the next connection attempt - to successfully auth. - """ - if not auth_provider: - raise UnauthorizedError("No auth provider") - - result = await auth( - auth_provider, server_url=url, authorization_code=authorization_code - ) - if result != "AUTHORIZED": - raise UnauthorizedError("Failed to authorize") - - @asynccontextmanager async def sse_client( url: str, headers: dict[str, Any] | None = None, timeout: float = 5, sse_read_timeout: float = 60 * 5, - auth_provider: OAuthClientProvider | None = None, ): """ Client transport for SSE. `sse_read_timeout` determines how long (in seconds) the client will wait for a new event before disconnecting. All other HTTP operations are controlled by `timeout`. - - Args: - url: SSE endpoint URL - headers: Optional HTTP headers - timeout: HTTP request timeout in seconds - sse_read_timeout: SSE read timeout in seconds - auth_provider: Optional OAuth client provider for authentication """ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] @@ -72,59 +42,17 @@ async def sse_client( read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - async def _auth_then_retry() -> None: - """Perform OAuth authentication flow.""" - if not auth_provider: - raise UnauthorizedError("No auth provider") - - result = await auth(auth_provider, server_url=url) - if result != "AUTHORIZED": - raise UnauthorizedError() - - async def _get_headers() -> dict[str, Any]: - """Get headers with OAuth authorization if available.""" - auth_headers = {} - if auth_provider: - tokens = await auth_provider.tokens() - if tokens: - auth_headers["Authorization"] = f"Bearer {tokens.access_token}" - - return {**(headers or {}), **auth_headers} - async with anyio.create_task_group() as tg: try: logger.info(f"Connecting to SSE endpoint: {remove_request_params(url)}") - async with create_mcp_http_client(headers=await _get_headers()) as client: + async with create_mcp_http_client(headers=headers) as client: async with aconnect_sse( client, "GET", url, timeout=httpx.Timeout(timeout, read=sse_read_timeout), ) as event_source: - # Handle OAuth authentication errors - if event_source.response.status_code == 401 and auth_provider: - try: - await _auth_then_retry() - # Retry connection with new auth headers - async with create_mcp_http_client( - headers=await _get_headers() - ) as retry_client: - async with aconnect_sse( - retry_client, - "GET", - url, - timeout=httpx.Timeout( - timeout, read=sse_read_timeout - ), - ) as retry_event_source: - retry_event_source.response.raise_for_status() - event_source = retry_event_source - except Exception as exc: - logger.error(f"Auth retry failed: {exc}") - raise - else: - event_source.response.raise_for_status() - + event_source.response.raise_for_status() logger.debug("SSE connection established") async def sse_reader( @@ -190,7 +118,6 @@ async def post_writer(endpoint_url: str): logger.debug( f"Sending client message: {session_message}" ) - post_headers = await _get_headers() response = await client.post( endpoint_url, json=session_message.message.model_dump( @@ -198,27 +125,7 @@ async def post_writer(endpoint_url: str): mode="json", exclude_none=True, ), - headers=post_headers, ) - - # Handle OAuth authentication errors - if response.status_code == 401 and auth_provider: - try: - await _auth_then_retry() - # Retry with new auth headers - retry_headers = await _get_headers() - response = await client.post( - endpoint_url, - json=session_message.message.model_dump( - by_alias=True, - mode="json", - exclude_none=True, - ), - headers=retry_headers, - ) - except Exception as exc: - logger.error(f"Auth retry failed: {exc}") - response.raise_for_status() logger.debug( "Client message sent successfully: " From ebd4a70d8ad6cd0ff7dea2e786443cc8fedb15e1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 23:19:37 +0100 Subject: [PATCH 07/19] clean up shttp --- src/mcp/client/streamable_http.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index fb6c99273..79b2995e1 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -106,13 +106,11 @@ def __init__( **self.headers, } - async def _update_headers_with_session( + def _update_headers_with_session( self, base_headers: dict[str, str] ) -> dict[str, str]: - """Update headers with session ID.""" + """Update headers with session ID if available.""" headers = base_headers.copy() - - # Add session ID if available if self.session_id: headers[MCP_SESSION_ID] = self.session_id return headers @@ -189,7 +187,7 @@ async def handle_get_stream( if not self.session_id: return - headers = await self._update_headers_with_session(self.request_headers) + headers = self._update_headers_with_session(self.request_headers) async with aconnect_sse( client, @@ -211,7 +209,7 @@ async def handle_get_stream( async def _handle_resumption_request(self, ctx: RequestContext) -> None: """Handle a resumption request using GET with SSE.""" - headers = await self._update_headers_with_session(ctx.headers) + headers = self._update_headers_with_session(ctx.headers) if ctx.metadata and ctx.metadata.resumption_token: headers[LAST_EVENT_ID] = ctx.metadata.resumption_token else: @@ -246,7 +244,7 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" - headers = await self._update_headers_with_session(ctx.headers) + headers = self._update_headers_with_session(ctx.headers) message = ctx.session_message.message is_initialization = self._is_initialization_request(message) @@ -410,7 +408,7 @@ async def terminate_session(self, client: httpx.AsyncClient) -> None: return try: - headers = await self._update_headers_with_session(self.request_headers) + headers = self._update_headers_with_session(self.request_headers) response = await client.delete(self.url, headers=headers) if response.status_code == 405: @@ -447,14 +445,6 @@ async def streamablehttp_client( `sse_read_timeout` determines how long (in seconds) the client will wait for a new event before disconnecting. All other HTTP operations are controlled by `timeout`. - Args: - url: StreamableHTTP endpoint URL - headers: Optional HTTP headers - timeout: HTTP request timeout - sse_read_timeout: SSE read timeout - terminate_on_close: Whether to terminate session on close - auth: Optional HTTPX authentication handler - Yields: Tuple containing: - read_stream: Stream for reading messages from the server From 91f0afbf7beaa3bdd5dd84b21c426efd752b7fda Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 16 May 2025 23:31:14 +0100 Subject: [PATCH 08/19] refactor --- .../mcp_simple_auth_client/main.py | 2 +- src/mcp/client/{oauth_auth.py => auth.py} | 49 ++---------------- src/mcp/client/token_storage.py | 50 +++++++++++++++++++ 3 files changed, 54 insertions(+), 47 deletions(-) rename src/mcp/client/{oauth_auth.py => auth.py} (92%) create mode 100644 src/mcp/client/token_storage.py diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 270dcc9c2..be50912ed 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -15,7 +15,7 @@ from typing import Any from urllib.parse import parse_qs, urlparse -from mcp.client.oauth_auth import OAuthAuth +from mcp.client.auth import OAuthAuth from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientMetadata diff --git a/src/mcp/client/oauth_auth.py b/src/mcp/client/auth.py similarity index 92% rename from src/mcp/client/oauth_auth.py rename to src/mcp/client/auth.py index 69b86c6e4..d8b51f104 100644 --- a/src/mcp/client/oauth_auth.py +++ b/src/mcp/client/auth.py @@ -2,7 +2,8 @@ Production-ready OAuth2 Authentication implementation for HTTPX using anyio. This module provides a complete OAuth 2.0 authentication implementation -that handles authorization code flow with PKCE, automatic token refresh and proper error handling. +that handles authorization code flow with PKCE, +automatic token refresh and proper error handling. The callback server implementation should be handled by the calling code. """ @@ -12,13 +13,13 @@ import string import time import webbrowser -from abc import ABC, abstractmethod from collections.abc import AsyncGenerator, Awaitable, Callable from urllib.parse import urljoin import anyio import httpx +from mcp.client.token_storage import InMemoryTokenStorage, TokenStorage from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -28,50 +29,6 @@ from mcp.types import LATEST_PROTOCOL_VERSION -class TokenStorage(ABC): - """Abstract base class for token storage implementations.""" - - @abstractmethod - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - pass - - @abstractmethod - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - pass - - @abstractmethod - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - pass - - @abstractmethod - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - pass - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage implementation.""" - - def __init__(self): - self._tokens: OAuthToken | None = None - self._client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self._tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self._client_info = client_info - - async def discover_oauth_metadata(server_url: str) -> OAuthMetadata | None: """ Discovers OAuth metadata from the server's well-known endpoint. diff --git a/src/mcp/client/token_storage.py b/src/mcp/client/token_storage.py new file mode 100644 index 000000000..0d3fd7a57 --- /dev/null +++ b/src/mcp/client/token_storage.py @@ -0,0 +1,50 @@ +""" +Token storage implementations for OAuth authentication. + +This module provides protocol and concrete implementations for storing +OAuth tokens and client information. +""" + +from typing import Protocol + +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class TokenStorage(Protocol): + """Protocol for token storage implementations.""" + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + ... + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + ... + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + ... + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + ... + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info From 8f0411289f37e49400f20c5972e4030115f3f1a1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 18 May 2025 15:35:59 +0100 Subject: [PATCH 09/19] refactor --- .../mcp_simple_auth_client/main.py | 36 ++- src/mcp/client/auth.py | 290 +++++++++--------- src/mcp/client/token_storage.py | 50 --- 3 files changed, 172 insertions(+), 204 deletions(-) delete mode 100644 src/mcp/client/token_storage.py diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index be50912ed..e98c4f2ed 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -14,11 +14,32 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlparse +import webbrowser -from mcp.client.auth import OAuthAuth +from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.auth import OAuthClientMetadata +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info class CallbackHandler(BaseHTTPRequestHandler): @@ -145,14 +166,19 @@ async def callback_handler() -> tuple[str, str | None]: "scope": "read", # Default scope, will be updated } + async def _default_redirect_handler(authorization_url: str) -> None: + """Default redirect handler that opens the URL in a browser.""" + print(f"Opening browser for authorization: {authorization_url}") + webbrowser.open(authorization_url) + # Create OAuth authentication handler using the new interface - oauth_auth = OAuthAuth( + oauth_auth = OAuthClientProvider( server_url=self.server_url.replace("/mcp", ""), client_metadata=OAuthClientMetadata.model_validate( client_metadata_dict ), - storage=None, # Use in-memory storage - redirect_handler=None, # Use default (open browser) + storage=InMemoryTokenStorage(), + redirect_handler=_default_redirect_handler, callback_handler=callback_handler, ) diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index d8b51f104..da3e13baf 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -9,17 +9,17 @@ import base64 import hashlib +import logging import secrets import string import time -import webbrowser from collections.abc import AsyncGenerator, Awaitable, Callable -from urllib.parse import urljoin +from typing import Protocol +from urllib.parse import urlencode, urljoin import anyio import httpx -from mcp.client.token_storage import InMemoryTokenStorage, TokenStorage from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -28,117 +28,32 @@ ) from mcp.types import LATEST_PROTOCOL_VERSION +logger = logging.getLogger(__name__) -async def discover_oauth_metadata(server_url: str) -> OAuthMetadata | None: - """ - Discovers OAuth metadata from the server's well-known endpoint. - - Args: - server_url: Base URL of the OAuth server - - Returns: - OAuthMetadata if found, None otherwise - """ - url = urljoin(server_url, "/.well-known/oauth-authorization-server") - headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - if response.status_code == 404: - return None - response.raise_for_status() - metadata_json = response.json() - print(f"OAuth metadata discovered: {metadata_json}") - return OAuthMetadata.model_validate(metadata_json) - except Exception: - # Try without MCP protocol version header for CORS issues - try: - response = await client.get(url) - if response.status_code == 404: - return None - response.raise_for_status() - metadata_json = response.json() - print(f"OAuth metadata discovered (no MCP header): {metadata_json}") - return OAuthMetadata.model_validate(metadata_json) - except Exception as e: - print(f"Failed to discover OAuth metadata: {e}") - return None - - -async def register_oauth_client( - server_url: str, - client_metadata: OAuthClientMetadata, - metadata: OAuthMetadata | None = None, -) -> OAuthClientInformationFull: - """ - Registers an OAuth client with the server. - - Args: - server_url: Base URL of the OAuth server - client_metadata: Client metadata for registration - metadata: Optional OAuth metadata (will be discovered if not provided) - - Returns: - Registered client information - """ - if not metadata: - metadata = await discover_oauth_metadata(server_url) - - if metadata and metadata.registration_endpoint: - registration_url = str(metadata.registration_endpoint) - else: - registration_url = urljoin(server_url, "/register") - - # Prepare registration data and adjust scope based on server metadata - registration_data = client_metadata.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - - # If the server has supported scopes, use them instead of the requested scope - if metadata and metadata.scopes_supported: - # Use the first supported scope or "user" if available - if "user" in metadata.scopes_supported: - registration_data["scope"] = "user" - else: - registration_data["scope"] = metadata.scopes_supported[0] - print(f"Adjusted scope to server-supported: {registration_data['scope']}") - print(f"Attempting registration at {registration_url}") - print(f"Registration data: {registration_data}") +class TokenStorage(Protocol): + """Protocol for token storage implementations.""" - async with httpx.AsyncClient() as client: - try: - response = await client.post( - registration_url, - json=registration_data, - headers={"Content-Type": "application/json"}, - ) + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + ... - if response.status_code not in (200, 201): - print( - f"Registration failed with {response.status_code}: {response.text}" - ) - raise httpx.HTTPStatusError( - f"Registration failed: {response.status_code}", - request=response.request, - response=response, - ) + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + ... - response_data = response.json() - print(f"Registration successful: {response_data}") - return OAuthClientInformationFull.model_validate(response_data) + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + ... - except httpx.HTTPStatusError: - raise - except Exception as e: - print(f"Registration error: {e}") - raise + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + ... -class ProductionOAuth2Auth(httpx.Auth): +class OAuthClientProvider(httpx.Auth): """ - Production-ready OAuth2 Authentication for httpx using anyio. + Authentication for httpx using anyio. Handles OAuth flow with automatic client registration and token storage. """ @@ -146,9 +61,9 @@ def __init__( self, server_url: str, client_metadata: OAuthClientMetadata, - storage: TokenStorage | None = None, - redirect_handler: Callable[[str], Awaitable[None]] | None = None, - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, + storage: TokenStorage, + redirect_handler: Callable[[str], Awaitable[None]], + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]], timeout: float = 300.0, # 5 minutes timeout for OAuth flow ): """ @@ -158,14 +73,15 @@ def __init__( server_url: Base URL of the OAuth server client_metadata: OAuth client metadata storage: Token storage implementation (defaults to in-memory) - redirect_handler: Function to handle authorization URL (defaults to opening browser) - callback_handler: Function to wait for callback and return (auth_code, state) + redirect_handler: Function to handle authorization URL like opening browser + callback_handler: Function to wait for callback + and return (auth_code, state) timeout: Timeout for OAuth flow in seconds """ self.server_url = server_url self.client_metadata = client_metadata - self.storage = storage or InMemoryTokenStorage() - self.redirect_handler = redirect_handler or self._default_redirect_handler + self.storage = storage + self.redirect_handler = redirect_handler self.callback_handler = callback_handler self.timeout = timeout @@ -182,11 +98,6 @@ def __init__( # Lock for thread safety during token operations self._token_lock = anyio.Lock() - async def _default_redirect_handler(self, authorization_url: str) -> None: - """Default redirect handler that opens the URL in a browser.""" - print(f"Opening browser for authorization: {authorization_url}") - webbrowser.open(authorization_url) - def _generate_code_verifier(self) -> str: """Generate a cryptographically random code verifier for PKCE.""" return "".join( @@ -199,6 +110,110 @@ def _generate_code_challenge(self, code_verifier: str) -> str: digest = hashlib.sha256(code_verifier.encode()).digest() return base64.urlsafe_b64encode(digest).decode().rstrip("=") + async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None: + """ + Discovers OAuth metadata from the server's well-known endpoint. + + Args: + server_url: Base URL of the OAuth server + + Returns: + OAuthMetadata if found, None otherwise + """ + url = urljoin(server_url, "/.well-known/oauth-authorization-server") + headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + if response.status_code == 404: + return None + response.raise_for_status() + metadata_json = response.json() + logger.debug(f"OAuth metadata discovered: {metadata_json}") + return OAuthMetadata.model_validate(metadata_json) + except Exception: + # Try without MCP protocol version header for CORS issues + try: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + metadata_json = response.json() + logger.debug( + f"OAuth metadata discovered (no MCP header): {metadata_json}" + ) + return OAuthMetadata.model_validate(metadata_json) + except Exception: + logger.exception("Failed to discover OAuth metadata") + return None + + async def _register_oauth_client( + self, + server_url: str, + client_metadata: OAuthClientMetadata, + metadata: OAuthMetadata | None = None, + ) -> OAuthClientInformationFull: + """ + Registers an OAuth client with the server. + + Args: + server_url: Base URL of the OAuth server + client_metadata: Client metadata for registration + metadata: Optional OAuth metadata (will be discovered if not provided) + + Returns: + Registered client information + """ + if not metadata: + metadata = await self._discover_oauth_metadata(server_url) + + if metadata and metadata.registration_endpoint: + registration_url = str(metadata.registration_endpoint) + else: + registration_url = urljoin(server_url, "/register") + + # Prepare registration data and adjust scope based on server metadata + registration_data = client_metadata.model_dump( + by_alias=True, mode="json", exclude_none=True + ) + + # If the server has supported scopes, use them instead of the requested scope + if metadata and metadata.scopes_supported: + # Use the first supported scope or "user" if available + if "user" in metadata.scopes_supported: + registration_data["scope"] = "user" + else: + registration_data["scope"] = metadata.scopes_supported[0] + logger.debug( + f"Adjusted scope to server-supported: {registration_data['scope']}" + ) + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + registration_url, + json=registration_data, + headers={"Content-Type": "application/json"}, + ) + + if response.status_code not in (200, 201): + raise httpx.HTTPStatusError( + f"Registration failed: {response.status_code}", + request=response.request, + response=response, + ) + + response_data = response.json() + logger.debug(f"Registration successful: {response_data}") + return OAuthClientInformationFull.model_validate(response_data) + + except httpx.HTTPStatusError: + raise + except Exception: + logger.exception("Registration error") + raise + async def async_auth_flow( self, request: httpx.Request ) -> AsyncGenerator[httpx.Request, httpx.Response]: @@ -206,7 +221,6 @@ async def async_auth_flow( Handle authentication flow for requests. This method adds the Bearer token if available and handles 401 responses. - The actual OAuth flow initialization should be done before using this auth handler. """ if not self._has_valid_token(): @@ -247,22 +261,13 @@ async def _get_or_register_client(self) -> OAuthClientInformationFull: """Get existing client info or register a new client.""" if not self._client_info: try: - self._client_info = await register_oauth_client( + self._client_info = await self._register_oauth_client( self.server_url, self.client_metadata, self._metadata ) await self.storage.set_client_info(self._client_info) - print(f"Successfully registered client: {self._client_info.client_id}") - except Exception as e: - print(f"Client registration failed: {e}") - print("Using fallback client configuration for testing") - # Create a fallback client configuration for testing - # This allows us to test the OAuth flow even if registration isn't supported - self._client_info = OAuthClientInformationFull( - client_id="simple-auth-client", - client_secret=None, # Some servers don't require client secrets - **self.client_metadata.model_dump(exclude_none=True), - ) - await self.storage.set_client_info(self._client_info) + except Exception: + logger.exception("Client registration failed") + raise return self._client_info async def ensure_token(self) -> None: @@ -285,11 +290,11 @@ async def ensure_token(self) -> None: async def _perform_oauth_flow(self) -> None: """Perform complete OAuth2 authorization code flow.""" - print("Starting OAuth2 authentication flow...") + logger.debug("Starting authentication flow.") # Discover metadata if not already done if not self._metadata: - self._metadata = await discover_oauth_metadata(self.server_url) + self._metadata = await self._discover_oauth_metadata(self.server_url) # Get or register client client_info = await self._get_or_register_client() @@ -325,17 +330,11 @@ async def _perform_oauth_flow(self) -> None: elif self.client_metadata.scope: auth_params["scope"] = self.client_metadata.scope - from urllib.parse import urlencode - auth_url = f"{auth_url_base}?{urlencode(auth_params)}" # Handle redirect (open browser or custom handler) await self.redirect_handler(auth_url) - # Wait for callback using the provided callback handler - if not self.callback_handler: - raise Exception("No callback handler provided for OAuth flow") - auth_code, returned_state = await self.callback_handler() # Validate state parameter @@ -395,8 +394,6 @@ async def _exchange_code_for_token( await self.storage.set_tokens(token_response) self._current_tokens = token_response - print("Successfully obtained access token!") - async def _refresh_access_token(self) -> bool: """Refresh the access token using refresh token.""" if not self._current_tokens or not self._current_tokens.refresh_token: @@ -430,7 +427,7 @@ async def _refresh_access_token(self) -> bool: ) if response.status_code != 200: - print(f"Token refresh failed: {response.status_code}") + logger.error(f"Token refresh failed: {response.status_code}") return False # Parse and store new tokens @@ -446,13 +443,8 @@ async def _refresh_access_token(self) -> bool: await self.storage.set_tokens(token_response) self._current_tokens = token_response - print("Successfully refreshed access token!") return True - except Exception as e: - print(f"Token refresh error: {e}") + except Exception: + logger.exception("Token refresh failed") return False - - -# Maintain compatibility with existing OAuthAuth class -OAuthAuth = ProductionOAuth2Auth diff --git a/src/mcp/client/token_storage.py b/src/mcp/client/token_storage.py deleted file mode 100644 index 0d3fd7a57..000000000 --- a/src/mcp/client/token_storage.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Token storage implementations for OAuth authentication. - -This module provides protocol and concrete implementations for storing -OAuth tokens and client information. -""" - -from typing import Protocol - -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken - - -class TokenStorage(Protocol): - """Protocol for token storage implementations.""" - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - ... - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - ... - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - ... - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - ... - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage implementation.""" - - def __init__(self): - self._tokens: OAuthToken | None = None - self._client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self._tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self._client_info = client_info From e701d0e16fbd5662944cf29234371e16907c9ef6 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 18 May 2025 15:38:55 +0100 Subject: [PATCH 10/19] fix --- .../clients/simple-auth-client/mcp_simple_auth_client/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index e98c4f2ed..781e2ab47 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -10,11 +10,11 @@ import os import threading import time +import webbrowser from datetime import timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlparse -import webbrowser from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.session import ClientSession From f93ab349ae8084036f8aa84fe18e6aef214173db Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 18 May 2025 16:15:13 +0100 Subject: [PATCH 11/19] fixes --- .../mcp_simple_auth_client/main.py | 3 +- src/mcp/client/auth.py | 123 ++++++++++++++---- 2 files changed, 97 insertions(+), 29 deletions(-) diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 781e2ab47..55c5c9267 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -162,8 +162,7 @@ async def callback_handler() -> tuple[str, str | None]: "redirect_uris": ["http://localhost:3000/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], - "token_endpoint_auth_method": "client_secret_post", # Use client secret - "scope": "read", # Default scope, will be updated + "token_endpoint_auth_method": "client_secret_post", } async def _default_redirect_handler(authorization_url: str) -> None: diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index da3e13baf..533b21605 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -110,6 +110,21 @@ def _generate_code_challenge(self, code_verifier: str) -> str: digest = hashlib.sha256(code_verifier.encode()).digest() return base64.urlsafe_b64encode(digest).decode().rstrip("=") + def _get_authorization_base_url(self, server_url: str) -> str: + """ + Determine the authorization base URL by discarding any path component. + + Per MCP spec Section 2.3.2: "The authorization base URL MUST be determined + from the MCP server URL by discarding any existing path component." + + Example: https://api.example.com/v1/mcp -> https://api.example.com + """ + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(server_url) + # Discard path component by setting it to empty + return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) + async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None: """ Discovers OAuth metadata from the server's well-known endpoint. @@ -120,7 +135,9 @@ async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | Non Returns: OAuthMetadata if found, None otherwise """ - url = urljoin(server_url, "/.well-known/oauth-authorization-server") + # Get authorization base URL per MCP spec Section 2.3.2 + auth_base_url = self._get_authorization_base_url(server_url) + url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server") headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} async with httpx.AsyncClient() as client: @@ -171,24 +188,15 @@ async def _register_oauth_client( if metadata and metadata.registration_endpoint: registration_url = str(metadata.registration_endpoint) else: - registration_url = urljoin(server_url, "/register") + # Use authorization base URL for fallback registration endpoint + auth_base_url = self._get_authorization_base_url(server_url) + registration_url = urljoin(auth_base_url, "/register") - # Prepare registration data and adjust scope based on server metadata + # Prepare registration data registration_data = client_metadata.model_dump( by_alias=True, mode="json", exclude_none=True ) - # If the server has supported scopes, use them instead of the requested scope - if metadata and metadata.scopes_supported: - # Use the first supported scope or "user" if available - if "user" in metadata.scopes_supported: - registration_data["scope"] = "user" - else: - registration_data["scope"] = metadata.scopes_supported[0] - logger.debug( - f"Adjusted scope to server-supported: {registration_data['scope']}" - ) - async with httpx.AsyncClient() as client: try: response = await client.post( @@ -252,6 +260,55 @@ def _has_valid_token(self) -> bool: return True + async def _validate_token_scopes(self, token_response: OAuthToken) -> None: + """ + Validate that returned scopes are a subset of requested scopes. + + Per OAuth 2.1 Section 3.3, the authorization server may issue a narrower + set of scopes than requested, but must not grant additional scopes. + """ + if not token_response.scope: + # If no scope is returned, validation passes (server didn't grant anything extra) + return + + # Get the originally requested scopes + requested_scopes: set[str] = set() + + # Check for explicitly requested scopes from client metadata + if self.client_metadata.scope: + requested_scopes.update(self.client_metadata.scope.split()) + + # If we have registered client info with specific scopes, use those + # (This handles cases where scopes were negotiated during registration) + if ( + self._client_info + and hasattr(self._client_info, "scope") + and self._client_info.scope + ): + # Only override if the client metadata didn't have explicit scopes + # This represents what was actually registered/negotiated with the server + if not requested_scopes: + requested_scopes.update(self._client_info.scope.split()) + + # Parse returned scopes + returned_scopes: set[str] = set(token_response.scope.split()) + + # Validate that returned scopes are a subset of requested scopes + # Only enforce strict validation if we actually have requested scopes + if requested_scopes: + unauthorized_scopes: set[str] = returned_scopes - requested_scopes + if unauthorized_scopes: + raise Exception( + f"Server granted unauthorized scopes: {unauthorized_scopes}. " + f"Requested: {requested_scopes}, Returned: {returned_scopes}" + ) + else: + # If no scopes were originally requested (fell back to server defaults), + # accept whatever the server returned + logger.debug( + f"No specific scopes were requested, accepting server-granted scopes: {returned_scopes}" + ) + async def initialize(self) -> None: """Initialize the auth handler by loading stored tokens and client info.""" self._current_tokens = await self.storage.get_tokens() @@ -307,7 +364,9 @@ async def _perform_oauth_flow(self) -> None: if self._metadata and self._metadata.authorization_endpoint: auth_url_base = str(self._metadata.authorization_endpoint) else: - auth_url_base = urljoin(self.server_url, "/authorize") + # Use authorization base URL for fallback authorization endpoint + auth_base_url = self._get_authorization_base_url(self.server_url) + auth_url_base = urljoin(auth_base_url, "/authorize") # Build authorization URL auth_params = { @@ -319,16 +378,16 @@ async def _perform_oauth_flow(self) -> None: "code_challenge_method": "S256", } - if hasattr(client_info, "scope") and client_info.scope: - auth_params["scope"] = client_info.scope - elif self._metadata and self._metadata.scopes_supported: - # Use "user" if available, otherwise the first supported scope - if "user" in self._metadata.scopes_supported: - auth_params["scope"] = "user" - else: - auth_params["scope"] = self._metadata.scopes_supported[0] - elif self.client_metadata.scope: + # Set scope parameter following OAuth 2.1 principles: + # 1. Use client's explicit request first (what developer wants) + # 2. Use registered client scope as fallback (what was negotiated) + # 3. No scope = let server decide (omit scope parameter) + if self.client_metadata.scope: auth_params["scope"] = self.client_metadata.scope + elif hasattr(client_info, "scope") and client_info.scope: + auth_params["scope"] = client_info.scope + # If no scope specified anywhere, don't include scope parameter + # This lets the server grant default scopes per OAuth 2.1 auth_url = f"{auth_url_base}?{urlencode(auth_params)}" @@ -339,7 +398,7 @@ async def _perform_oauth_flow(self) -> None: # Validate state parameter if returned_state != auth_params["state"]: - raise Exception("State parameter mismatch - possible CSRF attack") + raise Exception("State parameter mismatch") if not auth_code: raise Exception("No authorization code received") @@ -355,7 +414,9 @@ async def _exchange_code_for_token( if self._metadata and self._metadata.token_endpoint: token_url = str(self._metadata.token_endpoint) else: - token_url = urljoin(self.server_url, "/token") + # Use authorization base URL for fallback token endpoint + auth_base_url = self._get_authorization_base_url(self.server_url) + token_url = urljoin(auth_base_url, "/token") token_data = { "grant_type": "authorization_code", @@ -384,6 +445,9 @@ async def _exchange_code_for_token( # Parse and store tokens token_response = OAuthToken.model_validate(response.json()) + # Validate returned scopes against requested scopes (OAuth 2.1 Section 3.3) + await self._validate_token_scopes(token_response) + # Calculate expiry time if available if token_response.expires_in: self._token_expiry_time = time.time() + token_response.expires_in @@ -406,7 +470,9 @@ async def _refresh_access_token(self) -> bool: if self._metadata and self._metadata.token_endpoint: token_url = str(self._metadata.token_endpoint) else: - token_url = urljoin(self.server_url, "/token") + # Use authorization base URL for fallback token endpoint + auth_base_url = self._get_authorization_base_url(self.server_url) + token_url = urljoin(auth_base_url, "/token") refresh_data = { "grant_type": "refresh_token", @@ -433,6 +499,9 @@ async def _refresh_access_token(self) -> bool: # Parse and store new tokens token_response = OAuthToken.model_validate(response.json()) + # Validate returned scopes against requested scopes (OAuth 2.1 Section 3.3) + await self._validate_token_scopes(token_response) + # Calculate expiry time if available if token_response.expires_in: self._token_expiry_time = time.time() + token_response.expires_in From 36be8db6149831f9e9b9a35ef5467e8f7c48daa5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 09:33:37 +0100 Subject: [PATCH 12/19] tests and readme --- README.md | 11 +- examples/clients/simple-auth-client/README.md | 89 +- .../mcp_simple_auth_client/__init__.py | 2 +- .../mcp_simple_auth_client/main.py | 2 +- src/mcp/client/auth.py | 40 +- tests/client/test_auth.py | 913 ++++++++++++++++++ 6 files changed, 970 insertions(+), 87 deletions(-) create mode 100644 tests/client/test_auth.py diff --git a/README.md b/README.md index 5e48721d7..dd8af7fb0 100644 --- a/README.md +++ b/README.md @@ -801,7 +801,7 @@ async def main(): The SDK supports OAuth 2.0 client authentication for secure access to MCP servers that require authentication: ```python -from mcp.client.auth import OAuthClientProvider, UnauthorizedError +from mcp.client.auth import UnauthorizedError from mcp.client.oauth_providers import InMemoryOAuthProvider from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientMetadata @@ -817,6 +817,7 @@ oauth_provider = InMemoryOAuthProvider( ), ) + async def main(): # Connect with OAuth authentication async with streamablehttp_client( @@ -834,6 +835,7 @@ async def main(): # Handle authorization required print("Authorization required. Check your browser.") + # Handle OAuth callback after user authorization async def handle_callback(authorization_code: str): from mcp.client.streamable_http import StreamableHTTPTransport @@ -856,14 +858,17 @@ You can implement custom OAuth storage by creating your own provider: ```python from mcp.client.oauth_providers import InMemoryOAuthProvider + class DatabaseOAuthProvider(InMemoryOAuthProvider): async def save_tokens(self, tokens): # Save to database - await db.save_tokens(self.client_id, tokens) + # await db.save_tokens(self.client_id, tokens) + pass async def tokens(self): # Load from database - return await db.load_tokens(self.client_id) + # return await db.load_tokens(self.client_id) + return None # Implement other methods as needed... ``` diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md index 314ccab59..9a856b3c8 100644 --- a/examples/clients/simple-auth-client/README.md +++ b/examples/clients/simple-auth-client/README.md @@ -1,125 +1,70 @@ # Simple Auth Client Example -This example demonstrates how to use the MCP Python SDK to create a client that connects to an MCP server using OAuth authentication over streamable HTTP transport. +A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP transport. ## Features - OAuth 2.0 authentication with PKCE - Streamable HTTP transport - Interactive command-line interface -- Tool listing and execution - -## Prerequisites - -1. Python 3.9 or higher -2. An MCP server that supports OAuth authentication (like `mcp-simple-auth`) -3. uv for dependency management ## Installation ```bash cd examples/clients/simple-auth-client -uv install +uv sync --reinstall ``` ## Usage -### 1. Start the Auth Server - -First, start the MCP auth server in another terminal: +### 1. Start an MCP server with OAuth support ```bash +# Example with mcp-simple-auth cd path/to/mcp-simple-auth uv run mcp-simple-auth --transport streamable-http --port 3001 ``` -### 2. Run the Client +### 2. Run the client ```bash -# Run the client uv run mcp-simple-auth-client # Or with custom server URL MCP_SERVER_URL=http://localhost:3001 uv run mcp-simple-auth-client ``` -### 3. Authentication Flow - -1. The client will attempt to connect to the server -2. If authentication is required, the client will open your default browser -3. Complete the OAuth flow in the browser -4. Return to the client - it should now be connected - -### 4. Interactive Commands +### 3. Complete OAuth flow -Once connected, you can use these commands: +The client will open your browser for authentication. After completing OAuth, you can use commands: -- `list` - List available tools from the server -- `call [args]` - Call a tool with optional JSON arguments -- `quit` - Exit the client +- `list` - List available tools +- `call [args]` - Call a tool with optional JSON arguments +- `quit` - Exit -### Example Session +## Example ``` -=ļæ½ Simple MCP Auth Client +šŸ” Simple MCP Auth Client Connecting to: http://localhost:3001 Please visit the following URL to authorize the application: http://localhost:3001/authorize?response_type=code&client_id=... - Connected to MCP server at http://localhost:3001 -Session ID: abc123 - -<ļæ½ Interactive MCP Client -Commands: - list - List available tools - call [args] - Call a tool - quit - Exit the client +āœ… Connected to MCP server at http://localhost:3001 mcp> list - -=ļæ½ Available tools: -1. echo - Description: Echo back the input text +šŸ“‹ Available tools: +1. echo - Echo back the input text mcp> call echo {"text": "Hello, world!"} - -=' Tool 'echo' result: +šŸ”§ Tool 'echo' result: Hello, world! mcp> quit -=K Goodbye! +šŸ‘‹ Goodbye! ``` ## Configuration -You can customize the client behavior with environment variables: - - `MCP_SERVER_URL` - Server URL (default: http://localhost:3001) -- `AUTH_CODE` - Authorization code for completing OAuth flow - -## Implementation Details - -This example shows how to: - -1. **Create an OAuth provider** - Implement the `OAuthClientProvider` protocol -2. **Use streamable HTTP transport** - Connect using the `streamablehttp_client` context manager -3. **Handle authentication** - Manage OAuth flow including browser redirect -4. **Interactive tool usage** - List and call tools from the command line - -The key components are: - -- `SimpleOAuthProvider` - Minimal OAuth provider implementation -- `SimpleAuthClient` - Main client class that handles connection and tool operations -- Interactive loop for user commands - -## Error Handling - -The client handles common error scenarios: - -- Server connection failures -- Authentication errors -- Invalid tool calls -- Network timeouts - -All errors are displayed with helpful messages to guide debugging. \ No newline at end of file diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py index 2baf91a34..06eb1f29d 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py @@ -1 +1 @@ -"""Simple OAuth client for MCP simple-auth server.""" \ No newline at end of file +"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 55c5c9267..6508cfc1a 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -135,7 +135,7 @@ class SimpleAuthClient: def __init__(self, server_url: str): self.server_url = server_url # Extract base URL for auth server (remove /mcp endpoint for auth endpoints) - # Use default redirect URI - this is where the auth server will redirect the user + # Use default redirect URI - this is where the auth server will redirect # The user will need to copy the authorization code from this callback URL self.session: ClientSession | None = None diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 533b21605..c4db06955 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -268,7 +268,8 @@ async def _validate_token_scopes(self, token_response: OAuthToken) -> None: set of scopes than requested, but must not grant additional scopes. """ if not token_response.scope: - # If no scope is returned, validation passes (server didn't grant anything extra) + # If no scope is returned, validation passes + # (server didn't grant anything extra) return # Get the originally requested scopes @@ -306,7 +307,8 @@ async def _validate_token_scopes(self, token_response: OAuthToken) -> None: # If no scopes were originally requested (fell back to server defaults), # accept whatever the server returned logger.debug( - f"No specific scopes were requested, accepting server-granted scopes: {returned_scopes}" + f"No specific scopes were requested, accepting server-granted " + f"scopes: {returned_scopes}" ) async def initialize(self) -> None: @@ -372,7 +374,7 @@ async def _perform_oauth_flow(self) -> None: auth_params = { "response_type": "code", "client_id": client_info.client_id, - "redirect_uri": self.client_metadata.redirect_uris[0], + "redirect_uri": str(self.client_metadata.redirect_uris[0]), "state": secrets.token_urlsafe(32), "code_challenge": self._code_challenge, "code_challenge_method": "S256", @@ -396,9 +398,15 @@ async def _perform_oauth_flow(self) -> None: auth_code, returned_state = await self.callback_handler() - # Validate state parameter - if returned_state != auth_params["state"]: - raise Exception("State parameter mismatch") + # Validate state parameter using constant-time comparison + # to prevent timing attacks + if returned_state is None: + raise Exception("State parameter mismatch - possible CSRF attack") + # Type cast to ensure both args are str for compare_digest + expected_state = str(auth_params["state"]) + actual_state = str(returned_state) + if not secrets.compare_digest(actual_state, expected_state): + raise Exception("State parameter mismatch - possible CSRF attack") if not auth_code: raise Exception("No authorization code received") @@ -438,9 +446,20 @@ async def _exchange_code_for_token( ) if response.status_code != 200: - raise Exception( - f"Token exchange failed: {response.status_code} {response.text}" - ) + # Try to parse OAuth error response, otherwise use basic error + try: + error_data = response.json() + error_msg = error_data.get( + "error_description", error_data.get("error", "Unknown error") + ) + raise Exception( + f"Token exchange failed: {error_msg} " + f"(HTTP {response.status_code})" + ) + except Exception: + raise Exception( + f"Token exchange failed: {response.status_code} {response.text}" + ) # Parse and store tokens token_response = OAuthToken.model_validate(response.json()) @@ -499,7 +518,8 @@ async def _refresh_access_token(self) -> bool: # Parse and store new tokens token_response = OAuthToken.model_validate(response.json()) - # Validate returned scopes against requested scopes (OAuth 2.1 Section 3.3) + # Validate returned scopes against requested scopes + # (OAuth 2.1 Section 3.3) await self._validate_token_scopes(token_response) # Calculate expiry time if available diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py new file mode 100644 index 000000000..00b490bec --- /dev/null +++ b/tests/client/test_auth.py @@ -0,0 +1,913 @@ +""" +Tests for OAuth client authentication implementation. +""" + +import base64 +import hashlib +import time +from unittest.mock import AsyncMock, Mock, patch +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from pydantic import AnyHttpUrl + +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, +) + + +class MockTokenStorage: + """Mock token storage for testing.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +@pytest.fixture +def mock_storage(): + return MockTokenStorage() + + +@pytest.fixture +def client_metadata(): + return OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")], + client_name="Test Client", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="read write", + ) + + +@pytest.fixture +def oauth_metadata(): + return OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + registration_endpoint=AnyHttpUrl("https://auth.example.com/register"), + scopes_supported=["read", "write", "admin"], + response_types_supported=["code"], + grant_types_supported=["authorization_code", "refresh_token"], + code_challenge_methods_supported=["S256"], + ) + + +@pytest.fixture +def oauth_client_info(): + return OAuthClientInformationFull( + client_id="test_client_id", + client_secret="test_client_secret", + redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")], + client_name="Test Client", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="read write", + ) + + +@pytest.fixture +def oauth_token(): + return OAuthToken( + access_token="test_access_token", + token_type="bearer", + expires_in=3600, + refresh_token="test_refresh_token", + scope="read write", + ) + + +@pytest.fixture +async def oauth_provider(client_metadata, mock_storage): + async def mock_redirect_handler(url: str) -> None: + pass + + async def mock_callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" + + return OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=mock_redirect_handler, + callback_handler=mock_callback_handler, + ) + + +class TestOAuthClientProvider: + """Test OAuth client provider functionality.""" + + @pytest.mark.anyio + async def test_init(self, oauth_provider, client_metadata, mock_storage): + """Test OAuth provider initialization.""" + assert oauth_provider.server_url == "https://api.example.com/v1/mcp" + assert oauth_provider.client_metadata == client_metadata + assert oauth_provider.storage == mock_storage + assert oauth_provider.timeout == 300.0 + + def test_generate_code_verifier(self, oauth_provider): + """Test PKCE code verifier generation.""" + verifier = oauth_provider._generate_code_verifier() + + # Check length (128 characters) + assert len(verifier) == 128 + + # Check charset (RFC 7636: A-Z, a-z, 0-9, "-", ".", "_", "~") + allowed_chars = set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + assert set(verifier) <= allowed_chars + + # Check uniqueness (generate multiple and ensure they're different) + verifiers = {oauth_provider._generate_code_verifier() for _ in range(10)} + assert len(verifiers) == 10 + + def test_generate_code_challenge(self, oauth_provider): + """Test PKCE code challenge generation.""" + verifier = "test_code_verifier_123" + challenge = oauth_provider._generate_code_challenge(verifier) + + # Manually calculate expected challenge + expected_digest = hashlib.sha256(verifier.encode()).digest() + expected_challenge = ( + base64.urlsafe_b64encode(expected_digest).decode().rstrip("=") + ) + + assert challenge == expected_challenge + + # Verify it's base64url without padding + assert "=" not in challenge + assert "+" not in challenge + assert "/" not in challenge + + def test_get_authorization_base_url(self, oauth_provider): + """Test authorization base URL extraction.""" + # Test with path + assert ( + oauth_provider._get_authorization_base_url("https://api.example.com/v1/mcp") + == "https://api.example.com" + ) + + # Test with no path + assert ( + oauth_provider._get_authorization_base_url("https://api.example.com") + == "https://api.example.com" + ) + + # Test with port + assert ( + oauth_provider._get_authorization_base_url( + "https://api.example.com:8080/path/to/mcp" + ) + == "https://api.example.com:8080" + ) + + @pytest.mark.anyio + async def test_discover_oauth_metadata_success( + self, oauth_provider, oauth_metadata + ): + """Test successful OAuth metadata discovery.""" + metadata_response = oauth_metadata.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = metadata_response + mock_client.get.return_value = mock_response + + result = await oauth_provider._discover_oauth_metadata( + "https://api.example.com/v1/mcp" + ) + + assert result is not None + assert ( + result.authorization_endpoint == oauth_metadata.authorization_endpoint + ) + assert result.token_endpoint == oauth_metadata.token_endpoint + + # Verify correct URL was called + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args[0] + assert ( + call_args[0] + == "https://api.example.com/.well-known/oauth-authorization-server" + ) + + @pytest.mark.anyio + async def test_discover_oauth_metadata_not_found(self, oauth_provider): + """Test OAuth metadata discovery when not found.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 404 + mock_client.get.return_value = mock_response + + result = await oauth_provider._discover_oauth_metadata( + "https://api.example.com/v1/mcp" + ) + + assert result is None + + @pytest.mark.anyio + async def test_discover_oauth_metadata_cors_fallback( + self, oauth_provider, oauth_metadata + ): + """Test OAuth metadata discovery with CORS fallback.""" + metadata_response = oauth_metadata.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + # First call fails (CORS), second succeeds + mock_response_success = Mock() + mock_response_success.status_code = 200 + mock_response_success.json.return_value = metadata_response + + mock_client.get.side_effect = [ + TypeError("CORS error"), # First call fails + mock_response_success, # Second call succeeds + ] + + result = await oauth_provider._discover_oauth_metadata( + "https://api.example.com/v1/mcp" + ) + + assert result is not None + assert mock_client.get.call_count == 2 + + @pytest.mark.anyio + async def test_register_oauth_client_success( + self, oauth_provider, oauth_metadata, oauth_client_info + ): + """Test successful OAuth client registration.""" + registration_response = oauth_client_info.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = registration_response + mock_client.post.return_value = mock_response + + result = await oauth_provider._register_oauth_client( + "https://api.example.com/v1/mcp", + oauth_provider.client_metadata, + oauth_metadata, + ) + + assert result.client_id == oauth_client_info.client_id + assert result.client_secret == oauth_client_info.client_secret + + # Verify correct registration endpoint was used + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == str(oauth_metadata.registration_endpoint) + + @pytest.mark.anyio + async def test_register_oauth_client_fallback_endpoint( + self, oauth_provider, oauth_client_info + ): + """Test OAuth client registration with fallback endpoint.""" + registration_response = oauth_client_info.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = registration_response + mock_client.post.return_value = mock_response + + # Mock metadata discovery to return None (fallback) + with patch.object( + oauth_provider, "_discover_oauth_metadata", return_value=None + ): + result = await oauth_provider._register_oauth_client( + "https://api.example.com/v1/mcp", + oauth_provider.client_metadata, + None, + ) + + assert result.client_id == oauth_client_info.client_id + + # Verify fallback endpoint was used + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "https://api.example.com/register" + + @pytest.mark.anyio + async def test_register_oauth_client_failure(self, oauth_provider): + """Test OAuth client registration failure.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + mock_client.post.return_value = mock_response + + # Mock metadata discovery to return None (fallback) + with patch.object( + oauth_provider, "_discover_oauth_metadata", return_value=None + ): + with pytest.raises(httpx.HTTPStatusError): + await oauth_provider._register_oauth_client( + "https://api.example.com/v1/mcp", + oauth_provider.client_metadata, + None, + ) + + def test_has_valid_token_no_token(self, oauth_provider): + """Test token validation with no token.""" + assert not oauth_provider._has_valid_token() + + def test_has_valid_token_valid(self, oauth_provider, oauth_token): + """Test token validation with valid token.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._token_expiry_time = time.time() + 3600 # Future expiry + + assert oauth_provider._has_valid_token() + + def test_has_valid_token_expired(self, oauth_provider, oauth_token): + """Test token validation with expired token.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._token_expiry_time = time.time() - 3600 # Past expiry + + assert not oauth_provider._has_valid_token() + + @pytest.mark.anyio + async def test_validate_token_scopes_no_scope(self, oauth_provider): + """Test scope validation with no scope returned.""" + token = OAuthToken(access_token="test", token_type="bearer") + + # Should not raise exception + await oauth_provider._validate_token_scopes(token) + + @pytest.mark.anyio + async def test_validate_token_scopes_valid(self, oauth_provider, client_metadata): + """Test scope validation with valid scopes.""" + oauth_provider.client_metadata = client_metadata + token = OAuthToken( + access_token="test", + token_type="bearer", + scope="read write", + ) + + # Should not raise exception + await oauth_provider._validate_token_scopes(token) + + @pytest.mark.anyio + async def test_validate_token_scopes_subset(self, oauth_provider, client_metadata): + """Test scope validation with subset of requested scopes.""" + oauth_provider.client_metadata = client_metadata + token = OAuthToken( + access_token="test", + token_type="bearer", + scope="read", + ) + + # Should not raise exception (servers can grant subset) + await oauth_provider._validate_token_scopes(token) + + @pytest.mark.anyio + async def test_validate_token_scopes_unauthorized( + self, oauth_provider, client_metadata + ): + """Test scope validation with unauthorized scopes.""" + oauth_provider.client_metadata = client_metadata + token = OAuthToken( + access_token="test", + token_type="bearer", + scope="read write admin", # Includes unauthorized "admin" + ) + + with pytest.raises(Exception, match="Server granted unauthorized scopes"): + await oauth_provider._validate_token_scopes(token) + + @pytest.mark.anyio + async def test_validate_token_scopes_no_requested(self, oauth_provider): + """Test scope validation with no requested scopes.""" + # No scope in client metadata + oauth_provider.client_metadata.scope = None + token = OAuthToken( + access_token="test", + token_type="bearer", + scope="admin super", + ) + + # Should not raise exception when no scopes were requested + await oauth_provider._validate_token_scopes(token) + + @pytest.mark.anyio + async def test_initialize( + self, oauth_provider, mock_storage, oauth_token, oauth_client_info + ): + """Test initialization loading from storage.""" + mock_storage._tokens = oauth_token + mock_storage._client_info = oauth_client_info + + await oauth_provider.initialize() + + assert oauth_provider._current_tokens == oauth_token + assert oauth_provider._client_info == oauth_client_info + + @pytest.mark.anyio + async def test_get_or_register_client_existing( + self, oauth_provider, oauth_client_info + ): + """Test getting existing client info.""" + oauth_provider._client_info = oauth_client_info + + result = await oauth_provider._get_or_register_client() + + assert result == oauth_client_info + + @pytest.mark.anyio + async def test_get_or_register_client_register_new( + self, oauth_provider, oauth_client_info + ): + """Test registering new client.""" + with patch.object( + oauth_provider, "_register_oauth_client", return_value=oauth_client_info + ) as mock_register: + result = await oauth_provider._get_or_register_client() + + assert result == oauth_client_info + assert oauth_provider._client_info == oauth_client_info + mock_register.assert_called_once() + + @pytest.mark.anyio + async def test_exchange_code_for_token_success( + self, oauth_provider, oauth_client_info, oauth_token + ): + """Test successful code exchange for token.""" + oauth_provider._code_verifier = "test_verifier" + token_response = oauth_token.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = token_response + mock_client.post.return_value = mock_response + + with patch.object( + oauth_provider, "_validate_token_scopes" + ) as mock_validate: + await oauth_provider._exchange_code_for_token( + "test_auth_code", oauth_client_info + ) + + assert ( + oauth_provider._current_tokens.access_token + == oauth_token.access_token + ) + mock_validate.assert_called_once() + + @pytest.mark.anyio + async def test_exchange_code_for_token_failure( + self, oauth_provider, oauth_client_info + ): + """Test failed code exchange for token.""" + oauth_provider._code_verifier = "test_verifier" + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Invalid grant" + mock_client.post.return_value = mock_response + + with pytest.raises(Exception, match="Token exchange failed"): + await oauth_provider._exchange_code_for_token( + "invalid_auth_code", oauth_client_info + ) + + @pytest.mark.anyio + async def test_refresh_access_token_success( + self, oauth_provider, oauth_client_info, oauth_token + ): + """Test successful token refresh.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._client_info = oauth_client_info + + new_token = OAuthToken( + access_token="new_access_token", + token_type="bearer", + expires_in=3600, + refresh_token="new_refresh_token", + scope="read write", + ) + token_response = new_token.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = token_response + mock_client.post.return_value = mock_response + + with patch.object( + oauth_provider, "_validate_token_scopes" + ) as mock_validate: + result = await oauth_provider._refresh_access_token() + + assert result is True + assert ( + oauth_provider._current_tokens.access_token + == new_token.access_token + ) + mock_validate.assert_called_once() + + @pytest.mark.anyio + async def test_refresh_access_token_no_refresh_token(self, oauth_provider): + """Test token refresh with no refresh token.""" + oauth_provider._current_tokens = OAuthToken( + access_token="test", + token_type="bearer", + # No refresh_token + ) + + result = await oauth_provider._refresh_access_token() + assert result is False + + @pytest.mark.anyio + async def test_refresh_access_token_failure( + self, oauth_provider, oauth_client_info, oauth_token + ): + """Test failed token refresh.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._client_info = oauth_client_info + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 400 + mock_client.post.return_value = mock_response + + result = await oauth_provider._refresh_access_token() + assert result is False + + @pytest.mark.anyio + async def test_perform_oauth_flow_success( + self, oauth_provider, oauth_metadata, oauth_client_info + ): + """Test successful OAuth flow.""" + oauth_provider._metadata = oauth_metadata + oauth_provider._client_info = oauth_client_info + + # Mock the redirect handler to capture the auth URL + auth_url_captured = None + + async def mock_redirect_handler(url: str) -> None: + nonlocal auth_url_captured + auth_url_captured = url + + oauth_provider.redirect_handler = mock_redirect_handler + + # Mock callback handler with matching state + async def mock_callback_handler() -> tuple[str, str | None]: + # Extract state from auth URL to return matching value + if auth_url_captured: + parsed_url = urlparse(auth_url_captured) + query_params = parse_qs(parsed_url.query) + state = query_params.get("state", [None])[0] + return "test_auth_code", state + return "test_auth_code", "test_state" + + oauth_provider.callback_handler = mock_callback_handler + + with patch.object(oauth_provider, "_exchange_code_for_token") as mock_exchange: + await oauth_provider._perform_oauth_flow() + + # Verify auth URL was generated correctly + assert auth_url_captured is not None + parsed_url = urlparse(auth_url_captured) + query_params = parse_qs(parsed_url.query) + + assert query_params["response_type"][0] == "code" + assert query_params["client_id"][0] == oauth_client_info.client_id + assert query_params["code_challenge_method"][0] == "S256" + assert "code_challenge" in query_params + assert "state" in query_params + + # Verify code exchange was called + mock_exchange.assert_called_once_with("test_auth_code", oauth_client_info) + + @pytest.mark.anyio + async def test_perform_oauth_flow_state_mismatch( + self, oauth_provider, oauth_metadata, oauth_client_info + ): + """Test OAuth flow with state parameter mismatch.""" + oauth_provider._metadata = oauth_metadata + oauth_provider._client_info = oauth_client_info + + # Mock callback handler to return mismatched state + async def mock_callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "wrong_state" + + oauth_provider.callback_handler = mock_callback_handler + + async def mock_redirect_handler(url: str) -> None: + pass + + oauth_provider.redirect_handler = mock_redirect_handler + + with pytest.raises(Exception, match="State parameter mismatch"): + await oauth_provider._perform_oauth_flow() + + @pytest.mark.anyio + async def test_ensure_token_existing_valid(self, oauth_provider, oauth_token): + """Test ensure_token with existing valid token.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._token_expiry_time = time.time() + 3600 + + await oauth_provider.ensure_token() + + # Should not trigger new auth flow + assert oauth_provider._current_tokens == oauth_token + + @pytest.mark.anyio + async def test_ensure_token_refresh(self, oauth_provider, oauth_token): + """Test ensure_token with expired token that can be refreshed.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._token_expiry_time = time.time() - 3600 # Expired + + with patch.object( + oauth_provider, "_refresh_access_token", return_value=True + ) as mock_refresh: + await oauth_provider.ensure_token() + mock_refresh.assert_called_once() + + @pytest.mark.anyio + async def test_ensure_token_full_flow(self, oauth_provider): + """Test ensure_token triggering full OAuth flow.""" + # No existing token + with patch.object(oauth_provider, "_perform_oauth_flow") as mock_flow: + await oauth_provider.ensure_token() + mock_flow.assert_called_once() + + @pytest.mark.anyio + async def test_async_auth_flow_add_token(self, oauth_provider, oauth_token): + """Test async auth flow adding Bearer token to request.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._token_expiry_time = time.time() + 3600 + + request = httpx.Request("GET", "https://api.example.com/data") + + # Mock response + mock_response = Mock() + mock_response.status_code = 200 + + auth_flow = oauth_provider.async_auth_flow(request) + updated_request = await auth_flow.__anext__() + + assert ( + updated_request.headers["Authorization"] + == f"Bearer {oauth_token.access_token}" + ) + + # Send mock response + try: + await auth_flow.asend(mock_response) + except StopAsyncIteration: + pass + + @pytest.mark.anyio + async def test_async_auth_flow_401_response(self, oauth_provider, oauth_token): + """Test async auth flow handling 401 response.""" + oauth_provider._current_tokens = oauth_token + oauth_provider._token_expiry_time = time.time() + 3600 + + request = httpx.Request("GET", "https://api.example.com/data") + + # Mock 401 response + mock_response = Mock() + mock_response.status_code = 401 + + auth_flow = oauth_provider.async_auth_flow(request) + await auth_flow.__anext__() + + # Send 401 response + try: + await auth_flow.asend(mock_response) + except StopAsyncIteration: + pass + + # Should clear current tokens + assert oauth_provider._current_tokens is None + + @pytest.mark.anyio + async def test_async_auth_flow_no_token(self, oauth_provider): + """Test async auth flow with no token triggers auth flow.""" + request = httpx.Request("GET", "https://api.example.com/data") + + with ( + patch.object(oauth_provider, "initialize") as mock_init, + patch.object(oauth_provider, "ensure_token") as mock_ensure, + ): + auth_flow = oauth_provider.async_auth_flow(request) + updated_request = await auth_flow.__anext__() + + mock_init.assert_called_once() + mock_ensure.assert_called_once() + + # No Authorization header should be added if no token + assert "Authorization" not in updated_request.headers + + def test_scope_priority_client_metadata_first( + self, oauth_provider, oauth_client_info + ): + """Test that client metadata scope takes priority.""" + oauth_provider.client_metadata.scope = "read write" + oauth_provider._client_info = oauth_client_info + oauth_provider._client_info.scope = "admin" + + # Build auth params to test scope logic + auth_params = { + "response_type": "code", + "client_id": "test_client", + "redirect_uri": "http://localhost:3000/callback", + "state": "test_state", + "code_challenge": "test_challenge", + "code_challenge_method": "S256", + } + + # Apply scope logic from _perform_oauth_flow + if oauth_provider.client_metadata.scope: + auth_params["scope"] = oauth_provider.client_metadata.scope + elif ( + hasattr(oauth_provider._client_info, "scope") + and oauth_provider._client_info.scope + ): + auth_params["scope"] = oauth_provider._client_info.scope + + assert auth_params["scope"] == "read write" + + def test_scope_priority_client_info_fallback( + self, oauth_provider, oauth_client_info + ): + """Test that client info scope is used as fallback.""" + oauth_provider.client_metadata.scope = None + oauth_provider._client_info = oauth_client_info + oauth_provider._client_info.scope = "admin" + + # Build auth params to test scope logic + auth_params = { + "response_type": "code", + "client_id": "test_client", + "redirect_uri": "http://localhost:3000/callback", + "state": "test_state", + "code_challenge": "test_challenge", + "code_challenge_method": "S256", + } + + # Apply scope logic from _perform_oauth_flow + if oauth_provider.client_metadata.scope: + auth_params["scope"] = oauth_provider.client_metadata.scope + elif ( + hasattr(oauth_provider._client_info, "scope") + and oauth_provider._client_info.scope + ): + auth_params["scope"] = oauth_provider._client_info.scope + + assert auth_params["scope"] == "admin" + + def test_scope_priority_no_scope(self, oauth_provider, oauth_client_info): + """Test that no scope parameter is set when no scopes specified.""" + oauth_provider.client_metadata.scope = None + oauth_provider._client_info = oauth_client_info + oauth_provider._client_info.scope = None + + # Build auth params to test scope logic + auth_params = { + "response_type": "code", + "client_id": "test_client", + "redirect_uri": "http://localhost:3000/callback", + "state": "test_state", + "code_challenge": "test_challenge", + "code_challenge_method": "S256", + } + + # Apply scope logic from _perform_oauth_flow + if oauth_provider.client_metadata.scope: + auth_params["scope"] = oauth_provider.client_metadata.scope + elif ( + hasattr(oauth_provider._client_info, "scope") + and oauth_provider._client_info.scope + ): + auth_params["scope"] = oauth_provider._client_info.scope + + # No scope should be set + assert "scope" not in auth_params + + @pytest.mark.anyio + async def test_state_parameter_validation_uses_constant_time( + self, oauth_provider, oauth_metadata, oauth_client_info + ): + """Test that state parameter validation uses constant-time comparison.""" + oauth_provider._metadata = oauth_metadata + oauth_provider._client_info = oauth_client_info + + # Mock callback handler to return mismatched state + async def mock_callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "wrong_state" + + oauth_provider.callback_handler = mock_callback_handler + + async def mock_redirect_handler(url: str) -> None: + pass + + oauth_provider.redirect_handler = mock_redirect_handler + + # Patch secrets.compare_digest to verify it's being called + with patch( + "mcp.client.auth.secrets.compare_digest", return_value=False + ) as mock_compare: + with pytest.raises( + Exception, match="State parameter mismatch - possible CSRF attack" + ): + await oauth_provider._perform_oauth_flow() + + # Verify constant-time comparison was used + mock_compare.assert_called_once() + + @pytest.mark.anyio + async def test_state_parameter_validation_none_state( + self, oauth_provider, oauth_metadata, oauth_client_info + ): + """Test that None state is handled correctly.""" + oauth_provider._metadata = oauth_metadata + oauth_provider._client_info = oauth_client_info + + # Mock callback handler to return None state + async def mock_callback_handler() -> tuple[str, str | None]: + return "test_auth_code", None + + oauth_provider.callback_handler = mock_callback_handler + + async def mock_redirect_handler(url: str) -> None: + pass + + oauth_provider.redirect_handler = mock_redirect_handler + + with pytest.raises( + Exception, match="State parameter mismatch - possible CSRF attack" + ): + await oauth_provider._perform_oauth_flow() + + @pytest.mark.anyio + async def test_token_exchange_error_basic(self, oauth_provider, oauth_client_info): + """Test token exchange error handling (basic).""" + oauth_provider._code_verifier = "test_verifier" + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + # Mock error response + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + mock_client.post.return_value = mock_response + + with pytest.raises(Exception, match="Token exchange failed"): + await oauth_provider._exchange_code_for_token( + "invalid_auth_code", oauth_client_info + ) From c7a361e2c9ee0ddde9f14c56376c145cb384f5ee Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 10:02:24 +0100 Subject: [PATCH 13/19] small fixes --- .../mcp_simple_auth_client/main.py | 45 ++-- src/mcp/client/auth.py | 204 +++++++----------- tests/client/test_auth.py | 22 +- 3 files changed, 121 insertions(+), 150 deletions(-) diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 6508cfc1a..55cb80e45 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -45,9 +45,10 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None class CallbackHandler(BaseHTTPRequestHandler): """Simple HTTP handler to capture OAuth callback.""" - authorization_code = None - state = None - error = None + def __init__(self, request, client_address, server, callback_data): + """Initialize with callback data storage.""" + self.callback_data = callback_data + super().__init__(request, client_address, server) def do_GET(self): """Handle GET request from OAuth redirect.""" @@ -55,8 +56,8 @@ def do_GET(self): query_params = parse_qs(parsed.query) if "code" in query_params: - CallbackHandler.authorization_code = query_params["code"][0] - CallbackHandler.state = query_params.get("state", [None])[0] + self.callback_data["authorization_code"] = query_params["code"][0] + self.callback_data["state"] = query_params.get("state", [None])[0] self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() @@ -70,7 +71,7 @@ def do_GET(self): """) elif "error" in query_params: - CallbackHandler.error = query_params["error"][0] + self.callback_data["error"] = query_params["error"][0] self.send_response(400) self.send_header("Content-type", "text/html") self.end_headers() @@ -101,10 +102,26 @@ def __init__(self, port=3000): self.port = port self.server = None self.thread = None + self.callback_data = { + "authorization_code": None, + "state": None, + "error": None + } + + def _create_handler_with_data(self): + """Create a handler class with access to callback data.""" + callback_data = self.callback_data + + class DataCallbackHandler(CallbackHandler): + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server, callback_data) + + return DataCallbackHandler def start(self): """Start the callback server in a background thread.""" - self.server = HTTPServer(("localhost", self.port), CallbackHandler) + handler_class = self._create_handler_with_data() + self.server = HTTPServer(("localhost", self.port), handler_class) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() print(f"šŸ–„ļø Started callback server on http://localhost:{self.port}") @@ -121,12 +138,16 @@ def wait_for_callback(self, timeout=300): """Wait for OAuth callback with timeout.""" start_time = time.time() while time.time() - start_time < timeout: - if CallbackHandler.authorization_code: - return CallbackHandler.authorization_code - elif CallbackHandler.error: - raise Exception(f"OAuth error: {CallbackHandler.error}") + if self.callback_data["authorization_code"]: + return self.callback_data["authorization_code"] + elif self.callback_data["error"]: + raise Exception(f"OAuth error: {self.callback_data['error']}") time.sleep(0.1) raise Exception("Timeout waiting for OAuth callback") + + def get_state(self): + """Get the received state parameter.""" + return self.callback_data["state"] class SimpleAuthClient: @@ -153,7 +174,7 @@ async def callback_handler() -> tuple[str, str | None]: print("ā³ Waiting for authorization callback...") try: auth_code = callback_server.wait_for_callback(timeout=300) - return auth_code, CallbackHandler.state + return auth_code, callback_server.get_state() finally: callback_server.stop() diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index c4db06955..18a07d3c8 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -1,10 +1,7 @@ """ -Production-ready OAuth2 Authentication implementation for HTTPX using anyio. +OAuth2 Authentication implementation for HTTPX. -This module provides a complete OAuth 2.0 authentication implementation -that handles authorization code flow with PKCE, -automatic token refresh and proper error handling. -The callback server implementation should be handled by the calling code. +Implements authorization code flow with PKCE and automatic token refresh. """ import base64 @@ -64,7 +61,7 @@ def __init__( storage: TokenStorage, redirect_handler: Callable[[str], Awaitable[None]], callback_handler: Callable[[], Awaitable[tuple[str, str | None]]], - timeout: float = 300.0, # 5 minutes timeout for OAuth flow + timeout: float = 300.0, ): """ Initialize OAuth2 authentication. @@ -85,17 +82,20 @@ def __init__( self.callback_handler = callback_handler self.timeout = timeout - # Cache for current tokens and metadata + # Cached authentication state self._current_tokens: OAuthToken | None = None self._metadata: OAuthMetadata | None = None self._client_info: OAuthClientInformationFull | None = None self._token_expiry_time: float | None = None - # PKCE parameters + # PKCE flow parameters self._code_verifier: str | None = None self._code_challenge: str | None = None - # Lock for thread safety during token operations + # State parameter for CSRF protection + self._auth_state: str | None = None + + # Thread safety lock self._token_lock = anyio.Lock() def _generate_code_verifier(self) -> str: @@ -112,30 +112,21 @@ def _generate_code_challenge(self, code_verifier: str) -> str: def _get_authorization_base_url(self, server_url: str) -> str: """ - Determine the authorization base URL by discarding any path component. - - Per MCP spec Section 2.3.2: "The authorization base URL MUST be determined - from the MCP server URL by discarding any existing path component." - - Example: https://api.example.com/v1/mcp -> https://api.example.com + Extract base URL by removing path component. + + Per MCP spec 2.3.2: https://api.example.com/v1/mcp -> https://api.example.com """ from urllib.parse import urlparse, urlunparse parsed = urlparse(server_url) - # Discard path component by setting it to empty + # Remove path component return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None: """ - Discovers OAuth metadata from the server's well-known endpoint. - - Args: - server_url: Base URL of the OAuth server - - Returns: - OAuthMetadata if found, None otherwise + Discover OAuth metadata from server's well-known endpoint. """ - # Get authorization base URL per MCP spec Section 2.3.2 + # Extract base URL per MCP spec auth_base_url = self._get_authorization_base_url(server_url) url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server") headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} @@ -150,7 +141,7 @@ async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | Non logger.debug(f"OAuth metadata discovered: {metadata_json}") return OAuthMetadata.model_validate(metadata_json) except Exception: - # Try without MCP protocol version header for CORS issues + # Retry without MCP header for CORS compatibility try: response = await client.get(url) if response.status_code == 404: @@ -172,15 +163,7 @@ async def _register_oauth_client( metadata: OAuthMetadata | None = None, ) -> OAuthClientInformationFull: """ - Registers an OAuth client with the server. - - Args: - server_url: Base URL of the OAuth server - client_metadata: Client metadata for registration - metadata: Optional OAuth metadata (will be discovered if not provided) - - Returns: - Registered client information + Register OAuth client with server. """ if not metadata: metadata = await self._discover_oauth_metadata(server_url) @@ -188,11 +171,11 @@ async def _register_oauth_client( if metadata and metadata.registration_endpoint: registration_url = str(metadata.registration_endpoint) else: - # Use authorization base URL for fallback registration endpoint + # Use fallback registration endpoint auth_base_url = self._get_authorization_base_url(server_url) registration_url = urljoin(auth_base_url, "/register") - # Prepare registration data + # Serialize client metadata registration_data = client_metadata.model_dump( by_alias=True, mode="json", exclude_none=True ) @@ -226,15 +209,13 @@ async def async_auth_flow( self, request: httpx.Request ) -> AsyncGenerator[httpx.Request, httpx.Response]: """ - Handle authentication flow for requests. - - This method adds the Bearer token if available and handles 401 responses. + HTTPX auth flow integration. """ if not self._has_valid_token(): await self.initialize() await self.ensure_token() - # Add token to request if available + # Add Bearer token if available if self._current_tokens and self._current_tokens.access_token: request.headers["Authorization"] = ( f"Bearer {self._current_tokens.access_token}" @@ -242,11 +223,8 @@ async def async_auth_flow( response = yield request - # If we get a 401, we could attempt refresh or re-auth - # but due to the synchronous nature of this method, the calling code - # should handle token refresh/re-authentication at a higher level + # Clear token on 401 to trigger re-auth if response.status_code == 401: - # Clear the token so next request will trigger re-auth self._current_tokens = None def _has_valid_token(self) -> bool: @@ -254,7 +232,7 @@ def _has_valid_token(self) -> bool: if not self._current_tokens or not self._current_tokens.access_token: return False - # Check token expiry if available + # Check expiry time if self._token_expiry_time and time.time() > self._token_expiry_time: return False @@ -262,62 +240,44 @@ def _has_valid_token(self) -> bool: async def _validate_token_scopes(self, token_response: OAuthToken) -> None: """ - Validate that returned scopes are a subset of requested scopes. - - Per OAuth 2.1 Section 3.3, the authorization server may issue a narrower - set of scopes than requested, but must not grant additional scopes. + Validate returned scopes against requested scopes. + + Per OAuth 2.1 Section 3.3: server may grant subset, not superset. """ if not token_response.scope: - # If no scope is returned, validation passes - # (server didn't grant anything extra) + # No scope returned = validation passes return - # Get the originally requested scopes + # Check explicitly requested scopes only requested_scopes: set[str] = set() - # Check for explicitly requested scopes from client metadata if self.client_metadata.scope: - requested_scopes.update(self.client_metadata.scope.split()) - - # If we have registered client info with specific scopes, use those - # (This handles cases where scopes were negotiated during registration) - if ( - self._client_info - and hasattr(self._client_info, "scope") - and self._client_info.scope - ): - # Only override if the client metadata didn't have explicit scopes - # This represents what was actually registered/negotiated with the server - if not requested_scopes: - requested_scopes.update(self._client_info.scope.split()) - - # Parse returned scopes - returned_scopes: set[str] = set(token_response.scope.split()) - - # Validate that returned scopes are a subset of requested scopes - # Only enforce strict validation if we actually have requested scopes - if requested_scopes: - unauthorized_scopes: set[str] = returned_scopes - requested_scopes + # Validate against explicit scope request + requested_scopes = set(self.client_metadata.scope.split()) + + # Check for unauthorized scopes + returned_scopes = set(token_response.scope.split()) + unauthorized_scopes = returned_scopes - requested_scopes + if unauthorized_scopes: raise Exception( f"Server granted unauthorized scopes: {unauthorized_scopes}. " f"Requested: {requested_scopes}, Returned: {returned_scopes}" ) else: - # If no scopes were originally requested (fell back to server defaults), - # accept whatever the server returned + # No explicit scopes requested - accept server defaults logger.debug( - f"No specific scopes were requested, accepting server-granted " - f"scopes: {returned_scopes}" + f"No explicit scopes requested, accepting server-granted " + f"scopes: {set(token_response.scope.split())}" ) async def initialize(self) -> None: - """Initialize the auth handler by loading stored tokens and client info.""" + """Load stored tokens and client info.""" self._current_tokens = await self.storage.get_tokens() self._client_info = await self.storage.get_client_info() async def _get_or_register_client(self) -> OAuthClientInformationFull: - """Get existing client info or register a new client.""" + """Get or register client with server.""" if not self._client_info: try: self._client_info = await self._register_oauth_client( @@ -330,13 +290,13 @@ async def _get_or_register_client(self) -> OAuthClientInformationFull: return self._client_info async def ensure_token(self) -> None: - """Ensure we have a valid access token, performing OAuth flow if needed.""" + """Ensure valid access token, refreshing or re-authenticating as needed.""" async with self._token_lock: - # Check if we have a valid token + # Return early if token is valid if self._has_valid_token(): return - # Try to refresh token first + # Try refreshing existing token if ( self._current_tokens and self._current_tokens.refresh_token @@ -344,85 +304,78 @@ async def ensure_token(self) -> None: ): return - # Perform full OAuth flow + # Fall back to full OAuth flow await self._perform_oauth_flow() async def _perform_oauth_flow(self) -> None: - """Perform complete OAuth2 authorization code flow.""" + """Execute OAuth2 authorization code flow with PKCE.""" logger.debug("Starting authentication flow.") - # Discover metadata if not already done + # Discover OAuth metadata if not self._metadata: self._metadata = await self._discover_oauth_metadata(self.server_url) - # Get or register client + # Ensure client registration client_info = await self._get_or_register_client() - # Generate PKCE parameters + # Generate PKCE challenge self._code_verifier = self._generate_code_verifier() self._code_challenge = self._generate_code_challenge(self._code_verifier) - # Determine endpoints from metadata or use defaults + # Get authorization endpoint if self._metadata and self._metadata.authorization_endpoint: auth_url_base = str(self._metadata.authorization_endpoint) else: - # Use authorization base URL for fallback authorization endpoint + # Use fallback authorization endpoint auth_base_url = self._get_authorization_base_url(self.server_url) auth_url_base = urljoin(auth_base_url, "/authorize") # Build authorization URL + self._auth_state = secrets.token_urlsafe(32) auth_params = { "response_type": "code", "client_id": client_info.client_id, "redirect_uri": str(self.client_metadata.redirect_uris[0]), - "state": secrets.token_urlsafe(32), + "state": self._auth_state, "code_challenge": self._code_challenge, "code_challenge_method": "S256", } - # Set scope parameter following OAuth 2.1 principles: - # 1. Use client's explicit request first (what developer wants) - # 2. Use registered client scope as fallback (what was negotiated) - # 3. No scope = let server decide (omit scope parameter) + # Include explicit scopes only if self.client_metadata.scope: auth_params["scope"] = self.client_metadata.scope - elif hasattr(client_info, "scope") and client_info.scope: - auth_params["scope"] = client_info.scope - # If no scope specified anywhere, don't include scope parameter - # This lets the server grant default scopes per OAuth 2.1 auth_url = f"{auth_url_base}?{urlencode(auth_params)}" - # Handle redirect (open browser or custom handler) + # Redirect user for authorization await self.redirect_handler(auth_url) auth_code, returned_state = await self.callback_handler() - # Validate state parameter using constant-time comparison - # to prevent timing attacks - if returned_state is None: - raise Exception("State parameter mismatch - possible CSRF attack") - # Type cast to ensure both args are str for compare_digest - expected_state = str(auth_params["state"]) - actual_state = str(returned_state) - if not secrets.compare_digest(actual_state, expected_state): - raise Exception("State parameter mismatch - possible CSRF attack") + # Validate state parameter for CSRF protection + if returned_state is None or not secrets.compare_digest( + returned_state, self._auth_state + ): + raise Exception("State parameter mismatch") + + # Clear state after validation + self._auth_state = None if not auth_code: raise Exception("No authorization code received") - # Exchange code for token + # Exchange authorization code for tokens await self._exchange_code_for_token(auth_code, client_info) async def _exchange_code_for_token( self, auth_code: str, client_info: OAuthClientInformationFull ) -> None: """Exchange authorization code for access token.""" - # Determine token endpoint + # Get token endpoint if self._metadata and self._metadata.token_endpoint: token_url = str(self._metadata.token_endpoint) else: - # Use authorization base URL for fallback token endpoint + # Use fallback token endpoint auth_base_url = self._get_authorization_base_url(self.server_url) token_url = urljoin(auth_base_url, "/token") @@ -446,7 +399,7 @@ async def _exchange_code_for_token( ) if response.status_code != 200: - # Try to parse OAuth error response, otherwise use basic error + # Parse OAuth error response try: error_data = response.json() error_msg = error_data.get( @@ -461,35 +414,35 @@ async def _exchange_code_for_token( f"Token exchange failed: {response.status_code} {response.text}" ) - # Parse and store tokens + # Parse token response token_response = OAuthToken.model_validate(response.json()) - # Validate returned scopes against requested scopes (OAuth 2.1 Section 3.3) + # Validate token scopes await self._validate_token_scopes(token_response) - # Calculate expiry time if available + # Calculate token expiry if token_response.expires_in: self._token_expiry_time = time.time() + token_response.expires_in else: self._token_expiry_time = None - # Store tokens in storage and cache + # Store tokens await self.storage.set_tokens(token_response) self._current_tokens = token_response async def _refresh_access_token(self) -> bool: - """Refresh the access token using refresh token.""" + """Refresh access token using refresh token.""" if not self._current_tokens or not self._current_tokens.refresh_token: return False - # Get client info + # Get client credentials client_info = await self._get_or_register_client() - # Determine token endpoint + # Get token endpoint if self._metadata and self._metadata.token_endpoint: token_url = str(self._metadata.token_endpoint) else: - # Use authorization base URL for fallback token endpoint + # Use fallback token endpoint auth_base_url = self._get_authorization_base_url(self.server_url) token_url = urljoin(auth_base_url, "/token") @@ -515,20 +468,19 @@ async def _refresh_access_token(self) -> bool: logger.error(f"Token refresh failed: {response.status_code}") return False - # Parse and store new tokens + # Parse refreshed tokens token_response = OAuthToken.model_validate(response.json()) - # Validate returned scopes against requested scopes - # (OAuth 2.1 Section 3.3) + # Validate token scopes await self._validate_token_scopes(token_response) - # Calculate expiry time if available + # Calculate token expiry if token_response.expires_in: self._token_expiry_time = time.time() + token_response.expires_in else: self._token_expiry_time = None - # Store tokens in storage and cache + # Store refreshed tokens await self.storage.set_tokens(token_response) self._current_tokens = token_response diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 00b490bec..e53f296f5 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -414,7 +414,7 @@ async def test_validate_token_scopes_unauthorized( @pytest.mark.anyio async def test_validate_token_scopes_no_requested(self, oauth_provider): - """Test scope validation with no requested scopes.""" + """Test scope validation with no requested scopes accepts any server scopes.""" # No scope in client metadata oauth_provider.client_metadata.scope = None token = OAuthToken( @@ -423,7 +423,8 @@ async def test_validate_token_scopes_no_requested(self, oauth_provider): scope="admin super", ) - # Should not raise exception when no scopes were requested + # Should not raise exception when no scopes were explicitly requested + # (accepts server defaults) await oauth_provider._validate_token_scopes(token) @pytest.mark.anyio @@ -780,10 +781,10 @@ def test_scope_priority_client_metadata_first( assert auth_params["scope"] == "read write" - def test_scope_priority_client_info_fallback( + def test_scope_priority_no_client_metadata_scope( self, oauth_provider, oauth_client_info ): - """Test that client info scope is used as fallback.""" + """Test that no scope parameter is set when client metadata has no scope.""" oauth_provider.client_metadata.scope = None oauth_provider._client_info = oauth_client_info oauth_provider._client_info.scope = "admin" @@ -798,16 +799,13 @@ def test_scope_priority_client_info_fallback( "code_challenge_method": "S256", } - # Apply scope logic from _perform_oauth_flow + # Apply simplified scope logic from _perform_oauth_flow if oauth_provider.client_metadata.scope: auth_params["scope"] = oauth_provider.client_metadata.scope - elif ( - hasattr(oauth_provider._client_info, "scope") - and oauth_provider._client_info.scope - ): - auth_params["scope"] = oauth_provider._client_info.scope + # No fallback to client_info scope in simplified logic - assert auth_params["scope"] == "admin" + # No scope should be set since client metadata doesn't have explicit scope + assert "scope" not in auth_params def test_scope_priority_no_scope(self, oauth_provider, oauth_client_info): """Test that no scope parameter is set when no scopes specified.""" @@ -888,7 +886,7 @@ async def mock_redirect_handler(url: str) -> None: oauth_provider.redirect_handler = mock_redirect_handler with pytest.raises( - Exception, match="State parameter mismatch - possible CSRF attack" + Exception, match="State parameter is missing - possible CSRF attack" ): await oauth_provider._perform_oauth_flow() From d6b8d58de0c61a9f430b7ecf4f2ad92743aecd8e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 10:24:20 +0100 Subject: [PATCH 14/19] tests --- tests/client/test_auth.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index e53f296f5..996534e9c 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -858,9 +858,7 @@ async def mock_redirect_handler(url: str) -> None: with patch( "mcp.client.auth.secrets.compare_digest", return_value=False ) as mock_compare: - with pytest.raises( - Exception, match="State parameter mismatch - possible CSRF attack" - ): + with pytest.raises(Exception, match="State parameter mismatch"): await oauth_provider._perform_oauth_flow() # Verify constant-time comparison was used @@ -885,9 +883,7 @@ async def mock_redirect_handler(url: str) -> None: oauth_provider.redirect_handler = mock_redirect_handler - with pytest.raises( - Exception, match="State parameter is missing - possible CSRF attack" - ): + with pytest.raises(Exception, match="State parameter mismatch"): await oauth_provider._perform_oauth_flow() @pytest.mark.anyio From b2716008b622ccb6d06b558d33d22e9989ffd0eb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 19 May 2025 11:25:09 +0100 Subject: [PATCH 15/19] set scopes on client metadata if supported --- src/mcp/client/auth.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 18a07d3c8..595a194c8 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -175,6 +175,10 @@ async def _register_oauth_client( auth_base_url = self._get_authorization_base_url(server_url) registration_url = urljoin(auth_base_url, "/register") + # Handle default scope + if client_metadata.scope is None and metadata and metadata.scopes_supported is not None: + client_metadata.scope = " ".join(metadata.scopes_supported) + # Serialize client metadata registration_data = client_metadata.model_dump( by_alias=True, mode="json", exclude_none=True @@ -356,7 +360,7 @@ async def _perform_oauth_flow(self) -> None: if returned_state is None or not secrets.compare_digest( returned_state, self._auth_state ): - raise Exception("State parameter mismatch") + raise Exception(f"State parameter mismatch: {returned_state} != {self._auth_state}") # Clear state after validation self._auth_state = None From 3f2793ce0a707c073cd271f099bad73b45d330c8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 12:13:06 +0100 Subject: [PATCH 16/19] remove comments --- .../mcp_simple_auth_client/main.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 55cb80e45..843300e1d 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -102,20 +102,16 @@ def __init__(self, port=3000): self.port = port self.server = None self.thread = None - self.callback_data = { - "authorization_code": None, - "state": None, - "error": None - } + self.callback_data = {"authorization_code": None, "state": None, "error": None} def _create_handler_with_data(self): """Create a handler class with access to callback data.""" callback_data = self.callback_data - + class DataCallbackHandler(CallbackHandler): def __init__(self, request, client_address, server): super().__init__(request, client_address, server, callback_data) - + return DataCallbackHandler def start(self): @@ -144,7 +140,7 @@ def wait_for_callback(self, timeout=300): raise Exception(f"OAuth error: {self.callback_data['error']}") time.sleep(0.1) raise Exception("Timeout waiting for OAuth callback") - + def get_state(self): """Get the received state parameter.""" return self.callback_data["state"] @@ -155,9 +151,6 @@ class SimpleAuthClient: def __init__(self, server_url: str): self.server_url = server_url - # Extract base URL for auth server (remove /mcp endpoint for auth endpoints) - # Use default redirect URI - this is where the auth server will redirect - # The user will need to copy the authorization code from this callback URL self.session: ClientSession | None = None async def connect(self): @@ -202,8 +195,6 @@ async def _default_redirect_handler(authorization_url: str) -> None: callback_handler=callback_handler, ) - # Initialize the auth handler and ensure we have tokens - # Create streamable HTTP transport with auth handler stream_context = streamablehttp_client( url=self.server_url, From 2c8d9ec925da3e68225b9fad20abd4cfd095362e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 13:49:04 +0100 Subject: [PATCH 17/19] update readme --- README.md | 90 +++++++++++++------------------------------------------ 1 file changed, 20 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index dd8af7fb0..6160acbf3 100644 --- a/README.md +++ b/README.md @@ -798,88 +798,38 @@ async def main(): ### OAuth Authentication for Clients -The SDK supports OAuth 2.0 client authentication for secure access to MCP servers that require authentication: +The SDK includes OAuth 2.0 support for connecting to protected MCP servers: ```python -from mcp.client.auth import UnauthorizedError -from mcp.client.oauth_providers import InMemoryOAuthProvider +from mcp.client.auth import OAuthClientProvider from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientMetadata -from mcp import ClientSession +import webbrowser -# Create an OAuth provider -oauth_provider = InMemoryOAuthProvider( - redirect_url="http://localhost:8080/callback", +# Set up OAuth authentication +oauth_auth = OAuthClientProvider( + server_url="https://api.example.com", client_metadata=OAuthClientMetadata( - redirect_uris=["http://localhost:8080/callback"], - client_name="My MCP Client", - scope="tools resources", # Request specific scopes + client_name="My Client", + redirect_uris=["http://localhost:3000/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="read write" ), + storage=InMemoryTokenStorage(), # Implement TokenStorage protocol + redirect_handler=lambda url: webbrowser.open(url), + callback_handler=handle_oauth_callback, # Handle OAuth callback ) - -async def main(): - # Connect with OAuth authentication - async with streamablehttp_client( - "https://example.com/mcp", - auth_provider=oauth_provider, - ) as (read_stream, write_stream, _): - # Create a session - async with ClientSession(read_stream, write_stream) as session: - # Initialize (this may trigger OAuth flow) - try: - await session.initialize() - # Use authenticated session - result = await session.call_tool("protected_tool", {"arg": "value"}) - except UnauthorizedError: - # Handle authorization required - print("Authorization required. Check your browser.") - - -# Handle OAuth callback after user authorization -async def handle_callback(authorization_code: str): - from mcp.client.streamable_http import StreamableHTTPTransport - - # Create a transport instance to handle auth completion - transport = StreamableHTTPTransport( - url="https://example.com/mcp", - auth_provider=oauth_provider, - ) - - # Exchange authorization code for tokens - await transport.finish_auth(authorization_code) - print("Authorization successful!") -``` - -#### Custom OAuth Providers - -You can implement custom OAuth storage by creating your own provider: - -```python -from mcp.client.oauth_providers import InMemoryOAuthProvider - - -class DatabaseOAuthProvider(InMemoryOAuthProvider): - async def save_tokens(self, tokens): - # Save to database - # await db.save_tokens(self.client_id, tokens) - pass - - async def tokens(self): - # Load from database - # return await db.load_tokens(self.client_id) - return None - - # Implement other methods as needed... +# Use with streamable HTTP client +async with streamablehttp_client("https://api.example.com/mcp", auth=oauth_auth) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + # Authenticated session ready ``` -The OAuth client implementation supports: +For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). -- Dynamic client registration -- Authorization code flow with PKCE -- Token refresh -- Multiple storage providers (in-memory and file-based included) -- Automatic token management and retry logic ### MCP Primitives From d9441a2e9082fc9cf3a285ea5279fd1c03227b09 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 13:51:09 +0100 Subject: [PATCH 18/19] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6160acbf3..c2d18d170 100644 --- a/README.md +++ b/README.md @@ -798,7 +798,7 @@ async def main(): ### OAuth Authentication for Clients -The SDK includes OAuth 2.0 support for connecting to protected MCP servers: +The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: ```python from mcp.client.auth import OAuthClientProvider From a894b6b4fbe1408271c98e37cce4b9c1adf3f652 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 19 May 2025 20:30:48 +0100 Subject: [PATCH 19/19] CI fixes --- README.md | 65 +++++++++++++++++++++++++++--------------- src/mcp/client/auth.py | 14 ++++++--- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c2d18d170..26f43cfd9 100644 --- a/README.md +++ b/README.md @@ -801,31 +801,50 @@ async def main(): The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: ```python -from mcp.client.auth import OAuthClientProvider +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.auth import OAuthClientMetadata -import webbrowser - -# Set up OAuth authentication -oauth_auth = OAuthClientProvider( - server_url="https://api.example.com", - client_metadata=OAuthClientMetadata( - client_name="My Client", - redirect_uris=["http://localhost:3000/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="read write" - ), - storage=InMemoryTokenStorage(), # Implement TokenStorage protocol - redirect_handler=lambda url: webbrowser.open(url), - callback_handler=handle_oauth_callback, # Handle OAuth callback -) +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class CustomTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + async def get_tokens(self) -> OAuthToken | None: + pass + + async def set_tokens(self, tokens: OAuthToken) -> None: + pass + + async def get_client_info(self) -> OAuthClientInformationFull | None: + pass + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + pass -# Use with streamable HTTP client -async with streamablehttp_client("https://api.example.com/mcp", auth=oauth_auth) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - # Authenticated session ready + +async def main(): + # Set up OAuth authentication + oauth_auth = OAuthClientProvider( + server_url="https://api.example.com", + client_metadata=OAuthClientMetadata( + client_name="My Client", + redirect_uris=["http://localhost:3000/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ), + storage=CustomTokenStorage(), + redirect_handler=lambda url: print(f"Visit: {url}"), + callback_handler=lambda: ("auth_code", None), + ) + + # Use with streamable HTTP client + async with streamablehttp_client( + "https://api.example.com/mcp", auth=oauth_auth + ) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + # Authenticated session ready ``` For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 595a194c8..fc6c96a43 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -113,7 +113,7 @@ def _generate_code_challenge(self, code_verifier: str) -> str: def _get_authorization_base_url(self, server_url: str) -> str: """ Extract base URL by removing path component. - + Per MCP spec 2.3.2: https://api.example.com/v1/mcp -> https://api.example.com """ from urllib.parse import urlparse, urlunparse @@ -176,7 +176,11 @@ async def _register_oauth_client( registration_url = urljoin(auth_base_url, "/register") # Handle default scope - if client_metadata.scope is None and metadata and metadata.scopes_supported is not None: + if ( + client_metadata.scope is None + and metadata + and metadata.scopes_supported is not None + ): client_metadata.scope = " ".join(metadata.scopes_supported) # Serialize client metadata @@ -245,7 +249,7 @@ def _has_valid_token(self) -> bool: async def _validate_token_scopes(self, token_response: OAuthToken) -> None: """ Validate returned scopes against requested scopes. - + Per OAuth 2.1 Section 3.3: server may grant subset, not superset. """ if not token_response.scope: @@ -360,7 +364,9 @@ async def _perform_oauth_flow(self) -> None: if returned_state is None or not secrets.compare_digest( returned_state, self._auth_state ): - raise Exception(f"State parameter mismatch: {returned_state} != {self._auth_state}") + raise Exception( + f"State parameter mismatch: {returned_state} != {self._auth_state}" + ) # Clear state after validation self._auth_state = None