From 873bbe0f45ed548acb233242cd75cb2d9cc25896 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Mon, 23 Jun 2025 10:52:29 -0700 Subject: [PATCH 01/12] Implement client credentials auth flow --- src/mcp/client/auth.py | 76 +++++++++++++++++++++++++++++++----------- src/mcp/shared/auth.py | 13 ++++---- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index c174385ea..5127acddb 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -12,7 +12,7 @@ import time from collections.abc import AsyncGenerator, Awaitable, Callable from dataclasses import dataclass, field -from typing import Protocol +from typing import Optional, Protocol from urllib.parse import urlencode, urljoin, urlparse import anyio @@ -87,8 +87,8 @@ class OAuthContext: server_url: str client_metadata: OAuthClientMetadata storage: TokenStorage - redirect_handler: Callable[[str], Awaitable[None]] - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] + redirect_handler: Optional[Callable[[str], Awaitable[None]]] + callback_handler: Optional[Callable[[], Awaitable[tuple[str, str | None]]]] timeout: float = 300.0 # Discovered metadata @@ -164,8 +164,8 @@ def __init__( server_url: str, client_metadata: OAuthClientMetadata, storage: TokenStorage, - redirect_handler: Callable[[str], Awaitable[None]], - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]], + redirect_handler: Optional[Callable[[str], Awaitable[None]]] = None, + callback_handler: Optional[Callable[[], Awaitable[tuple[str, str | None]]]] = None, timeout: float = 300.0, ): """Initialize OAuth2 authentication.""" @@ -250,8 +250,27 @@ async def _handle_registration_response(self, response: httpx.Response) -> None: except ValidationError as e: raise OAuthRegistrationError(f"Invalid registration response: {e}") - async def _perform_authorization(self) -> tuple[str, str]: + async def _perform_authorization(self) -> httpx.Request: + """Perform the authorization flow.""" + if not self.context.client_info: + raise OAuthFlowError("No client info available for authorization") + + if "client_credentials" in self.context.client_info.grant_types: + token_request = await self._exchange_token_client_credentials() + return token_request + pass + else: + auth_code, code_verifier = await self._perform_authorization_code_grant() + token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) + return token_request + + async def _perform_authorization_code_grant(self) -> tuple[str, str]: """Perform the authorization redirect and get auth code.""" + if not self.context.redirect_handler: + raise OAuthFlowError("No redirect handler provided for authorization code grant") + if not self.context.callback_handler: + raise OAuthFlowError("No callback handler provided for authorization code grant") + if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) else: @@ -293,8 +312,8 @@ async def _perform_authorization(self) -> tuple[str, str]: # Return auth code and code verifier for token exchange return auth_code, pkce_params.code_verifier - async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Request: - """Build token exchange request.""" + async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx.Request: + """Build token exchange request for authorization_code flow.""" if not self.context.client_info: raise OAuthFlowError("Missing client info") @@ -320,6 +339,31 @@ async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Req "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} ) + async def _exchange_token_client_credentials(self) -> httpx.Request: + """Build token exchange request for client_credentials flow.""" + if not self.context.client_info: + raise OAuthFlowError("Missing client info") + + if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: + token_url = str(self.context.oauth_metadata.token_endpoint) + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + token_url = urljoin(auth_base_url, "/token") + + token_data = { + "grant_type": "client_credentials", + "resource": self.context.get_resource_url(), # RFC 8707 + } + + if self.context.client_info.client_id: + token_data["client_id"] = self.context.client_info.client_id + if self.context.client_info.client_secret: + token_data["client_secret"] = self.context.client_info.client_secret + + return httpx.Request( + "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + async def _handle_token_response(self, response: httpx.Response) -> None: """Handle token exchange response.""" if response.status_code != 200: @@ -429,12 +473,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. registration_response = yield registration_request await self._handle_registration_response(registration_response) - # Step 4: Perform authorization - auth_code, code_verifier = await self._perform_authorization() - - # Step 5: Exchange authorization code for tokens - token_request = await self._exchange_token(auth_code, code_verifier) - token_response = yield token_request + # Step 4: Perform authorization and complete token exchange + token_response = yield await self._perform_authorization() await self._handle_token_response(token_response) except Exception as e: logger.error(f"OAuth flow error: {e}") @@ -475,12 +515,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. registration_response = yield registration_request await self._handle_registration_response(registration_response) - # Step 4: Perform authorization - auth_code, code_verifier = await self._perform_authorization() - - # Step 5: Exchange authorization code for tokens - token_request = await self._exchange_token(auth_code, code_verifier) - token_response = yield token_request + # Step 4: Perform authorization and complete token exchange + token_response = yield await self._perform_authorization() await self._handle_token_response(token_response) # Retry with new tokens diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 1f2d1659a..437054f03 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -42,13 +42,12 @@ class OAuthClientMetadata(BaseModel): """ redirect_uris: list[AnyUrl] = Field(..., min_length=1) - # token_endpoint_auth_method: this implementation only supports none & - # client_secret_post; - # ie: we do not support client_secret_basic - token_endpoint_auth_method: Literal["none", "client_secret_post"] = "client_secret_post" - # grant_types: this implementation only supports authorization_code & refresh_token - grant_types: list[Literal["authorization_code", "refresh_token"]] = [ + # supported auth methods for the token endpoint + token_endpoint_auth_method: Literal["none", "client_secret_basic", "client_secret_post"] = "client_secret_post" + # supported grant_types of this implementation + grant_types: list[Literal["authorization_code", "client_credentials", "refresh_token"]] = [ "authorization_code", + "client_credentials", "refresh_token", ] # this implementation only supports code; ie: it does not support implicit grants @@ -96,7 +95,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): (client information plus metadata). """ - client_id: str + client_id: str | None = None client_secret: str | None = None client_id_issued_at: int | None = None client_secret_expires_at: int | None = None From 7c65d76ecfd27383a0687b225aa22ce021abdb4f Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Mon, 23 Jun 2025 15:31:17 -0700 Subject: [PATCH 02/12] Add example client and server for client_credentials flow --- .../README.md | 74 ++++++ .../__init__.py | 1 + .../main.py | 251 ++++++++++++++++++ .../pyproject.toml | 30 +++ .../clients/simple-auth-client/pyproject.toml | 11 +- .../simple-auth-client-credentials/README.md | 112 ++++++++ .../__init__.py | 1 + .../__main__.py | 7 + .../auth_server.py | 120 +++++++++ .../server.py | 195 ++++++++++++++ .../token_verifier.py | 64 +++++ .../pyproject.toml | 32 +++ pyproject.toml | 4 +- src/mcp/client/auth.py | 35 ++- src/mcp/shared/auth.py | 6 +- uv.lock | 142 +++++++++- 16 files changed, 1058 insertions(+), 27 deletions(-) create mode 100644 examples/clients/simple-auth-client-client-credentials/README.md create mode 100644 examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/__init__.py create mode 100644 examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py create mode 100644 examples/clients/simple-auth-client-client-credentials/pyproject.toml create mode 100644 examples/servers/simple-auth-client-credentials/README.md create mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__init__.py create mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__main__.py create mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py create mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py create mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py create mode 100644 examples/servers/simple-auth-client-credentials/pyproject.toml diff --git a/examples/clients/simple-auth-client-client-credentials/README.md b/examples/clients/simple-auth-client-client-credentials/README.md new file mode 100644 index 000000000..cf6050b1c --- /dev/null +++ b/examples/clients/simple-auth-client-client-credentials/README.md @@ -0,0 +1,74 @@ +# Simple Auth Client Example + +A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. + +## Features + +- OAuth 2.0 authentication with PKCE +- Support for both StreamableHTTP and SSE transports +- Interactive command-line interface + +## Installation + +```bash +cd examples/clients/simple-auth-client +uv sync --reinstall +``` + +## Usage + +### 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 + +```bash +uv run mcp-simple-auth-client + +# Or with custom server URL +MCP_SERVER_PORT=3001 uv run mcp-simple-auth-client + +# Use SSE transport +MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client +``` + +### 3. Complete OAuth flow + +The client will open your browser for authentication. After completing OAuth, you can use commands: + +- `list` - List available tools +- `call [args]` - Call a tool with optional JSON arguments +- `quit` - Exit + +## Example + +``` +šŸ” 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 + +mcp> list +šŸ“‹ Available tools: +1. echo - Echo back the input text + +mcp> call echo {"text": "Hello, world!"} +šŸ”§ Tool 'echo' result: +Hello, world! + +mcp> quit +šŸ‘‹ Goodbye! +``` + +## Configuration + +- `MCP_SERVER_PORT` - Server URL (default: 8000) +- `MCP_TRANSPORT_TYPE` - Transport type: `streamable_http` (default) or `sse` diff --git a/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/__init__.py b/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/__init__.py new file mode 100644 index 000000000..06eb1f29d --- /dev/null +++ b/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/__init__.py @@ -0,0 +1 @@ +"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py b/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py new file mode 100644 index 000000000..0d1070f7a --- /dev/null +++ b/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py @@ -0,0 +1,251 @@ +#!/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. + +""" + +import asyncio +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, TokenStorage +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self, client_id: str | None, client_secret: str | None): + self._tokens: OAuthToken | None = None + self._client_info = OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=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 SimpleAuthClient: + """Simple MCP client with auth support.""" + + def __init__(self, server_url: str, transport_type: str = "streamable_http"): + self.server_url = server_url + self.transport_type = transport_type + self.session: ClientSession | None = None + + async def connect(self): + """Connect to the MCP server.""" + print(f"šŸ”— Attempting to connect to {self.server_url}...") + + try: + client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": None, + "grant_types": ["client_credentials"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": "identify" + } + + # Create OAuth authentication handler using the new interface + oauth_auth = OAuthClientProvider( + server_url=self.server_url.replace("/mcp", ""), + client_metadata=OAuthClientMetadata.model_validate( + client_metadata_dict + ), + storage=InMemoryTokenStorage( + client_id=os.environ.get("MCP_DISCORD_CLIENT_ID"), + client_secret=os.environ.get("MCP_DISCORD_CLIENT_SECRET"), + ), + ) + oauth_auth.context.client_info = OAuthClientInformationFull( + redirect_uris=None, + ) + + # Create transport with auth handler based on transport type + if self.transport_type == "sse": + print("šŸ“” Opening SSE transport connection with auth...") + async with sse_client( + url=self.server_url, + auth=oauth_auth, + timeout=60, + ) as (read_stream, write_stream): + await self._run_session(read_stream, write_stream, None) + else: + print("šŸ“” Opening StreamableHTTP transport connection with auth...") + async with streamablehttp_client( + url=self.server_url, + auth=oauth_auth, + timeout=timedelta(seconds=60), + ) as (read_stream, write_stream, get_session_id): + await self._run_session(read_stream, write_stream, get_session_id) + + except Exception as e: + print(f"āŒ Failed to connect: {e}") + import traceback + + traceback.print_exc() + + async def _run_session(self, read_stream, write_stream, get_session_id): + """Run the MCP session with the given streams.""" + 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}") + if get_session_id: + session_id = get_session_id() + if session_id: + print(f"Session ID: {session_id}") + + # Run interactive loop + await self.interactive_loop() + + 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 content.type == "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_PORT", 8000) + transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable_http") + server_url = ( + f"http://localhost:{server_url}/mcp" + if transport_type == "streamable_http" + else f"http://localhost:{server_url}/sse" + ) + + print("šŸš€ Simple MCP Auth Client") + print(f"Connecting to: {server_url}") + print(f"Transport type: {transport_type}") + + # Start connection flow - OAuth will be handled automatically + client = SimpleAuthClient(server_url, transport_type) + 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-client-credentials/pyproject.toml b/examples/clients/simple-auth-client-client-credentials/pyproject.toml new file mode 100644 index 000000000..112e0a575 --- /dev/null +++ b/examples/clients/simple-auth-client-client-credentials/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "mcp-simple-auth-client-client-credentials" +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"] + +[project.scripts] +mcp-simple-auth-client-client-credentials = "mcp_simple_auth_client_client_credentials.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client_client_credentials"] + +[tool.uv] +dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml index 5ae7c6b9d..bbe7f9b8f 100644 --- a/examples/clients/simple-auth-client/pyproject.toml +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -14,10 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = [ - "click>=8.0.0", - "mcp>=1.0.0", -] +dependencies = ["click>=8.0.0", "mcp"] [project.scripts] mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" @@ -44,9 +41,3 @@ 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/servers/simple-auth-client-credentials/README.md b/examples/servers/simple-auth-client-credentials/README.md new file mode 100644 index 000000000..5eca71824 --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/README.md @@ -0,0 +1,112 @@ +# MCP OAuth Authentication Demo + +This example demonstrates OAuth 2.0 authentication with the Model Context Protocol as an OAuth 2.0 Resource Server using the `client_credentials` token exchange. + +--- + +## Setup Requirements + +**Create a Discord OAuth App:** + +- Go to the [Discord Developer Portal](https://discord.com/developers/applications) > New Application +- Navigate to Settings > OAuth2 +- Note down your **Client ID** +- Reset your **Client Secret** and note it down + +**Set environment variables:** + +```bash +export MCP_DISCORD_CLIENT_ID="your_client_id_here" +export MCP_DISCORD_CLIENT_SECRET="your_client_secret_here" +``` + +--- + +## Running the Servers + +### Step 1: Start Authorization Server + +```bash +# Navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Authorization Server on port 9000 +uv run mcp-simple-auth-as --port=9000 +``` + +**What it provides:** + +- OAuth 2.0 flows (registration, authorization, token exchange) +- Discord OAuth integration for user authentication + +--- + +### Step 2: Start Resource Server (MCP Server) + +```bash +# In another terminal, navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Resource Server on port 8001, connected to Authorization Server +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http +``` + +### Step 3: Test with Client + +```bash +cd examples/clients/simple-auth-client-client-credentials +# Start client with streamable HTTP +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable_http uv run mcp-simple-auth-client-client-credentials +``` + +## How It Works + +### RFC 9728 Discovery + +**Client → Resource Server:** + +```bash +curl http://localhost:8001/.well-known/oauth-protected-resource +``` + +```json +{ + "resource": "http://localhost:8001", + "authorization_servers": ["http://localhost:9000"] +} +``` + +**Client → Authorization Server:** + +```bash +curl http://localhost:9000/.well-known/oauth-authorization-server +``` + +```json +{ + "issuer": "http://localhost:9000", + "authorization_endpoint": "http://localhost:9000/authorize", + "token_endpoint": "http://localhost:9000/token" +} +``` + +## Manual Testing + +### Test Discovery + +```bash +# Test Resource Server discovery endpoint (new architecture) +curl -v http://localhost:8001/.well-known/oauth-protected-resource + +# Test Authorization Server metadata +curl -v http://localhost:9000/.well-known/oauth-authorization-server +``` + +### Test Token Introspection + +```bash +# After getting a token through OAuth flow: +curl -X POST http://localhost:9000/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=your_access_token" +``` diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__init__.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__init__.py new file mode 100644 index 000000000..35ed549de --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__init__.py @@ -0,0 +1 @@ +"""Simple MCP server with Discord OAuth authentication over client credentials.""" diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__main__.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__main__.py new file mode 100644 index 000000000..468c339b4 --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/__main__.py @@ -0,0 +1,7 @@ +"""Main entry point for simple MCP server with Discord OAuth authentication over client credentials.""" + +import sys + +from mcp_simple_auth_client_credentials.server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py new file mode 100644 index 000000000..f3d31d20e --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py @@ -0,0 +1,120 @@ +""" +Authorization Server for MCP Split Demo. + +This server handles OAuth flows, client registration, and token issuance. +Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +Usage: + python -m mcp_simple_auth.auth_server --port=9000 +""" + +import asyncio +import logging + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.endpoints import HTTPEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from uvicorn import Config, Server + +from mcp.server.auth.handlers.metadata import MetadataHandler +from mcp.server.auth.routes import cors_middleware, create_auth_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.auth import OAuthMetadata + +logger = logging.getLogger(__name__) + +API_BASE = "https://discord.com" +API_ENDPOINT = f"{API_BASE}/api/v10" + + +class AuthServerSettings(BaseModel): + """Settings for the Authorization Server.""" + + # Server settings + host: str = "localhost" + port: int = 9000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + +def create_authorization_server() -> Starlette: + """Create the Authorization Server application.""" + + routes = [ + # Create RFC 8414 authorization server metadata endpoint + Route( + "/.well-known/oauth-authorization-server", + endpoint=cors_middleware( + MetadataHandler(metadata=OAuthMetadata( + issuer=AnyHttpUrl(API_BASE), + authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), + token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), + token_endpoint_auth_methods_supported=["client_secret_basic"], + response_types_supported=["code"], + grant_types_supported=["client_credentials"], + scopes_supported=["identify"] + )).handle, + ["GET", "OPTIONS"], + ), + methods=["GET", "OPTIONS"], + ), + ] + + return Starlette(routes=routes) + + +async def run_server(server_settings: AuthServerSettings): + """Run the Authorization Server.""" + auth_server = create_authorization_server() + + config = Config( + auth_server, + host=server_settings.host, + port=server_settings.port, + log_level="info", + ) + server = Server(config) + + logger.info("=" * 80) + logger.info("MCP AUTHORIZATION PROXY SERVER") + logger.info("=" * 80) + logger.info(f"Server URL: {server_settings.server_url}") + logger.info("Endpoints:") + logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server") + logger.info("") + logger.info("=" * 80) + + await server.serve() + + +@click.command() +@click.option("--port", default=9000, help="Port to listen on") +def main(port: int) -> int: + """ + Run the MCP Authorization Server. + + This server handles OAuth flows and can be used by multiple Resource Servers. + """ + logging.basicConfig(level=logging.INFO) + + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = AuthServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + ) + + asyncio.run(run_server(server_settings)) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py new file mode 100644 index 000000000..fd6452363 --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py @@ -0,0 +1,195 @@ +""" +MCP Resource Server. + +Usage: + python -m mcp_simple_auth.server --port=8001 +""" + +import logging +from typing import Any, Literal + +import click +import httpx +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP + +from .token_verifier import IntrospectionTokenVerifier + + +logger = logging.getLogger(__name__) + +API_ENDPOINT = "https://discord.com/api/v10" + +class ResourceServerSettings(BaseSettings): + """Settings for the MCP Resource Server.""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + # Server settings + host: str = "localhost" + port: int = 8001 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001") + + # Authorization Server settings + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = f"{API_ENDPOINT}/oauth2/@me" + auth_server_discord_user_endpoint: str = f"{API_ENDPOINT}/users/@me" + + # MCP settings + mcp_scope: str = "identify" + + def __init__(self, **data): + """Initialize settings with values from environment variables.""" + super().__init__(**data) + + +def create_resource_server(settings: ResourceServerSettings) -> FastMCP: + """ + Create MCP Resource Server. + """ + + # Create token verifier for introspection with RFC 8707 resource validation + token_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + ) + + # Create FastMCP server as a Resource Server + app = FastMCP( + name="MCP Resource Server", + host=settings.host, + port=settings.port, + debug=True, + token_verifier=token_verifier, + auth=AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ), + ) + + async def get_discord_user_data() -> dict[str, Any]: + """ + Get Discord user data via the Discord API. + """ + access_token = get_access_token() + if not access_token: + raise ValueError("Not authenticated") + + async with httpx.AsyncClient() as client: + response = await client.get( + settings.auth_server_discord_user_endpoint, + headers={ + "Authorization": f"Bearer {access_token.token}", + }, + ) + + if response.status_code != 200: + raise ValueError(f"Discord user data fetch failed: {response.status_code} - {response.text}") + + return response.json() + + @app.tool() + async def get_user_profile() -> dict[str, Any]: + """ + Get the authenticated user's Discord profile information. + """ + return await get_discord_user_data() + + @app.tool() + async def get_user_info() -> dict[str, Any]: + """ + Get information about the currently authenticated user. + + Returns token and scope information from the Resource Server's perspective. + """ + access_token = get_access_token() + if not access_token: + raise ValueError("Not authenticated") + + return { + "authenticated": True, + "client_id": access_token.client_id, + "scopes": access_token.scopes, + "token_expires_at": access_token.expires_at, + "token_type": "Bearer", + "resource_server": str(settings.server_url), + "authorization_server": str(settings.auth_server_url), + } + + return app + + +@click.command() +@click.option("--port", default=8001, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"]) -> int: + """ + Run the MCP Resource Server. + """ + logging.basicConfig(level=logging.INFO) + + try: + # Parse auth server URL + auth_server_url = AnyHttpUrl(auth_server) + + # Create settings + host = "localhost" + server_url = f"http://{host}:{port}" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=auth_server_url, + auth_server_introspection_endpoint=f"{API_ENDPOINT}/oauth2/@me", + auth_server_discord_user_endpoint=f"{API_ENDPOINT}/users/@me", + ) + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Make sure to provide a valid Authorization Server URL") + return 1 + + try: + mcp_server = create_resource_server(settings) + + logger.info("=" * 80) + logger.info("šŸ“¦ MCP RESOURCE SERVER") + logger.info("=" * 80) + logger.info(f"🌐 Server URL: {settings.server_url}") + logger.info(f"šŸ”‘ Authorization Server: {settings.auth_server_url}") + logger.info("šŸ“‹ Endpoints:") + logger.info(f" ā”Œā”€ Protected Resource Metadata: {settings.server_url}/.well-known/oauth-protected-resource") + mcp_path = "sse" if transport == "sse" else "mcp" + logger.info(f" ā”œā”€ MCP Protocol: {settings.server_url}/{mcp_path}") + logger.info(f" └─ Token Introspection: {settings.auth_server_introspection_endpoint}") + logger.info("") + logger.info("šŸ› ļø Available Tools:") + logger.info(" ā”œā”€ get_user_profile() - Get Discord user profile") + logger.info(" └─ get_user_info() - Get authentication status") + logger.info("") + logger.info("šŸ” Tokens validated via Authorization Server introspection") + logger.info("šŸ“± Clients discover Authorization Server via Protected Resource Metadata") + logger.info("=" * 80) + + # Run the server - this should block and keep running + mcp_server.run(transport=transport) + logger.info("Server stopped") + return 0 + except Exception as e: + logger.error(f"Server error: {e}") + logger.exception("Exception details:") + return 1 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py new file mode 100644 index 000000000..ca3ad1ebc --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py @@ -0,0 +1,64 @@ +"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" + +from datetime import datetime +import logging + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). + """ + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + # Validate URL to prevent SSRF attacks + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): + logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") + return None + + # Configure secure HTTP client + timeout = httpx.Timeout(10.0, connect=5.0) + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + + async with httpx.AsyncClient( + timeout=timeout, + limits=limits, + verify=True, # Enforce SSL verification + headers={ + "Authorization": f"Bearer {token}", + }, + ) as client: + try: + response = await client.get( + self.introspection_endpoint, + ) + + if response.status_code != 200: + logger.debug(f"Token introspection returned status {response.status_code}") + return None + + data = response.json() + return AccessToken( + token=token, + client_id=data.get("application", {"id": "unknown"}).get("id", "unknown"), + scopes=data.get("scopes", "") if data.get("scopes") else [], + expires_at=int(datetime.fromisoformat(data.get("expires")).timestamp()), + ) + except Exception as e: + logger.warning(f"Token introspection failed: {e}") + return None diff --git a/examples/servers/simple-auth-client-credentials/pyproject.toml b/examples/servers/simple-auth-client-credentials/pyproject.toml new file mode 100644 index 000000000..8def0f7a2 --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "mcp-simple-auth-client-credentials" +version = "0.1.0" +description = "A simple MCP server demonstrating OAuth authentication with client credentials (2LO)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +license = { text = "MIT" } +dependencies = [ + "anyio>=4.5", + "click>=8.1.0", + "httpx>=0.27", + "mcp", + "pydantic>=2.0", + "pydantic-settings>=2.5.2", + "sse-starlette>=1.6.1", + "uvicorn>=0.23.1; sys_platform != 'emscripten'", +] + +[project.scripts] +mcp-simple-auth-rs = "mcp_simple_auth_client_credentials.server:main" +mcp-simple-auth-as = "mcp_simple_auth_client_credentials.auth_server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client_credentials"] + +[tool.uv] +dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/pyproject.toml b/pyproject.toml index 9ad50ab58..d626c36eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ target-version = "py310" "tests/server/fastmcp/test_func_metadata.py" = ["E501"] [tool.uv.workspace] -members = ["examples/servers/*"] +members = ["examples/clients/*", "examples/servers/*"] [tool.uv.sources] mcp = { workspace = true } @@ -122,5 +122,5 @@ filterwarnings = [ # This should be fixed on Uvicorn's side. "ignore::DeprecationWarning:websockets", "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", - "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel" + "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel", ] diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 5127acddb..5a9d0cecb 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -258,7 +258,6 @@ async def _perform_authorization(self) -> httpx.Request: if "client_credentials" in self.context.client_info.grant_types: token_request = await self._exchange_token_client_credentials() return token_request - pass else: auth_code, code_verifier = await self._perform_authorization_code_grant() token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) @@ -266,6 +265,8 @@ async def _perform_authorization(self) -> httpx.Request: async def _perform_authorization_code_grant(self) -> tuple[str, str]: """Perform the authorization redirect and get auth code.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") if not self.context.redirect_handler: raise OAuthFlowError("No redirect handler provided for authorization code grant") if not self.context.callback_handler: @@ -314,6 +315,8 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx.Request: """Build token exchange request for authorization_code flow.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") if not self.context.client_info: raise OAuthFlowError("Missing client info") @@ -355,19 +358,33 @@ async def _exchange_token_client_credentials(self) -> httpx.Request: "resource": self.context.get_resource_url(), # RFC 8707 } - if self.context.client_info.client_id: - token_data["client_id"] = self.context.client_info.client_id - if self.context.client_info.client_secret: - token_data["client_secret"] = self.context.client_info.client_secret + headers = {"Content-Type": "application/x-www-form-urlencoded"} - return httpx.Request( - "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} - ) + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + if self.context.client_metadata.token_endpoint_auth_method == "client_secret_post": + # Include in request body + if self.context.client_info.client_id: + token_data["client_id"] = self.context.client_info.client_id + if self.context.client_info.client_secret: + token_data["client_secret"] = self.context.client_info.client_secret + elif self.context.client_metadata.token_endpoint_auth_method == "client_secret_basic": + # Include as Basic auth header + if not self.context.client_info.client_id: + raise OAuthTokenError("Missing client_id in Basic auth flow") + if not self.context.client_info.client_secret: + raise OAuthTokenError("Missing client_secret in Basic auth flow") + headers["Authorization"] = f"Basic {base64.b64encode(f'{self.context.client_info.client_id}:{self.context.client_info.client_secret}'.encode()).decode()}" + + return httpx.Request("POST", token_url, data=token_data, headers=headers) async def _handle_token_response(self, response: httpx.Response) -> None: """Handle token exchange response.""" if response.status_code != 200: - raise OAuthTokenError(f"Token exchange failed: {response.status_code}") + body = await response.aread() + body = body.decode("utf-8") + raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body}") try: content = await response.aread() diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 437054f03..801b9ade0 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -41,7 +41,7 @@ class OAuthClientMetadata(BaseModel): for the full specification. """ - redirect_uris: list[AnyUrl] = Field(..., min_length=1) + redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) # supported auth methods for the token endpoint token_endpoint_auth_method: Literal["none", "client_secret_basic", "client_secret_post"] = "client_secret_post" # supported grant_types of this implementation @@ -80,10 +80,10 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: if redirect_uri is not None: # Validate redirect_uri against client's registered redirect URIs - if redirect_uri not in self.redirect_uris: + if self.redirect_uris is None or redirect_uri not in self.redirect_uris: raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client") return redirect_uri - elif len(self.redirect_uris) == 1: + elif self.redirect_uris is not None and len(self.redirect_uris) == 1: return self.redirect_uris[0] else: raise InvalidRedirectUriError("redirect_uri must be specified when client " "has multiple registered URIs") diff --git a/uv.lock b/uv.lock index 180d5a9c1..105d1b70c 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,10 @@ resolution-mode = "lowest-direct" members = [ "mcp", "mcp-simple-auth", + "mcp-simple-auth-client", + "mcp-simple-auth-client-client-credentials", + "mcp-simple-auth-client-credentials", + "mcp-simple-chatbot", "mcp-simple-prompt", "mcp-simple-resource", "mcp-simple-streamablehttp", @@ -649,6 +653,138 @@ dev = [ { name = "ruff", specifier = ">=0.8.5" }, ] +[[package]] +name = "mcp-simple-auth-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-auth-client" } +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", editable = "." }, +] + +[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 = "mcp-simple-auth-client-client-credentials" +version = "0.1.0" +source = { editable = "examples/clients/simple-auth-client-client-credentials" } +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", editable = "." }, +] + +[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 = "mcp-simple-auth-client-credentials" +version = "0.1.0" +source = { editable = "examples/servers/simple-auth-client-credentials" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.1.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "ruff", specifier = ">=0.8.5" }, +] + +[[package]] +name = "mcp-simple-chatbot" +version = "0.1.0" +source = { editable = "examples/clients/simple-chatbot" } +dependencies = [ + { name = "mcp" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", editable = "." }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "uvicorn", specifier = ">=0.32.1" }, +] + +[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 = "mcp-simple-prompt" version = "0.1.0" @@ -1691,16 +1827,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.30.0" +version = "0.32.1" 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/d3/f7/4ad826703a49b320a4adf2470fdd2a3481ea13f4460cb615ad12c75be003/uvicorn-0.30.0.tar.gz", hash = "sha256:f678dec4fa3a39706bbf49b9ec5fc40049d42418716cea52b53f07828a60aa37", size = 42560, upload-time = "2024-05-28T07:20:42.231Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/3c/21dba3e7d76138725ef307e3d7ddd29b763119b3aa459d02cc05fefcff75/uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175", size = 77630, upload-time = "2024-11-20T19:41:13.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a1/d57e38417a8dabb22df02b6aebc209dc73485792e6c5620e501547133d0b/uvicorn-0.30.0-py3-none-any.whl", hash = "sha256:78fa0b5f56abb8562024a59041caeb555c86e48d0efdd23c3fe7de7a4075bdab", size = 62388, upload-time = "2024-05-28T07:20:38.256Z" }, + { url = "https://files.pythonhosted.org/packages/50/c1/2d27b0a15826c2b71dcf6e2f5402181ef85acf439617bb2f1453125ce1f3/uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e", size = 63828, upload-time = "2024-11-20T19:41:11.244Z" }, ] [[package]] From d927bc07aa5ca6633ae7da81a4e148d8207534c7 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Mon, 23 Jun 2025 17:32:51 -0700 Subject: [PATCH 03/12] Set issuer to AS URI for RFC8414 compliance in auth example --- .../mcp_simple_auth_client_credentials/auth_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py index f3d31d20e..2cecdd8d8 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py @@ -43,7 +43,7 @@ class AuthServerSettings(BaseModel): port: int = 9000 server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") -def create_authorization_server() -> Starlette: +def create_authorization_server(server_settings: AuthServerSettings) -> Starlette: """Create the Authorization Server application.""" routes = [ @@ -52,7 +52,7 @@ def create_authorization_server() -> Starlette: "/.well-known/oauth-authorization-server", endpoint=cors_middleware( MetadataHandler(metadata=OAuthMetadata( - issuer=AnyHttpUrl(API_BASE), + issuer=server_settings.server_url, authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), token_endpoint_auth_methods_supported=["client_secret_basic"], @@ -71,7 +71,7 @@ def create_authorization_server() -> Starlette: async def run_server(server_settings: AuthServerSettings): """Run the Authorization Server.""" - auth_server = create_authorization_server() + auth_server = create_authorization_server(server_settings) config = Config( auth_server, From 20b5dfc096825c921ee86c138dd6d1e5bc53fd4e Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Mon, 23 Jun 2025 18:53:44 -0700 Subject: [PATCH 04/12] Merge RS/AS in client_credentials example --- .../simple-auth-client-credentials/README.md | 50 ++------ .../auth_server.py | 120 ------------------ .../server.py | 68 ++++++++-- .../pyproject.toml | 1 - 4 files changed, 69 insertions(+), 170 deletions(-) delete mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py diff --git a/examples/servers/simple-auth-client-credentials/README.md b/examples/servers/simple-auth-client-credentials/README.md index 5eca71824..ddc0df1c8 100644 --- a/examples/servers/simple-auth-client-credentials/README.md +++ b/examples/servers/simple-auth-client-credentials/README.md @@ -24,34 +24,17 @@ export MCP_DISCORD_CLIENT_SECRET="your_client_secret_here" ## Running the Servers -### Step 1: Start Authorization Server +### Step 1: Start Resource Server (MCP Server) ```bash -# Navigate to the simple-auth directory -cd examples/servers/simple-auth +# Navigate to the simple-auth-client-credentials directory +cd examples/servers/simple-auth-client-credentials -# Start Authorization Server on port 9000 -uv run mcp-simple-auth-as --port=9000 +# Start Resource Server on port 8001 +uv run mcp-simple-auth-rs --port=8001 --transport=streamable-http ``` -**What it provides:** - -- OAuth 2.0 flows (registration, authorization, token exchange) -- Discord OAuth integration for user authentication - ---- - -### Step 2: Start Resource Server (MCP Server) - -```bash -# In another terminal, navigate to the simple-auth directory -cd examples/servers/simple-auth - -# Start Resource Server on port 8001, connected to Authorization Server -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http -``` - -### Step 3: Test with Client +### Step 2: Test with Client ```bash cd examples/clients/simple-auth-client-client-credentials @@ -72,21 +55,21 @@ curl http://localhost:8001/.well-known/oauth-protected-resource ```json { "resource": "http://localhost:8001", - "authorization_servers": ["http://localhost:9000"] + "authorization_servers": ["http://localhost:8001"] } ``` **Client → Authorization Server:** ```bash -curl http://localhost:9000/.well-known/oauth-authorization-server +curl http://localhost:8001/.well-known/oauth-authorization-server ``` ```json { - "issuer": "http://localhost:9000", - "authorization_endpoint": "http://localhost:9000/authorize", - "token_endpoint": "http://localhost:9000/token" + "issuer": "http://localhost:8001", + "authorization_endpoint": "https://discord.com/api/v10/oauth2/authorize", + "token_endpoint": "https://discord.com/api/v10/oauth2/token" } ``` @@ -99,14 +82,5 @@ curl http://localhost:9000/.well-known/oauth-authorization-server curl -v http://localhost:8001/.well-known/oauth-protected-resource # Test Authorization Server metadata -curl -v http://localhost:9000/.well-known/oauth-authorization-server -``` - -### Test Token Introspection - -```bash -# After getting a token through OAuth flow: -curl -X POST http://localhost:9000/introspect \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "token=your_access_token" +curl -v http://localhost:8001/.well-known/oauth-authorization-server ``` diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py deleted file mode 100644 index 2cecdd8d8..000000000 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Authorization Server for MCP Split Demo. - -This server handles OAuth flows, client registration, and token issuance. -Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -Usage: - python -m mcp_simple_auth.auth_server --port=9000 -""" - -import asyncio -import logging - -import click -from pydantic import AnyHttpUrl, BaseModel -from starlette.applications import Starlette -from starlette.endpoints import HTTPEndpoint -from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from starlette.routing import Route -from uvicorn import Config, Server - -from mcp.server.auth.handlers.metadata import MetadataHandler -from mcp.server.auth.routes import cors_middleware, create_auth_routes -from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions -from mcp.shared._httpx_utils import create_mcp_http_client -from mcp.shared.auth import OAuthMetadata - -logger = logging.getLogger(__name__) - -API_BASE = "https://discord.com" -API_ENDPOINT = f"{API_BASE}/api/v10" - - -class AuthServerSettings(BaseModel): - """Settings for the Authorization Server.""" - - # Server settings - host: str = "localhost" - port: int = 9000 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - -def create_authorization_server(server_settings: AuthServerSettings) -> Starlette: - """Create the Authorization Server application.""" - - routes = [ - # Create RFC 8414 authorization server metadata endpoint - Route( - "/.well-known/oauth-authorization-server", - endpoint=cors_middleware( - MetadataHandler(metadata=OAuthMetadata( - issuer=server_settings.server_url, - authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), - token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), - token_endpoint_auth_methods_supported=["client_secret_basic"], - response_types_supported=["code"], - grant_types_supported=["client_credentials"], - scopes_supported=["identify"] - )).handle, - ["GET", "OPTIONS"], - ), - methods=["GET", "OPTIONS"], - ), - ] - - return Starlette(routes=routes) - - -async def run_server(server_settings: AuthServerSettings): - """Run the Authorization Server.""" - auth_server = create_authorization_server(server_settings) - - config = Config( - auth_server, - host=server_settings.host, - port=server_settings.port, - log_level="info", - ) - server = Server(config) - - logger.info("=" * 80) - logger.info("MCP AUTHORIZATION PROXY SERVER") - logger.info("=" * 80) - logger.info(f"Server URL: {server_settings.server_url}") - logger.info("Endpoints:") - logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server") - logger.info("") - logger.info("=" * 80) - - await server.serve() - - -@click.command() -@click.option("--port", default=9000, help="Port to listen on") -def main(port: int) -> int: - """ - Run the MCP Authorization Server. - - This server handles OAuth flows and can be used by multiple Resource Servers. - """ - logging.basicConfig(level=logging.INFO) - - # Create server settings - host = "localhost" - server_url = f"http://{host}:{port}" - server_settings = AuthServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - ) - - asyncio.run(run_server(server_settings)) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py index fd6452363..0ec7cd5b4 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py @@ -5,6 +5,7 @@ python -m mcp_simple_auth.server --port=8001 """ +import asyncio import logging from typing import Any, Literal @@ -12,10 +13,16 @@ import httpx from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.applications import Starlette +from starlette.routing import Mount, Route +from uvicorn import Config, Server +from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.routes import cors_middleware from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp.server import FastMCP +from mcp.shared.auth import OAuthMetadata from .token_verifier import IntrospectionTokenVerifier @@ -33,9 +40,10 @@ class ResourceServerSettings(BaseSettings): host: str = "localhost" port: int = 8001 server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001") + transport: Literal["sse", "streamable-http"] = "streamable-http" # Authorization Server settings - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001") auth_server_introspection_endpoint: str = f"{API_ENDPOINT}/oauth2/@me" auth_server_discord_user_endpoint: str = f"{API_ENDPOINT}/users/@me" @@ -47,7 +55,7 @@ def __init__(self, **data): super().__init__(**data) -def create_resource_server(settings: ResourceServerSettings) -> FastMCP: +def create_resource_server(settings: ResourceServerSettings) -> Starlette: """ Create MCP Resource Server. """ @@ -59,10 +67,8 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP: ) # Create FastMCP server as a Resource Server - app = FastMCP( + resource_server = FastMCP( name="MCP Resource Server", - host=settings.host, - port=settings.port, debug=True, token_verifier=token_verifier, auth=AuthSettings( @@ -93,14 +99,14 @@ async def get_discord_user_data() -> dict[str, Any]: return response.json() - @app.tool() + @resource_server.tool() async def get_user_profile() -> dict[str, Any]: """ Get the authenticated user's Discord profile information. """ return await get_discord_user_data() - @app.tool() + @resource_server.tool() async def get_user_info() -> dict[str, Any]: """ Get information about the currently authenticated user. @@ -121,12 +127,53 @@ async def get_user_info() -> dict[str, Any]: "authorization_server": str(settings.auth_server_url), } + # Create Starlette app to mount the MCP server and host RFC8414 + # metadata to jump to Discord's authorization server + app = Starlette( + debug=True, + routes=[ + Route( + "/.well-known/oauth-authorization-server", + endpoint=cors_middleware( + MetadataHandler(metadata=OAuthMetadata( + issuer=settings.server_url, + authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), + token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), + token_endpoint_auth_methods_supported=["client_secret_basic"], + response_types_supported=["code"], + grant_types_supported=["client_credentials"], + scopes_supported=["identify"] + )).handle, + ["GET", "OPTIONS"], + ), + methods=["GET", "OPTIONS"], + ), + Mount( + "/", + app=resource_server.streamable_http_app() if settings.transport == "streamable-http" else resource_server.sse_app() + ), + ], + lifespan=lambda app: resource_server.session_manager.run(), + ) + return app +async def run_server(settings: ResourceServerSettings): + mcp_server = create_resource_server(settings) + config = Config( + mcp_server, + host=settings.host, + port=settings.port, + log_level="info", + ) + server = Server(config) + await server.serve() + + @click.command() @click.option("--port", default=8001, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option("--auth-server", default="http://localhost:8001", help="Authorization Server URL") @click.option( "--transport", default="streamable-http", @@ -153,6 +200,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http auth_server_url=auth_server_url, auth_server_introspection_endpoint=f"{API_ENDPOINT}/oauth2/@me", auth_server_discord_user_endpoint=f"{API_ENDPOINT}/users/@me", + transport=transport, ) except ValueError as e: logger.error(f"Configuration error: {e}") @@ -160,8 +208,6 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http return 1 try: - mcp_server = create_resource_server(settings) - logger.info("=" * 80) logger.info("šŸ“¦ MCP RESOURCE SERVER") logger.info("=" * 80) @@ -182,7 +228,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http logger.info("=" * 80) # Run the server - this should block and keep running - mcp_server.run(transport=transport) + asyncio.run(run_server(settings)) logger.info("Server stopped") return 0 except Exception as e: diff --git a/examples/servers/simple-auth-client-credentials/pyproject.toml b/examples/servers/simple-auth-client-credentials/pyproject.toml index 8def0f7a2..eb6c3f748 100644 --- a/examples/servers/simple-auth-client-credentials/pyproject.toml +++ b/examples/servers/simple-auth-client-credentials/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ [project.scripts] mcp-simple-auth-rs = "mcp_simple_auth_client_credentials.server:main" -mcp-simple-auth-as = "mcp_simple_auth_client_credentials.auth_server:main" [build-system] requires = ["hatchling"] From 1a5f104f2010b9c254bbe172f9728d6f2f80d46c Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 24 Jun 2025 11:33:46 -0700 Subject: [PATCH 05/12] Revert "Merge RS/AS in client_credentials example" This reverts commit 20b5dfc096825c921ee86c138dd6d1e5bc53fd4e. --- .../simple-auth-client-credentials/README.md | 50 ++++++-- .../auth_server.py | 120 ++++++++++++++++++ .../server.py | 68 ++-------- .../pyproject.toml | 1 + 4 files changed, 170 insertions(+), 69 deletions(-) create mode 100644 examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py diff --git a/examples/servers/simple-auth-client-credentials/README.md b/examples/servers/simple-auth-client-credentials/README.md index ddc0df1c8..5eca71824 100644 --- a/examples/servers/simple-auth-client-credentials/README.md +++ b/examples/servers/simple-auth-client-credentials/README.md @@ -24,17 +24,34 @@ export MCP_DISCORD_CLIENT_SECRET="your_client_secret_here" ## Running the Servers -### Step 1: Start Resource Server (MCP Server) +### Step 1: Start Authorization Server ```bash -# Navigate to the simple-auth-client-credentials directory -cd examples/servers/simple-auth-client-credentials +# Navigate to the simple-auth directory +cd examples/servers/simple-auth -# Start Resource Server on port 8001 -uv run mcp-simple-auth-rs --port=8001 --transport=streamable-http +# Start Authorization Server on port 9000 +uv run mcp-simple-auth-as --port=9000 ``` -### Step 2: Test with Client +**What it provides:** + +- OAuth 2.0 flows (registration, authorization, token exchange) +- Discord OAuth integration for user authentication + +--- + +### Step 2: Start Resource Server (MCP Server) + +```bash +# In another terminal, navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Resource Server on port 8001, connected to Authorization Server +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http +``` + +### Step 3: Test with Client ```bash cd examples/clients/simple-auth-client-client-credentials @@ -55,21 +72,21 @@ curl http://localhost:8001/.well-known/oauth-protected-resource ```json { "resource": "http://localhost:8001", - "authorization_servers": ["http://localhost:8001"] + "authorization_servers": ["http://localhost:9000"] } ``` **Client → Authorization Server:** ```bash -curl http://localhost:8001/.well-known/oauth-authorization-server +curl http://localhost:9000/.well-known/oauth-authorization-server ``` ```json { - "issuer": "http://localhost:8001", - "authorization_endpoint": "https://discord.com/api/v10/oauth2/authorize", - "token_endpoint": "https://discord.com/api/v10/oauth2/token" + "issuer": "http://localhost:9000", + "authorization_endpoint": "http://localhost:9000/authorize", + "token_endpoint": "http://localhost:9000/token" } ``` @@ -82,5 +99,14 @@ curl http://localhost:8001/.well-known/oauth-authorization-server curl -v http://localhost:8001/.well-known/oauth-protected-resource # Test Authorization Server metadata -curl -v http://localhost:8001/.well-known/oauth-authorization-server +curl -v http://localhost:9000/.well-known/oauth-authorization-server +``` + +### Test Token Introspection + +```bash +# After getting a token through OAuth flow: +curl -X POST http://localhost:9000/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=your_access_token" ``` diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py new file mode 100644 index 000000000..2cecdd8d8 --- /dev/null +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py @@ -0,0 +1,120 @@ +""" +Authorization Server for MCP Split Demo. + +This server handles OAuth flows, client registration, and token issuance. +Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +Usage: + python -m mcp_simple_auth.auth_server --port=9000 +""" + +import asyncio +import logging + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.endpoints import HTTPEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from uvicorn import Config, Server + +from mcp.server.auth.handlers.metadata import MetadataHandler +from mcp.server.auth.routes import cors_middleware, create_auth_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.auth import OAuthMetadata + +logger = logging.getLogger(__name__) + +API_BASE = "https://discord.com" +API_ENDPOINT = f"{API_BASE}/api/v10" + + +class AuthServerSettings(BaseModel): + """Settings for the Authorization Server.""" + + # Server settings + host: str = "localhost" + port: int = 9000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + +def create_authorization_server(server_settings: AuthServerSettings) -> Starlette: + """Create the Authorization Server application.""" + + routes = [ + # Create RFC 8414 authorization server metadata endpoint + Route( + "/.well-known/oauth-authorization-server", + endpoint=cors_middleware( + MetadataHandler(metadata=OAuthMetadata( + issuer=server_settings.server_url, + authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), + token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), + token_endpoint_auth_methods_supported=["client_secret_basic"], + response_types_supported=["code"], + grant_types_supported=["client_credentials"], + scopes_supported=["identify"] + )).handle, + ["GET", "OPTIONS"], + ), + methods=["GET", "OPTIONS"], + ), + ] + + return Starlette(routes=routes) + + +async def run_server(server_settings: AuthServerSettings): + """Run the Authorization Server.""" + auth_server = create_authorization_server(server_settings) + + config = Config( + auth_server, + host=server_settings.host, + port=server_settings.port, + log_level="info", + ) + server = Server(config) + + logger.info("=" * 80) + logger.info("MCP AUTHORIZATION PROXY SERVER") + logger.info("=" * 80) + logger.info(f"Server URL: {server_settings.server_url}") + logger.info("Endpoints:") + logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server") + logger.info("") + logger.info("=" * 80) + + await server.serve() + + +@click.command() +@click.option("--port", default=9000, help="Port to listen on") +def main(port: int) -> int: + """ + Run the MCP Authorization Server. + + This server handles OAuth flows and can be used by multiple Resource Servers. + """ + logging.basicConfig(level=logging.INFO) + + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = AuthServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + ) + + asyncio.run(run_server(server_settings)) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py index 0ec7cd5b4..fd6452363 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py @@ -5,7 +5,6 @@ python -m mcp_simple_auth.server --port=8001 """ -import asyncio import logging from typing import Any, Literal @@ -13,16 +12,10 @@ import httpx from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.applications import Starlette -from starlette.routing import Mount, Route -from uvicorn import Config, Server -from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.middleware.auth_context import get_access_token -from mcp.server.auth.routes import cors_middleware from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp.server import FastMCP -from mcp.shared.auth import OAuthMetadata from .token_verifier import IntrospectionTokenVerifier @@ -40,10 +33,9 @@ class ResourceServerSettings(BaseSettings): host: str = "localhost" port: int = 8001 server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001") - transport: Literal["sse", "streamable-http"] = "streamable-http" # Authorization Server settings - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001") + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") auth_server_introspection_endpoint: str = f"{API_ENDPOINT}/oauth2/@me" auth_server_discord_user_endpoint: str = f"{API_ENDPOINT}/users/@me" @@ -55,7 +47,7 @@ def __init__(self, **data): super().__init__(**data) -def create_resource_server(settings: ResourceServerSettings) -> Starlette: +def create_resource_server(settings: ResourceServerSettings) -> FastMCP: """ Create MCP Resource Server. """ @@ -67,8 +59,10 @@ def create_resource_server(settings: ResourceServerSettings) -> Starlette: ) # Create FastMCP server as a Resource Server - resource_server = FastMCP( + app = FastMCP( name="MCP Resource Server", + host=settings.host, + port=settings.port, debug=True, token_verifier=token_verifier, auth=AuthSettings( @@ -99,14 +93,14 @@ async def get_discord_user_data() -> dict[str, Any]: return response.json() - @resource_server.tool() + @app.tool() async def get_user_profile() -> dict[str, Any]: """ Get the authenticated user's Discord profile information. """ return await get_discord_user_data() - @resource_server.tool() + @app.tool() async def get_user_info() -> dict[str, Any]: """ Get information about the currently authenticated user. @@ -127,53 +121,12 @@ async def get_user_info() -> dict[str, Any]: "authorization_server": str(settings.auth_server_url), } - # Create Starlette app to mount the MCP server and host RFC8414 - # metadata to jump to Discord's authorization server - app = Starlette( - debug=True, - routes=[ - Route( - "/.well-known/oauth-authorization-server", - endpoint=cors_middleware( - MetadataHandler(metadata=OAuthMetadata( - issuer=settings.server_url, - authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), - token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), - token_endpoint_auth_methods_supported=["client_secret_basic"], - response_types_supported=["code"], - grant_types_supported=["client_credentials"], - scopes_supported=["identify"] - )).handle, - ["GET", "OPTIONS"], - ), - methods=["GET", "OPTIONS"], - ), - Mount( - "/", - app=resource_server.streamable_http_app() if settings.transport == "streamable-http" else resource_server.sse_app() - ), - ], - lifespan=lambda app: resource_server.session_manager.run(), - ) - return app -async def run_server(settings: ResourceServerSettings): - mcp_server = create_resource_server(settings) - config = Config( - mcp_server, - host=settings.host, - port=settings.port, - log_level="info", - ) - server = Server(config) - await server.serve() - - @click.command() @click.option("--port", default=8001, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:8001", help="Authorization Server URL") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") @click.option( "--transport", default="streamable-http", @@ -200,7 +153,6 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http auth_server_url=auth_server_url, auth_server_introspection_endpoint=f"{API_ENDPOINT}/oauth2/@me", auth_server_discord_user_endpoint=f"{API_ENDPOINT}/users/@me", - transport=transport, ) except ValueError as e: logger.error(f"Configuration error: {e}") @@ -208,6 +160,8 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http return 1 try: + mcp_server = create_resource_server(settings) + logger.info("=" * 80) logger.info("šŸ“¦ MCP RESOURCE SERVER") logger.info("=" * 80) @@ -228,7 +182,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http logger.info("=" * 80) # Run the server - this should block and keep running - asyncio.run(run_server(settings)) + mcp_server.run(transport=transport) logger.info("Server stopped") return 0 except Exception as e: diff --git a/examples/servers/simple-auth-client-credentials/pyproject.toml b/examples/servers/simple-auth-client-credentials/pyproject.toml index eb6c3f748..8def0f7a2 100644 --- a/examples/servers/simple-auth-client-credentials/pyproject.toml +++ b/examples/servers/simple-auth-client-credentials/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ [project.scripts] mcp-simple-auth-rs = "mcp_simple_auth_client_credentials.server:main" +mcp-simple-auth-as = "mcp_simple_auth_client_credentials.auth_server:main" [build-system] requires = ["hatchling"] From fe548e52ad9f3ef623a7de33aa149c06b0ca9429 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 24 Jun 2025 13:59:52 -0700 Subject: [PATCH 06/12] Implement simplified RS+AS token mapping without DCR --- .../main.py | 24 +-- .../simple-auth-client-credentials/README.md | 11 +- .../auth_server.py | 200 ++++++++++++++++-- .../server.py | 14 +- .../token_verifier.py | 5 +- .../mcp_simple_auth/github_oauth_provider.py | 3 + src/mcp/client/auth.py | 13 +- .../fastmcp/auth/test_auth_integration.py | 3 + 8 files changed, 219 insertions(+), 54 deletions(-) diff --git a/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py b/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py index 0d1070f7a..9674dd158 100644 --- a/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py +++ b/examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py @@ -8,13 +8,8 @@ import asyncio 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, TokenStorage from mcp.client.session import ClientSession @@ -22,6 +17,11 @@ from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +# Hardcoded credentials assuming a preconfigured client, to demonstrate +# working with an AS that does not have DCR support +MCP_CLIENT_ID = "0000000000000000000" +MCP_CLIENT_SECRET = "aaaaaaaaaaaaaaaaaaa" + class InMemoryTokenStorage(TokenStorage): """Simple in-memory token storage implementation.""" @@ -66,18 +66,16 @@ async def connect(self): "grant_types": ["client_credentials"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_basic", - "scope": "identify" + "scope": "identify", } # Create OAuth authentication handler using the new interface oauth_auth = OAuthClientProvider( server_url=self.server_url.replace("/mcp", ""), - client_metadata=OAuthClientMetadata.model_validate( - client_metadata_dict - ), + client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), storage=InMemoryTokenStorage( - client_id=os.environ.get("MCP_DISCORD_CLIENT_ID"), - client_secret=os.environ.get("MCP_DISCORD_CLIENT_SECRET"), + client_id=MCP_CLIENT_ID, + client_secret=MCP_CLIENT_SECRET, ), ) oauth_auth.context.client_info = OAuthClientInformationFull( @@ -210,9 +208,7 @@ async def interactive_loop(self): await self.call_tool(tool_name, arguments) else: - print( - "āŒ Unknown command. Try 'list', 'call ', or 'quit'" - ) + print("āŒ Unknown command. Try 'list', 'call ', or 'quit'") except KeyboardInterrupt: print("\n\nšŸ‘‹ Goodbye!") diff --git a/examples/servers/simple-auth-client-credentials/README.md b/examples/servers/simple-auth-client-credentials/README.md index 5eca71824..d5526420c 100644 --- a/examples/servers/simple-auth-client-credentials/README.md +++ b/examples/servers/simple-auth-client-credentials/README.md @@ -1,6 +1,7 @@ # MCP OAuth Authentication Demo -This example demonstrates OAuth 2.0 authentication with the Model Context Protocol as an OAuth 2.0 Resource Server using the `client_credentials` token exchange. +This example demonstrates OAuth 2.0 authentication with the Model Context Protocol as an OAuth 2.0 Resource Server using the `client_credentials` token exchange, with +an Authorization Server that does not support Dynamic Client Registration. --- @@ -27,8 +28,8 @@ export MCP_DISCORD_CLIENT_SECRET="your_client_secret_here" ### Step 1: Start Authorization Server ```bash -# Navigate to the simple-auth directory -cd examples/servers/simple-auth +# Navigate to the simple-auth-client-credentials directory +cd examples/servers/simple-auth-client-credentials # Start Authorization Server on port 9000 uv run mcp-simple-auth-as --port=9000 @@ -44,8 +45,8 @@ uv run mcp-simple-auth-as --port=9000 ### Step 2: Start Resource Server (MCP Server) ```bash -# In another terminal, navigate to the simple-auth directory -cd examples/servers/simple-auth +# In another terminal, navigate to the simple-auth-client-credentials directory +cd examples/servers/simple-auth-client-credentials # Start Resource Server on port 8001, connected to Authorization Server uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py index 2cecdd8d8..09286e5f1 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py @@ -13,26 +13,43 @@ import asyncio import logging +import secrets +from base64 import b64decode, b64encode import click from pydantic import AnyHttpUrl, BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.applications import Starlette from starlette.endpoints import HTTPEndpoint from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route +from starlette.types import Receive, Scope, Send from uvicorn import Config, Server from mcp.server.auth.handlers.metadata import MetadataHandler -from mcp.server.auth.routes import cors_middleware, create_auth_routes -from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.server.auth.routes import cors_middleware from mcp.shared._httpx_utils import create_mcp_http_client -from mcp.shared.auth import OAuthMetadata +from mcp.shared.auth import OAuthMetadata, OAuthToken logger = logging.getLogger(__name__) -API_BASE = "https://discord.com" -API_ENDPOINT = f"{API_BASE}/api/v10" +API_ENDPOINT = "https://discord.com/api/v10" + + +class DiscordOAuthSettings(BaseSettings): + """Discord OAuth settings.""" + + model_config = SettingsConfigDict(env_prefix="MCP_") + + # Discord OAuth settings - MUST be provided via environment variables + discord_client_id: str | None = None + discord_client_secret: str | None = None + + # Discord OAuth URL + discord_token_url: str = f"{API_ENDPOINT}/oauth2/token" + + discord_scope: str = "identify" class AuthServerSettings(BaseModel): @@ -43,7 +60,142 @@ class AuthServerSettings(BaseModel): port: int = 9000 server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") -def create_authorization_server(server_settings: AuthServerSettings) -> Starlette: + +# Hardcoded credentials assuming a preconfigured client, to demonstrate +# working with an AS that does not have DCR support +MCP_CLIENT_ID = "0000000000000000000" +MCP_CLIENT_SECRET = "aaaaaaaaaaaaaaaaaaa" + +# Map of MCP server tokens to Discord API tokens +TOKEN_MAP: dict[str, str] = {} + + +class TokenEndpoint(HTTPEndpoint): + # Map of MCP client IDs to Discord client IDs + client_map: dict[str, str] = {} + client_credentials: dict[str, str] = {} + + discord_client_credentials: dict[str, str] = {} + + def __init__(self, scope: Scope, receive: Receive, send: Send): + super().__init__(scope, receive, send) + self.discord_settings = DiscordOAuthSettings() + + assert self.discord_settings.discord_client_id is not None, "Discord client ID not set" + assert self.discord_settings.discord_client_secret is not None, "Discord client secret not set" + + # Assume a preconfigured client ID to demonstrate working with an AS that does not have DCR support + self.client_map = { + MCP_CLIENT_ID: self.discord_settings.discord_client_id, + } + self.client_credentials = { + MCP_CLIENT_ID: MCP_CLIENT_SECRET, + } + self.discord_client_credentials = { + self.discord_settings.discord_client_id: self.discord_settings.discord_client_secret, + } + + async def post(self, request: Request) -> Response: + # Get client_id and client_secret from Basic auth header + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Basic "): + return JSONResponse({"error": "Invalid authorization header"}, status_code=401) + auth_header_encoded = auth_header.split(" ")[1] + auth_header_raw = b64decode(auth_header_encoded).decode("utf-8") + client_id, client_secret = auth_header_raw.split(":") + + # Validate MCP client + if client_id not in self.client_map: + return JSONResponse({"error": "Invalid client"}, status_code=401) + # Check if client secret matches + if client_secret != self.client_credentials[client_id]: + return JSONResponse({"error": "Invalid client secret"}, status_code=401) + + # Get mapped credentials + discord_client_id = self.client_map[client_id] + discord_client_secret = self.discord_client_credentials[discord_client_id] + + # Get request data (application/x-www-form-urlencoded) + data = await request.form() + + # Validate scopes + scopes = str(data.get("scope", "")).split(" ") + if not set(scopes).issubset(set(self.discord_settings.discord_scope.split(" "))): + return JSONResponse({"error": "Invalid scope"}, status_code=400) + + # Set credentials in HTTP client + headers = { + "Authorization": f"Basic {b64encode(f'{discord_client_id}:{discord_client_secret}'.encode()).decode()}" + } + + # Create HTTP client + async with create_mcp_http_client() as http_client: + # Forward request to Discord API + method = getattr(http_client, request.method.lower()) + response = await method(self.discord_settings.discord_token_url, data=data, headers=headers) + if response.status_code != 200: + body = await response.aread() + return Response(body, status_code=response.status_code, headers=response.headers) + + # Generate MCP access token + mcp_token = f"mcp_{secrets.token_hex(32)}" + + # Store mapped access token + TOKEN_MAP[mcp_token] = response.json()["access_token"] + + # Return response + return JSONResponse( + OAuthToken( + access_token=mcp_token, + token_type="Bearer", + expires_in=response.json()["expires_in"], + scope=self.discord_settings.discord_scope, + ).model_dump(), + status_code=response.status_code, + ) + + +class DiscordAPIProxy(HTTPEndpoint): + """Proxy for Discord API.""" + + async def get(self, request: Request) -> Response: + """Proxy GET requests to Discord API.""" + return await self.handle(request) + + async def post(self, request: Request) -> Response: + """Proxy POST requests to Discord API.""" + return await self.handle(request) + + async def handle(self, request: Request) -> Response: + """Proxy requests to Discord API.""" + path = request.url.path[len("/discord") :] + query = request.url.query + + # Get access token from Authorization header + access_token = request.headers.get("Authorization", "").split(" ")[1] + if not access_token: + return JSONResponse({"error": "Missing access token"}, status_code=401) + + # Map access token to Discord access token + access_token = TOKEN_MAP.get(access_token, None) + if not access_token: + return JSONResponse({"error": "Invalid access token"}, status_code=401) + + # Set mapped access token in HTTP client + headers = {"Authorization": f"Bearer {access_token}"} + + # Create HTTP client + async with create_mcp_http_client() as http_client: + # Forward request to Discord API + response = await http_client.get(f"{API_ENDPOINT}{path}?{query}", headers=headers) + + # Return response + return JSONResponse(response.json(), status_code=response.status_code) + + +def create_authorization_server( + server_settings: AuthServerSettings, discord_settings: DiscordOAuthSettings +) -> Starlette: """Create the Authorization Server application.""" routes = [ @@ -51,27 +203,33 @@ def create_authorization_server(server_settings: AuthServerSettings) -> Starlett Route( "/.well-known/oauth-authorization-server", endpoint=cors_middleware( - MetadataHandler(metadata=OAuthMetadata( - issuer=server_settings.server_url, - authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"), - token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"), - token_endpoint_auth_methods_supported=["client_secret_basic"], - response_types_supported=["code"], - grant_types_supported=["client_credentials"], - scopes_supported=["identify"] - )).handle, + MetadataHandler( + metadata=OAuthMetadata( + issuer=server_settings.server_url, + authorization_endpoint=AnyHttpUrl(f"{server_settings.server_url}authorize"), + token_endpoint=AnyHttpUrl(f"{server_settings.server_url}token"), + token_endpoint_auth_methods_supported=["client_secret_basic"], + response_types_supported=["code"], + grant_types_supported=["client_credentials"], + scopes_supported=[discord_settings.discord_scope], + ) + ).handle, ["GET", "OPTIONS"], ), methods=["GET", "OPTIONS"], ), + # Create OAuth 2.0 token endpoint + Route("/token", TokenEndpoint), + # Create API proxy endpoint + Route("/discord/{path:path}", DiscordAPIProxy), ] return Starlette(routes=routes) -async def run_server(server_settings: AuthServerSettings): +async def run_server(server_settings: AuthServerSettings, discord_settings: DiscordOAuthSettings): """Run the Authorization Server.""" - auth_server = create_authorization_server(server_settings) + auth_server = create_authorization_server(server_settings, discord_settings) config = Config( auth_server, @@ -86,7 +244,9 @@ async def run_server(server_settings: AuthServerSettings): logger.info("=" * 80) logger.info(f"Server URL: {server_settings.server_url}") logger.info("Endpoints:") - logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server") + logger.info(f" - OAuth Metadata: {server_settings.server_url}.well-known/oauth-authorization-server") + logger.info(f" - Token Exchange: {server_settings.server_url}token") + logger.info(f" - Discord API Proxy: {server_settings.server_url}discord") logger.info("") logger.info("=" * 80) @@ -112,7 +272,9 @@ def main(port: int) -> int: server_url=AnyHttpUrl(server_url), ) - asyncio.run(run_server(server_settings)) + discord_settings = DiscordOAuthSettings() + + asyncio.run(run_server(server_settings, discord_settings)) return 0 diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py index fd6452363..61287b318 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py @@ -19,11 +19,11 @@ from .token_verifier import IntrospectionTokenVerifier - logger = logging.getLogger(__name__) API_ENDPOINT = "https://discord.com/api/v10" + class ResourceServerSettings(BaseSettings): """Settings for the MCP Resource Server.""" @@ -36,8 +36,8 @@ class ResourceServerSettings(BaseSettings): # Authorization Server settings auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = f"{API_ENDPOINT}/oauth2/@me" - auth_server_discord_user_endpoint: str = f"{API_ENDPOINT}/users/@me" + auth_server_introspection_endpoint: str = "http://localhost:9000/discord/oauth2/@me" + auth_server_discord_user_endpoint: str = "http://localhost:9000/discord/users/@me" # MCP settings mcp_scope: str = "identify" @@ -151,8 +151,8 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http port=port, server_url=AnyHttpUrl(server_url), auth_server_url=auth_server_url, - auth_server_introspection_endpoint=f"{API_ENDPOINT}/oauth2/@me", - auth_server_discord_user_endpoint=f"{API_ENDPOINT}/users/@me", + auth_server_introspection_endpoint=f"{auth_server_url}discord/oauth2/@me", + auth_server_discord_user_endpoint=f"{auth_server_url}discord/users/@me", ) except ValueError as e: logger.error(f"Configuration error: {e}") @@ -168,9 +168,9 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http logger.info(f"🌐 Server URL: {settings.server_url}") logger.info(f"šŸ”‘ Authorization Server: {settings.auth_server_url}") logger.info("šŸ“‹ Endpoints:") - logger.info(f" ā”Œā”€ Protected Resource Metadata: {settings.server_url}/.well-known/oauth-protected-resource") + logger.info(f" ā”Œā”€ Protected Resource Metadata: {settings.server_url}.well-known/oauth-protected-resource") mcp_path = "sse" if transport == "sse" else "mcp" - logger.info(f" ā”œā”€ MCP Protocol: {settings.server_url}/{mcp_path}") + logger.info(f" ā”œā”€ MCP Protocol: {settings.server_url}{mcp_path}") logger.info(f" └─ Token Introspection: {settings.auth_server_introspection_endpoint}") logger.info("") logger.info("šŸ› ļø Available Tools:") diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py index ca3ad1ebc..4089e35db 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py @@ -1,7 +1,7 @@ """Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" -from datetime import datetime import logging +from datetime import datetime from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.shared.auth_utils import resource_url_from_server_url @@ -10,8 +10,7 @@ class IntrospectionTokenVerifier(TokenVerifier): - """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). - """ + """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).""" def __init__( self, diff --git a/examples/servers/simple-auth/mcp_simple_auth/github_oauth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/github_oauth_provider.py index c64db96b7..510a0c44b 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/github_oauth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/github_oauth_provider.py @@ -75,6 +75,7 @@ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: async def register_client(self, client_info: OAuthClientInformationFull): """Register a new OAuth client.""" + assert client_info.client_id is not None self.clients[client_info.client_id] = client_info async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: @@ -178,6 +179,8 @@ async def exchange_authorization_code( """Exchange authorization code for tokens.""" if authorization_code.code not in self.auth_codes: raise ValueError("Invalid authorization code") + if client.client_id is None: + raise ValueError("No client_id provided") # Generate MCP access token mcp_token = f"mcp_{secrets.token_hex(32)}" diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 5a9d0cecb..4292795a0 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -12,7 +12,7 @@ import time from collections.abc import AsyncGenerator, Awaitable, Callable from dataclasses import dataclass, field -from typing import Optional, Protocol +from typing import Protocol from urllib.parse import urlencode, urljoin, urlparse import anyio @@ -87,8 +87,8 @@ class OAuthContext: server_url: str client_metadata: OAuthClientMetadata storage: TokenStorage - redirect_handler: Optional[Callable[[str], Awaitable[None]]] - callback_handler: Optional[Callable[[], Awaitable[tuple[str, str | None]]]] + redirect_handler: Callable[[str], Awaitable[None]] | None + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None timeout: float = 300.0 # Discovered metadata @@ -164,8 +164,8 @@ def __init__( server_url: str, client_metadata: OAuthClientMetadata, storage: TokenStorage, - redirect_handler: Optional[Callable[[str], Awaitable[None]]] = None, - callback_handler: Optional[Callable[[], Awaitable[tuple[str, str | None]]]] = None, + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, ): """Initialize OAuth2 authentication.""" @@ -375,7 +375,8 @@ async def _exchange_token_client_credentials(self) -> httpx.Request: raise OAuthTokenError("Missing client_id in Basic auth flow") if not self.context.client_info.client_secret: raise OAuthTokenError("Missing client_secret in Basic auth flow") - headers["Authorization"] = f"Basic {base64.b64encode(f'{self.context.client_info.client_id}:{self.context.client_info.client_secret}'.encode()).decode()}" + raw_auth = f"{self.context.client_info.client_id}:{self.context.client_info.client_secret}" + headers["Authorization"] = f"Basic {base64.b64encode(raw_auth.encode()).decode()}" return httpx.Request("POST", token_url, data=token_data, headers=headers) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index 5db5d58c2..3f902db92 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -50,6 +50,7 @@ async def register_client(self, client_info: OAuthClientInformationFull): async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: # toy authorize implementation which just immediately generates an authorization # code and completes the redirect + assert client.client_id is not None code = AuthorizationCode( code=f"code_{int(time.time())}", client_id=client.client_id, @@ -78,6 +79,7 @@ async def exchange_authorization_code( refresh_token = f"refresh_{secrets.token_hex(32)}" # Store the tokens + assert client.client_id is not None self.tokens[access_token] = AccessToken( token=access_token, client_id=client.client_id, @@ -139,6 +141,7 @@ async def exchange_refresh_token( new_refresh_token = f"refresh_{secrets.token_hex(32)}" # Store the new tokens + assert client.client_id is not None self.tokens[new_access_token] = AccessToken( token=new_access_token, client_id=client.client_id, From 7e0dfafa0d597ce80d356a6d2004ec416d85e809 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 24 Jun 2025 14:12:36 -0700 Subject: [PATCH 07/12] Update naming and docs for client credentials example --- .../README.md | 45 ++++++++++++------- .../simple-auth-client-credentials/README.md | 9 ---- .../server.py | 6 +-- .../token_verifier.py | 11 +++-- 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/examples/clients/simple-auth-client-client-credentials/README.md b/examples/clients/simple-auth-client-client-credentials/README.md index cf6050b1c..6608f8f05 100644 --- a/examples/clients/simple-auth-client-client-credentials/README.md +++ b/examples/clients/simple-auth-client-client-credentials/README.md @@ -1,28 +1,29 @@ # Simple Auth Client Example -A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. +A demonstration of how to use the MCP Python SDK with OAuth authentication using client credentials over streamable HTTP or SSE transport. +This example demonstrates integration with an authorization server that does not implement Dynamic Client Registration. ## Features -- OAuth 2.0 authentication with PKCE +- OAuth 2.0 authentication with the `client_credentials` flow - Support for both StreamableHTTP and SSE transports - Interactive command-line interface ## Installation ```bash -cd examples/clients/simple-auth-client -uv sync --reinstall +cd examples/clients/simple-auth-client-client-credentials +uv sync --reinstall ``` ## Usage -### 1. Start an MCP server with OAuth support +### 1. Start an MCP server with OAuth support using client credentials ```bash -# Example with mcp-simple-auth -cd path/to/mcp-simple-auth -uv run mcp-simple-auth --transport streamable-http --port 3001 +# Example with mcp-simple-auth-client-credentials +cd path/to/mcp-simple-auth-client-credentials +uv run mcp-simple-auth-client-credentials --transport streamable-http --port 3001 ``` ### 2. Run the client @@ -39,22 +40,32 @@ MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client ### 3. Complete OAuth flow -The client will open your browser for authentication. After completing OAuth, you can use commands: +The client will automatically authenticate using dummy client credentials for the demo authorization server. After completing OAuth, you can use commands: - `list` - List available tools -- `call [args]` - Call a tool with optional JSON arguments +- `call [args]` - Call a tool with optional JSON arguments - `quit` - Exit ## Example ``` -šŸ” 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 +šŸš€ Simple MCP Auth Client +Connecting to: http://localhost:8001/mcp +Transport type: streamable_http +šŸ”— Attempting to connect to http://localhost:8001/mcp... +šŸ“” Opening StreamableHTTP transport connection with auth... +šŸ¤ Initializing MCP session... +⚔ Starting session initialization... +✨ Session initialization complete! + +āœ… Connected to MCP server at http://localhost:8001/mcp +Session ID: ... + +šŸŽÆ Interactive MCP Client +Commands: + list - List available tools + call [args] - Call a tool + quit - Exit the client mcp> list šŸ“‹ Available tools: diff --git a/examples/servers/simple-auth-client-credentials/README.md b/examples/servers/simple-auth-client-credentials/README.md index d5526420c..22a5203a7 100644 --- a/examples/servers/simple-auth-client-credentials/README.md +++ b/examples/servers/simple-auth-client-credentials/README.md @@ -102,12 +102,3 @@ curl -v http://localhost:8001/.well-known/oauth-protected-resource # Test Authorization Server metadata curl -v http://localhost:9000/.well-known/oauth-authorization-server ``` - -### Test Token Introspection - -```bash -# After getting a token through OAuth flow: -curl -X POST http://localhost:9000/introspect \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "token=your_access_token" -``` diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py index 61287b318..03c160bdc 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py @@ -17,7 +17,7 @@ from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp.server import FastMCP -from .token_verifier import IntrospectionTokenVerifier +from .token_verifier import PartialIntrospectionTokenVerifier logger = logging.getLogger(__name__) @@ -52,8 +52,8 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP: Create MCP Resource Server. """ - # Create token verifier for introspection with RFC 8707 resource validation - token_verifier = IntrospectionTokenVerifier( + # Create partial token verifier + token_verifier = PartialIntrospectionTokenVerifier( introspection_endpoint=settings.auth_server_introspection_endpoint, server_url=str(settings.server_url), ) diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py index 4089e35db..9131534e5 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/token_verifier.py @@ -1,4 +1,4 @@ -"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" +"""Example token verifier implementation.""" import logging from datetime import datetime @@ -9,8 +9,13 @@ logger = logging.getLogger(__name__) -class IntrospectionTokenVerifier(TokenVerifier): - """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).""" +class PartialIntrospectionTokenVerifier(TokenVerifier): + """ + Example token verifier. + + Discord doesn't actually support token introspection, but this is required by FastMCP, so + we shim a non-strict verifier on top of it that leverages the "current application" endpoint. + """ def __init__( self, From 4ab922c57c0e13e3f1a0630469ac94559dd31c37 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 24 Jun 2025 14:23:32 -0700 Subject: [PATCH 08/12] Implement client_secret_post support in client credentials example --- .../auth_server.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py index 09286e5f1..0c6475104 100644 --- a/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py +++ b/examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py @@ -15,6 +15,7 @@ import logging import secrets from base64 import b64decode, b64encode +from typing import Literal import click from pydantic import AnyHttpUrl, BaseModel @@ -46,6 +47,8 @@ class DiscordOAuthSettings(BaseSettings): discord_client_id: str | None = None discord_client_secret: str | None = None + token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic" + # Discord OAuth URL discord_token_url: str = f"{API_ENDPOINT}/oauth2/token" @@ -96,13 +99,21 @@ def __init__(self, scope: Scope, receive: Receive, send: Send): } async def post(self, request: Request) -> Response: - # Get client_id and client_secret from Basic auth header - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Basic "): - return JSONResponse({"error": "Invalid authorization header"}, status_code=401) - auth_header_encoded = auth_header.split(" ")[1] - auth_header_raw = b64decode(auth_header_encoded).decode("utf-8") - client_id, client_secret = auth_header_raw.split(":") + # Get request data (application/x-www-form-urlencoded) + data = await request.form() + + if self.discord_settings.token_endpoint_auth_method == "client_secret_basic": + # Get client_id and client_secret from Basic auth header + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Basic "): + return JSONResponse({"error": "Invalid authorization header"}, status_code=401) + auth_header_encoded = auth_header.split(" ")[1] + auth_header_raw = b64decode(auth_header_encoded).decode("utf-8") + client_id, client_secret = auth_header_raw.split(":") + else: + # Get from body + client_id = str(data.get("client_id")) + client_secret = str(data.get("client_secret")) # Validate MCP client if client_id not in self.client_map: @@ -115,9 +126,6 @@ async def post(self, request: Request) -> Response: discord_client_id = self.client_map[client_id] discord_client_secret = self.discord_client_credentials[discord_client_id] - # Get request data (application/x-www-form-urlencoded) - data = await request.form() - # Validate scopes scopes = str(data.get("scope", "")).split(" ") if not set(scopes).issubset(set(self.discord_settings.discord_scope.split(" "))): @@ -208,7 +216,7 @@ def create_authorization_server( issuer=server_settings.server_url, authorization_endpoint=AnyHttpUrl(f"{server_settings.server_url}authorize"), token_endpoint=AnyHttpUrl(f"{server_settings.server_url}token"), - token_endpoint_auth_methods_supported=["client_secret_basic"], + token_endpoint_auth_methods_supported=["client_secret_post"], response_types_supported=["code"], grant_types_supported=["client_credentials"], scopes_supported=[discord_settings.discord_scope], From e3a2b6dad0666a023cf7cb4caf7a54f80e8de301 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 24 Jun 2025 15:03:09 -0700 Subject: [PATCH 09/12] Don't use client_credentials by default; fix test --- src/mcp/shared/auth.py | 1 - tests/client/test_auth.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 801b9ade0..97efb019f 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -47,7 +47,6 @@ class OAuthClientMetadata(BaseModel): # supported grant_types of this implementation grant_types: list[Literal["authorization_code", "client_credentials", "refresh_token"]] = [ "authorization_code", - "client_credentials", "refresh_token", ] # this implementation only supports code; ie: it does not support implicit grants diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 8dee687a9..0a9f00637 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -246,7 +246,7 @@ async def test_token_exchange_request(self, oauth_provider): redirect_uris=[AnyUrl("http://localhost:3030/callback")], ) - request = await oauth_provider._exchange_token("test_auth_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier") assert request.method == "POST" assert str(request.url) == "https://api.example.com/token" From a8067e19a4a5f8a413cd5a5f52e8ecb334b1fe5b Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 25 Jun 2025 13:24:36 -0700 Subject: [PATCH 10/12] Add tests for client_credentials flow --- tests/client/test_auth.py | 64 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index b36052ae4..fcd2ec02e 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2,7 +2,10 @@ Tests for refactored OAuth client authentication implementation. """ +import base64 import time +import urllib +import urllib.parse import httpx import pytest @@ -386,7 +389,7 @@ async def test_register_client_skip_if_registered(self, oauth_provider, mock_sto assert request is None @pytest.mark.anyio - async def test_token_exchange_request(self, oauth_provider): + async def test_token_exchange_request_authorization_code(self, oauth_provider): """Test token exchange request building.""" # Set up required context oauth_provider.context.client_info = OAuthClientInformationFull( @@ -409,6 +412,65 @@ async def test_token_exchange_request(self, oauth_provider): assert "client_id=test_client" in content assert "client_secret=test_secret" in content + @pytest.mark.anyio + async def test_token_exchange_request_client_credentials_basic(self, oauth_provider): + """Test token exchange request building.""" + # Set up required context + oauth_provider.context.client_info = oauth_provider.context.client_metadata = OAuthClientInformationFull( + grant_types=["client_credentials"], + token_endpoint_auth_method="client_secret_basic", + client_id="test_client", + client_secret="test_secret", + redirect_uris=None, + scope="read write", + ) + + request = await oauth_provider._exchange_token_client_credentials() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + assert "client_id=test_client" not in content + assert "client_secret=test_secret" not in content + + # Check auth header + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + assert base64.b64decode(request.headers["Authorization"].split(" ")[1]).decode() == "test_client:test_secret" + + @pytest.mark.anyio + async def test_token_exchange_request_client_credentials_post(self, oauth_provider): + """Test token exchange request building.""" + # Set up required context + oauth_provider.context.client_info = oauth_provider.context.client_metadata = OAuthClientInformationFull( + grant_types=["client_credentials"], + token_endpoint_auth_method="client_secret_post", + client_id="test_client", + client_secret="test_secret", + redirect_uris=None, + scope="read write", + ) + + request = await oauth_provider._exchange_token_client_credentials() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + assert "client_id=test_client" in content + assert "client_secret=test_secret" in content + @pytest.mark.anyio async def test_refresh_token_request(self, oauth_provider, valid_tokens): """Test refresh token request building.""" From 13b3478b00c609fb5c734bb4c3acf62ec4b4471b Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 25 Jun 2025 13:28:29 -0700 Subject: [PATCH 11/12] Update function name in PRM unit tests --- tests/client/test_auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 867ca4578..fe30b08cc 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -512,7 +512,7 @@ async def test_resource_param_included_with_recent_protocol_version(self, oauth_ ) # Test in token exchange - request = await oauth_provider._exchange_token("test_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") content = request.content.decode() assert "resource=" in content # Check URL-encoded resource parameter @@ -543,7 +543,7 @@ async def test_resource_param_excluded_with_old_protocol_version(self, oauth_pro ) # Test in token exchange - request = await oauth_provider._exchange_token("test_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") content = request.content.decode() assert "resource=" not in content @@ -573,7 +573,7 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa ) # Test in token exchange - request = await oauth_provider._exchange_token("test_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") content = request.content.decode() assert "resource=" in content From efecc7d0a1c5f85b2d6e733b87157b9c015cdf53 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 26 Jun 2025 11:38:53 -0700 Subject: [PATCH 12/12] Use client_metadata to determine if 2LO should be used --- src/mcp/client/auth.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 38a0bae59..4fee9e4bb 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -320,10 +320,7 @@ async def _handle_registration_response(self, response: httpx.Response) -> None: async def _perform_authorization(self) -> httpx.Request: """Perform the authorization flow.""" - if not self.context.client_info: - raise OAuthFlowError("No client info available for authorization") - - if "client_credentials" in self.context.client_info.grant_types: + if "client_credentials" in self.context.client_metadata.grant_types: token_request = await self._exchange_token_client_credentials() return token_request else: