diff --git a/README.md b/README.md index 3ef906b..976ccb7 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,21 @@ ## Which Package? -| You want... | Install | Guide | +| You want to... | Install | Guide | |---|---|---| -| Auth for MCP servers (using the `mcp` SDK) | `pip install keycardai-mcp` | [Quick Start](#quick-start-standard-mcp) | -| Auth for FastMCP servers | `pip install keycardai-mcp-fastmcp` | [Quick Start](#quick-start-fastmcp) | -| OAuth 2.0 client only | `pip install keycardai-oauth` | [Package docs](packages/oauth/) | +| Add auth to MCP servers (using the `mcp` SDK) | `pip install keycardai-mcp` | [Quick Start](#quick-start-standard-mcp) | +| Add auth to FastMCP servers | `pip install keycardai-mcp-fastmcp` | [Quick Start](#quick-start-fastmcp) | +| Connect to MCP servers as a client | `pip install keycardai-mcp` | [MCP Client docs](packages/mcp/src/keycardai/mcp/client/) | +| Build agent-to-agent (A2A) services | `pip install keycardai-agents` | [Agents docs](packages/agents/) | +| Use the OAuth 2.0 client directly | `pip install keycardai-oauth` | [OAuth docs](packages/oauth/) | + +## Key Concepts + +- **Zone** — A Keycard environment that groups your identity providers, MCP resources, and access policies. Get your zone ID from [console.keycard.ai](https://console.keycard.ai). +- **Delegated Access** — Calling external APIs (Google, GitHub, Slack, etc.) on behalf of your authenticated users via [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange. +- **`@grant` decorator** — Declares which external APIs a tool needs. Automatically exchanges the user's token for a scoped token before your function runs. +- **AccessContext** — The result of a grant. Contains exchanged tokens or errors. Non-throwing by design — always check `.has_errors()` before using tokens. +- **Application Credentials** — How your server authenticates with Keycard for token exchange. Three types: `ClientSecret`, `WebIdentity`, `EKSWorkloadIdentity`. ## Known Limitations & Non-Goals @@ -439,8 +449,13 @@ app = auth_provider.app(mcp, middleware=middleware) ## Documentation - [Full documentation](https://docs.keycard.ai) — API reference, tutorials, integration guides -- [Package docs](packages/) — Detailed README for each package -- [Examples](packages/mcp/examples/) — Runnable example projects +- **Package docs:** + - [keycardai-mcp](packages/mcp/) — MCP server authentication + - [keycardai-mcp-fastmcp](packages/mcp-fastmcp/) — FastMCP integration + - [keycardai-mcp client](packages/mcp/src/keycardai/mcp/client/) — MCP client (CLI, web apps, AI agent integrations) + - [keycardai-agents](packages/agents/) — Agent-to-agent delegation (A2A) + - [keycardai-oauth](packages/oauth/) — OAuth 2.0 client +- **Examples:** [MCP](packages/mcp/examples/) · [FastMCP](packages/mcp-fastmcp/examples/) · [OAuth](packages/oauth/examples/) · [Agents](packages/agents/examples/) ## License diff --git a/docs/concepts.mdx b/docs/concepts.mdx new file mode 100644 index 0000000..0889f22 --- /dev/null +++ b/docs/concepts.mdx @@ -0,0 +1,84 @@ +--- +title: "Key Concepts" +description: "Core concepts and terminology used across the Keycard Python SDK" +--- + +# Key Concepts + +This page defines the core terms used throughout the Keycard SDK. Each term links to the relevant documentation for deeper details. + +## Zone + +A **zone** is a Keycard environment that groups your identity providers, MCP resources, and access policies. Every Keycard integration starts with a zone. + +- **zone_id** — The unique identifier for your zone, used in SDK configuration +- **zone_url** — The full URL of your zone's OAuth endpoints (alternative to zone_id) + +Get your zone ID from [console.keycard.ai](https://console.keycard.ai). You can use either `zone_id` or `zone_url` to configure the SDK — zone_id is simpler, zone_url gives more control. + +## Delegated Access + +**Delegated access** lets your MCP tools call external APIs (Google Calendar, GitHub, Slack, etc.) on behalf of your authenticated users. Under the hood, it uses [RFC 8693 token exchange](https://datatracker.ietf.org/doc/html/rfc8693) to swap the user's MCP token for a scoped token targeting the external API. + +See the [Delegated Access guide](examples/delegated-access) for how it works and how to set it up. + +## @grant Decorator + +The **`@grant` decorator** declares which external APIs a tool needs access to. It takes one or more audience URLs and automatically performs token exchange before your function runs. + +```python +@auth_provider.grant("https://www.googleapis.com/calendar/v3") +async def get_events(ctx: Context) -> dict: + access_context = ctx.get_state("keycardai") + token = access_context.access("https://www.googleapis.com/calendar/v3").access_token + # Use token to call Google Calendar API +``` + +## AccessContext + +**AccessContext** is the result of a grant. It contains the exchanged tokens (or errors) for each requested audience. It is **non-throwing by design** — token exchange failures are captured as errors on the context, not raised as exceptions. + +Always check before using tokens: + +```python +if access_context.has_errors(): + return {"error": access_context.get_errors()} + +token = access_context.access("https://api.example.com").access_token +``` + +How you get the AccessContext depends on the package: + +| Package | How to get AccessContext | +|---|---| +| `keycardai-mcp-fastmcp` | `ctx.get_state("keycardai")` | +| `keycardai-mcp` | Function parameter: `access_ctx: AccessContext` | + +## Application Credentials + +**Application credentials** are how your MCP server authenticates with Keycard for token exchange. They're required for [delegated access](#delegated-access). + +Three types are supported: + +| Type | Use Case | +|---|---| +| `ClientSecret` | Most common — client ID + secret pair | +| `WebIdentity` | Private key JWT assertion ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523)) | +| `EKSWorkloadIdentity` | AWS EKS workload identity for Kubernetes deployments | + +Set credentials via environment variables (`KEYCARD_CLIENT_ID`, `KEYCARD_CLIENT_SECRET`) or pass them explicitly in code. + +## AuthProvider + +**AuthProvider** is the main entry point for adding Keycard authentication to your MCP server. Each package has its own AuthProvider: + +- **FastMCP:** `from keycardai.mcp.integrations.fastmcp import AuthProvider` +- **Standard MCP:** `from keycardai.mcp.server.auth import AuthProvider` + +The AuthProvider handles OAuth metadata serving, token validation, and (with application credentials) token exchange. + +## MCP Client + +The **MCP Client** connects *to* authenticated MCP servers. It handles OAuth flows, multi-server connections, and provides integrations for AI agent frameworks (LangChain, OpenAI Agents, CrewAI). + +See the [MCP Client documentation](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md) for complete usage guide. diff --git a/docs/docs.json b/docs/docs.json index ccdf9fe..f9411c3 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -28,15 +28,18 @@ { "group": "Getting Started", "pages": [ - "welcome" + "welcome", + "concepts" ] }, { "group": "Guides", "pages": [ "examples/fastmcp-integration", + "examples/mcp-server-auth", "examples/mcp-client-usage", - "examples/mcp-server-auth" + "examples/delegated-access", + "examples/testing" ] } ], @@ -110,7 +113,8 @@ "group": "Architecture Decisions", "pages": [ "project/decisions/0001-use-uv-workspaces-for-package-management", - "project/decisions/0002-modular-package-structure-for-minimal-dependencies" + "project/decisions/0002-modular-package-structure-for-minimal-dependencies", + "project/decisions/0003-use-commitizen-for-commit-validation-and-changelog-management" ] }, { diff --git a/docs/examples/delegated-access.mdx b/docs/examples/delegated-access.mdx new file mode 100644 index 0000000..0869a84 --- /dev/null +++ b/docs/examples/delegated-access.mdx @@ -0,0 +1,50 @@ +--- +title: "Delegated Access" +description: "How token exchange lets your MCP tools call external APIs on behalf of users" +--- + +# Delegated Access + +Delegated access is Keycard's core value proposition: your MCP tools can call external APIs (Google Calendar, GitHub, Slack, etc.) **on behalf of authenticated users** without ever handling their credentials directly. + +## How it works + +When a user calls a tool that needs external API access: + +1. The `@grant` decorator intercepts the call and identifies which APIs are needed +2. Keycard exchanges the user's MCP token for scoped tokens targeting each external API ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange) +3. Exchanged tokens are collected in an `AccessContext` object +4. Your function receives the tokens and calls the external APIs + +If any token exchange fails, the error is set on the `AccessContext` — no exceptions are thrown. Always check `.has_errors()` before using tokens. + +## Setup requirements + +Delegated access requires **application credentials** so your server can authenticate with Keycard for token exchange. Set these as environment variables: + +```bash +export KEYCARD_CLIENT_ID="your-client-id" # From console.keycard.ai +export KEYCARD_CLIENT_SECRET="your-client-secret" # From console.keycard.ai +``` + +Three credential types are supported: `ClientSecret` (most common), `WebIdentity` (private key JWT), and `EKSWorkloadIdentity` (AWS EKS). See the package READMEs for details on each. + +## Get started + +Runnable examples with full setup instructions (including GitHub App configuration): + +- **FastMCP:** [Delegated Access example](https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp/examples/delegated_access) — GitHub API integration with error handling patterns +- **Standard MCP:** [Delegated Access example](https://github.com/keycardai/python-sdk/tree/main/packages/mcp/examples/delegated_access) — Same flow using the low-level MCP SDK + +## Key differences between packages + +| | FastMCP (`keycardai-mcp-fastmcp`) | Standard MCP (`keycardai-mcp`) | +|---|---|---| +| **AccessContext** | Retrieved from context: `ctx.get_state("keycardai")` | Injected as function parameter: `access_ctx: AccessContext` | +| **Grant decorator** | `@auth_provider.grant("https://api.example.com")` | `@auth_provider.grant("https://api.example.com")` | + +## Reference + +- [keycardai-mcp-fastmcp README — Delegated Access](https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp#delegated-access) — Full configuration, credential types, error handling +- [keycardai-mcp README — Delegated Access](https://github.com/keycardai/python-sdk/tree/main/packages/mcp#delegated-access) — Same for standard MCP +- [Root README — Delegated Access](https://github.com/keycardai/python-sdk#delegated-access) — Side-by-side code comparison diff --git a/docs/examples/mcp-client-usage.mdx b/docs/examples/mcp-client-usage.mdx index 8a3598d..db353f2 100644 --- a/docs/examples/mcp-client-usage.mdx +++ b/docs/examples/mcp-client-usage.mdx @@ -12,7 +12,7 @@ The Keycard MCP client connects to authenticated MCP servers with built-in OAuth - **OAuth 2.0 flows** — Automatic browser-based authentication when connecting to protected servers - **Multi-server connections** — Connect to multiple MCP servers with independent session and status tracking - **Graceful failures** — Connection issues are reported via session status, not exceptions. Your app stays running even if one server is down. -- **AI agent integrations** — Pre-built integrations with LangChain and OpenAI Agents SDK +- **AI agent integrations** — Pre-built integrations with LangChain, OpenAI Agents SDK, and CrewAI ## How it works @@ -23,8 +23,30 @@ The `Client` accepts a dictionary of server configurations. Each server specifie 3. Tracks each server's session status independently 4. Provides `list_tools()` and `call_tool()` across all connected servers -## Get started +## Use cases + +The MCP client supports multiple execution environments, each with its own auth coordinator and storage backend: + +| Use Case | Auth Coordinator | Storage | Guide Section | +|----------|-----------------|---------|---------------| +| **CLI / Desktop apps** | `LocalAuthCoordinator` (opens browser, blocks until auth) | `InMemoryBackend` | [CLI Applications](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md#1-cli-applications) | +| **Web apps** | `StarletteAuthCoordinator` (returns auth URL, non-blocking) | `InMemoryBackend` or `SQLiteBackend` | [Web Applications](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md#2-web-applications) | +| **Event-driven / bots** | `StarletteAuthCoordinator` + event subscribers | Configurable | [Event-Driven](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md#3-event-driven--metadata-propagation) | +| **Serverless** | `StarletteAuthCoordinator` | `SQLiteBackend` (persists to disk) | [Web Applications](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md#2-web-applications) | + +## AI agent integrations -For complete documentation, configuration examples, and advanced usage patterns (web applications, event-driven architectures, AI agent integrations): +The client provides framework integrations that automatically handle authentication for AI agents: + +| Framework | Installation | Notes | +|-----------|-------------|-------| +| **LangChain** | `uv add keycardai-mcp langchain` | Native async support | +| **OpenAI Agents** | `uv add keycardai-mcp openai-agents` | Native async support | +| **CrewAI** | `uv add "keycardai-mcp[crewai]"` | Requires `[crewai]` extra for sync/async bridge | + +Each integration provides `get_tools()`, `get_system_prompt()`, and `get_auth_tools()` — see the full client documentation for code examples. + +## Get started -- **[MCP Client README](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md)** — Full client documentation +- **[MCP Client README](https://github.com/keycardai/python-sdk/blob/main/packages/mcp/src/keycardai/mcp/client/README.md)** — Complete documentation with use case examples, configuration, AI agent integrations, and troubleshooting +- **[Client Connection Example](https://github.com/keycardai/python-sdk/tree/main/packages/mcp/examples/client_connection)** — Runnable example project diff --git a/docs/examples/testing.mdx b/docs/examples/testing.mdx new file mode 100644 index 0000000..c0d7f61 --- /dev/null +++ b/docs/examples/testing.mdx @@ -0,0 +1,39 @@ +--- +title: "Testing" +description: "How to test MCP servers that use Keycard authentication" +--- + +# Testing + +Keycard provides testing utilities so you can write unit tests for your MCP tools without needing a real Keycard zone or OAuth flow. + +## How it works + +The `mock_access_context()` function creates a fake `AccessContext` that simulates token exchange results. You can configure it to return tokens, errors, or a mix of both — matching any scenario your tool might encounter in production. + +This lets you test: + +- **Default token** — The token from the authenticated user's session +- **Custom tokens** — Tokens exchanged for specific external APIs +- **Resource-specific tokens** — Different tokens for different APIs in a multi-grant tool +- **Error scenarios** — What happens when token exchange fails + +## Package support + +Testing utilities are currently available for the FastMCP integration: + +```python +from keycardai.mcp.integrations.fastmcp.testing import mock_access_context +``` + +For standard MCP (`keycardai-mcp`), test by constructing `AccessContext` objects directly. + +## Get started + +The FastMCP README has a comprehensive testing section with examples for all scenarios: + +- [Testing section](https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp#testing) — `mock_access_context()` usage, test patterns, and complete examples + +## Reference + +- [mock_access_context API](sdk/keycardai-mcp-integrations-fastmcp-testing-test_utils) — Auto-generated API reference diff --git a/docs/welcome.mdx b/docs/welcome.mdx index 5133eea..571e523 100644 --- a/docs/welcome.mdx +++ b/docs/welcome.mdx @@ -13,11 +13,13 @@ description: "Python SDK for Keycard — OAuth, identity, and access for MCP ser ## Packages -| You want... | Install | Guide | +| You want to... | Install | Guide | |---|---|---| -| Auth for MCP servers (using the `mcp` SDK) | `pip install keycardai-mcp` | [MCP Server Auth](examples/mcp-server-auth) | -| Auth for FastMCP servers | `pip install keycardai-mcp-fastmcp` | [FastMCP Integration](examples/fastmcp-integration) | -| OAuth 2.0 client only | `pip install keycardai-oauth` | [OAuth package docs](https://github.com/keycardai/python-sdk/tree/main/packages/oauth) | +| Add auth to MCP servers (using the `mcp` SDK) | `pip install keycardai-mcp` | [MCP Server Auth](examples/mcp-server-auth) | +| Add auth to FastMCP servers | `pip install keycardai-mcp-fastmcp` | [FastMCP Integration](examples/fastmcp-integration) | +| Connect to MCP servers as a client | `pip install keycardai-mcp` | [MCP Client](examples/mcp-client-usage) | +| Build agent-to-agent (A2A) services | `pip install keycardai-agents` | [Agents docs](https://github.com/keycardai/python-sdk/tree/main/packages/agents) | +| Use the OAuth 2.0 client directly | `pip install keycardai-oauth` | [OAuth docs](https://github.com/keycardai/python-sdk/tree/main/packages/oauth) | ## Quick Start diff --git a/packages/agents/examples/a2a_jsonrpc_usage.py b/packages/agents/examples/a2a_jsonrpc_usage.py deleted file mode 100644 index 2387d92..0000000 --- a/packages/agents/examples/a2a_jsonrpc_usage.py +++ /dev/null @@ -1,238 +0,0 @@ -""" -Example: Using A2A JSONRPC protocol with Keycard agent services. - -This example demonstrates how to call agent services using the standard -A2A JSONRPC protocol instead of the custom /invoke endpoint. - -Both approaches work with the same agent service - you can choose based on your needs: -- A2A JSONRPC: Standards-compliant, event-driven, supports streaming -- Custom /invoke: Simple, direct, synchronous - -This example shows the A2A JSONRPC approach. -""" - -import httpx - -from keycardai.agents import AgentServiceConfig -from keycardai.agents.client import AgentClient - - -async def example_a2a_jsonrpc_call(): - """Demonstrate calling an agent service via A2A JSONRPC protocol.""" - - # Configure client identity - my_config = AgentServiceConfig( - service_name="My Client App", - client_id="my_client_app_id", - client_secret="", # Public client for PKCE - identity_url="https://my-app.example.com", - zone_id="abc1234", - agent_executor=None, # Not running a service, just calling others - ) - - # Example 1: Using A2A SDK client directly - print("Example 1: Using A2A SDK client for JSONRPC") - print("=" * 60) - - from a2a.client import A2AClient - from a2a.types import Message, MessageSendParams - - # Create A2A client - async with A2AClient(base_url="https://agent-service.example.com/a2a") as a2a_client: - # Call agent using JSONRPC message/send method - message = Message( - role="user", - parts=[{"text": "Analyze this pull request: #123"}], - ) - - params = MessageSendParams(message=message) - - try: - # This calls POST /a2a/jsonrpc with method="message/send" - result = await a2a_client.send_message(params) - - print(f"Task ID: {result.id}") - print(f"Status: {result.status.state}") - print(f"Result: {result.history[-1].parts[0]['text']}") - except Exception as e: - print(f"Error: {e}") - - # Example 2: Manual JSONRPC call with httpx - print("\nExample 2: Manual JSONRPC call with httpx") - print("=" * 60) - - async with httpx.AsyncClient() as client: - # Construct JSONRPC request - jsonrpc_request = { - "jsonrpc": "2.0", - "id": "1", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [{"text": "What is the status of deployment?"}], - } - }, - } - - try: - # Call A2A JSONRPC endpoint - response = await client.post( - "https://agent-service.example.com/a2a/jsonrpc", - json=jsonrpc_request, - headers={ - "Content-Type": "application/json", - "Authorization": "Bearer ", - }, - ) - - result = response.json() - print(f"JSONRPC Response: {result}") - - # Extract task from JSONRPC result - if "result" in result: - task = result["result"] - print(f"Task ID: {task['id']}") - print(f"Status: {task['status']['state']}") - except Exception as e: - print(f"Error: {e}") - - # Example 3: Compare with custom /invoke endpoint - print("\nExample 3: Custom /invoke endpoint (for comparison)") - print("=" * 60) - - async with AgentClient( - my_config, - redirect_uri="http://localhost:8765/callback", - callback_port=8765, - ) as keycard_client: - try: - # This calls POST /invoke (custom Keycard endpoint) - result = await keycard_client.invoke( - service_url="https://agent-service.example.com", - task="What is the status of deployment?", - ) - - print(f"Result: {result['result']}") - print(f"Delegation chain: {result['delegation_chain']}") - except Exception as e: - print(f"Error: {e}") - - -async def example_a2a_streaming(): - """Demonstrate A2A streaming with message/stream method.""" - - print("\nExample 4: A2A Streaming with message/stream") - print("=" * 60) - - from a2a.client import A2AClient - from a2a.types import Message, MessageSendParams - - async with A2AClient(base_url="https://agent-service.example.com/a2a") as client: - message = Message( - role="user", - parts=[{"text": "Generate a detailed analysis report"}], - ) - - params = MessageSendParams(message=message) - - try: - # Stream events from agent - async for event in client.stream_message(params): - # Events can be Task updates, Message chunks, etc. - print(f"Event: {event}") - - # Check if task is complete - if hasattr(event, "status") and event.status.state == "completed": - print("Task completed!") - break - except Exception as e: - print(f"Error: {e}") - - -async def example_a2a_task_management(): - """Demonstrate A2A task management (get, cancel).""" - - print("\nExample 5: A2A Task Management") - print("=" * 60) - - from a2a.client import A2AClient - from a2a.types import TaskIdParams - - async with A2AClient(base_url="https://agent-service.example.com/a2a") as client: - # Get task by ID - try: - task = await client.get_task( - TaskIdParams(id="task-123") - ) - - print(f"Task: {task.id}") - print(f"Status: {task.status.state}") - print(f"History length: {len(task.history)}") - except Exception as e: - print(f"Error getting task: {e}") - - # Cancel task - try: - canceled_task = await client.cancel_task( - TaskIdParams(id="task-123") - ) - - print(f"Task canceled: {canceled_task.status.state}") - except Exception as e: - print(f"Error canceling task: {e}") - - -async def main(): - """Run all A2A JSONRPC examples.""" - - print("A2A JSONRPC Protocol Examples") - print("=" * 60) - print() - print("This example demonstrates calling Keycard agent services") - print("using the standard A2A JSONRPC protocol.") - print() - print("The server exposes both:") - print(" 1. POST /a2a/jsonrpc - A2A JSONRPC endpoint (standards-compliant)") - print(" 2. POST /invoke - Custom Keycard endpoint (simple)") - print() - print("=" * 60) - print() - - # Run examples - await example_a2a_jsonrpc_call() - await example_a2a_streaming() - await example_a2a_task_management() - - print("\n" + "=" * 60) - print("Examples complete!") - print() - print("Key Differences:") - print(" A2A JSONRPC:") - print(" - Standards-compliant protocol") - print(" - Event-driven, supports streaming") - print(" - Task management (get, cancel, resubscribe)") - print(" - Uses Message/Task types") - print() - print(" Custom /invoke:") - print(" - Simple request/response") - print(" - Direct task execution") - print(" - Delegation chain tracking") - print(" - Easier for simple use cases") - print() - print("Choose based on your needs - both work with the same agent!") - - -if __name__ == "__main__": - # Note: This is a demonstration example showing API usage - # In real usage, you would: - # 1. Have a running agent service with OAuth configured - # 2. Obtain valid OAuth tokens - # 3. Use actual service URLs - - print("Note: This is a code demonstration.") - print("To run against a real service, update the URLs and credentials.") - print() - - # Uncomment to run examples: - # asyncio.run(main()) diff --git a/packages/agents/examples/a2a_jsonrpc_usage/README.md b/packages/agents/examples/a2a_jsonrpc_usage/README.md new file mode 100644 index 0000000..aabd3b7 --- /dev/null +++ b/packages/agents/examples/a2a_jsonrpc_usage/README.md @@ -0,0 +1,66 @@ +# A2A JSONRPC Usage + +A minimal example showing how to call Keycard agent services using the standard A2A JSONRPC protocol. + +## When to Use This + +Keycard agent services expose two endpoints: + +| Endpoint | Protocol | Best For | +|----------|----------|----------| +| `POST /a2a/jsonrpc` | A2A JSONRPC | Standards-compliant clients, streaming, task management | +| `POST /invoke` | Custom | Simple request/response, delegation chain tracking | + +Use the A2A JSONRPC protocol when you need streaming, task cancellation, or interoperability with other A2A-compliant systems. + +## Prerequisites + +Before running this example, set up Keycard: + +1. **Sign up** at [keycard.ai](https://keycard.ai) +2. **Create a zone** — this is your authentication boundary +3. **Configure an identity provider** (Google, Microsoft, etc.) +4. **Have an agent service running** that your client will call + +## Quick Start + +### 1. Set Environment Variables + +```bash +export KEYCARD_ZONE_ID="your-zone-id" +export KEYCARD_CLIENT_ID="your-client-id" +export AGENT_SERVICE_URL="https://your-agent-service.example.com" +``` + +### 2. Install Dependencies + +```bash +cd packages/agents/examples/a2a_jsonrpc_usage +uv sync +``` + +### 3. Run the Example + +```bash +uv run python main.py +``` + +The example demonstrates three approaches: +1. **Manual JSONRPC** — Raw httpx request to `/a2a/jsonrpc` +2. **A2A SDK client** — Using the `a2a-sdk` package for typed requests +3. **Custom /invoke** — Using `AgentClient` for comparison + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `KEYCARD_ZONE_ID` | Yes | Your Keycard zone ID | +| `KEYCARD_CLIENT_ID` | Yes | OAuth client ID from Keycard console | +| `AGENT_SERVICE_URL` | No | URL of the agent service to call | +| `AUTH_TOKEN` | No | Bearer token for manual JSONRPC calls (Example 1) | + +## Learn More + +- [Agents Package README](../../) — Full documentation for the agents SDK +- [OAuth Client Example](../oauth_client_usage/) — PKCE user authentication +- [Keycard Documentation](https://docs.keycard.ai) diff --git a/packages/agents/examples/a2a_jsonrpc_usage/main.py b/packages/agents/examples/a2a_jsonrpc_usage/main.py new file mode 100644 index 0000000..f66ba3a --- /dev/null +++ b/packages/agents/examples/a2a_jsonrpc_usage/main.py @@ -0,0 +1,160 @@ +""" +Example: Using the A2A JSONRPC protocol with Keycard agent services. + +Demonstrates how to call agent services using the standard A2A JSONRPC protocol. + +Both approaches work with the same agent service — choose based on your needs: +- A2A JSONRPC: Standards-compliant, event-driven, supports streaming +- Custom /invoke: Simple, direct, synchronous +""" + +import asyncio +import os + +import httpx + +from keycardai.agents import AgentServiceConfig +from keycardai.agents.client import AgentClient + + +async def example_manual_jsonrpc(): + """Call an agent service via manual JSONRPC request.""" + + service_url = os.getenv( + "AGENT_SERVICE_URL", "https://agent-service.example.com" + ) + + print("Example 1: Manual JSONRPC call with httpx") + print("=" * 60) + + async with httpx.AsyncClient() as client: + jsonrpc_request = { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"text": "What is the status of deployment?"}], + } + }, + } + + try: + response = await client.post( + f"{service_url}/a2a/jsonrpc", + json=jsonrpc_request, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {os.getenv('AUTH_TOKEN', '')}", + }, + ) + + result = response.json() + print(f"JSONRPC Response: {result}") + + if "result" in result: + task = result["result"] + print(f"Task ID: {task['id']}") + print(f"Status: {task['status']['state']}") + except Exception as e: + print(f"Error: {e}") + + +async def example_a2a_sdk_client(): + """Call an agent service via the A2A SDK client.""" + + service_url = os.getenv( + "AGENT_SERVICE_URL", "https://agent-service.example.com" + ) + + print("\nExample 2: Using A2A SDK client") + print("=" * 60) + + from a2a.client import A2AClient + from a2a.types import Message, MessageSendParams + + async with A2AClient(base_url=f"{service_url}/a2a") as a2a_client: + message = Message( + role="user", + parts=[{"text": "Analyze this pull request: #123"}], + ) + + params = MessageSendParams(message=message) + + try: + result = await a2a_client.send_message(params) + print(f"Task ID: {result.id}") + print(f"Status: {result.status.state}") + print(f"Result: {result.history[-1].parts[0]['text']}") + except Exception as e: + print(f"Error: {e}") + + +async def example_invoke_comparison(): + """Compare with the custom /invoke endpoint using AgentClient.""" + + print("\nExample 3: Custom /invoke endpoint (for comparison)") + print("=" * 60) + + config = AgentServiceConfig( + service_name="My Client App", + client_id=os.getenv("KEYCARD_CLIENT_ID", "my_client_app_id"), + client_secret="", + identity_url=os.getenv("IDENTITY_URL", "https://my-app.example.com"), + zone_id=os.getenv("KEYCARD_ZONE_ID", "your-zone-id"), + agent_executor=None, + ) + + service_url = os.getenv( + "AGENT_SERVICE_URL", "https://agent-service.example.com" + ) + + async with AgentClient( + config, + redirect_uri="http://localhost:8765/callback", + callback_port=8765, + ) as keycard_client: + try: + result = await keycard_client.invoke( + service_url=service_url, + task="What is the status of deployment?", + ) + print(f"Result: {result['result']}") + print(f"Delegation chain: {result['delegation_chain']}") + except Exception as e: + print(f"Error: {e}") + + +async def main(): + """Run all A2A JSONRPC examples.""" + + print("A2A JSONRPC Protocol Examples") + print("=" * 60) + print() + print("The server exposes both:") + print(" 1. POST /a2a/jsonrpc - A2A JSONRPC endpoint (standards-compliant)") + print(" 2. POST /invoke - Custom Keycard endpoint (simple)") + print() + + await example_manual_jsonrpc() + await example_a2a_sdk_client() + await example_invoke_comparison() + + print("\n" + "=" * 60) + print("Key Differences:") + print(" A2A JSONRPC: Standards-compliant, streaming, task management") + print(" /invoke: Simple request/response, delegation chain tracking") + print("Choose based on your needs — both work with the same agent!") + + +def run(): + """Entry point.""" + print("Note: This is a code demonstration.") + print("Update the URLs and credentials to run against a real service.") + print() + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/packages/agents/examples/a2a_jsonrpc_usage/pyproject.toml b/packages/agents/examples/a2a_jsonrpc_usage/pyproject.toml new file mode 100644 index 0000000..b1bd2b7 --- /dev/null +++ b/packages/agents/examples/a2a_jsonrpc_usage/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "a2a-jsonrpc-usage" +version = "0.1.0" +description = "Example: Calling agent services via the A2A JSONRPC protocol" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-agents", + "httpx>=0.27.2", +] + +[tool.uv.sources] +keycardai-agents = { path = "../../", editable = true } + +[project.scripts] +a2a-jsonrpc-usage = "main:run" diff --git a/packages/agents/examples/oauth_client_usage.py b/packages/agents/examples/oauth_client_usage.py deleted file mode 100644 index 544e967..0000000 --- a/packages/agents/examples/oauth_client_usage.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Example: Using AgentClient with PKCE user authentication. - -This example demonstrates how AgentClient automatically handles -OAuth PKCE authentication (browser-based user login) when calling protected agent services. -""" - -import asyncio - -from keycardai.agents import AgentServiceConfig -from keycardai.agents.client import AgentClient - - -async def main(): - """Demonstrate automatic OAuth PKCE handling with AgentClient.""" - - # Configure client identity - # Note: For simple client usage, you may not need full service config - my_config = AgentServiceConfig( - service_name="My Client App", - client_id="my_client_app_id", # From Keycard dashboard (OAuth Public Client) - client_secret="", # Not needed for PKCE public clients - identity_url="https://my-app.example.com", - zone_id="abc1234", # Your Keycard zone ID - agent_executor=None, # Not running a service, just calling others - ) - - # Create OAuth-enabled client - # NOTE: Make sure to register your redirect_uri with OAuth authorization server! - async with AgentClient( - my_config, - redirect_uri="http://localhost:8765/callback", # Must be registered! - callback_port=8765, - ) as client: - - # Example 1: Call protected service - # The client automatically: - # 1. Attempts the call - # 2. Receives 401 with WWW-Authenticate header - # 3. Discovers OAuth configuration from resource_metadata URL - # 4. Generates PKCE parameters - # 5. Opens browser for user to log in - # 6. Receives authorization code from callback - # 7. Exchanges code for user's access token - # 8. Retries the call with user token - - print("Example 1: Calling protected service with user authentication...") - print("ℹ️ Your browser will open for login") - try: - result = await client.invoke( - service_url="https://protected-service.example.com", - task="Analyze this data", - inputs={"data": "Sample data to analyze"} - ) - print(f"✅ Success: {result['result']}") - print(f" Delegation chain: {result['delegation_chain']}") - except Exception as e: - print(f"❌ Error: {e}") - - # Example 2: Token caching - # After first successful OAuth, token is cached - print("\nExample 2: Token reuse (cached)...") - try: - result = await client.invoke( - service_url="https://protected-service.example.com", - task="Another task", - ) - # This call uses the cached token - no OAuth discovery needed! - print(f"✅ Success with cached token: {result['result']}") - except Exception as e: - print(f"❌ Error: {e}") - - # Example 3: Discover service capabilities first - print("\nExample 3: Service discovery...") - try: - agent_card = await client.discover_service( - "https://protected-service.example.com" - ) - print(f"✅ Discovered service: {agent_card['name']}") - print(f" Skills: {[s['id'] for s in agent_card.get('skills', [])]}") - print(f" Capabilities: {agent_card.get('capabilities', {})}") - except Exception as e: - print(f"❌ Error: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/packages/agents/examples/oauth_client_usage/README.md b/packages/agents/examples/oauth_client_usage/README.md new file mode 100644 index 0000000..1e49ee3 --- /dev/null +++ b/packages/agents/examples/oauth_client_usage/README.md @@ -0,0 +1,68 @@ +# OAuth Client Usage (PKCE Authentication) + +A minimal example showing how to use `AgentClient` with automatic PKCE user authentication to call protected agent services. + +## Why Keycard? + +Keycard handles OAuth for agent-to-agent communication. When your client calls a protected agent service, Keycard automatically opens the browser for user login, exchanges tokens, and retries the call — no manual token management needed. + +## Prerequisites + +Before running this example, set up Keycard: + +1. **Sign up** at [keycard.ai](https://keycard.ai) +2. **Create a zone** — this is your authentication boundary +3. **Configure an identity provider** (Google, Microsoft, etc.) — this is how your users will sign in +4. **Register a public OAuth client** in the Keycard console (PKCE clients don't need a secret) +5. **Have an agent service running** that your client will call + +## When to Use This + +- Building CLI tools that call protected agent services +- Creating client applications that need user-scoped access to agents +- Testing agent services with real OAuth flows + +## Quick Start + +### 1. Set Environment Variables + +```bash +export KEYCARD_ZONE_ID="your-zone-id" +export KEYCARD_CLIENT_ID="your-public-client-id" +export AGENT_SERVICE_URL="https://your-agent-service.example.com" +``` + +### 2. Install Dependencies + +```bash +cd packages/agents/examples/oauth_client_usage +uv sync +``` + +### 3. Run the Example + +```bash +uv run python main.py +``` + +**What happens:** +1. The client attempts to call the protected agent service +2. On receiving a 401, it discovers OAuth configuration automatically +3. Your browser opens for login +4. After login, the token is cached and the call is retried +5. Subsequent calls reuse the cached token + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `KEYCARD_ZONE_ID` | Yes | Your Keycard zone ID | +| `KEYCARD_CLIENT_ID` | Yes | OAuth public client ID from Keycard console | +| `AGENT_SERVICE_URL` | No | URL of the agent service to call | +| `IDENTITY_URL` | No | Identity URL for your client app | + +## Learn More + +- [Agents Package README](../../) — Full documentation for the agents SDK +- [A2A JSONRPC Example](../a2a_jsonrpc_usage/) — Calling agents via the A2A protocol +- [Keycard Documentation](https://docs.keycard.ai) diff --git a/packages/agents/examples/oauth_client_usage/main.py b/packages/agents/examples/oauth_client_usage/main.py new file mode 100644 index 0000000..38dadb7 --- /dev/null +++ b/packages/agents/examples/oauth_client_usage/main.py @@ -0,0 +1,91 @@ +""" +Example: Using AgentClient with PKCE user authentication. + +Demonstrates how AgentClient automatically handles OAuth PKCE authentication +(browser-based user login) when calling protected agent services. +""" + +import asyncio +import os + +from keycardai.agents import AgentServiceConfig +from keycardai.agents.client import AgentClient + + +async def main(): + """Demonstrate automatic OAuth PKCE handling with AgentClient.""" + + config = AgentServiceConfig( + service_name="My Client App", + client_id=os.getenv("KEYCARD_CLIENT_ID", "my_client_app_id"), + client_secret="", # Not needed for PKCE public clients + identity_url=os.getenv("IDENTITY_URL", "https://my-app.example.com"), + zone_id=os.getenv("KEYCARD_ZONE_ID", "your-zone-id"), + agent_executor=None, # Not running a service, just calling others + ) + + # Create OAuth-enabled client + # NOTE: Make sure to register your redirect_uri with the OAuth authorization server! + async with AgentClient( + config, + redirect_uri="http://localhost:8765/callback", + callback_port=8765, + ) as client: + + # The client automatically: + # 1. Attempts the call + # 2. Receives 401 with WWW-Authenticate header + # 3. Discovers OAuth configuration from resource_metadata URL + # 4. Generates PKCE parameters + # 5. Opens browser for user to log in + # 6. Receives authorization code from callback + # 7. Exchanges code for user's access token + # 8. Retries the call with user token + + service_url = os.getenv( + "AGENT_SERVICE_URL", "https://protected-service.example.com" + ) + + # Call a protected service (browser opens automatically for login) + print("Calling protected service with user authentication...") + print("Your browser will open for login") + try: + result = await client.invoke( + service_url=service_url, + task="Analyze this data", + inputs={"data": "Sample data to analyze"}, + ) + print(f"Success: {result['result']}") + print(f"Delegation chain: {result['delegation_chain']}") + except Exception as e: + print(f"Error: {e}") + + # After first successful OAuth, the token is cached. + # This call reuses the cached token — no browser needed. + print("\nCalling again with cached token...") + try: + result = await client.invoke( + service_url=service_url, + task="Another task", + ) + print(f"Success with cached token: {result['result']}") + except Exception as e: + print(f"Error: {e}") + + # Discover service capabilities + print("\nDiscovering service capabilities...") + try: + agent_card = await client.discover_service(service_url) + print(f"Service: {agent_card['name']}") + print(f"Skills: {[s['id'] for s in agent_card.get('skills', [])]}") + except Exception as e: + print(f"Error: {e}") + + +def run(): + """Entry point.""" + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/packages/agents/examples/oauth_client_usage/pyproject.toml b/packages/agents/examples/oauth_client_usage/pyproject.toml new file mode 100644 index 0000000..766727e --- /dev/null +++ b/packages/agents/examples/oauth_client_usage/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "oauth-client-usage" +version = "0.1.0" +description = "Example: AgentClient with PKCE user authentication" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-agents", +] + +[tool.uv.sources] +keycardai-agents = { path = "../../", editable = true } + +[project.scripts] +oauth-client-usage = "main:run" diff --git a/packages/mcp-fastmcp/README.md b/packages/mcp-fastmcp/README.md index 59dc8b3..b5924b2 100644 --- a/packages/mcp-fastmcp/README.md +++ b/packages/mcp-fastmcp/README.md @@ -2,6 +2,8 @@ A Python package that provides seamless integration between Keycard and FastMCP servers, enabling secure token exchange and authentication for MCP tools. +> **Using `mcp.server.fastmcp` (the MCP SDK's built-in FastMCP)?** See [keycardai-mcp](../mcp/) instead. This package is for the standalone [FastMCP](https://github.com/jlowin/fastmcp) framework. For help choosing, see the [root README](../../README.md#which-package). + ## Requirements - **Python 3.10 or greater** diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 8c02d52..a37e123 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -2,6 +2,8 @@ A comprehensive Python SDK for Model Context Protocol (MCP) functionality that simplifies authentication and authorization concerns for developers working with AI/LLM integrations. +> **Using the FastMCP framework?** See [keycardai-mcp-fastmcp](../mcp-fastmcp/) instead. For help choosing, see the [root README](../../README.md#which-package). + ## Requirements - **Python 3.10 or greater** diff --git a/packages/mcp/src/keycardai/mcp/client/README.md b/packages/mcp/src/keycardai/mcp/client/README.md index 33b63b2..2bb9cac 100644 --- a/packages/mcp/src/keycardai/mcp/client/README.md +++ b/packages/mcp/src/keycardai/mcp/client/README.md @@ -971,7 +971,7 @@ uv run mcp-crewai ## Support -- **Documentation:** [https://docs.keycard.cloud](https://docs.keycard.cloud) +- **Documentation:** [https://docs.keycard.ai](https://docs.keycard.ai) - **GitHub:** [https://github.com/keycardai/python-sdk](https://github.com/keycardai/python-sdk) - **Issues:** [https://github.com/keycardai/python-sdk/issues](https://github.com/keycardai/python-sdk/issues)