diff --git a/packages/mcp/examples/client_connection/.python-version b/packages/mcp/examples/client_connection/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/packages/mcp/examples/client_connection/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/packages/mcp/examples/client_connection/README.md b/packages/mcp/examples/client_connection/README.md new file mode 100644 index 0000000..76f092f --- /dev/null +++ b/packages/mcp/examples/client_connection/README.md @@ -0,0 +1,209 @@ +# MCP Client Connection with Keycard OAuth + +A complete example demonstrating how to connect to an MCP server as a client using OAuth authentication with `StarletteAuthCoordinator`. + +## Why Keycard? + +Keycard enables secure OAuth authentication for MCP connections. This example shows the client-side flow: + +- **Connect to authenticated MCP servers** using OAuth 2.0 +- **Handle auth challenges** with automatic redirect URL generation +- **Persist tokens** across application restarts with SQLite storage + +## Prerequisites + +Before running this example: + +### 1. Sign up at [keycard.ai](https://keycard.ai) + +### 2. Create a Zone + +Create an authentication zone in the Keycard console. + +### 3. Start an Authenticated MCP Server + +This example connects to an MCP server. Start the `hello_world_server` example first: + +```bash +cd ../hello_world_server +export KEYCARD_ZONE_ID="your-zone-id" +uv sync && uv run python main.py +``` + +The server will start on `http://localhost:8000`. + +## Quick Start + +### 1. Set Environment Variables (Optional) + +```bash +# Default values work for local development +export MCP_SERVER_URL="http://localhost:8000/mcp" +export CALLBACK_HOST="localhost" +export CALLBACK_PORT="8080" +``` + +### 2. Install Dependencies + +```bash +cd packages/mcp/examples/client_connection +uv sync +``` + +### 3. Run the Client + +```bash +uv run python main.py +``` + +### 4. Open in Browser + +Navigate to http://localhost:8080/ and follow the authentication flow: + +1. Click "Authenticate" to start OAuth flow +2. Complete authentication with Keycard +3. Refresh the page to see connected status +4. Test calling the `hello_world` tool + +## How It Works + +### Connection Status Lifecycle + +``` +INITIALIZING + | + v +CONNECTING ---> AUTHENTICATING ---> AUTH_PENDING (waiting for user) + | | + v v +CONNECTION_FAILED CONNECTED (after OAuth callback) +``` + +### OAuth Flow + +``` +Client Browser Keycard MCP Server + | | | | + |-- connect() ------------>| | | + | | |<-- 401 Unauthorized --| + |<-- AUTH_PENDING ---------| | | + | | | | + |-- get_auth_challenges() -| | | + |-- (show auth URL) ------>| | | + | |-- User authenticates ->| | + | |<-- Redirect to callback| | + |<-- OAuth callback -------| | | + | | | | + |-- (auto-reconnect) ----->| | | + |<-- CONNECTED ------------| |<-- Authenticated -----| +``` + +1. Client attempts to connect to MCP server +2. Server returns 401, triggering OAuth challenge +3. Client generates authorization URL and sets status to `AUTH_PENDING` +4. User authenticates in browser +5. OAuth callback is received at `/oauth/callback` +6. Tokens are stored, session auto-reconnects +7. Status becomes `CONNECTED` + +## Session Status Properties + +| Property | Description | +|----------|-------------| +| `is_operational` | `True` when fully connected and ready to call tools | +| `requires_user_action` | `True` when waiting for OAuth completion | +| `is_failed` | `True` when in a failure state | +| `can_retry` | `True` when reconnection is possible | + +## Key Patterns Demonstrated + +### 1. Setting up StarletteAuthCoordinator + +```python +from keycardai.mcp.client import StarletteAuthCoordinator, SQLiteBackend + +storage = SQLiteBackend("client_auth.db") +coordinator = StarletteAuthCoordinator( + backend=storage, + redirect_uri="http://localhost:8080/oauth/callback" +) +``` + +### 2. Creating an OAuth-Enabled Client + +```python +from keycardai.mcp.client import Client + +SERVERS = { + "my-server": { + "url": "http://localhost:8000/mcp", + "transport": "streamable-http", + "auth": {"type": "oauth"}, + } +} + +client = Client( + servers=SERVERS, + storage_backend=storage, + auth_coordinator=coordinator, +) +await client.connect() +``` + +### 3. Checking Session Status + +```python +session = client.sessions["my-server"] + +if session.requires_user_action: + # User needs to authenticate + challenges = await client.get_auth_challenges() + auth_url = challenges[0]["authorization_url"] + print(f"Please authenticate: {auth_url}") + +elif session.is_operational: + # Ready to call tools + result = await client.call_tool("hello_world", {"name": "World"}) +``` + +### 4. Registering the Callback Endpoint + +```python +from starlette.routing import Route + +app = Starlette(routes=[ + Route("/oauth/callback", coordinator.get_completion_endpoint()), +]) +``` + +## Environment Variables Reference + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `MCP_SERVER_URL` | No | `http://localhost:8000/mcp` | URL of MCP server to connect to | +| `CALLBACK_HOST` | No | `localhost` | Host for callback server | +| `CALLBACK_PORT` | No | `8080` | Port for callback server | + +## Troubleshooting + +### "Session stuck in AUTH_PENDING" + +- Ensure you completed the OAuth flow in the browser +- Check that the callback URL matches what's configured in Keycard +- Refresh the page after authentication + +### "Connection refused to MCP server" + +- Verify the `hello_world_server` is running on port 8000 +- Check `MCP_SERVER_URL` environment variable + +### "OAuth callback not received" + +- Ensure `CALLBACK_HOST` and `CALLBACK_PORT` are accessible +- For remote development, use a tunnel (ngrok, Cloudflare Tunnel) + +## Learn More + +- [Keycard Documentation](https://docs.keycard.ai) +- [MCP Client SDK Documentation](https://docs.keycard.ai/sdk/python/client) +- [Hello World Server Example](../hello_world_server/) diff --git a/packages/mcp/examples/client_connection/main.py b/packages/mcp/examples/client_connection/main.py new file mode 100644 index 0000000..5aeb3a3 --- /dev/null +++ b/packages/mcp/examples/client_connection/main.py @@ -0,0 +1,255 @@ +"""MCP Client Connection Example with Keycard OAuth. + +Demonstrates connecting to an MCP server as a client using OAuth authentication +with StarletteAuthCoordinator for handling OAuth callbacks. + +Key concepts: +- StarletteAuthCoordinator for web-based OAuth callback handling +- Session status lifecycle (connecting -> auth_pending -> connected) +- Auth challenge handling and user redirection +- Tool calling on authenticated servers +""" + +import os +from contextlib import asynccontextmanager + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse +from starlette.routing import Route + +from keycardai.mcp.client import ( + Client, + SQLiteBackend, + StarletteAuthCoordinator, +) + +# Configuration from environment +MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp") +CALLBACK_HOST = os.getenv("CALLBACK_HOST", "localhost") +CALLBACK_PORT = int(os.getenv("CALLBACK_PORT", "8080")) +CALLBACK_PATH = "/oauth/callback" + +# Server configuration - connect to an authenticated MCP server +SERVERS = { + "hello-world": { + "url": MCP_SERVER_URL, + "transport": "streamable-http", + "auth": {"type": "oauth"}, + } +} + +# Storage backend - SQLite persists tokens across restarts +storage = SQLiteBackend("client_auth.db") + +# OAuth coordinator - handles callbacks via HTTP endpoint +coordinator = StarletteAuthCoordinator( + backend=storage, + redirect_uri=f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}", +) + +# Client instance - initialized in lifespan +client: Client | None = None + + +async def homepage(request: Request) -> HTMLResponse: + """Display connection status and available actions.""" + if client is None: + return HTMLResponse("
Click the button below to authenticate with Keycard:
+ +After authenticating, refresh this page.
+ """ + elif session.is_operational: + # Connected - show tools and allow calling them + tools = await client.list_tools("hello-world") + tool_list = "The connection to the MCP server failed.
+ + """ + else: + html += """ +Connection in progress. Refresh to check status.
+ """ + + html += """ + + + """ + + return HTMLResponse(html) + + +async def call_tool(request: Request) -> HTMLResponse: + """Handle tool call form submission.""" + if client is None: + return RedirectResponse("/", status_code=303) + + session = client.sessions.get("hello-world") + if session is None or not session.is_operational: + return RedirectResponse("/", status_code=303) + + form = await request.form() + name = str(form.get("name", "World")) + + try: + result = await client.call_tool("hello_world", {"name": name}) + + # Extract text content from result + text_parts = [] + for content in result.content: + if hasattr(content, "text"): + text_parts.append(content.text) + text = "\n".join(text_parts) or "No text content returned" + + return HTMLResponse(f""" + +{text}
+