diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2390d8c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/fix-license-header.yml b/.github/workflows/fix-license-header.yml new file mode 100644 index 0000000..838faf0 --- /dev/null +++ b/.github/workflows/fix-license-header.yml @@ -0,0 +1,62 @@ +name: Fix License Headers + +on: + pull_request_target: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + header-license-fix: + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Checkout the branch from the PR that triggered the job + run: gh pr checkout ${{ github.event.pull_request.number }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Fix License Header + # pin to include https://github.com/apache/skywalking-eyes/pull/168 + uses: apache/skywalking-eyes/header@07a607ff5b0759f5ed47306c865aac50fe9b3985 + with: + mode: fix + + - name: List files changed + id: files-changed + shell: bash -l {0} + run: | + set -ex + export CHANGES=$(git status --porcelain | tee /tmp/modified.log | wc -l) + cat /tmp/modified.log + + echo "N_CHANGES=${CHANGES}" >> $GITHUB_OUTPUT + + git diff + + - name: Commit any changes + if: steps.files-changed.outputs.N_CHANGES != '0' + shell: bash -l {0} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git pull --no-tags + + git add * + git commit -m "Automatic application of license header" + + git config push.default upstream + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.licenserc.yaml b/.licenserc.yaml new file mode 100644 index 0000000..0c87c45 --- /dev/null +++ b/.licenserc.yaml @@ -0,0 +1,52 @@ +header: + license: + spdx-id: Datalayer + copyright-owner: Datalayer, Inc. + copyright-year: 2023-2025 + content: | + Copyright (c) [year] [owner] + Distributed under the terms of the Modified BSD License. + + paths-ignore: + - '**/*.toml' + - '**/*.apt' + - '**/*.cedar' + - '**/*.dash' + - '**/*.fga' + - '**/*.ipynb' + - '**/*.j2' + - '**/*.json' + - '**/*.mamba' + - '**/*.md' + - '**/*.mdx' + - '**/*.mod' + - '**/*.nblink' + - '**/*.rego' + - '**/*.sum' + - '**/*.svg' + - '**/*.template' + - '**/*.tsbuildinfo' + - '**/*.txt' + - '**/*.yaml' + - '**/*.yml' + - '**/*_key' + - '**/*_key.pub' + - '**/.*' + - '**/LICENSE.txt' + - '**/MANIFEST.in' + - '**/build' + - '**/lib' + - '**/node_modules' + - '**/schemas' + - '**/ssh/*' + - '**/static' + - '**/themes' + - '**/typings' + - '**/*.patch' + - '**/*.bundle.js' + - '**/*.map.js' + - 'LICENSE' + - 'src/stories' + - '.husky/pre-commit' + + comment: on-failure diff --git a/README.md b/README.md index aede370..c7ff587 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [![License](https://img.shields.io/badge/license-BSD--3--Clause-green)](LICENSE) [![Docker](https://img.shields.io/badge/docker-ready-blue)](Dockerfile) -> **A powerful, production-ready framework for composing and orchestrating Model Context Protocol (MCP) servers with advanced management capabilities, REST API, and modern Web UI.** +> **Similar to Docker Compose - Orchestrate Model Context Protocol (MCP) servers with management capabilities, REST API, and Web UI.** ## ๐ŸŽฏ Overview diff --git a/examples/mcp-auth/Makefile b/examples/mcp-auth/Makefile index 9cb1118..f63b9da 100644 --- a/examples/mcp-auth/Makefile +++ b/examples/mcp-auth/Makefile @@ -59,6 +59,19 @@ agent: @echo "" mcp-auth-agent azure-openai:gpt-4o-mini + +# Run MCP Inspector +inspector: + @echo "๐Ÿ” Starting Model Context Protocol Inspector..." + npx @modelcontextprotocol/inspector + +# Test Dynamic Client Registration +test-dcr: + @echo "๐Ÿงช Testing Dynamic Client Registration (DCR)..." + @echo "Make sure the server is running in another terminal: make server" + @echo "" + python test_dcr.py + # Run tests test: @echo "๐Ÿงช Running tests..." diff --git a/examples/mcp-auth/README.md b/examples/mcp-auth/README.md index ab883a8..022a26d 100644 --- a/examples/mcp-auth/README.md +++ b/examples/mcp-auth/README.md @@ -15,6 +15,7 @@ A clear, educational example demonstrating OAuth2 authentication for MCP (Model ## ๐Ÿ“š What You'll Learn - **OAuth2** - Authorization Code flow with PKCE +- **Dynamic Client Registration (DCR)** - RFC 7591 implementation for automatic client registration - **MCP Authorization** - Official specification (2025-06-18) - **Security** - Token validation, CSRF protection, resource indicators - **MCP SDK** - Building servers with FastMCP and clients with MCP SDK @@ -39,9 +40,11 @@ make install |------|---------|------------------| | **[docs/QUICKSTART.md](docs/QUICKSTART.md)** | Get running in 5 minutes | You want to try it immediately | | **[docs/GITHUB.md](docs/GITHUB.md)** | GitHub OAuth app setup | Setting up for the first time | +| **[docs/INSPECTOR.md](docs/INSPECTOR.md)** | MCP Inspector setup and testing | You want to test with MCP Inspector | | **[docs/FLOW_EXPLAINED.md](docs/FLOW_EXPLAINED.md)** | Detailed OAuth flow | You want to understand how it works | | **[docs/DIAGRAMS.md](docs/DIAGRAMS.md)** | Visual explanations | You prefer diagrams | | **[docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md)** | Technical details | You're implementing your own | +| **[docs/DYNAMIC_CLIENT_REGISTRATION.md](docs/DYNAMIC_CLIENT_REGISTRATION.md)** | Dynamic Client Registration (DCR) | You want to understand automatic client registration | ## ๐Ÿ“ Project Structure @@ -75,7 +78,7 @@ mcp-auth/ - OAuth2 endpoints integrated with FastAPI - Three example tools (calculator, greeter, server_info) - Token validation middleware - - Compatible with both SSE and STDIO transports + - Uses **HTTP Streaming (NDJSON)** transport for efficient communication ### 2. **MCP Client** (`mcp_auth_example/client.py`) - Built with **official MCP Python SDK** @@ -101,7 +104,7 @@ mcp-auth/ - Implements MCP Authorization specification (2025-06-18) - Exposes OAuth metadata endpoints (RFC 9728, RFC 8414) - Validates access tokens before serving tools - - Provides MCP tools via HTTP/SSE transport + - Provides MCP tools via **HTTP Streaming (NDJSON)** transport - Tools: calculator (add, multiply), greeter (hello, goodbye), server info ### 2. **MCP Client** (`mcp_auth_example/client.py`) @@ -109,14 +112,14 @@ mcp-auth/ - Automatically opens browser for user authentication - Manages access tokens - Connects to MCP server using **MCP SDK client** - - Makes authenticated requests via MCP protocol (SSE transport) - - Demonstrates proper MCP tool invocation + - Makes authenticated requests via MCP protocol (**HTTP Streaming transport**) + - Demonstrates proper MCP tool invocation with NDJSON format ### 3. **Pydantic AI Agent** (`mcp_auth_example/agent.py`) โœจ NEW - Interactive CLI agent powered by **pydantic-ai** - Uses **Anthropic Claude Sonnet 4.5** model - Automatically authenticates with OAuth2 - - Connects to MCP server with authenticated tools + - Connects to MCP server with authenticated tools via HTTP Streaming - Natural language interface to MCP tools - Example: "What is 15 + 27?" โ†’ Uses calculator_add tool diff --git a/examples/mcp-auth/config.template.json b/examples/mcp-auth/config.template.json.disabled similarity index 100% rename from examples/mcp-auth/config.template.json rename to examples/mcp-auth/config.template.json.disabled diff --git a/examples/mcp-auth/docs/AGENT.md b/examples/mcp-auth/docs/AGENT.md index 6b7c154..1225621 100644 --- a/examples/mcp-auth/docs/AGENT.md +++ b/examples/mcp-auth/docs/AGENT.md @@ -65,8 +65,8 @@ The agent provides a natural language interface to the MCP server tools, powered โ”‚ โ”‚ โ”‚ โ”‚ โ–ผ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ MCPServerSSE (pydantic_ai.mcp) โ”‚ โ”‚ -โ”‚ โ”‚ - URL: http://localhost:8080/sse โ”‚ โ”‚ +โ”‚ โ”‚ MCPServerStreamableHTTP (pydantic_ai.mcp) โ”‚ โ”‚ +โ”‚ โ”‚ - URL: http://localhost:8080/mcp โ”‚ โ”‚ โ”‚ โ”‚ - Auth: Bearer token from OAuth โ”‚ โ”‚ โ”‚ โ”‚ - Tools: calculator_*, greeter_*, get_server_info โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ @@ -77,7 +77,7 @@ The agent provides a natural language interface to the MCP server tools, powered โ”‚ MCP Server (server.py) โ”‚ โ”‚ - Validates Bearer token with GitHub โ”‚ โ”‚ - Executes tool functions โ”‚ -โ”‚ - Returns results via SSE โ”‚ +โ”‚ - Returns results via HTTP Streaming (NDJSON) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` @@ -88,16 +88,16 @@ The agent provides a natural language interface to the MCP server tools, powered - Manages PKCE flow for security - Returns access token for MCP server -2. **Agent Setup** (`agent.py`) +3. **Agent Setup** (`agent.py`) - Creates `httpx.AsyncClient` with Bearer token - - Initializes `MCPServerSSE` connection + - Initializes `MCPServerStreamableHTTP` connection - Creates `Agent` with Anthropic Claude model - Registers MCP server as toolset -3. **Tool Invocation Flow** +4. **Tool Invocation Flow** - User types natural language query - Agent (Claude) analyzes query and decides which tool to call - - Agent calls tool via MCP protocol (HTTP SSE) + - Agent calls tool via MCP protocol (HTTP Streaming) - MCP server validates token and executes tool - Agent receives result and formulates response - Agent displays natural language answer to user @@ -121,17 +121,17 @@ if oauth.authenticate(): ```python import httpx from pydantic_ai import Agent -from pydantic_ai.mcp import MCPServerSSE +from pydantic_ai.mcp import MCPServerStreamableHTTP # Create HTTP client with authentication http_client = httpx.AsyncClient( headers={"Authorization": f"Bearer {token}"}, - timeout=httpx.Timeout(30.0) + timeout=30.0 ) # Connect to MCP server -mcp_server = MCPServerSSE( - url=f"{server_url}/sse", +mcp_server = MCPServerStreamableHTTP( + url=f"{server_url}/mcp", http_client=http_client ) diff --git a/examples/mcp-auth/docs/DYNAMIC_CLIENT_REGISTRATION.md b/examples/mcp-auth/docs/DYNAMIC_CLIENT_REGISTRATION.md new file mode 100644 index 0000000..4b2b23c --- /dev/null +++ b/examples/mcp-auth/docs/DYNAMIC_CLIENT_REGISTRATION.md @@ -0,0 +1,442 @@ +# Dynamic Client Registration (DCR) + +## Overview + +The MCP Auth server now supports **Dynamic Client Registration (DCR)** per RFC 7591. This allows OAuth clients to register themselves dynamically without requiring pre-configured client credentials. + +## Benefits + +### Before DCR +- โŒ Clients needed pre-shared `client_id` (e.g., "mcp-client", "claude-desktop") +- โŒ Server had to maintain a hardcoded list of known clients +- โŒ Each new client required server configuration changes +- โŒ Redirect URIs had to be configured in advance + +### With DCR +- โœ… Clients register themselves on-the-fly +- โœ… No pre-configuration needed +- โœ… Server generates unique `client_id` for each registration +- โœ… Clients specify their own redirect URIs +- โœ… Perfect for dynamic clients like MCP Inspector + +## How It Works + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Client โ”‚ +โ”‚ (e.g. Inspector)โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ 1. POST /register + โ”‚ { + โ”‚ "redirect_uris": ["http://localhost:6274/oauth/callback"], + โ”‚ "client_name": "MCP Inspector" + โ”‚ } + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Server โ”‚ +โ”‚ (Authorization Server)โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ 2. Generates client_id + โ”‚ Stores client metadata + โ”‚ + โ†“ + โ”‚ 3. Returns registration + โ”‚ { + โ”‚ "client_id": "dcr_abc123...", + โ”‚ "redirect_uris": [...], + โ”‚ ... + โ”‚ } + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Client โ”‚ +โ”‚ (Now Registered)โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ 4. Use client_id in OAuth flow + โ”‚ GET /authorize?client_id=dcr_abc123... + โ†“ + [Normal OAuth Flow] +``` + +## Registration Endpoint + +### POST /register + +Registers a new OAuth client dynamically. + +**Request:** + +```bash +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "client_name": "MCP Inspector", + "client_uri": "https://github.com/modelcontextprotocol/inspector", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + }' +``` + +**Request Body Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `redirect_uris` | array | **Yes** | List of valid redirect URIs | +| `client_name` | string | No | Human-readable client name | +| `client_uri` | string | No | URL of client's homepage | +| `logo_uri` | string | No | URL of client's logo | +| `scope` | string | No | Space-separated scopes (default: "openid read:mcp write:mcp") | +| `grant_types` | array | No | OAuth grant types (default: ["authorization_code"]) | +| `response_types` | array | No | OAuth response types (default: ["code"]) | +| `token_endpoint_auth_method` | string | No | Auth method (default: "none") | + +**Response (201 Created):** + +```json +{ + "client_id": "dcr_R7x3mK2pQnB8vYwT9zLcA1fE5hJ6sN4d", + "client_id_issued_at": 1700000000, + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "client_name": "MCP Inspector", + "client_uri": "https://github.com/modelcontextprotocol/inspector", + "scope": "openid read:mcp write:mcp" +} +``` + +**Error Responses:** + +```json +// Missing redirect_uris +{ + "error": "invalid_redirect_uri", + "error_description": "redirect_uris is required and must be a non-empty array" +} + +// Invalid JSON +{ + "error": "invalid_request", + "error_description": "Invalid JSON in request body" +} + +// Server error +{ + "error": "server_error", + "error_description": "Internal server error during client registration" +} +``` + +## Client ID Format + +DCR generates client IDs with the prefix `dcr_` followed by a cryptographically secure random string: + +``` +dcr_R7x3mK2pQnB8vYwT9zLcA1fE5hJ6sN4d +โ””โ”€โ”ฌโ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ +Prefix Random token (32 bytes, URL-safe base64) +``` + +## Authorization Flow with DCR + +### 1. Client Registration + +```bash +# Step 1: Register the client +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:9000/callback"], + "client_name": "My MCP Client" + }' + +# Response: +# { +# "client_id": "dcr_abc123...", +# ... +# } +``` + +### 2. Authorization Request + +```bash +# Step 2: Start OAuth flow with the registered client_id +GET /authorize?client_id=dcr_abc123...&redirect_uri=http://localhost:9000/callback&response_type=code&state=xyz&code_challenge=...&code_challenge_method=S256 +``` + +### 3. Redirect URI Validation + +The server validates that the `redirect_uri` in the authorization request matches one of the URIs registered during client registration. + +```python +if redirect_uri not in client_metadata["redirect_uris"]: + return error("invalid_request", "redirect_uri not registered for this client") +``` + +### 4. Complete OAuth Flow + +After validation, the normal OAuth flow proceeds: + +1. User authenticates with GitHub +2. Server issues authorization code +3. Client exchanges code for JWT token +4. Client uses JWT to access MCP endpoints + +## Security Considerations + +### Public Clients (token_endpoint_auth_method: "none") + +- **No client_secret required** - Suitable for browser-based clients like MCP Inspector +- **PKCE required** - Code challenge prevents authorization code interception +- **Redirect URI validation** - Server strictly validates redirect URIs + +### Confidential Clients (token_endpoint_auth_method: "client_secret_post") + +- **client_secret issued** - Server generates a secret during registration +- **Secret required at /token** - Client must authenticate when exchanging code +- **More secure** - Suitable for server-side applications + +### Production Security + +**Current Implementation (Development)**: +- โœ… In-memory client registry (fast but non-persistent) +- โœ… Strict redirect URI validation +- โœ… PKCE support for public clients +- โš ๏ธ No rate limiting on /register +- โš ๏ธ No client authentication for registration endpoint + +**Production Recommendations**: +- ๐Ÿ”’ Database-backed client registry (persistent storage) +- ๐Ÿ”’ Rate limiting on /register endpoint (prevent abuse) +- ๐Ÿ”’ Optional: Require authentication for registration (e.g., initial access token) +- ๐Ÿ”’ Client secret rotation support +- ๐Ÿ”’ Audit logging for all registrations +- ๐Ÿ”’ Client metadata validation (URL schemes, etc.) +- ๐Ÿ”’ Automatic cleanup of unused clients + +## Examples + +### MCP Inspector Registration + +```bash +# MCP Inspector can register itself +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "client_name": "MCP Inspector", + "client_uri": "https://github.com/modelcontextprotocol/inspector", + "token_endpoint_auth_method": "none" + }' +``` + +### Python Client + +```python +import requests + +# Register the client +response = requests.post( + "http://localhost:8080/register", + json={ + "redirect_uris": ["http://localhost:8888/callback"], + "client_name": "Python MCP Client", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + } +) + +registration = response.json() +client_id = registration["client_id"] + +print(f"Registered client: {client_id}") + +# Now use client_id in OAuth flow +auth_url = ( + f"http://localhost:8080/authorize" + f"?client_id={client_id}" + f"&redirect_uri=http://localhost:8888/callback" + f"&response_type=code" + f"&state=xyz" + f"&code_challenge={code_challenge}" + f"&code_challenge_method=S256" +) +``` + +### JavaScript Client + +```javascript +// Register the client +const response = await fetch('http://localhost:8080/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Web MCP Client', + client_uri: 'https://example.com', + token_endpoint_auth_method: 'none' + }) +}); + +const registration = await response.json(); +const clientId = registration.client_id; + +console.log('Registered client:', clientId); + +// Use in OAuth flow +const authUrl = new URL('http://localhost:8080/authorize'); +authUrl.searchParams.set('client_id', clientId); +authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback'); +authUrl.searchParams.set('response_type', 'code'); +authUrl.searchParams.set('state', 'xyz'); +authUrl.searchParams.set('code_challenge', codeChallenge); +authUrl.searchParams.set('code_challenge_method', 'S256'); + +window.location.href = authUrl.toString(); +``` + +## Discovery + +The authorization server metadata advertises DCR support: + +```bash +curl http://localhost:8080/.well-known/oauth-authorization-server + +# Response includes: +{ + "issuer": "http://localhost:8080", + "authorization_endpoint": "http://localhost:8080/authorize", + "token_endpoint": "http://localhost:8080/token", + "registration_endpoint": "http://localhost:8080/register", + ... +} +``` + +Clients can discover the registration endpoint automatically by fetching the authorization server metadata. + +## Backward Compatibility + +DCR is **fully backward compatible**: + +- โœ… **Pre-configured clients still work** - Clients like "claude-desktop" or "mcp-client" work as before +- โœ… **Optional registration** - Registration is only validated if client_id exists in registry +- โœ… **Public client support** - Clients without pre-shared credentials work with or without DCR +- โœ… **Legacy agent/client** - agent.py and client.py continue to work with their existing flows + +### Example: Pre-configured Client (Still Works) + +```bash +# This still works without registration +GET /authorize?client_id=claude-desktop&redirect_uri=claude://oauth-callback&... +``` + +### Example: Dynamically Registered Client (New) + +```bash +# 1. Register first +POST /register +Response: {"client_id": "dcr_abc123..."} + +# 2. Then use in OAuth flow +GET /authorize?client_id=dcr_abc123...&redirect_uri=http://localhost:9000/callback&... +``` + +## Testing + +### Test Registration + +```bash +# Register a test client +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:9999/callback"], + "client_name": "Test Client" + }' | jq + +# Expected output: +# { +# "client_id": "dcr_...", +# "client_id_issued_at": 1700000000, +# "redirect_uris": ["http://localhost:9999/callback"], +# ... +# } +``` + +### Test Authorization with Registered Client + +```bash +# Extract client_id from registration response +CLIENT_ID="dcr_..." + +# Start OAuth flow +curl "http://localhost:8080/authorize?client_id=$CLIENT_ID&redirect_uri=http://localhost:9999/callback&response_type=code&state=test123&code_challenge=CHALLENGE&code_challenge_method=S256" + +# Should redirect to GitHub for authentication +``` + +### Test Invalid Redirect URI + +```bash +# Register with one URI +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{"redirect_uris": ["http://localhost:9999/callback"], "client_name": "Test"}' + +# Try to use different URI (should fail) +curl "http://localhost:8080/authorize?client_id=dcr_...&redirect_uri=http://evil.com/callback&..." + +# Expected error: +# { +# "error": "invalid_request", +# "error_description": "redirect_uri not registered for this client. Registered URIs: http://localhost:9999/callback" +# } +``` + +## FAQ + +### Q: Do I need to use DCR? + +**A:** No, it's optional. Pre-configured clients still work. DCR is useful for dynamic clients that don't have pre-shared credentials. + +### Q: Can I register the same redirect_uri multiple times? + +**A:** Yes, each registration creates a new client_id. This is useful for testing or having multiple instances. + +### Q: Is client_secret required? + +**A:** Only if you specify `token_endpoint_auth_method` as something other than "none". For public clients (browser-based), use "none" and rely on PKCE. + +### Q: How do I update client metadata? + +**A:** Currently not supported. In production, implement PUT /register/{client_id} per RFC 7592 (Dynamic Client Registration Management). + +### Q: How long do registrations last? + +**A:** Currently, registrations are stored in memory and persist until server restart. In production, use a database for persistent storage. + +### Q: Can I revoke a client registration? + +**A:** Currently not supported. In production, implement DELETE /register/{client_id} per RFC 7592. + +## References + +- **RFC 7591**: OAuth 2.0 Dynamic Client Registration Protocol +- **RFC 7592**: OAuth 2.0 Dynamic Client Registration Management Protocol +- **RFC 7636**: Proof Key for Code Exchange (PKCE) +- **RFC 6749**: The OAuth 2.0 Authorization Framework + +## Related Documentation + +- [OAUTH_FLOW.md](../OAUTH_FLOW.md) - Complete OAuth flow documentation +- [CLAUDE_DESKTOP_SETUP.md](CLAUDE_DESKTOP_SETUP.md) - Claude Desktop setup guide +- [README.md](../README.md) - MCP Auth example overview diff --git a/examples/mcp-auth/docs/INSPECTOR.md b/examples/mcp-auth/docs/INSPECTOR.md new file mode 100644 index 0000000..665b3f4 --- /dev/null +++ b/examples/mcp-auth/docs/INSPECTOR.md @@ -0,0 +1,529 @@ +# MCP Inspector Setup Guide + +This guide explains how to connect MCP Inspector to the authenticated MCP server. + +## Overview + +MCP Inspector is a browser-based tool for testing and debugging MCP servers. This server implements full OAuth 2.0 support with: + +1. **OAuth Authorization Server**: Server acts as its own OAuth provider + - Issues JWT tokens for MCP access + - Delegates user authentication to GitHub + - Implements PKCE for security + - **Dynamic Client Registration (DCR)** - Clients can register themselves automatically + +2. **Discovery Endpoints**: Properly configured metadata + - `/.well-known/oauth-protected-resource` - Points to our OAuth endpoints + - `/.well-known/oauth-authorization-server` - Describes OAuth capabilities including DCR + - WWW-Authenticate header includes `resource_metadata` parameter + +3. **OAuth Flow Endpoints**: + - `/register` - Dynamic Client Registration (RFC 7591) + - `/authorize` - Starts OAuth flow, redirects to GitHub + - `/oauth/callback` - Receives GitHub auth, issues authorization code + - `/token` - Exchanges code for JWT access token + +4. **Token Validation**: Dual support + - JWT tokens (issued by this server) for Inspector and other OAuth clients + - GitHub tokens (for backward compatibility with client.py/agent.py) + +## Architecture + +``` +MCP Inspector (Browser) + โ†“ + โ†“ 1. (Optional) POST /register - Dynamic Client Registration + โ†“ Request: {"redirect_uris": ["http://localhost:6274/oauth/callback"], ...} + โ†“ Response: {"client_id": "dcr_abc123...", ...} + โ†“ + โ†“ 2. GET /mcp (no token) + โ†“ 401 + WWW-Authenticate (discovery) + โ†“ + โ†“ 3. GET /.well-known/oauth-protected-resource + โ†“ Response includes authorization_endpoint, token_endpoint, registration_endpoint + โ†“ + โ†“ 4. Opens browser โ†’ /authorize + โ†“ Redirects to GitHub + โ†“ + โ†“ 5. User signs in to GitHub + โ†“ + โ†“ 6. GitHub โ†’ /oauth/callback + โ†“ Server validates GitHub user + โ†“ Issues authorization code + โ†“ + โ†“ 7. Inspector โ†’ POST /token + โ†“ Exchanges authorization code for JWT + โ†“ + โ†“ 8. Inspector โ†’ GET /mcp + Bearer JWT + โœ… Connected! MCP tools available +``` + +## Prerequisites + +1. **GitHub OAuth App**: Register an OAuth app at https://github.com/settings/developers + - Application name: "MCP Auth Example" (or your choice) + - Homepage URL: `http://localhost:8080` (or `https://localhost:8080` for HTTPS) + - Authorization callback URL: `http://localhost:8080/oauth/callback` + - Copy Client ID and Client Secret to `config.json` + +2. **Optional: HTTPS**: For production or testing HTTPS + - Follow [SSL.md](SSL.md) to set up mkcert certificates + - Inspector works with both HTTP and HTTPS + +## Quick Start + +### Step 1: Configure GitHub OAuth App + +1. Go to https://github.com/settings/developers +2. Click "New OAuth App" +3. Fill in: + - **Application name**: MCP Auth Example + - **Homepage URL**: `http://localhost:8080` + - **Authorization callback URL**: `http://localhost:8080/oauth/callback` +4. Click "Register application" +5. Copy the **Client ID** +6. Generate a new **Client Secret** and copy it + +### Step 2: Update config.json + +Edit `examples/mcp-auth/config.json`: + +```json +{ + "github": { + "client_id": "YOUR_GITHUB_CLIENT_ID", + "client_secret": "YOUR_GITHUB_CLIENT_SECRET" + }, + "server": { + "host": "localhost", + "port": 8080 + } +} +``` + +### Step 3: Start the Server + +```bash +# For HTTP (recommended for local development) +python -m mcp_auth_example.server + +# Or use make +make server +``` + +Verify the server shows: +``` +๐Ÿ“‹ Server URL: http://localhost:8080 +๐Ÿ”— OAuth Endpoints: + Dynamic Client Registration: http://localhost:8080/register + Authorization: http://localhost:8080/authorize + Token Exchange: http://localhost:8080/token +๐Ÿ”— OAuth Metadata Endpoints: + Protected Resource: http://localhost:8080/.well-known/oauth-protected-resource + Authorization Server: http://localhost:8080/.well-known/oauth-authorization-server +``` + +### Step 4: Start MCP Inspector + +```bash +# Using npm/npx +npx @modelcontextprotocol/inspector + +# Or use make +make inspector +``` + +This will: +1. Start the Inspector on `http://localhost:6274` +2. Open your browser automatically + +### Step 5: Connect Inspector to Server + +1. In MCP Inspector, enter the server URL: + ``` + http://localhost:8080/mcp + ``` + +2. Click **"Connect"** + +3. **Inspector automatically discovers OAuth endpoints** and may optionally register itself via DCR + +4. **Browser opens** for GitHub authentication + +5. **Sign in to GitHub** and authorize the application + +6. You're **redirected back** to Inspector + +7. **Inspector exchanges authorization code for JWT token** + +8. **Connected!** You can now: + - View available tools + - Test tool calls + - Inspect request/response messages + - Debug MCP protocol flow + +## Dynamic Client Registration (DCR) + +The server supports **Dynamic Client Registration** per RFC 7591. Inspector can register itself automatically: + +### How It Works + +1. Inspector detects `registration_endpoint` in metadata: + ```json + { + "registration_endpoint": "http://localhost:8080/register" + } + ``` + +2. Inspector registers itself: + ```bash + POST /register + { + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "client_name": "MCP Inspector" + } + ``` + +3. Server responds with client credentials: + ```json + { + "client_id": "dcr_R7x3mK2pQnB8vYwT9zLcA1fE5hJ6sN4d", + "redirect_uris": ["http://localhost:6274/oauth/callback"] + } + ``` + +4. Inspector uses `client_id` in OAuth flow + +### Manual Registration (Optional) + +You can also manually register Inspector: + +```bash +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "client_name": "MCP Inspector", + "client_uri": "https://github.com/modelcontextprotocol/inspector" + }' +``` + +Response: +```json +{ + "client_id": "dcr_abc123...", + "client_id_issued_at": 1700000000, + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "client_name": "MCP Inspector" +} +``` + +See [DYNAMIC_CLIENT_REGISTRATION.md](DYNAMIC_CLIENT_REGISTRATION.md) for complete DCR documentation. + +## Available MCP Tools + +Once connected, Inspector can test these tools: + +- **calculator_add** - Add two numbers +- **calculator_multiply** - Multiply two numbers +- **greeter_hello** - Greet someone +- **greeter_goodbye** - Say goodbye +- **get_server_info** - Get server information + +### Testing Tools in Inspector + +1. **Select a tool** from the list (e.g., `calculator_add`) +2. **Fill in parameters**: + ```json + { + "a": 15, + "b": 27 + } + ``` +3. **Click "Call Tool"** +4. **View the result**: + ```json + { + "content": [ + { + "type": "text", + "text": "42" + } + ] + } + ``` + +## Verification + +### Test Server Discovery + +```bash +# Test 401 response with WWW-Authenticate header +curl -i http://localhost:8080/mcp + +# Expected response: +# HTTP/1.1 401 Unauthorized +# WWW-Authenticate: Bearer realm="mcp", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource", scope="openid read:mcp write:mcp" +``` + +### Test Protected Resource Metadata + +```bash +curl http://localhost:8080/.well-known/oauth-protected-resource | jq + +# Expected response: +# { +# "issuer": "http://localhost:8080", +# "authorization_endpoint": "http://localhost:8080/authorize", +# "token_endpoint": "http://localhost:8080/token", +# "scopes_supported": ["openid", "read:mcp", "write:mcp"], +# ... +# } +``` + +### Test Authorization Server Metadata + +```bash +curl http://localhost:8080/.well-known/oauth-authorization-server | jq + +# Expected response: +# { +# "issuer": "http://localhost:8080", +# "authorization_endpoint": "http://localhost:8080/authorize", +# "token_endpoint": "http://localhost:8080/token", +# "registration_endpoint": "http://localhost:8080/register", +# "code_challenge_methods_supported": ["S256"], +# ... +# } +``` + +### Test Dynamic Client Registration + +```bash +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{"redirect_uris": ["http://localhost:9999/callback"], "client_name": "Test"}' | jq + +# Expected response: +# { +# "client_id": "dcr_...", +# "client_id_issued_at": 1700000000, +# "redirect_uris": ["http://localhost:9999/callback"], +# ... +# } +``` + +### Test DCR Suite + +```bash +# Run comprehensive DCR tests +make test-dcr +``` + +## Troubleshooting + +### Inspector Can't Connect + +**Check 1: Is the server running?** +```bash +lsof -i :8080 +# Should show Python process +``` + +**Check 2: Test server health** +```bash +curl http://localhost:8080/health +# Should return: {"status": "healthy", ...} +``` + +**Check 3: Check server logs** + +Look for errors in the terminal where `make server` is running. + +### OAuth Flow Fails + +**Problem:** "Missing code or error in response" + +**Solutions:** + +1. **Check callback URL**: Ensure GitHub OAuth app has `http://localhost:8080/oauth/callback` +2. **Check server logs**: Look for errors during `/oauth/callback` +3. **Verify GitHub credentials**: Ensure `config.json` has correct client_id and client_secret +4. **Test OAuth manually**: Try the authorization flow in browser + +### 401 Unauthorized Error + +**Problem:** Inspector shows "Authentication failed" + +**Solutions:** + +1. **Token expired**: Disconnect and reconnect Inspector +2. **Invalid token**: Check server logs for token validation errors +3. **GitHub token invalid**: Ensure GitHub OAuth app is active + +### Connection Timeout + +**Problem:** Inspector shows "Connection timeout" + +**Solutions:** + +1. **Check firewall**: Allow connections to localhost:8080 +2. **Server not running**: Start with `make server` +3. **Wrong port**: Verify port 8080 in server config +4. **Check URL**: Ensure using `http://localhost:8080/mcp` (not `https://` unless configured) + +### Tools Not Appearing + +**Problem:** Connected but no tools show up + +**Solutions:** + +1. **Check server logs**: Should show "tools/list" request +2. **Refresh Inspector**: Reload the browser page +3. **Check authentication**: Verify Bearer token is being sent +4. **Test with curl**: + ```bash + curl -H "Authorization: Bearer YOUR_JWT" http://localhost:8080/mcp + ``` + +### CORS Errors + +**Problem:** Browser console shows CORS errors + +**Solution:** Server already has CORS enabled for all origins. If you still see errors: +1. Check that server is running +2. Clear browser cache +3. Try incognito/private mode + +## Security Best Practices + +### Development + +โœ… Use HTTP for local development (simpler setup) +โœ… Use short-lived tokens (default: 1 hour) +โœ… Never commit tokens to version control +โœ… Use `.env` files for sensitive data (added to `.gitignore`) +โœ… Test with DCR to ensure proper client registration + +### Production + +โŒ Never use HTTP in production +โœ… Use HTTPS with proper CA-signed certificates (Let's Encrypt) +โœ… Implement token refresh flow +โœ… Use secure token storage +โœ… Implement rate limiting (especially on `/register`) +โœ… Add comprehensive logging and monitoring +โœ… Use database for client registry (not in-memory) +โœ… Implement client secret rotation + +## Testing Flows + +### Test OAuth Flow End-to-End + +1. **Start server**: `make server` +2. **Start Inspector**: `make inspector` +3. **Connect**: Enter `http://localhost:8080/mcp`, click Connect +4. **Authenticate**: Sign in to GitHub when browser opens +5. **Verify**: Check that tools appear in Inspector + +### Test Tool Calls + +1. **Select tool**: `calculator_add` +2. **Set parameters**: `{"a": 15, "b": 27}` +3. **Call**: Click "Call Tool" +4. **Verify**: Result should be `42` + +### Test Error Handling + +1. **Invalid parameters**: Try calling `calculator_add` with `{"a": "not a number"}` +2. **Missing parameters**: Try calling without parameters +3. **Unknown tool**: Try calling a non-existent tool + +## Implementation Details + +### OAuth Flow + +1. **Discovery**: Inspector fetches `/.well-known/oauth-protected-resource` +2. **Registration (Optional)**: Inspector registers via `/register` if DCR is supported +3. **Authorization**: User redirected to `/authorize` โ†’ GitHub โ†’ `/oauth/callback` +4. **Token Exchange**: Inspector exchanges code at `/token` for JWT +5. **MCP Access**: Inspector uses JWT to access `/mcp` endpoint + +### Token Format + +JWT tokens issued by the server contain: +```json +{ + "sub": "github_username", + "iss": "http://localhost:8080", + "iat": 1700000000, + "exp": 1700003600, + "scope": "read:mcp write:mcp" +} +``` + +### Client Registry + +Registered clients are stored in-memory (development) or database (production): +```python +{ + "dcr_abc123...": { + "client_id": "dcr_abc123...", + "client_name": "MCP Inspector", + "redirect_uris": ["http://localhost:6274/oauth/callback"], + "client_id_issued_at": 1700000000 + } +} +``` + +## Additional Clients + +### Python Agent (Automated OAuth) + +```bash +make agent +``` + +The agent: +- Runs a local callback server on port 8888 +- Opens browser for GitHub OAuth +- Automatically captures token +- No manual copy/paste needed! + +### Python Client (Direct MCP) + +```bash +make client +``` + +The client: +- Performs OAuth flow +- Lists all available tools +- Calls each tool with example parameters +- Shows results + +## Additional Resources + +- **MCP Specification**: https://modelcontextprotocol.io/ +- **MCP Authorization**: https://modelcontextprotocol.io/docs/specification/authentication +- **MCP Inspector**: https://github.com/modelcontextprotocol/inspector +- **RFC 7591** (Dynamic Client Registration): https://www.rfc-editor.org/rfc/rfc7591 +- **RFC 9728** (Protected Resource Metadata): https://www.rfc-editor.org/rfc/rfc9728 +- **RFC 8414** (OAuth Server Metadata): https://www.rfc-editor.org/rfc/rfc8414 +- **RFC 7636** (PKCE): https://www.rfc-editor.org/rfc/rfc7636 +- **GitHub OAuth**: https://docs.github.com/en/developers/apps/building-oauth-apps + +## Related Documentation + +- [DYNAMIC_CLIENT_REGISTRATION.md](DYNAMIC_CLIENT_REGISTRATION.md) - Complete DCR documentation +- [OAUTH_FLOW.md](../OAUTH_FLOW.md) - Detailed OAuth flow documentation +- [SSL.md](SSL.md) - HTTPS setup with mkcert (optional) +- [README.md](../README.md) - MCP Auth example overview + +## Support + +For issues or questions: +- Check server logs for error messages +- Review Inspector browser console for client-side errors +- Test with `curl` to isolate server vs client issues +- Open an issue at https://github.com/datalayer/mcp-compose/issues diff --git a/examples/mcp-auth/docs/OAUTH_CALLBACK_UPDATE.md b/examples/mcp-auth/docs/OAUTH_CALLBACK_UPDATE.md new file mode 100644 index 0000000..68594d1 --- /dev/null +++ b/examples/mcp-auth/docs/OAUTH_CALLBACK_UPDATE.md @@ -0,0 +1,174 @@ +# OAuth Callback URL Update + +## Overview + +Updated the OAuth flow to work with the GitHub OAuth app callback URL change from `http://localhost:8081/callback` to `https://localhost:8080/oauth/callback`. + +## Changes Made + +### 1. Server Changes (`server.py`) + +#### Updated `/oauth/callback` endpoint + +Now detects legacy flows (agent/client) and handles them differently from Claude Desktop: + +```python +# Check if this is a legacy flow (redirect_uri is our /callback endpoint) +redirect_uri = session["redirect_uri"] + +if redirect_uri.endswith("/callback"): + # Legacy flow: redirect to /callback with GitHub token + callback_url = f"{redirect_uri}?token={gh_token}&state={state}&username={username}" + return RedirectResponse(url=callback_url) +else: + # Claude Desktop flow: issue authorization code for JWT +``` + +#### Updated `/callback` endpoint + +Now receives the GitHub access token directly from `/oauth/callback` instead of exchanging an authorization code: + +```python +@mcp.custom_route("/callback", ["GET"]) +async def oauth_callback_legacy(request: Request): + """ + Legacy OAuth callback endpoint for agent.py and client.py + + Receives GitHub access token from /oauth/callback and displays it + to the user for copy/paste into their terminal. + """ + token = request.query_params.get("token") + username = request.query_params.get("username") +``` + +**Features:** +- Displays token in a beautiful HTML page with automatic clipboard copy +- Includes JavaScript to auto-close window after 30 seconds +- User-friendly instructions for pasting token in terminal +- Works with HTTPS and proper CORS + +### 2. OAuth Client Changes (`oauth_client.py`) + +#### Updated `callback_url` Property + +Changed from hosting a local callback server to using the server's endpoint: + +```python +@property +def callback_url(self) -> str: + """Get the OAuth callback URL - uses server's /callback endpoint""" + return f"{self.server_url}/callback" +``` + +#### Simplified `authenticate()` Method + +**Before:** Started a local HTTP server on port 8081 to receive OAuth callback + +**After:** Opens browser and prompts user to paste the token displayed by the server + +Key improvements: +- โœ… No need to manage local callback server +- โœ… No port conflicts +- โœ… Works with HTTPS server +- โœ… Cleaner user experience +- โœ… Verifies token by making a test request to `/health` endpoint + +#### Removed Unused Code + +- Removed `OAuthCallbackHandler` class (no longer needed) +- Cleaned up imports: + - Removed `http.server` imports (`HTTPServer`, `BaseHTTPRequestHandler`) + - Removed `parse_qs`, `urlparse` imports + - Removed `threading`, `time` imports + - Added `os` import for file system operations + +## User Flow + +### Old Flow (Local Callback Server) +1. Agent starts local HTTP server on port 8081 +2. Opens browser with GitHub OAuth URL +3. User authorizes on GitHub +4. GitHub redirects to `http://localhost:8081/callback` +5. Local server receives code and exchanges for token +6. Agent continues with token + +**Problems:** +- Required port 8081 to be available +- HTTP vs HTTPS mismatch with server +- Complex threading for callback server +- GitHub OAuth app had to point to different URL + +### New Flow (Server Callback) +1. Agent calls `/authorize` endpoint with `redirect_uri=https://localhost:8080/callback` +2. Server's `/authorize` redirects to GitHub with `redirect_uri=https://localhost:8080/oauth/callback` +3. User authorizes on GitHub +4. GitHub redirects to `https://localhost:8080/oauth/callback` +5. Server's `/oauth/callback` detects legacy flow, exchanges code for GitHub token +6. Server redirects to `https://localhost:8080/callback?token=...&username=...` +7. Browser displays token with automatic clipboard copy +8. User pastes token in terminal +9. Agent verifies token against `/health` endpoint and continues + +**Benefits:** +- โœ… Consistent HTTPS communication +- โœ… No port management issues +- โœ… Simpler code (no local server needed) +- โœ… User can verify the token before using it +- โœ… Works with GitHub OAuth app callback to server + +## Testing + +### Test Agent Flow +```bash +cd examples/mcp-auth +make agent +``` + +**Expected:** +1. Browser opens for GitHub authorization +2. After authorizing, browser shows token with copy button +3. Terminal prompts: "๐Ÿ”‘ Paste your access token:" +4. Paste token and press Enter +5. Token is verified against `/health` endpoint +6. Agent starts with authenticated MCP connection + +### Test Client Flow +```bash +cd examples/mcp-auth +make client +``` + +Same flow as agent. + +## Backward Compatibility + +The `/callback` endpoint ensures backward compatibility for: +- โœ… `agent.py` - Pydantic-AI interactive agent +- โœ… `client.py` - MCP SDK demo client + +Claude Desktop uses the `/oauth/callback` endpoint which issues JWT tokens instead. + +## Security Notes + +1. **Token Display**: The token is displayed in the browser for the user to copy. While this requires manual input, it: + - Gives users visibility into what token they're using + - Prevents automatic token theft from callback interception + - Works with HTTPS and proper CORS + +2. **Token Verification**: The client verifies the token by making a test request to the `/health` endpoint before proceeding + +3. **Auto-Close**: The browser window automatically closes after 30 seconds to minimize token exposure + +## Configuration + +GitHub OAuth app settings: +- **Authorization callback URL**: `https://localhost:8080/oauth/callback` +- This single callback URL works for both Claude Desktop (JWT flow) and agent/client (GitHub token flow) + +## Future Enhancements + +Potential improvements: +1. Add WebSocket connection to automatically send token to waiting client +2. Implement QR code display for mobile OAuth flows +3. Add token expiration indicator in the browser UI +4. Support multiple concurrent authentication sessions diff --git a/examples/mcp-auth/docs/OAUTH_FLOW.md b/examples/mcp-auth/docs/OAUTH_FLOW.md new file mode 100644 index 0000000..498080e --- /dev/null +++ b/examples/mcp-auth/docs/OAUTH_FLOW.md @@ -0,0 +1,203 @@ +# OAuth Flow Documentation + +## Overview + +The MCP Auth example now supports **automated OAuth flow** for agent.py and client.py, eliminating the need for manual token input. + +## Supported Clients + +### 1. Agent/Client (Automated) - NEW โœจ + +**Callback URL**: `http://localhost:8888/callback` + +**Flow Type**: Direct GitHub token (legacy flow) + +**Behavior**: +- Starts a local HTTP server on port 8888 +- Opens browser for GitHub authentication +- Automatically captures the GitHub access token from callback +- No manual copy/paste required +- Auto-closes browser window after 3 seconds + +**Usage**: +```bash +make agent +# or +make client +``` + +The authentication is now fully automated! + +### 2. MCP Inspector + +**Callback URL**: `http://localhost:6274/oauth/callback` (Inspector's own callback) + +**Flow Type**: OAuth 2.0 Authorization Code + PKCE + +**Behavior**: +- Full OAuth 2.0 flow with authorization code exchange +- Inspector exchanges code for JWT token at `/token` endpoint +- MCP server issues its own JWT tokens (HS256) + +**Usage**: +```bash +make inspector +``` + +### 3. Claude Desktop + +**Callback URL**: Configured in Claude Desktop settings + +**Flow Type**: OAuth 2.0 Authorization Code + PKCE + +**Behavior**: +- Full OAuth 2.0 flow per MCP specification +- Metadata discovery via `.well-known` endpoints +- JWT token issuance by MCP server +- Token used for all MCP tool invocations + +## Flow Detection Logic + +The server's `/oauth/callback` endpoint detects which flow to use: + +```python +# Legacy flows use direct GitHub token (not authorization code): +# 1. Server's own /callback endpoint (old agent/client) +# 2. localhost:8888/callback (new agent/client with automated callback) +is_legacy_flow = ( + redirect_uri == f"{config.server_url}/callback" or + redirect_uri.startswith("http://localhost:8888/") +) + +if is_legacy_flow: + # Redirect with GitHub token: ?token=...&state=...&username=... + callback_url = f"{redirect_uri}?token={gh_token}&state={state}&username={username}" +else: + # Inspector/Claude Desktop: issue authorization code + auth_code = gen_random() + # Store code for exchange at /token endpoint + callback_url = f"{redirect_uri}?code={auth_code}&state={state}" +``` + +## GitHub OAuth App Configuration + +Configure your GitHub OAuth App with these callback URLs: + +1. `http://localhost:8080/oauth/callback` - Server's OAuth callback (receives GitHub code) +2. `http://localhost:8888/callback` - Agent/Client automated callback (optional, for additional security) + +**Note**: The server's callback (`http://localhost:8080/oauth/callback`) is the primary callback that receives the GitHub authorization code. The server then redirects to the client's callback URL with either: +- A GitHub token (for agent/client) +- An authorization code (for Inspector/Claude Desktop) + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ GitHub โ”‚ +โ”‚ OAuth โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 1. User authorizes + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Server (port 8080) โ”‚ +โ”‚ /oauth/callback โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”œโ”€โ†’ Legacy Flow (Agent/Client) + โ”‚ 2. Extract GitHub token + โ”‚ 3. Redirect to http://localhost:8888/callback?token=... + โ”‚ + โ””โ”€โ†’ Modern Flow (Inspector/Claude) + 2. Issue authorization code + 3. Redirect to client callback?code=... + 4. Client exchanges code at /token for JWT + +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Client Callback Server โ”‚ +โ”‚ (port 8888) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ†“ + 4. Token captured automatically + 5. Display success page + 6. Auto-close browser +``` + +## Benefits + +### Before (Manual) +1. User authenticates with GitHub +2. Server displays token in browser +3. User copies token manually +4. User pastes token in terminal +5. Continue... + +### After (Automated) +1. User authenticates with GitHub +2. Token automatically captured +3. Browser auto-closes +4. Continue immediately! + +## Testing + +1. **Start the server**: + ```bash + make server + ``` + +2. **Test automated agent flow**: + ```bash + make agent + ``` + - Browser opens automatically + - Authenticate with GitHub + - Browser shows success and auto-closes + - Agent continues automatically + +3. **Test Inspector flow**: + ```bash + make inspector + ``` + - Enter server URL: `http://localhost:8080/mcp` + - Click "Connect" + - Authenticate with GitHub + - Inspector receives authorization code + - Exchanges for JWT token + - Connected! + +## Troubleshooting + +### Port 8888 already in use + +If you see an error about port 8888: +```bash +# Find the process using port 8888 +lsof -i :8888 + +# Kill it +kill -9 +``` + +Or change the port in `oauth_client.py`: +```python +@property +def callback_url(self) -> str: + return "http://localhost:9999/callback" # Change port +``` + +And update the callback server: +```python +self.callback_server = HTTPServer(('localhost', 9999), CallbackHandler) # Change port +``` + +### Browser doesn't auto-close + +Some browsers prevent JavaScript from closing windows. This is normal - just close it manually. + +### Token verification fails + +Make sure: +1. Server is running on port 8080 +2. GitHub OAuth app is configured correctly +3. `config.json` has correct client_id and client_secret diff --git a/examples/mcp-auth/docs/SSL.md b/examples/mcp-auth/docs/SSL.md new file mode 100644 index 0000000..87957de --- /dev/null +++ b/examples/mcp-auth/docs/SSL.md @@ -0,0 +1,345 @@ +# HTTPS/SSL Setup for MCP Server + +## Why HTTPS is Required + +### Claude Desktop Requirement + +**Claude Desktop requires HTTPS connections** when connecting to MCP servers. This is a security requirement that cannot be bypassed: + +- Claude Desktop will **refuse to connect** to `http://` URLs +- Only `https://` URLs are accepted for MCP server connections +- This applies even to `localhost` connections +- Self-signed certificates are **not trusted** by default + +### Security Benefits + +HTTPS provides essential security features: +- **Encryption**: All communication between Claude Desktop and the MCP server is encrypted +- **Authentication**: Certificates verify the server's identity +- **Integrity**: Prevents man-in-the-middle attacks and tampering + +## Solution: mkcert for Local Development + +For local development, **mkcert** is the recommended solution. It creates locally-trusted SSL certificates that work seamlessly with Claude Desktop and browsers. + +### Why mkcert? + +โœ… **Trusted by System**: Installs a local Certificate Authority (CA) in your system's trust store +โœ… **Works with Claude Desktop**: Certificates are automatically trusted +โœ… **Works with Browsers**: Chrome, Firefox, Safari, Edge all trust these certificates +โœ… **Zero Configuration**: No certificate warnings or security bypasses needed +โœ… **Free and Open Source**: No cost, widely used by developers +โœ… **Simple to Use**: Just two commands to get HTTPS working + +### Alternative Solutions (Not Recommended) + +| Solution | Pros | Cons | +|----------|------|------| +| **Self-signed certificates** | Free, quick to generate | โŒ Not trusted by default
โŒ Requires manual trust configuration
โŒ Security warnings in browsers | +| **ngrok/tunneling** | Real domain with valid cert | โŒ Requires internet connection
โŒ External service dependency
โŒ Potential latency | +| **Production certificates** | Fully trusted everywhere | โŒ Requires public domain
โŒ Complex setup for localhost
โŒ Unnecessary for development | + +## Installation and Setup + +### Step 1: Install mkcert + +**Linux:** +```bash +# Download latest mkcert +curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64" +chmod +x mkcert-v*-linux-amd64 +sudo mv mkcert-v*-linux-amd64 /usr/local/bin/mkcert + +# Verify installation +mkcert --version +``` + +**macOS:** +```bash +brew install mkcert +brew install nss # For Firefox support +``` + +**Windows:** +```powershell +# Using Chocolatey +choco install mkcert + +# Or download from: https://github.com/FiloSottile/mkcert/releases +``` + +### Step 2: Install Local CA + +This installs mkcert's certificate authority in your system's trust store: + +```bash +mkcert -install +``` + +Output: +``` +Created a new local CA ๐Ÿ’ฅ +The local CA is now installed in the system trust store! โšก๏ธ +The local CA is now installed in the Firefox and/or Chrome/Chromium trust store! ๐ŸฆŠ +``` + +### Step 3: Generate Certificates + +Navigate to your project directory and generate certificates: + +```bash +cd /path/to/mcp-compose/examples/mcp-auth +mkcert localhost 127.0.0.1 ::1 +``` + +This creates two files: +- `localhost+2.pem` - SSL certificate +- `localhost+2-key.pem` - Private key + +Output: +``` +Created a new certificate valid for the following names ๐Ÿ“œ + - "localhost" + - "127.0.0.1" + - "::1" + +The certificate is at "./localhost+2.pem" and the key at "./localhost+2-key.pem" โœ… + +It will expire on 18 February 2028 ๐Ÿ—“ +``` + +### Step 4: Start the Server + +The MCP server automatically detects the certificates and enables HTTPS: + +```bash +make server +# Or: python -m mcp_auth_example.server +``` + +You should see: +``` +๐Ÿ”’ HTTPS enabled with certificates: + Certificate: /path/to/localhost+2.pem + Key: /path/to/localhost+2-key.pem + +====================================================================== +๐Ÿ” MCP Server with GitHub OAuth2 Authentication +====================================================================== + +๐Ÿ“‹ Server Information: + Server URL: https://localhost:8080 + MCP Transport: HTTP Streaming (NDJSON) + Authentication: GitHub OAuth2 +``` + +## Using with Claude Desktop + +### Configure MCP Server in Claude Desktop + +Edit your Claude Desktop configuration file: + +**macOS:** +```bash +~/Library/Application Support/Claude/claude_desktop_config.json +``` + +**Windows:** +```bash +%APPDATA%\Claude\claude_desktop_config.json +``` + +**Linux:** +```bash +~/.config/Claude/claude_desktop_config.json +``` + +### Configuration Example + +```json +{ + "mcpServers": { + "github-auth-mcp": { + "url": "https://localhost:8080/mcp", + "transport": "streamable-http", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_TOKEN" + } + } + } +} +``` + +**Important:** Replace `YOUR_GITHUB_TOKEN` with a valid OAuth token obtained through the authentication flow. + +## Verification + +### Test HTTPS Connection + +```bash +# Should return 401 (authentication required) +curl -k https://localhost:8080/mcp + +# With authentication +curl -H "Authorization: Bearer YOUR_TOKEN" https://localhost:8080/mcp +``` + +### Test in Browser + +Visit: `https://localhost:8080/` + +You should see: +- โœ… No certificate warnings +- โœ… Valid HTTPS padlock icon +- โœ… Server information page loads + +### Test with Claude Desktop + +1. Start the MCP server: `make server` +2. Configure Claude Desktop (see above) +3. Restart Claude Desktop +4. Claude should connect without errors +5. MCP tools should appear in Claude's interface + +## Troubleshooting + +### Certificate Not Trusted + +**Problem:** Browser shows security warning despite using mkcert + +**Solution:** +```bash +# Reinstall the local CA +mkcert -uninstall +mkcert -install + +# Regenerate certificates +rm localhost+2*.pem +mkcert localhost 127.0.0.1 ::1 + +# Restart browser/Claude Desktop +``` + +### Server Not Using HTTPS + +**Problem:** Server starts with HTTP instead of HTTPS + +**Solution:** +```bash +# Check certificates exist +ls -la localhost+2*.pem + +# Should show both files: +# -rw------- 1 user user 1598 Nov 18 10:30 localhost+2-key.pem +# -rw-r--r-- 1 user user 1468 Nov 18 10:30 localhost+2.pem + +# If missing, regenerate: +mkcert localhost 127.0.0.1 ::1 +``` + +### Claude Desktop Can't Connect + +**Problem:** Claude Desktop shows connection error + +**Checklist:** +1. โœ… Server is running: `curl https://localhost:8080/` +2. โœ… HTTPS is enabled: Look for "๐Ÿ”’ HTTPS enabled" in server logs +3. โœ… Token is valid: Test with curl using Bearer token +4. โœ… Config file syntax is correct: Validate JSON +5. โœ… Port is correct: Default is 8080 +6. โœ… Restart Claude Desktop after config changes + +### Permission Denied on Linux + +**Problem:** `mkcert -install` fails with permission error + +**Solution:** +```bash +# Run with sudo for system trust store installation +sudo mkcert -install + +# Verify +mkcert -CAROOT +``` + +## Certificate Management + +### View Certificate Information + +```bash +# View certificate details +openssl x509 -in localhost+2.pem -text -noout + +# Check expiration date +openssl x509 -in localhost+2.pem -noout -dates +``` + +### Renew Certificates + +Certificates expire after 825 days (just over 2 years). To renew: + +```bash +# Remove old certificates +rm localhost+2*.pem + +# Generate new ones +mkcert localhost 127.0.0.1 ::1 + +# Restart server +make server +``` + +### Remove mkcert CA (Uninstall) + +```bash +# Remove local CA from system trust store +mkcert -uninstall + +# Remove certificate files +rm localhost+2*.pem + +# Optional: Remove mkcert binary +sudo rm /usr/local/bin/mkcert # Linux +brew uninstall mkcert # macOS +``` + +## Security Considerations + +### Development Only + +โš ๏ธ **mkcert is for local development only** + +- Never use mkcert certificates in production +- The CA private key is stored locally without encryption +- Anyone with access to your CA can create trusted certificates for your machine + +### Production Deployment + +For production environments: +- Use **Let's Encrypt** for free, automated SSL certificates +- Use **AWS Certificate Manager** for AWS deployments +- Use **Caddy** for automatic HTTPS with built-in certificate management +- Use proper domain names (not localhost) + +### Token Security + +- Never commit certificates to version control +- Add `*.pem` to `.gitignore` +- Rotate OAuth tokens regularly +- Use environment variables for sensitive data in production + +## Additional Resources + +- **mkcert GitHub**: https://github.com/FiloSottile/mkcert +- **Let's Encrypt**: https://letsencrypt.org/ (for production) +- **SSL/TLS Best Practices**: https://wiki.mozilla.org/Security/Server_Side_TLS +- **MCP Specification**: https://modelcontextprotocol.io/ +- **Claude Desktop MCP Setup**: https://docs.anthropic.com/claude/docs/mcp + +## Summary + +1. **Install mkcert**: One-time setup per machine +2. **Generate certificates**: `mkcert localhost 127.0.0.1 ::1` +3. **Start server**: Automatically uses HTTPS if certificates exist +4. **Configure Claude Desktop**: Use `https://localhost:8080/mcp` +5. **Enjoy secure local development** with zero certificate warnings! ๐ŸŽ‰ diff --git a/examples/mcp-auth/localhost+2-key.pem.disabled b/examples/mcp-auth/localhost+2-key.pem.disabled new file mode 100644 index 0000000..e218f9d --- /dev/null +++ b/examples/mcp-auth/localhost+2-key.pem.disabled @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQWSsUH9nqM9F2 +WE0lAcR+oF95E94k8ehYOhwjksqvoe0GSNYUdgbvYU4s7lIceW8bVX5YDqdJY7gH +FwkzeqVvHhy5VKLcfSmuOCG1upUuGdXPQvM60efAU6e0R51Esyf3Tnb7BC335sft +sTdneXuLWJiTrbGswwTOUW7KqgUrkL+YLR71zfe3wmCFBwJFLcolXjhXNbYtY50g +3bbcydkcFu/SBafx3KQ+iv5bFAj6DJIvU0lc1bdESuBTAt4Ihc91ptO9S+K1Gopg +2ZcctUm0KP5JK5rtdyCNiZbgXZWEN6RdDNf9A/ALBgmzdaIqZPr6xsWgP9KPcAbi +ci/LFSS9AgMBAAECggEAZiQg02feDExFFxCpGUhpjW6P/6q20EPsFTy/yMzRIxNu +QRN6KGPIeJiqm6pmhOEfkDX0j7T3XCpP8OHhN+SbsAMCL/WHNjMCOQ/5rr7/Ha+6 +uzZmSeLYC9i3MdGeDy0JndtQxzTAWHVCdIvZzpem8qSHgHa50Sl2dLNFboO1ryoP +Fc6ZalsMKLCKyApgwHL3YDN7DQ93tJ9AvICfF9q1catMqZs1I3LfZx2dE30L68i1 +fOm6+96O53jFk3y27qjpa3RkEl8vIPePrYMHrpdmxokxgAL3Xd0WCbgxFPPXhIqj +skpfIdMiqhoyp+7K1uLdnllq8A8+DOkYy+yRQsII4QKBgQD7KPGi8auXT70U7DsD +Ok7CrM87LPPixHqpRGCv4EiNgtH+H+oCkMg79nOY6Us7y0FRhMJ2fwfyu2FNKvXe +d9k7ZAIabbMl+kC/cMt5zaEEhhHx6uwsSuPAkR/sZxCkr93Z+jlhEjj8Cdx4/xt0 +UogWxGv/D3/qq7jGh0WSbNKR+QKBgQDUXQUyj4VLMKFf1KRv+PxN+CpQEQjfj2Mf +CIAGxHXoJgo10mUWhp3GV6807V+I2gK9Hp27hAiIWmoYqgdKOqmAjXtTmEkaLQyC +2ACfkiDTHs7Rtwe3veDEiswD2S/GSTLNRV5FA0SaUvxRTZQ4C27uQhM2vcxwPBIK +LKWdDAFZ5QKBgHhJHKjYK0DVXI4XsQ+TrkLH9pu1pLwXM1O7vr6coMK9Q4r8h9tg +sbUeDDDQkkp5xree6G9N2WWj3i7SA1zfczdhZyx3G1R17OqCv8B+/b2n5BJDW4a+ ++yKvnmVe2va0j4CkuTRHQOlcY63DJ8fm+uxEeCB4sN+YDG9wO56r5ZEpAoGACOoK +sM+jeb+F1p73dBfQh3lWVVwRskizkXbq4N3YUTFflljJk4N9FflSSnd4XidAnC2v +01I8hXS+JWDlw3Do8pN9zMmEsAuaDdgBVrFsnVAawGTddxIKYFWvMK4qOjmSX1l9 +FoqHk67OFp+aDCw2sNunMNIQxdlPrIupPAln+R0CgYEAieiqXWKx+xQ98ay/0FNH +85SB1bPsDOP5SslphIoTEEQbtSXURnrH0z5yLe0VnA//zsvKWLlIkdG4D0rr9Ot5 +gyUoassps8HyC3TxbE4+LdGGFHyY4jmfodSaizy+8ESIj8sUtD1LSm0T5NJ183Wb +q3U1m1iH81DFSzuv/RD4AsA= +-----END PRIVATE KEY----- diff --git a/examples/mcp-auth/localhost+2.pem b/examples/mcp-auth/localhost+2.pem new file mode 100644 index 0000000..0d012fc --- /dev/null +++ b/examples/mcp-auth/localhost+2.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIETDCCArSgAwIBAgIRAJ6fkmCPamCuOcJ3F++C97AwDQYJKoZIhvcNAQELBQAw +eTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMScwJQYDVQQLDB5lY2hh +cmxlc0BlcmljLTIgKEVyaWMgQ2hhcmxlcykxLjAsBgNVBAMMJW1rY2VydCBlY2hh +cmxlc0BlcmljLTIgKEVyaWMgQ2hhcmxlcykwHhcNMjUxMTE4MDgzMjEwWhcNMjgw +MjE4MDgzMjEwWjBSMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlm +aWNhdGUxJzAlBgNVBAsMHmVjaGFybGVzQGVyaWMtMiAoRXJpYyBDaGFybGVzKTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANBZKxQf2eoz0XZYTSUBxH6g +X3kT3iTx6Fg6HCOSyq+h7QZI1hR2Bu9hTizuUhx5bxtVflgOp0ljuAcXCTN6pW8e +HLlUotx9Ka44IbW6lS4Z1c9C8zrR58BTp7RHnUSzJ/dOdvsELffmx+2xN2d5e4tY +mJOtsazDBM5RbsqqBSuQv5gtHvXN97fCYIUHAkUtyiVeOFc1ti1jnSDdttzJ2RwW +79IFp/HcpD6K/lsUCPoMki9TSVzVt0RK4FMC3giFz3Wm071L4rUaimDZlxy1SbQo +/kkrmu13II2JluBdlYQ3pF0M1/0D8AsGCbN1oipk+vrGxaA/0o9wBuJyL8sVJL0C +AwEAAaN2MHQwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8G +A1UdIwQYMBaAFEUUada7Aspcb47mfRAek9lVXRZBMCwGA1UdEQQlMCOCCWxvY2Fs +aG9zdIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAYEA +YTHe+UAvkkubffO9LVUWh7GPkB8OWgRB1zfWTBEnDnpm/puZOWSurT18RPZ1nGzW +vDLGsKxDSB4EeyIO1heWhj6YxPrAaoBZQSOhXhkUo1BxyiKJ9+kpi3emyoc4mle+ +oMRU2IjiVnPcH4wFmEsx+pFLFjBGm5iN0Hq2GEJ9z0p5X8JUTrBNUC7WlqimyjFo +hPbQ8qsKmtlUdMoMoZ/ya915vwFLSyXzkzxx3e9zrhBQnqrXae3it/pKkdg2qNYQ +/97zzSTUlZzqWrKaMI8f8SkH8kML9skRlO0ODjLBynkWwsd2Ol1Sl1M2Cic9GfsJ +kRtfjhublvubgk/MF6q4ejtOOZI7//TThAm7+N1EtL5XP9rdyqtdv/jlGESLuRHo +52Lc3F9VREsMr0T/sz4E0i0bQHUTbad3Ci32nIYcmU7v3ExQj4M6+7sqM+2rcOa5 +QxsCXmr+PhqgQmgeJFKM9NfrFNBqiaJ+FfRO7gYiUoTLtyZVOZ5rXrOD5Et5Zp0t +-----END CERTIFICATE----- diff --git a/examples/mcp-auth/mcp_auth_example/agent.py b/examples/mcp-auth/mcp_auth_example/agent.py index 56f2833..80cae2c 100644 --- a/examples/mcp-auth/mcp_auth_example/agent.py +++ b/examples/mcp-auth/mcp_auth_example/agent.py @@ -7,7 +7,7 @@ Features: - OAuth2 authentication with GitHub (via shared oauth_client) -- Connection to MCP server with Bearer token authentication +- Connection to MCP server with Bearer token authentication via HTTP Streaming - Interactive CLI interface powered by pydantic-ai - Access to all MCP server tools (calculator, greeter, server_info) - Uses Anthropic Claude Sonnet 4.5 model @@ -20,8 +20,8 @@ Learning Objectives: 1. Integrate pydantic-ai Agent with MCP servers -2. Handle OAuth2 authentication for AI agents -3. Use MCPServerSSE with authentication headers +2. Handle OAuth2 authentication for AI agents +3. Use HTTP Streaming transport with authentication headers 4. Build interactive CLI agents with pydantic-ai """ @@ -35,7 +35,8 @@ # Pydantic AI imports try: from pydantic_ai import Agent - from pydantic_ai.mcp import MCPServerSSE + from pydantic_ai.mcp import MCPServerStreamableHTTP + import httpx HAS_PYDANTIC_AI = True except ImportError: HAS_PYDANTIC_AI = False @@ -105,18 +106,25 @@ def create_agent(access_token: str, server_url: str, model: str = "anthropic:cla print("๐Ÿ—๏ธ Creating AI Agent with MCP Tools") print("=" * 70) - print(f"\n๐Ÿ“ก Connecting to MCP server: {server_url}/sse") + print(f"\n๐Ÿ“ก Connecting to MCP server: {server_url}/mcp") + print(" Using HTTP Streaming (Streamable HTTP) transport") print(" Using Bearer token authentication") - # Create MCP server connection with SSE transport and authentication headers - # pydantic-ai will manage the http client internally - mcp_server = MCPServerSSE( - url=f"{server_url}/sse", + # Disable SSL verification for localhost (development with mkcert) + # In production with proper certificates, set verify=True + verify_ssl = not server_url.startswith("https://localhost") + + # Create HTTP client with authentication headers + http_client = httpx.AsyncClient( headers={"Authorization": f"Bearer {access_token}"}, - # Increase read timeout for long-running tool calls - read_timeout=300.0, # 5 minutes - # Allow retries for transient failures - max_retries=2 + timeout=30.0, + verify=verify_ssl + ) + + # Create MCP server connection using pydantic-ai's MCPServerStreamableHTTP + mcp_server = MCPServerStreamableHTTP( + f"{server_url}/mcp", + http_client=http_client ) print(f"\n๐Ÿค– Initializing Agent with {model}") @@ -129,8 +137,7 @@ def create_agent(access_token: str, server_url: str, model: str = "anthropic:cla model_obj = OpenAIChatModel(deployment_name, provider='azure') print(f" Using Azure OpenAI deployment: {deployment_name}") - # Create Agent with the specified model - # The agent will have access to all tools from the MCP server + # Create Agent with the specified model and MCP server toolset agent = Agent( model=model_obj, toolsets=[mcp_server], diff --git a/examples/mcp-auth/mcp_auth_example/client.py b/examples/mcp-auth/mcp_auth_example/client.py index e75481b..8cb11b6 100644 --- a/examples/mcp-auth/mcp_auth_example/client.py +++ b/examples/mcp-auth/mcp_auth_example/client.py @@ -5,10 +5,10 @@ This client demonstrates how to: 1. Discover OAuth metadata from an MCP server -2. Perform OAuth2 authorization flow with GitHub -3. Handle PKCE for security -4. Connect to MCP server via SSE transport with authentication -5. Invoke MCP tools with proper authentication +2. Load configuration (OAuth app credentials, server URL) +3. Authenticate using OAuth2 with PKCE +4. Connect to MCP server via HTTP Streaming transport with authentication +5. List available tools Learning Objectives: 1. Understand OAuth2 discovery process @@ -17,8 +17,9 @@ 4. Use MCP SDK client with authenticated transport """ -from typing import Dict, Optional, Any +from typing import Dict, Optional, Any, AsyncIterator import asyncio +import json # Import shared OAuth client from .oauth_client import OAuthClient @@ -26,11 +27,12 @@ # MCP client imports try: from mcp import ClientSession - from mcp.client.sse import sse_client + from mcp.client.streamable_http import streamablehttp_client + import httpx HAS_MCP = True except ImportError: HAS_MCP = False - print("โš ๏ธ MCP SDK not installed. Install with: pip install mcp") + print("โš ๏ธ MCP SDK not installed. Install with: pip install mcp httpx") class MCPClient: @@ -81,16 +83,27 @@ def list_tools(self) -> Optional[Dict[str, Any]]: return None try: - # Use MCP protocol to list tools + # Use MCP protocol to list tools with HTTP streaming async def _list_tools(): - async with sse_client( - url=f"{self.oauth.get_server_url()}/sse", - headers={"Authorization": f"Bearer {self.access_token}"} - ) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tools_list = await session.list_tools() - return tools_list + # Disable SSL verification for localhost (development with mkcert) + server_url = self.oauth.get_server_url() + verify_ssl = not server_url.startswith("https://localhost") + + # Create HTTP client with auth headers + async with httpx.AsyncClient( + headers={"Authorization": f"Bearer {self.access_token}"}, + timeout=30.0, + verify=verify_ssl + ) as http_client: + # Connect using MCP SDK's streamable HTTP client + async with streamablehttp_client( + f"{self.oauth.get_server_url()}/mcp", + http_client=http_client + ) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + tools_list = await session.list_tools() + return tools_list # Run async function tools_list = asyncio.run(_list_tools()) @@ -113,7 +126,7 @@ async def _list_tools(): async def invoke_tool_mcp(self, tool_name: str, arguments: Dict[str, Any]) -> Optional[Any]: """ - Invoke an MCP tool using the MCP SDK client + Invoke an MCP tool using the MCP SDK client with HTTP streaming Args: tool_name: Name of the tool to invoke @@ -134,40 +147,47 @@ async def invoke_tool_mcp(self, tool_name: str, arguments: Dict[str, Any]) -> Op print(f" Arguments: {arguments}") try: - # Create headers with authentication - headers = {"Authorization": f"Bearer {self.access_token}"} + # Disable SSL verification for localhost (development with mkcert) + server_url = self.oauth.get_server_url() + verify_ssl = not server_url.startswith("https://localhost") - # Connect to MCP server via SSE - async with sse_client( - url=f"{self.oauth.get_server_url()}/sse", - headers=headers - ) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the session - await session.initialize() - - # Call the tool - result = await session.call_tool(tool_name, arguments) - - # Extract content from result - if hasattr(result, 'content'): - content = result.content - if isinstance(content, list) and len(content) > 0: - # Get the text content from the first item - first_content = content[0] - if hasattr(first_content, 'text'): - result_text = first_content.text + # Create HTTP client with auth headers + async with httpx.AsyncClient( + headers={"Authorization": f"Bearer {self.access_token}"}, + timeout=30.0, + verify=verify_ssl + ) as http_client: + # Connect to MCP server via HTTP streaming + async with streamablehttp_client( + f"{self.oauth.get_server_url()}/mcp", + http_client=http_client + ) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + await session.initialize() + + # Call the tool + result = await session.call_tool(tool_name, arguments) + + # Extract content from result + if hasattr(result, 'content'): + content = result.content + if isinstance(content, list) and len(content) > 0: + # Get the text content from the first item + first_content = content[0] + if hasattr(first_content, 'text'): + result_text = first_content.text + else: + result_text = str(first_content) else: - result_text = str(first_content) + result_text = str(content) else: - result_text = str(content) - else: - result_text = str(result) - - print(f"โœ… Tool invoked successfully") - print(f" Result: {result_text}") - - return result + result_text = str(result) + + print(f"โœ… Tool invoked successfully") + print(f" Result: {result_text}") + + return result except Exception as e: print(f"โŒ Error invoking tool: {e}") diff --git a/examples/mcp-auth/mcp_auth_example/oauth_client.py b/examples/mcp-auth/mcp_auth_example/oauth_client.py index 24a149f..9015e48 100644 --- a/examples/mcp-auth/mcp_auth_example/oauth_client.py +++ b/examples/mcp-auth/mcp_auth_example/oauth_client.py @@ -28,11 +28,11 @@ import base64 import webbrowser from typing import Dict, Optional -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import parse_qs, urlparse, urlencode +from urllib.parse import urlencode, urlparse, parse_qs import requests +import os +from http.server import HTTPServer, BaseHTTPRequestHandler import threading -import time class Config: @@ -52,15 +52,30 @@ def github_client_secret(self) -> str: @property def server_url(self) -> str: + """ + Get server URL with HTTPS if certificates are available. + Matches the logic in server.py for consistency. + """ + import os host = self.config["server"]["host"] port = self.config["server"]["port"] - return f"http://{host}:{port}" + + # Check for SSL certificates (same logic as server.py) + cert_file = os.path.join(os.path.dirname(__file__), "..", "localhost+2.pem") + key_file = os.path.join(os.path.dirname(__file__), "..", "localhost+2-key.pem") + ssl_enabled = os.path.exists(cert_file) and os.path.exists(key_file) + + protocol = "https" if ssl_enabled else "http" + return f"{protocol}://{host}:{port}" @property def callback_url(self) -> str: - """Callback URL for OAuth - uses port 8081 to avoid conflict with server on 8080""" - host = self.config["server"]["host"] - return f"http://{host}:8081/callback" + """ + Callback URL for OAuth + + Uses a local callback server for automated token capture. + """ + return "http://localhost:8888/callback" @property def server_host(self) -> str: @@ -90,38 +105,40 @@ def generate_code_challenge(verifier: str) -> str: return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=') -class OAuthCallbackHandler(BaseHTTPRequestHandler): - """Handles OAuth callback from GitHub""" +class CallbackHandler(BaseHTTPRequestHandler): + """HTTP handler for OAuth callback""" - authorization_code: Optional[str] = None - state: Optional[str] = None - error: Optional[str] = None + # Class variables to store callback data + callback_data: Optional[Dict[str, str]] = None + callback_received = threading.Event() def do_GET(self): - """Handle callback from OAuth provider""" - query_components = parse_qs(urlparse(self.path).query) - - # Extract authorization code, state, and error - if "code" in query_components: - OAuthCallbackHandler.authorization_code = query_components["code"][0] - - if "state" in query_components: - OAuthCallbackHandler.state = query_components["state"][0] - - if "error" in query_components: - OAuthCallbackHandler.error = query_components["error"][0] - - # Send success response - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() + """Handle GET request for OAuth callback""" + # Parse the callback URL + parsed_url = urlparse(self.path) + params = parse_qs(parsed_url.query) + + # Extract token or error from query parameters + token = params.get('token', [None])[0] + error = params.get('error', [None])[0] + state = params.get('state', [None])[0] + username = params.get('username', [None])[0] + + # Store callback data + CallbackHandler.callback_data = { + 'token': token, + 'error': error, + 'state': state, + 'username': username + } - if OAuthCallbackHandler.error: + # Send response to browser + if token: html = f""" - Authentication Error + Authentication Successful
-
โŒ
-

Authentication Error

-

Error: {OAuthCallbackHandler.error}

+
โœ…
+

Authentication Successful!

+ +

Token has been automatically captured.

You can close this window and return to your terminal.

+
""" + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) else: - html = """ + error_msg = error or 'Unknown error' + html = f""" - - Authentication Complete - - - -
- -

Authorization Complete!

-

You can close this window and return to your terminal.

-
+ Authentication Error + +

โŒ Authentication Error

+

Error: {error_msg}

+

You can close this window.

""" - self.wfile.write(html.encode()) + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + # Signal that callback was received + CallbackHandler.callback_received.set() def log_message(self, format, *args): - """Suppress log messages""" + """Suppress request logging""" pass @@ -209,6 +219,7 @@ class OAuthClient: - PKCE generation (RFC 7636) - Authorization code flow with GitHub - Token exchange + - Automated callback handling with local HTTP server """ def __init__(self, config_file: str = "config.json", verbose: bool = True): @@ -224,6 +235,20 @@ def __init__(self, config_file: str = "config.json", verbose: bool = True): self.access_token: Optional[str] = None self.server_metadata: Optional[Dict] = None self.auth_server_metadata: Optional[Dict] = None + self.callback_server: Optional[HTTPServer] = None + self.callback_thread: Optional[threading.Thread] = None + + def _should_verify_ssl(self, url: str) -> bool: + """ + Determine if SSL verification should be enabled for a URL. + + For local development with mkcert (https://localhost), we disable verification + since mkcert creates trusted certificates in the system store, but Python's + requests library may not find them depending on the environment. + + In production with proper CA-signed certificates, this should return True. + """ + return not url.startswith("https://localhost") def _print(self, message: str): """Print message if verbose mode is enabled""" @@ -249,9 +274,12 @@ def discover_metadata(self) -> bool: self._print("=" * 70) try: - # Make unauthenticated request to SSE endpoint - self._print(f"\n๐Ÿ“ก Requesting: {self.config.server_url}/sse") - response = requests.get(f"{self.config.server_url}/sse", timeout=5) + # Make unauthenticated request to MCP endpoint + self._print(f"\n๐Ÿ“ก Requesting: {self.config.server_url}/mcp") + # For local HTTPS (mkcert), disable SSL verification or it will fail + # In production, this should be True with proper certificates + verify_ssl = not self.config.server_url.startswith("https://localhost") + response = requests.get(f"{self.config.server_url}/mcp", timeout=5, verify=verify_ssl) if response.status_code == 401: self._print("โœ… Received 401 Unauthorized (expected)") @@ -264,15 +292,26 @@ def discover_metadata(self) -> bool: self._print(f" WWW-Authenticate: {www_auth}") - # Extract realm (protected resource metadata URL) - if 'realm=' in www_auth: + # Extract resource_metadata URL from WWW-Authenticate header + # New format: Bearer realm="mcp", resource_metadata="https://...", scope="..." + # Old format: Bearer realm="https://..." + metadata_url = None + if 'resource_metadata="' in www_auth: + # New format - extract resource_metadata parameter + metadata_url = www_auth.split('resource_metadata="')[1].split('"')[0] + elif 'realm="' in www_auth: + # Old format - use realm if it's a full URL realm = www_auth.split('realm="')[1].split('"')[0] - else: - realm = f"{self.config.server_url}/.well-known/oauth-protected-resource" + if realm.startswith('http'): + metadata_url = realm + + # Fallback to default well-known URL + if not metadata_url: + metadata_url = f"{self.config.server_url}/.well-known/oauth-protected-resource" # Fetch Protected Resource Metadata - self._print(f"\n๐Ÿ“ก Fetching metadata from: {realm}") - pr_response = requests.get(realm, timeout=5) + self._print(f"\n๐Ÿ“ก Fetching metadata from: {metadata_url}") + pr_response = requests.get(metadata_url, timeout=5, verify=self._should_verify_ssl(metadata_url)) if pr_response.status_code != 200: self._print(f"โŒ Error: Failed to fetch metadata (status: {pr_response.status_code})") @@ -283,27 +322,37 @@ def discover_metadata(self) -> bool: self._print("โœ… Protected Resource Metadata received:") self._print(f" {json.dumps(self.server_metadata, indent=3)}") - # Extract authorization server URL - auth_servers = self.server_metadata.get("authorization_servers", []) - if not auth_servers: - self._print("โŒ Error: No authorization servers found") - return False - - auth_server_url = auth_servers[0] - - # Fetch Authorization Server Metadata - as_metadata_url = f"{auth_server_url}/.well-known/oauth-authorization-server" - self._print(f"๐Ÿ“ก Fetching auth server metadata from: {as_metadata_url}") - - as_response = requests.get(as_metadata_url, timeout=5) - - if as_response.status_code != 200: - self._print(f"โŒ Error: Failed to fetch auth server metadata (status: {as_response.status_code})") - return False + # Handle two possible formats: + # 1. New format: metadata includes authorization_endpoint directly (server is its own auth server) + # 2. Old format: metadata includes authorization_servers array pointing to separate auth server - self.auth_server_metadata = as_response.json() - if self.verbose: - self._print("โœ… Authorization Server Metadata received") + if "authorization_endpoint" in self.server_metadata: + # New format: server metadata IS the authorization server metadata + self.auth_server_metadata = self.server_metadata + if self.verbose: + self._print("โœ… Server acts as its own authorization server") + else: + # Old format: need to fetch separate authorization server metadata + auth_servers = self.server_metadata.get("authorization_servers", []) + if not auth_servers: + self._print("โŒ Error: No authorization servers found") + return False + + auth_server_url = auth_servers[0] + + # Fetch Authorization Server Metadata + as_metadata_url = f"{auth_server_url}/.well-known/oauth-authorization-server" + self._print(f"๐Ÿ“ก Fetching auth server metadata from: {as_metadata_url}") + + as_response = requests.get(as_metadata_url, timeout=5, verify=self._should_verify_ssl(as_metadata_url)) + + if as_response.status_code != 200: + self._print(f"โŒ Error: Failed to fetch auth server metadata (status: {as_response.status_code})") + return False + + self.auth_server_metadata = as_response.json() + if self.verbose: + self._print("โœ… Authorization Server Metadata received") return True @@ -315,23 +364,65 @@ def discover_metadata(self) -> bool: self._print(f"โŒ Error during metadata discovery: {e}") return False + def _start_callback_server(self) -> bool: + """ + Start local HTTP server to receive OAuth callback + + Returns: + True if server started successfully, False otherwise + """ + try: + # Reset callback state + CallbackHandler.callback_data = None + CallbackHandler.callback_received.clear() + + # Start server on localhost:8888 + self.callback_server = HTTPServer(('localhost', 8888), CallbackHandler) + + # Run server in background thread + def serve(): + # Handle a single request then stop + self.callback_server.handle_request() + + self.callback_thread = threading.Thread(target=serve, daemon=True) + self.callback_thread.start() + + self._print("โœ… Local callback server started on http://localhost:8888") + return True + + except Exception as e: + self._print(f"โŒ Failed to start callback server: {e}") + self._print(" Make sure port 8888 is not in use") + return False + + def _stop_callback_server(self): + """Stop the local callback server""" + if self.callback_server: + try: + self.callback_server.server_close() + except: + pass + self.callback_server = None + self.callback_thread = None + def authenticate(self) -> bool: """ - Perform OAuth2 authentication flow + Perform OAuth2 authentication flow with automated callback handling Following OAuth 2.1 with PKCE (RFC 6749, RFC 7636): - 1. Generate PKCE parameters - 2. Build authorization URL - 3. Open browser for user authentication - 4. Receive authorization code via callback - 5. Exchange code for access token + 1. Start local callback server + 2. Generate PKCE parameters + 3. Build authorization URL + 4. Open browser for user authentication + 5. Automatically receive token via callback + 6. Verify token Returns: True if authentication successful, False otherwise """ if self.verbose: self._print("\n" + "=" * 70) - self._print("๐Ÿ” OAuth2 Authentication Flow") + self._print("๐Ÿ” OAuth2 Authentication Flow (Automated)") self._print("=" * 70) # Ensure metadata is available @@ -341,126 +432,113 @@ def authenticate(self) -> bool: self._print("โŒ Error: Metadata discovery failed") return False - # Generate PKCE parameters - self._print("\n๐Ÿ”‘ Generating PKCE parameters...") - code_verifier = PKCEHelper.generate_code_verifier() - code_challenge = PKCEHelper.generate_code_challenge(code_verifier) - - # Generate state for CSRF protection - state = secrets.token_urlsafe(32) - - # Build authorization URL - auth_endpoint = self.auth_server_metadata["authorization_endpoint"] - - params = { - "client_id": self.config.github_client_id, - "redirect_uri": self.config.callback_url, - "response_type": "code", - "scope": "user", - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - # RFC 8707: Resource parameter binds token to MCP server - "resource": self.config.server_url - } - - auth_url = f"{auth_endpoint}?{urlencode(params)}" - - self._print(f"\n๐ŸŒ Opening browser for GitHub authentication...") - - # Start local callback server on port 8081 - try: - callback_server = HTTPServer( - (self.config.server_host, 8081), - OAuthCallbackHandler - ) - except OSError as e: - self._print(f"โŒ Error: Failed to start callback server: {e}") - self._print(" Make sure port 8081 is available") - return False - - # Reset class variables - OAuthCallbackHandler.authorization_code = None - OAuthCallbackHandler.state = None - OAuthCallbackHandler.error = None - - # Run callback server in background thread - server_thread = threading.Thread(target=callback_server.handle_request) - server_thread.daemon = True - server_thread.start() - - # Open browser - webbrowser.open(auth_url) - - self._print("โณ Waiting for authorization...") - self._print(" (Callback server listening on port 8081)") - - # Wait for callback - timeout = 300 # 5 minutes - start_time = time.time() - - while OAuthCallbackHandler.authorization_code is None and OAuthCallbackHandler.error is None: - if time.time() - start_time > timeout: - self._print("โŒ Timeout waiting for authorization") - return False - time.sleep(0.5) - - # Give the server thread a moment to finish - time.sleep(0.5) - - # Check for errors - if OAuthCallbackHandler.error: - self._print(f"โŒ OAuth error: {OAuthCallbackHandler.error}") - return False - - self._print("โœ… Authorization code received") - - # Verify state - if OAuthCallbackHandler.state != state: - self._print("โŒ Error: State mismatch (possible CSRF attack)") + # Start local callback server + self._print("\n๐ŸŒ Starting local callback server...") + if not self._start_callback_server(): return False - # Exchange authorization code for access token - self._print("\n๐Ÿ”„ Exchanging code for access token...") - - token_endpoint = self.auth_server_metadata["token_endpoint"] - - token_data = { - "client_id": self.config.github_client_id, - "client_secret": self.config.github_client_secret, - "code": OAuthCallbackHandler.authorization_code, - "redirect_uri": self.config.callback_url, - "code_verifier": code_verifier, - "grant_type": "authorization_code" - } - try: - token_response = requests.post( - token_endpoint, - data=token_data, - headers={"Accept": "application/json"}, - timeout=10 - ) + # Generate PKCE parameters + self._print("\n๐Ÿ”‘ Generating PKCE parameters...") + code_verifier = PKCEHelper.generate_code_verifier() + code_challenge = PKCEHelper.generate_code_challenge(code_verifier) + + # Generate state for CSRF protection + state = secrets.token_urlsafe(32) + + # Build authorization URL + auth_endpoint = self.auth_server_metadata["authorization_endpoint"] + + params = { + "client_id": self.config.github_client_id, + "redirect_uri": self.config.callback_url, + "response_type": "code", + "scope": "user", + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + # RFC 8707: Resource parameter binds token to MCP server + "resource": self.config.server_url + } + + auth_url = f"{auth_endpoint}?{urlencode(params)}" + + self._print(f"\n๐ŸŒ Opening browser for GitHub authentication...") + self._print(f" Authorization URL: {auth_endpoint}") + self._print(f" Callback URL: {self.config.callback_url}") + + # Open browser + webbrowser.open(auth_url) + + self._print("\nโณ Waiting for you to complete authentication in the browser...") + self._print(" The token will be captured automatically.") + + # Wait for callback (with timeout) + callback_received = CallbackHandler.callback_received.wait(timeout=120) # 2 minutes - if token_response.status_code != 200: - self._print(f"โŒ Error: Token exchange failed (status: {token_response.status_code})") - self._print(f" Response: {token_response.text}") + if not callback_received: + self._print("โŒ Timeout: Did not receive callback within 2 minutes") return False - token_json = token_response.json() - self.access_token = token_json.get("access_token") + # Extract callback data + callback_data = CallbackHandler.callback_data - if not self.access_token: - self._print("โŒ Error: No access token in response") + if not callback_data: + self._print("โŒ Error: No callback data received") return False - self._print("โœ… Access token received") + # Check for errors + if callback_data.get('error'): + self._print(f"โŒ OAuth error: {callback_data['error']}") + return False - return True + # Verify state matches (CSRF protection) + if callback_data.get('state') != state: + self._print("โŒ Error: State mismatch (possible CSRF attack)") + return False + + # Extract token + token = callback_data.get('token') + username = callback_data.get('username') + + if not token: + self._print("โŒ Error: No token in callback") + return False + + self._print(f"โœ… Token received automatically!") + if username: + self._print(f" Authenticated as: {username}") + + # Store the token + self.access_token = token + + # Verify the token works by making a test request + self._print("\n๐Ÿ” Verifying token...") + test_response = requests.get( + f"{self.config.server_url}/health", + headers={"Authorization": f"Bearer {self.access_token}"}, + verify=self._should_verify_ssl(self.config.server_url) + ) + + if test_response.status_code == 200: + self._print("โœ… Token verified successfully") + return True + else: + self._print(f"โŒ Token verification failed (status: {test_response.status_code})") + self._print(f" Response: {test_response.text}") + return False + except KeyboardInterrupt: + self._print("\nโŒ Authentication cancelled") + return False except Exception as e: - self._print(f"โŒ Error during token exchange: {e}") + self._print(f"โŒ Error during authentication: {e}") + import traceback + traceback.print_exc() return False + finally: + # Clean up callback server + self._stop_callback_server() def get_token(self) -> Optional[str]: """ diff --git a/examples/mcp-auth/mcp_auth_example/server.py b/examples/mcp-auth/mcp_auth_example/server.py index 2765cc1..7fe8b7f 100644 --- a/examples/mcp-auth/mcp_auth_example/server.py +++ b/examples/mcp-auth/mcp_auth_example/server.py @@ -13,18 +13,32 @@ - Resource Indicators (RFC 8707) - Bearer token authentication -The server exposes MCP tools via HTTP transport while requiring +The server exposes MCP tools via HTTP Streaming (NDJSON) transport while requiring OAuth2 authentication for all tool invocations. """ import json +import logging +import time +import secrets +import hashlib +import base64 from typing import Dict, Optional, Any +from urllib.parse import urlencode import requests -from fastapi import Request, HTTPException -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi import Request, HTTPException, Form +from fastapi.responses import JSONResponse, HTMLResponse, StreamingResponse, RedirectResponse from mcp.server.fastmcp import FastMCP +try: + import jwt +except ImportError: + print("โš ๏ธ PyJWT not installed. Run: pip install PyJWT") + jwt = None + +logger = logging.getLogger(__name__) + class Config: """Configuration management""" @@ -51,7 +65,13 @@ def server_port(self) -> int: @property def server_url(self) -> str: - return f"http://{self.server_host}:{self.server_port}" + """Get server URL with HTTPS if certificates are available""" + import os + cert_file = os.path.join(os.path.dirname(__file__), "..", "localhost+2.pem") + key_file = os.path.join(os.path.dirname(__file__), "..", "localhost+2-key.pem") + ssl_enabled = os.path.exists(cert_file) and os.path.exists(key_file) + protocol = "https" if ssl_enabled else "http" + return f"{protocol}://{self.server_host}:{self.server_port}" class TokenValidator: @@ -100,6 +120,75 @@ def clear_cache(self): config = Config() token_validator = TokenValidator() +# ============================================================================ +# JWT TOKEN MANAGEMENT +# ============================================================================ + +JWT_SIGN_KEY = "dev_sign_key_change_in_production" # TODO: Use env var +ACCESS_TOKEN_EXPIRES = 3600 # 1 hour + +# In-memory stores (replace with database in production) +state_store: Dict[str, Dict[str, Any]] = {} # OAuth state -> session data +auth_code_store: Dict[str, Dict[str, Any]] = {} # auth_code -> user data +client_registry: Dict[str, Dict[str, Any]] = {} # client_id -> client metadata (DCR) + + +def gen_random() -> str: + """Generate random token""" + return secrets.token_urlsafe(32) + + +def mint_jwt(sub: str) -> str: + """ + Mint a JWT access token for MCP access + + Args: + sub: Subject (username/user ID) + + Returns: + JWT token string + """ + if not jwt: + raise RuntimeError("PyJWT not installed") + + now = int(time.time()) + payload = { + "sub": sub, + "iss": config.server_url, + "iat": now, + "exp": now + ACCESS_TOKEN_EXPIRES, + "scope": "read:mcp write:mcp" + } + token = jwt.encode(payload, JWT_SIGN_KEY, algorithm="HS256") + return token + + +def verify_jwt(token: str) -> Optional[Dict[str, Any]]: + """ + Verify JWT token + + Args: + token: JWT token string + + Returns: + Decoded payload if valid, None otherwise + """ + if not jwt: + return None + + try: + payload = jwt.decode( + token, + JWT_SIGN_KEY, + algorithms=["HS256"], + issuer=config.server_url + ) + return payload + except Exception as e: + logger.debug(f"JWT verification failed: {e}") + return None + + # Create FastMCP server for tools mcp = FastMCP("github-auth-mcp-server") @@ -216,7 +305,7 @@ def get_server_info() -> Dict[str, Any]: "name": "github-auth-mcp-server", "version": "1.0.0", "authentication": "GitHub OAuth2", - "transport": "HTTP with SSE", + "transport": "HTTP Streaming (NDJSON)", "tools": ["calculator_add", "calculator_multiply", "greeter_hello", "greeter_goodbye", "get_server_info"], "specification": "MCP Authorization 2025-06-18" } @@ -230,6 +319,10 @@ async def verify_token(authorization: Optional[str]) -> Dict[str, Any]: """ Verify OAuth token from Authorization header + Supports two token types: + 1. JWT tokens issued by this server (for Claude Desktop) + 2. GitHub tokens (for backward compatibility with client.py and agent.py) + Args: authorization: Authorization header value @@ -239,11 +332,13 @@ async def verify_token(authorization: Optional[str]) -> Dict[str, Any]: Raises: HTTPException: If token is missing or invalid """ + www_auth_header = f'Bearer realm="mcp", resource_metadata="{config.server_url}/.well-known/oauth-protected-resource", scope="openid read:mcp write:mcp"' + if not authorization: raise HTTPException( status_code=401, detail="Authentication required", - headers={"WWW-Authenticate": f'Bearer realm="{config.server_url}/.well-known/oauth-protected-resource"'} + headers={"WWW-Authenticate": www_auth_header} ) # Extract Bearer token @@ -251,22 +346,30 @@ async def verify_token(authorization: Optional[str]) -> Dict[str, Any]: raise HTTPException( status_code=401, detail="Invalid authorization header format", - headers={"WWW-Authenticate": f'Bearer realm="{config.server_url}/.well-known/oauth-protected-resource"'} + headers={"WWW-Authenticate": www_auth_header} ) token = authorization[7:] # Remove "Bearer " prefix - # Validate token - user_info = token_validator.validate_token(token) + # Try JWT first (for Claude Desktop) + jwt_payload = verify_jwt(token) + if jwt_payload: + return { + "login": jwt_payload["sub"], + "type": "jwt", + "scope": jwt_payload.get("scope", "") + } - if not user_info: - raise HTTPException( - status_code=401, - detail="Invalid or expired token", - headers={"WWW-Authenticate": f'Bearer realm="{config.server_url}/.well-known/oauth-protected-resource"'} - ) + # Fall back to GitHub token validation (for backward compatibility) + user_info = token_validator.validate_token(token) + if user_info: + return {**user_info, "type": "github"} - return user_info + raise HTTPException( + status_code=401, + detail="Invalid or expired token", + headers={"WWW-Authenticate": www_auth_header} + ) # ============================================================================ @@ -281,15 +384,20 @@ def print_startup_message(): print() print("๐Ÿ“‹ Server Information:") print(f" Server URL: {config.server_url}") - print(f" MCP Transport: HTTP with SSE") + print(f" MCP Transport: HTTP Streaming (NDJSON)") print(f" Authentication: GitHub OAuth2") print() print("๐Ÿ”— OAuth Metadata Endpoints:") print(f" Protected Resource: {config.server_url}/.well-known/oauth-protected-resource") print(f" Authorization Server: {config.server_url}/.well-known/oauth-authorization-server") print() + print("๐Ÿ”— OAuth Endpoints:") + print(f" Dynamic Client Registration: {config.server_url}/register") + print(f" Authorization: {config.server_url}/authorize") + print(f" Token Exchange: {config.server_url}/token") + print() print("๐Ÿ”— MCP Endpoints:") - print(f" SSE Endpoint: {config.server_url}/sse") + print(f" HTTP Streaming: {config.server_url}/mcp") print() print("๐Ÿ› ๏ธ Available Tools:") print(" - calculator_add - Add two numbers") @@ -307,93 +415,632 @@ def print_startup_message(): # OAUTH2 METADATA ENDPOINTS (RFC 9728, RFC 8414) - Using custom_route # ============================================================================ -@mcp.custom_route("/.well-known/oauth-protected-resource", ["GET"]) +@mcp.custom_route("/.well-known/oauth-protected-resource", ["GET", "OPTIONS"]) async def protected_resource_metadata(request: Request): """ Protected Resource Metadata (RFC 9728) - Indicates which authorization server(s) protect this resource + This tells Claude Desktop: + - The issuer (this MCP server itself) + - Where to get authorization (our /authorize endpoint) + - Where to exchange tokens (our /token endpoint) + - Supported scopes + + Claude will use this to initiate OAuth flow. """ - return JSONResponse({ - "resource": config.server_url, - "authorization_servers": [config.server_url], - "bearer_methods_supported": ["header"], - "resource_documentation": "https://github.com/datalayer/mcp-compose/tree/main/examples/mcp-auth" - }) + # Handle CORS preflight + if request.method == "OPTIONS": + return JSONResponse( + {}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + } + ) + + return JSONResponse( + { + "issuer": config.server_url, + "authorization_endpoint": f"{config.server_url}/authorize", + "token_endpoint": f"{config.server_url}/token", + "scopes_supported": ["openid", "read:mcp", "write:mcp"], + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + }, + headers={"Access-Control-Allow-Origin": "*"} + ) -@mcp.custom_route("/.well-known/oauth-authorization-server", ["GET"]) +@mcp.custom_route("/.well-known/oauth-authorization-server", ["GET", "OPTIONS"]) async def authorization_server_metadata(request: Request): """ Authorization Server Metadata (RFC 8414) - Describes OAuth endpoints and capabilities - This server proxies to GitHub OAuth + THIS MCP SERVER acts as the OAuth authorization server. + It delegates user authentication to GitHub, but issues its own JWT tokens. + Claude Desktop will use these endpoints for the OAuth flow. + """ + # Handle CORS preflight + if request.method == "OPTIONS": + return JSONResponse( + {}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + } + ) + + return JSONResponse( + { + "issuer": config.server_url, + "authorization_endpoint": f"{config.server_url}/authorize", + "token_endpoint": f"{config.server_url}/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], + "scopes_supported": ["openid", "read:mcp", "write:mcp"], + "registration_endpoint": f"{config.server_url}/register", + "service_documentation": "https://github.com/datalayer/mcp-compose/tree/main/examples/mcp-auth" + }, + headers={"Access-Control-Allow-Origin": "*"} + ) + + +# ============================================================================ +# DYNAMIC CLIENT REGISTRATION (RFC 7591) +# ============================================================================ + +@mcp.custom_route("/register", ["POST", "OPTIONS"]) +async def register_client(request: Request): + """ + Dynamic Client Registration Endpoint (RFC 7591) + + Allows clients to register themselves dynamically without pre-configuration. + This is useful for clients like MCP Inspector that don't have pre-shared credentials. + + Request body (JSON): + - redirect_uris: Array of redirect URIs (required) + - client_name: Human-readable client name (optional) + - client_uri: URL of client's homepage (optional) + - logo_uri: URL of client's logo (optional) + - scope: Space-separated list of scopes (optional) + - grant_types: Array of OAuth grant types (optional, default: ["authorization_code"]) + - response_types: Array of OAuth response types (optional, default: ["code"]) + - token_endpoint_auth_method: Authentication method (optional, default: "none") + + Response (JSON): + - client_id: Generated client identifier + - client_secret: Generated client secret (if applicable) + - client_id_issued_at: Unix timestamp + - redirect_uris: Registered redirect URIs + - grant_types: Supported grant types + - response_types: Supported response types + - token_endpoint_auth_method: Authentication method + """ + # Handle CORS preflight + if request.method == "OPTIONS": + return JSONResponse( + {}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + } + ) + + try: + # Parse request body + body = await request.json() + + # Validate required fields + redirect_uris = body.get("redirect_uris") + if not redirect_uris or not isinstance(redirect_uris, list) or len(redirect_uris) == 0: + return JSONResponse( + { + "error": "invalid_redirect_uri", + "error_description": "redirect_uris is required and must be a non-empty array" + }, + status_code=400, + headers={"Access-Control-Allow-Origin": "*"} + ) + + # Extract optional fields + client_name = body.get("client_name", "Unnamed Client") + client_uri = body.get("client_uri") + logo_uri = body.get("logo_uri") + scope = body.get("scope", "openid read:mcp write:mcp") + grant_types = body.get("grant_types", ["authorization_code"]) + response_types = body.get("response_types", ["code"]) + token_endpoint_auth_method = body.get("token_endpoint_auth_method", "none") + + # Generate client credentials + client_id = f"dcr_{gen_random()}" + client_secret = None + + # Only issue client_secret if not using "none" auth method + if token_endpoint_auth_method != "none": + client_secret = gen_random() + + # Store client metadata + client_metadata = { + "client_id": client_id, + "client_secret": client_secret, + "client_name": client_name, + "client_uri": client_uri, + "logo_uri": logo_uri, + "redirect_uris": redirect_uris, + "grant_types": grant_types, + "response_types": response_types, + "token_endpoint_auth_method": token_endpoint_auth_method, + "scope": scope, + "client_id_issued_at": int(time.time()) + } + + client_registry[client_id] = client_metadata + + logger.info(f"Registered new client: {client_id} ({client_name})") + logger.info(f" Redirect URIs: {redirect_uris}") + logger.info(f" Scopes: {scope}") + + # Prepare response (exclude internal fields) + response_data = { + "client_id": client_id, + "client_id_issued_at": client_metadata["client_id_issued_at"], + "redirect_uris": redirect_uris, + "grant_types": grant_types, + "response_types": response_types, + "token_endpoint_auth_method": token_endpoint_auth_method, + "client_name": client_name + } + + # Include client_secret if generated + if client_secret: + response_data["client_secret"] = client_secret + + # Include optional fields if provided + if client_uri: + response_data["client_uri"] = client_uri + if logo_uri: + response_data["logo_uri"] = logo_uri + if scope: + response_data["scope"] = scope + + return JSONResponse( + response_data, + status_code=201, + headers={"Access-Control-Allow-Origin": "*"} + ) + + except json.JSONDecodeError: + return JSONResponse( + { + "error": "invalid_request", + "error_description": "Invalid JSON in request body" + }, + status_code=400, + headers={"Access-Control-Allow-Origin": "*"} + ) + except Exception as e: + logger.error(f"Client registration error: {e}") + return JSONResponse( + { + "error": "server_error", + "error_description": "Internal server error during client registration" + }, + status_code=500, + headers={"Access-Control-Allow-Origin": "*"} + ) + + +# ============================================================================ +# OAUTH2 AUTHORIZATION FLOW ENDPOINTS +# ============================================================================ + +@mcp.custom_route("/authorize", ["GET"]) +async def authorize(request: Request): + """ + OAuth2 Authorization Endpoint + + Claude Desktop will redirect user here to start OAuth flow. + We redirect to GitHub for authentication, then issue our own tokens. + + Query params (from Claude): + - response_type: Should be "code" + - client_id: Claude's client ID (optional for public clients) + - redirect_uri: Where to send auth code (Claude's callback) + - scope: Requested scopes + - state: CSRF protection token + - code_challenge: PKCE challenge (S256) + - code_challenge_method: Should be "S256" + """ + # Extract OAuth params + client_id = request.query_params.get("client_id", "claude-desktop") + redirect_uri = request.query_params.get("redirect_uri") + state = request.query_params.get("state", gen_random()) + scope = request.query_params.get("scope", "openid read:mcp") + code_challenge = request.query_params.get("code_challenge") + code_challenge_method = request.query_params.get("code_challenge_method", "S256") + + # Validate redirect_uri + if not redirect_uri: + return JSONResponse( + {"error": "invalid_request", "error_description": "Missing redirect_uri"}, + status_code=400 + ) + + # Validate client_id and redirect_uri if client is registered via DCR + if client_id in client_registry: + client_metadata = client_registry[client_id] + registered_uris = client_metadata.get("redirect_uris", []) + + # Check if redirect_uri matches any registered URI + if redirect_uri not in registered_uris: + logger.warning(f"Client {client_id} attempted to use unregistered redirect_uri: {redirect_uri}") + return JSONResponse( + { + "error": "invalid_request", + "error_description": f"redirect_uri not registered for this client. Registered URIs: {', '.join(registered_uris)}" + }, + status_code=400 + ) + + logger.info(f"Validated registered client: {client_id} ({client_metadata.get('client_name', 'Unknown')})") + + # Store OAuth session state + state_store[state] = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "created_at": time.time() + } + + # Redirect user to GitHub for authentication + github_params = { + "client_id": config.github_client_id, + "redirect_uri": f"{config.server_url}/oauth/callback", + "scope": "read:user user:email", + "state": state, + "allow_signup": "false" + } + + github_auth_url = "https://github.com/login/oauth/authorize?" + urlencode(github_params) + return RedirectResponse(url=github_auth_url) + + +@mcp.custom_route("/oauth/callback", ["GET"]) +async def oauth_callback_github(request: Request): + """ + GitHub OAuth Callback + + After user authenticates with GitHub, GitHub redirects here. + We exchange the GitHub code for a token, verify the user, + then issue our own authorization code to Claude. """ + # Log all query parameters for debugging + logger.info(f"OAuth callback received with params: {dict(request.query_params)}") + + code = request.query_params.get("code") + state = request.query_params.get("state") + error = request.query_params.get("error") + + # Handle OAuth errors from GitHub + if error: + return HTMLResponse( + f"

Authentication Error

GitHub returned error: {error}

", + status_code=400 + ) + + # Validate state + if not code or not state or state not in state_store: + logger.error(f"Invalid callback: code={code}, state={state}, state_in_store={state in state_store if state else False}") + return HTMLResponse( + "

Invalid Request

Invalid state or missing authorization code

", + status_code=400 + ) + + session = state_store[state] + logger.info(f"GitHub callback received: code={code[:10]}..., state={state[:20]}...") + logger.info(f"Session data: redirect_uri={session['redirect_uri']}") + + # Exchange GitHub code for access token + try: + logger.info(f"Exchanging GitHub code for access token...") + token_resp = requests.post( + "https://github.com/login/oauth/access_token", + headers={"Accept": "application/json"}, + json={ + "client_id": config.github_client_id, + "client_secret": config.github_client_secret, + "code": code, + "redirect_uri": f"{config.server_url}/oauth/callback" + }, + timeout=10 + ) + token_json = token_resp.json() + logger.info(f"GitHub token response: {token_json}") + gh_token = token_json.get("access_token") + + if not gh_token: + logger.error(f"No access_token in GitHub response: {token_json}") + raise Exception(f"No access_token in response: {token_json}") + + # Fetch user info from GitHub + logger.info(f"Fetching user info from GitHub...") + user_resp = requests.get( + "https://api.github.com/user", + headers={ + "Authorization": f"Bearer {gh_token}", + "Accept": "application/json" + }, + timeout=5 + ) + gh_user = user_resp.json() + logger.info(f"GitHub user response status: {user_resp.status_code}") + username = gh_user.get("login") + + if not username: + logger.error(f"Unable to fetch GitHub username. Response: {gh_user}") + raise Exception("Unable to fetch GitHub username") + + logger.info(f"GitHub user authenticated: {username}") + + # Check if this is a legacy flow (agent/client with direct token) + redirect_uri = session["redirect_uri"] + + # Legacy flows use direct GitHub token (not authorization code): + # 1. Server's own /callback endpoint (old agent/client) + # 2. localhost:8888/callback (new agent/client with automated callback) + is_legacy_flow = ( + redirect_uri == f"{config.server_url}/callback" or + redirect_uri.startswith("http://localhost:8888/") + ) + + if is_legacy_flow: + # Legacy flow: agent.py or client.py + # Redirect to /callback with the GitHub access token in URL + callback_url = f"{redirect_uri}?token={gh_token}&state={state}&username={username}" + return RedirectResponse(url=callback_url) + else: + # Inspector and Claude Desktop flow: issue authorization code for token exchange + auth_code = gen_random() + auth_code_store[auth_code] = { + "sub": username, + "client_id": session.get("client_id"), + "scope": session.get("scope"), + "code_challenge": session.get("code_challenge"), + "expires_at": time.time() + 120 # 2 minutes + } + + callback_url = f"{redirect_uri}?code={auth_code}&state={state}" + logger.info(f"Redirecting to Inspector or Claude Desktop callback: {callback_url}") + logger.info(f"Authorization code: {auth_code}, expires in 120s") + return RedirectResponse(url=callback_url) + + except Exception as e: + logger.error(f"OAuth callback error: {e}") + return HTMLResponse( + f"

Authentication Failed

Error: {str(e)}

", + status_code=500 + ) + + +@mcp.custom_route("/token", ["POST"]) +async def token_endpoint(request: Request): + """ + OAuth2 Token Endpoint + + Claude Desktop exchanges the authorization code for an access token here. + We validate the code and issue a JWT token. + + Form params (from Claude): + - grant_type: Should be "authorization_code" + - code: Authorization code from /authorize flow + - redirect_uri: Must match original redirect_uri + - code_verifier: PKCE verifier (if code_challenge was provided) + - client_id: Optional client identifier + """ + form = await request.form() + grant_type = form.get("grant_type") + code = form.get("code") + code_verifier = form.get("code_verifier") + redirect_uri = form.get("redirect_uri") + + # Validate grant type + if grant_type != "authorization_code": + return JSONResponse( + {"error": "unsupported_grant_type"}, + status_code=400 + ) + + # Validate authorization code + if not code or code not in auth_code_store: + return JSONResponse( + {"error": "invalid_grant", "error_description": "Invalid authorization code"}, + status_code=400 + ) + + entry = auth_code_store.pop(code) # One-time use + + # Check expiration + if time.time() > entry["expires_at"]: + return JSONResponse( + {"error": "invalid_grant", "error_description": "Authorization code expired"}, + status_code=400 + ) + + # Verify PKCE if code_challenge was provided + if entry.get("code_challenge"): + if not code_verifier: + return JSONResponse( + {"error": "invalid_request", "error_description": "Missing code_verifier"}, + status_code=400 + ) + + # Compute challenge from verifier + import hashlib + import base64 + verifier_hash = hashlib.sha256(code_verifier.encode()).digest() + computed_challenge = base64.urlsafe_b64encode(verifier_hash).rstrip(b'=').decode('ascii') + + if computed_challenge != entry["code_challenge"]: + return JSONResponse( + {"error": "invalid_grant", "error_description": "PKCE verification failed"}, + status_code=400 + ) + + # Issue JWT access token + sub = entry["sub"] + access_token = mint_jwt(sub) + return JSONResponse({ - "issuer": config.server_url, - "authorization_endpoint": "https://github.com/login/oauth/authorize", - "token_endpoint": "https://github.com/login/oauth/access_token", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["client_secret_post"], - "service_documentation": "https://docs.github.com/en/developers/apps/building-oauth-apps" + "access_token": access_token, + "token_type": "bearer", + "expires_in": ACCESS_TOKEN_EXPIRES, + "scope": entry.get("scope", "openid read:mcp write:mcp") }) # ============================================================================ -# OAUTH2 CALLBACK ENDPOINT - Using custom_route +# OAUTH2 CALLBACK ENDPOINT (Legacy - for client.py and agent.py) # ============================================================================ @mcp.custom_route("/callback", ["GET"]) -async def oauth_callback(request: Request): - """ - OAuth callback endpoint - - Users are redirected here after authorizing with GitHub - """ - html = """ - - - - Authentication Successful - - - -
-
โœ…
-

Authentication Successful!

-

You have successfully authenticated with GitHub.

-

You can now close this window and return to your application.

-
- - - """ - return HTMLResponse(content=html) +async def oauth_callback_legacy(request: Request): + """ + Legacy OAuth callback endpoint for agent.py and client.py + + This endpoint receives the GitHub access token from /oauth/callback + and displays it to the user for copy/paste into their terminal. + + For Claude Desktop, /oauth/callback handles the full OAuth flow with JWT tokens. + """ + token = request.query_params.get("token") + username = request.query_params.get("username") + state = request.query_params.get("state") + error = request.query_params.get("error") + + if error: + html = f""" + + + Authentication Error + +

โŒ Authentication Error

+

Error: {error}

+ + + """ + return HTMLResponse(content=html, status_code=400) + + if not token: + html = """ + + + Missing Token + +

โŒ Missing Access Token

+

No access token received.

+ + + """ + return HTMLResponse(content=html, status_code=400) + + # Display the token to the user + try: + + html = f""" + + + + Authentication Successful + + + +
+
โœ…
+

Authentication Successful!

+ +

Copy this token and paste it in your terminal:

+
{token}
+

+ Token automatically copied to clipboard.
+ You can close this window and return to your terminal. +

+ +
+ + + """ + return HTMLResponse(content=html) + + except Exception as e: + logger.error(f"Legacy callback error: {e}") + html = f""" + + + Authentication Failed + +

โŒ Authentication Failed

+

Error: {str(e)}

+ + + """ + return HTMLResponse(content=html, status_code=500) # ============================================================================ @@ -407,9 +1054,9 @@ async def root(request: Request): "name": "MCP Server with GitHub OAuth2", "version": "1.0.0", "authentication": "GitHub OAuth2", - "transport": "HTTP with SSE", + "transport": "HTTP Streaming (NDJSON)", "mcp_endpoints": { - "sse": f"{config.server_url}/sse", + "http_streaming": f"{config.server_url}/mcp", }, "oauth_metadata": { "protected_resource": f"{config.server_url}/.well-known/oauth-protected-resource", @@ -430,14 +1077,14 @@ async def health_check(request: Request): # ============================================================================ -# AUTHENTICATION MIDDLEWARE FOR MCP SSE ENDPOINT +# AUTHENTICATION MIDDLEWARE FOR MCP HTTP STREAMING ENDPOINTS # ============================================================================ class AuthMiddleware: """ Pure ASGI middleware that validates OAuth2 token for MCP requests - This works properly with streaming responses (SSE) + This works properly with streaming responses (HTTP streaming) """ def __init__(self, app): self.app = app @@ -459,8 +1106,8 @@ async def __call__(self, scope, receive, send): await self.app(scope, receive, send) return - # Require auth for MCP endpoints (/sse, /messages) - if path in ["/sse", "/messages"] or path.startswith("/mcp/"): + # Require auth for MCP endpoint (/mcp) + if path == "/mcp" or path.startswith("/mcp/"): # Extract Authorization header headers = dict(scope.get("headers", [])) auth_header = headers.get(b"authorization", b"").decode("utf-8") @@ -498,6 +1145,7 @@ def main(): """Main entry point for running the server""" import sys import io + import os import uvicorn # Ensure stdout uses UTF-8 encoding for emoji support @@ -507,24 +1155,52 @@ def main(): # Print startup message print_startup_message() - # Get FastMCP's SSE ASGI app - # FastMCP creates an app with /sse endpoint and custom routes - app = mcp.sse_app() + # Get FastMCP's streamable HTTP app + # This includes our custom HTTP streaming endpoints and built-in MCP support + app = mcp.streamable_http_app() + + # Add CORS middleware for browser-based clients (like MCP Inspector) + from fastapi.middleware.cors import CORSMiddleware + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins for development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"], + ) # Wrap with authentication middleware (pure ASGI, supports streaming) app = AuthMiddleware(app) + # Check for SSL certificates (mkcert generated) + cert_file = os.path.join(os.path.dirname(__file__), "..", "localhost+2.pem") + key_file = os.path.join(os.path.dirname(__file__), "..", "localhost+2-key.pem") + + ssl_enabled = os.path.exists(cert_file) and os.path.exists(key_file) + # Run with uvicorn - uvicorn.run( - app, - host=config.server_host, - port=config.server_port, - log_level="info" - ) - - -if __name__ == "__main__": - main() + if ssl_enabled: + print(f"\n๐Ÿ”’ HTTPS enabled with certificates:") + print(f" Certificate: {cert_file}") + print(f" Key: {key_file}") + uvicorn.run( + app, + host=config.server_host, + port=config.server_port, + log_level="info", + ssl_certfile=cert_file, + ssl_keyfile=key_file + ) + else: + print(f"\nโš ๏ธ Running in HTTP mode (no SSL certificates found)") + print(f" To enable HTTPS, generate certificates with: mkcert localhost 127.0.0.1 ::1") + uvicorn.run( + app, + host=config.server_host, + port=config.server_port, + log_level="info" + ) if __name__ == "__main__": diff --git a/examples/mcp-auth/tests/test_auth.py b/examples/mcp-auth/tests/test_auth.py index c4a64ba..b99428a 100644 --- a/examples/mcp-auth/tests/test_auth.py +++ b/examples/mcp-auth/tests/test_auth.py @@ -46,10 +46,10 @@ def test_mcp_endpoints(): """Test that MCP endpoints exist""" print("\nTesting MCP endpoints...") - # Test SSE endpoint (should return 401 without auth) - response = requests.get("http://localhost:8080/sse") + # Test MCP endpoint (should return 401 without auth) + response = requests.get("http://localhost:8080/mcp") assert response.status_code == 401, f"Expected 401, got {response.status_code}" - print("โœ… SSE endpoint requires authentication") + print("โœ… MCP endpoint requires authentication") # Test root endpoint response = requests.get("http://localhost:8080/") diff --git a/examples/mcp-auth/tests/test_dcr.py b/examples/mcp-auth/tests/test_dcr.py new file mode 100755 index 0000000..89b9cf0 --- /dev/null +++ b/examples/mcp-auth/tests/test_dcr.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Test Dynamic Client Registration (DCR) + +This script tests the /register endpoint and verifies that: +1. Client registration works +2. Authorization flow validates registered redirect URIs +3. Invalid redirect URIs are rejected +""" + +import requests +import json +import sys + +SERVER_URL = "http://localhost:8080" + + +def test_register_client(): + """Test client registration""" + print("\n" + "=" * 70) + print("Testing Dynamic Client Registration") + print("=" * 70) + + # Test 1: Valid registration + print("\n1๏ธโƒฃ Testing valid client registration...") + response = requests.post( + f"{SERVER_URL}/register", + json={ + "redirect_uris": ["http://localhost:9999/callback", "http://localhost:9999/other"], + "client_name": "Test Client", + "client_uri": "https://example.com", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + } + ) + + if response.status_code != 201: + print(f"โŒ Registration failed: {response.status_code}") + print(response.text) + return None + + registration = response.json() + client_id = registration.get("client_id") + + print(f"โœ… Client registered successfully!") + print(f" Client ID: {client_id}") + print(f" Redirect URIs: {registration.get('redirect_uris')}") + print(f" Issued at: {registration.get('client_id_issued_at')}") + + # Test 2: Missing redirect_uris + print("\n2๏ธโƒฃ Testing registration without redirect_uris (should fail)...") + response = requests.post( + f"{SERVER_URL}/register", + json={ + "client_name": "Invalid Client" + } + ) + + if response.status_code == 400: + error = response.json() + print(f"โœ… Correctly rejected: {error.get('error_description')}") + else: + print(f"โŒ Should have returned 400, got {response.status_code}") + + # Test 3: Empty redirect_uris + print("\n3๏ธโƒฃ Testing registration with empty redirect_uris (should fail)...") + response = requests.post( + f"{SERVER_URL}/register", + json={ + "redirect_uris": [], + "client_name": "Invalid Client 2" + } + ) + + if response.status_code == 400: + error = response.json() + print(f"โœ… Correctly rejected: {error.get('error_description')}") + else: + print(f"โŒ Should have returned 400, got {response.status_code}") + + return client_id + + +def test_authorization_with_registered_client(client_id): + """Test authorization flow with registered client""" + print("\n" + "=" * 70) + print("Testing Authorization with Registered Client") + print("=" * 70) + + # Test 4: Valid redirect_uri (should work) + print("\n4๏ธโƒฃ Testing authorization with valid redirect_uri...") + response = requests.get( + f"{SERVER_URL}/authorize", + params={ + "client_id": client_id, + "redirect_uri": "http://localhost:9999/callback", + "response_type": "code", + "state": "test123", + "code_challenge": "CHALLENGE", + "code_challenge_method": "S256" + }, + allow_redirects=False + ) + + if response.status_code in (302, 307): + print(f"โœ… Authorization started (redirecting to GitHub)") + print(f" Redirect to: {response.headers.get('Location', '')[:100]}...") + else: + print(f"โŒ Expected redirect, got {response.status_code}") + print(response.text) + + # Test 5: Invalid redirect_uri (should fail) + print("\n5๏ธโƒฃ Testing authorization with invalid redirect_uri (should fail)...") + response = requests.get( + f"{SERVER_URL}/authorize", + params={ + "client_id": client_id, + "redirect_uri": "http://evil.com/callback", + "response_type": "code", + "state": "test123", + "code_challenge": "CHALLENGE", + "code_challenge_method": "S256" + }, + allow_redirects=False + ) + + if response.status_code == 400: + error = response.json() + print(f"โœ… Correctly rejected: {error.get('error_description')}") + else: + print(f"โŒ Should have returned 400, got {response.status_code}") + print(response.text) + + +def test_metadata_discovery(): + """Test that DCR is advertised in metadata""" + print("\n" + "=" * 70) + print("Testing Metadata Discovery") + print("=" * 70) + + print("\n6๏ธโƒฃ Testing authorization server metadata...") + response = requests.get(f"{SERVER_URL}/.well-known/oauth-authorization-server") + + if response.status_code != 200: + print(f"โŒ Failed to fetch metadata: {response.status_code}") + return + + metadata = response.json() + registration_endpoint = metadata.get("registration_endpoint") + + if registration_endpoint: + print(f"โœ… DCR advertised in metadata") + print(f" Registration endpoint: {registration_endpoint}") + else: + print(f"โŒ registration_endpoint not found in metadata") + + print(f"\n๐Ÿ“‹ Authorization Server Metadata:") + print(json.dumps(metadata, indent=2)) + + +def main(): + print("\n๐Ÿงช Dynamic Client Registration Test Suite") + print("=" * 70) + print(f"Testing server: {SERVER_URL}") + print("Make sure the server is running: make server") + print("=" * 70) + + # Check if server is running + try: + response = requests.get(f"{SERVER_URL}/health", timeout=2) + if response.status_code != 200: + print(f"\nโŒ Server returned {response.status_code}") + sys.exit(1) + except requests.exceptions.ConnectionError: + print(f"\nโŒ Cannot connect to {SERVER_URL}") + print(" Make sure the server is running: make server") + sys.exit(1) + + # Run tests + client_id = test_register_client() + + if client_id: + test_authorization_with_registered_client(client_id) + + test_metadata_discovery() + + print("\n" + "=" * 70) + print("โœ… All DCR tests completed!") + print("=" * 70) + print() + + +if __name__ == "__main__": + main() diff --git a/mcp_compose/cli.py b/mcp_compose/cli.py index 952ea0f..18dc4f6 100644 --- a/mcp_compose/cli.py +++ b/mcp_compose/cli.py @@ -582,16 +582,44 @@ async def http_tool_proxy(**kwargs): print("โœ“ All servers started successfully!") print() - # Get the FastMCP app with SSE endpoint - base_app = composer.composed_server.sse_app() + # Create the FastAPI REST API app + from .api import create_app + from .api.dependencies import set_composer + + # Set the composer instance for dependency injection + set_composer(composer) + + # Create the main FastAPI app with REST API routes + app = create_app() + + # Get the FastMCP SSE app and include its routes directly + try: + sse_app = composer.composed_server.sse_app() + + # Debug: Check if sse_app has routes + if hasattr(sse_app, 'routes'): + logger.info(f"SSE app has {len(sse_app.routes)} routes") + for route in sse_app.routes: + logger.info(f" Route: {route}") + + # Add SSE app routes directly to the main app instead of mounting + # This way /sse goes to /sse instead of /sse/sse + for route in sse_app.routes: + app.routes.append(route) + + logger.info("SSE routes added successfully to main app") + except Exception as e: + logger.error(f"Failed to add SSE routes: {e}") + print(f"โš ๏ธ Warning: SSE endpoint not available: {e}") # Add a /tools endpoint to list all available tools - # sse_app() returns a Starlette app, so we need to use Starlette routing - from starlette.applications import Starlette + from fastapi import APIRouter from starlette.responses import JSONResponse - from starlette.routing import Route - async def list_tools(request): + tools_router = APIRouter() + + @tools_router.get("/tools") + async def list_tools(): """List all available tools with their schemas.""" tools = [] for tool_name, tool_def in composer.composed_tools.items(): @@ -605,9 +633,8 @@ async def list_tools(request): "total": len(tools) }) - # Add the tools route to the existing app - base_app.routes.append(Route("/tools", list_tools)) - app = base_app + # Include the tools router + app.include_router(tools_router) print("=" * 70) print("๐Ÿ“ก MCP Server Endpoints") diff --git a/mcp_compose/exceptions.py b/mcp_compose/exceptions.py index e71e705..5b85ce9 100644 --- a/mcp_compose/exceptions.py +++ b/mcp_compose/exceptions.py @@ -102,3 +102,17 @@ def __init__( super().__init__(message) self.config_path = config_path self.validation_errors = validation_errors or [] + + +class ValidationError(MCPComposerError): + """Raised when data validation fails.""" + + def __init__( + self, + message: str, + field_name: Optional[str] = None, + invalid_value: Optional[Any] = None, + ) -> None: + super().__init__(message) + self.field_name = field_name + self.invalid_value = invalid_value