From 4cc3e0b00830e2838dfe89e0b62dae20356b3642 Mon Sep 17 00:00:00 2001 From: Larry-Osakwe Date: Mon, 9 Feb 2026 18:42:06 -0800 Subject: [PATCH 1/2] feat(examples): add MCP client connection example with OAuth Demonstrates connecting to authenticated MCP servers using StarletteAuthCoordinator for OAuth callback handling. Key patterns shown: - Session status lifecycle checking - Auth challenge handling with redirect URLs - Tool calling after authentication - SQLite token persistence --- .../client_connection/.python-version | 1 + .../mcp/examples/client_connection/README.md | 209 ++++++++++++++ .../mcp/examples/client_connection/main.py | 256 ++++++++++++++++++ .../examples/client_connection/pyproject.toml | 16 ++ 4 files changed, 482 insertions(+) create mode 100644 packages/mcp/examples/client_connection/.python-version create mode 100644 packages/mcp/examples/client_connection/README.md create mode 100644 packages/mcp/examples/client_connection/main.py create mode 100644 packages/mcp/examples/client_connection/pyproject.toml 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..bebdf4b --- /dev/null +++ b/packages/mcp/examples/client_connection/main.py @@ -0,0 +1,256 @@ +"""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 asyncio +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("

Client not initialized

", status_code=500) + + session = client.sessions.get("hello-world") + if session is None: + return HTMLResponse("

Session not found

", status_code=500) + + # Build status HTML + html = f""" + + + MCP Client Status + + + +

MCP Client Connection Status

+
+ Status: {session.status.value}
+ Operational: {session.is_operational}
+ Requires User Action: {session.requires_user_action} +
+ """ + + if session.requires_user_action: + # Need to authenticate - show auth link + challenges = await client.get_auth_challenges() + if challenges: + auth_url = challenges[0].get("authorization_url", "") + html += f""" +

Authentication Required

+

Click the button below to authenticate with Keycard:

+

Authenticate

+

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 = "" + + html += f""" +

Connected Successfully!

+

Available Tools

+ {tool_list} +

Test Tool Call

+
+ + +
+ """ + elif session.is_failed: + html += f""" +

Connection Failed

+

The connection to the MCP server failed.

+

Try Reconnecting

+ """ + else: + html += f""" +

Connecting...

+

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""" + + Tool Result + + + +

Tool Call Result

+
+ hello_world("{name}") +

{text}

+
+

Back to Status

+ + + """) + except Exception as e: + return HTMLResponse(f""" + + Tool Error + + + +

Tool Call Error

+
+ Error: {str(e)} +
+

Back to Status

+ + + """, status_code=500) + + +async def reconnect(request: Request) -> RedirectResponse: + """Attempt to reconnect to the server.""" + if client is not None: + await client.connect("hello-world", force_reconnect=True) + return RedirectResponse("/", status_code=303) + + +@asynccontextmanager +async def lifespan(app: Starlette): + """Initialize and cleanup the MCP client.""" + global client + + # Create client with OAuth configuration + client = Client( + servers=SERVERS, + storage_backend=storage, + auth_coordinator=coordinator, + ) + + # Attempt initial connection + # This won't block - if auth is required, status will be AUTH_PENDING + await client.connect() + + print(f"Client initialized. Session status: {client.sessions['hello-world'].status.value}") + + yield + + # Cleanup + await client.disconnect() + + +# Build the Starlette application +app = Starlette( + lifespan=lifespan, + routes=[ + Route("/", homepage), + Route("/call-tool", call_tool, methods=["POST"]), + Route("/reconnect", reconnect), + # OAuth callback endpoint from coordinator + Route(CALLBACK_PATH, coordinator.get_completion_endpoint()), + ], +) + + +def main(): + """Run the web application.""" + print("MCP Client Connection Example") + print("=" * 40) + print(f"MCP Server URL: {MCP_SERVER_URL}") + print(f"Callback URL: http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}") + print() + print(f"Open http://{CALLBACK_HOST}:{CALLBACK_PORT}/ in your browser") + print() + + uvicorn.run(app, host=CALLBACK_HOST, port=CALLBACK_PORT) + + +if __name__ == "__main__": + main() diff --git a/packages/mcp/examples/client_connection/pyproject.toml b/packages/mcp/examples/client_connection/pyproject.toml new file mode 100644 index 0000000..39a8e0e --- /dev/null +++ b/packages/mcp/examples/client_connection/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "client-connection-example" +version = "0.1.0" +description = "MCP client connection example with Keycard OAuth authentication" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-mcp", + "uvicorn>=0.30.0", +] + +[tool.uv.sources] +keycardai-mcp = { path = "../../", editable = true } + +[project.scripts] +client-connection = "main:main" From 6a2735da3116ca0bded3b7e98056573025f3e325 Mon Sep 17 00:00:00 2001 From: Larry-Osakwe Date: Tue, 10 Feb 2026 10:39:51 -0800 Subject: [PATCH 2/2] fix: resolve ruff lint errors in client_connection example --- packages/mcp/examples/client_connection/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/mcp/examples/client_connection/main.py b/packages/mcp/examples/client_connection/main.py index bebdf4b..5aeb3a3 100644 --- a/packages/mcp/examples/client_connection/main.py +++ b/packages/mcp/examples/client_connection/main.py @@ -10,7 +10,6 @@ - Tool calling on authenticated servers """ -import asyncio import os from contextlib import asynccontextmanager @@ -116,13 +115,13 @@ async def homepage(request: Request) -> HTMLResponse: """ elif session.is_failed: - html += f""" + html += """

Connection Failed

The connection to the MCP server failed.

Try Reconnecting

""" else: - html += f""" + html += """

Connecting...

Connection in progress. Refresh to check status.

"""