Skip to content

Commit ee89b57

Browse files
committed
feat(keycardai-mcp): automatic app cred discovery
1 parent 383cb7d commit ee89b57

3 files changed

Lines changed: 382 additions & 3 deletions

File tree

packages/mcp/README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,114 @@ Keycard allows MCP servers to access other resources on behalf of users with aut
112112
3. **Set MCP server dependencies** to allow delegated access
113113
4. **Create client secret identity** to provide authentication method
114114

115+
#### Application Credentials for Token Exchange
116+
117+
To enable token exchange (required for the `@grant` decorator), you need to configure application credentials. The SDK supports multiple credential types and provides automatic discovery via environment variables.
118+
119+
##### Credential Types
120+
121+
The SDK supports three types of application credentials:
122+
123+
1. **ClientSecret** - OAuth client credentials (client_id/client_secret) issued by Keycard
124+
2. **WebIdentity** - Private key JWT authentication for MCP servers
125+
3. **EKSWorkloadIdentity** - AWS EKS Pod Identity for Kubernetes deployments
126+
127+
##### Configuration Methods
128+
129+
**1. Explicit Configuration (Recommended for Production)**
130+
131+
Explicitly provide credentials when creating the `AuthProvider`:
132+
133+
```python
134+
from keycardai.mcp.server.auth import AuthProvider, ClientSecret
135+
136+
# Client Secret credentials
137+
auth_provider = AuthProvider(
138+
zone_id="your-zone-id",
139+
mcp_server_name="My MCP Server",
140+
application_credential=ClientSecret(("your_client_id", "your_client_secret"))
141+
)
142+
```
143+
144+
```python
145+
from keycardai.mcp.server.auth import AuthProvider, WebIdentity
146+
147+
# Web Identity (Private Key JWT)
148+
auth_provider = AuthProvider(
149+
zone_id="your-zone-id",
150+
mcp_server_name="My MCP Server",
151+
application_credential=WebIdentity(
152+
mcp_server_name="My MCP Server",
153+
storage_dir="./mcp_keys" # Directory for key storage
154+
)
155+
)
156+
```
157+
158+
```python
159+
from keycardai.mcp.server.auth import AuthProvider, EKSWorkloadIdentity
160+
161+
# EKS Workload Identity
162+
auth_provider = AuthProvider(
163+
zone_id="your-zone-id",
164+
mcp_server_name="My MCP Server",
165+
application_credential=EKSWorkloadIdentity()
166+
)
167+
```
168+
169+
**2. Environment Variable Discovery (Convenient for Development)**
170+
171+
The SDK automatically discovers credentials from environment variables:
172+
173+
```bash
174+
# Option A: Client Credentials
175+
export KEYCARD_CLIENT_ID="your_client_id"
176+
export KEYCARD_CLIENT_SECRET="your_client_secret"
177+
178+
# Option B: Explicit Credential Type
179+
export KEYCARD_APPLICATION_CREDENTIAL_TYPE="web_identity"
180+
export KEYCARD_WEB_IDENTITY_KEY_STORAGE_DIR="./mcp_keys" # Optional
181+
182+
# Option C: EKS Workload Identity
183+
export KEYCARD_APPLICATION_CREDENTIAL_TYPE="eks_workload_identity"
184+
export AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE="/var/run/secrets/token"
185+
```
186+
187+
With environment variables configured, create the `AuthProvider` without explicit credentials:
188+
189+
```python
190+
from keycardai.mcp.server.auth import AuthProvider
191+
192+
# Credentials automatically discovered from environment variables
193+
auth_provider = AuthProvider(
194+
zone_id="your-zone-id",
195+
mcp_server_name="My MCP Server"
196+
)
197+
```
198+
199+
##### Configuration Precedence
200+
201+
When multiple configuration methods are present, the SDK follows this precedence order (highest to lowest):
202+
203+
1. **Explicit `application_credential` parameter** - Always takes priority
204+
2. **`KEYCARD_CLIENT_ID` + `KEYCARD_CLIENT_SECRET`** - Client credentials via environment
205+
3. **`KEYCARD_APPLICATION_CREDENTIAL_TYPE`** - Explicit credential type selection
206+
4. **`AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`** - Automatic EKS detection
207+
5. **None** - No credentials configured (token exchange disabled)
208+
209+
##### Environment Variables Reference
210+
211+
| Environment Variable | Purpose | Used By | Default Value |
212+
|---------------------|---------|---------|---------------|
213+
| `KEYCARD_CLIENT_ID` | OAuth client identifier | `ClientSecret` | None |
214+
| `KEYCARD_CLIENT_SECRET` | OAuth client secret | `ClientSecret` | None |
215+
| `KEYCARD_APPLICATION_CREDENTIAL_TYPE` | Explicit credential type selection | All | None |
216+
| `KEYCARD_WEB_IDENTITY_KEY_STORAGE_DIR` | Directory for private key storage | `WebIdentity` | `"./mcp_keys"` |
217+
| `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` | Path to EKS token file | `EKSWorkloadIdentity` | None |
218+
219+
##### Running Without Application Credentials
220+
221+
If no application credentials are configured, the `AuthProvider` will work for basic authentication but the `@grant` decorator will be unable to perform token exchange. This is useful for MCP servers that only need user authentication without delegated access to external resources.
222+
115223
#### Add Delegation to Your Tools
116224

117225
```python

packages/mcp/src/keycardai/mcp/server/auth/provider.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import contextlib
33
import inspect
4+
import os
45
from collections.abc import Callable, Sequence
56
from functools import wraps
67
from typing import Any
@@ -31,6 +32,8 @@
3132
from ..routers.metadata import protected_mcp_router
3233
from .application_credentials import (
3334
ApplicationCredential,
35+
ClientSecret,
36+
EKSWorkloadIdentity,
3437
WebIdentity,
3538
)
3639
from .client_factory import ClientFactory, DefaultClientFactory
@@ -247,8 +250,8 @@ def __init__(
247250
self._init_lock: asyncio.Lock | None = None
248251
self.audience = audience
249252

250-
# Initialize application credential provider
251-
self.application_credential = application_credential
253+
# Initialize application credential provider with automatic discovery
254+
self.application_credential = self._discover_application_credential(application_credential)
252255

253256
# Get the auth strategy for the HTTP client doing the token exchange
254257
if self.application_credential is not None:
@@ -264,6 +267,45 @@ def __init__(
264267
# Backward compatibility: detect if using WebIdentity
265268
self.enable_private_key_identity = isinstance(self.application_credential, WebIdentity)
266269

270+
def _discover_application_credential(self, application_credential: ApplicationCredential | None) -> ApplicationCredential | None:
271+
"""Discover the application credential from the provided parameters.
272+
273+
Args:
274+
application_credential: Application credential to discover
275+
276+
Returns:
277+
ApplicationCredential: The discovered application credential
278+
"""
279+
if application_credential is not None:
280+
return application_credential
281+
282+
# discover environment variables
283+
client_id = os.getenv("KEYCARD_CLIENT_ID")
284+
client_secret = os.getenv("KEYCARD_CLIENT_SECRET")
285+
if client_id and client_secret:
286+
return ClientSecret((client_id, client_secret))
287+
288+
application_credential_type = os.getenv("KEYCARD_APPLICATION_CREDENTIAL_TYPE")
289+
if application_credential_type == "eks_workload_identity":
290+
return EKSWorkloadIdentity()
291+
elif application_credential_type == "web_identity":
292+
key_storage_dir = os.getenv("KEYCARD_WEB_IDENTITY_KEY_STORAGE_DIR")
293+
return WebIdentity(
294+
mcp_server_name=self.mcp_server_name,
295+
storage_dir=key_storage_dir,
296+
)
297+
elif application_credential_type is not None:
298+
raise AuthProviderConfigurationError(
299+
message=f"Unknown application credential type: {application_credential_type}. Supported types: eks_workload_identity, web_identity"
300+
)
301+
302+
# detect workload identity from environment variables
303+
workload_identity_token = os.getenv(EKSWorkloadIdentity.default_env_var_name)
304+
if workload_identity_token:
305+
return EKSWorkloadIdentity()
306+
307+
return None
308+
267309
def _create_zone_scoped_url(self, base_url: str, zone_id: str) -> str:
268310
"""Create zone-scoped URL by prepending zone_id to the host."""
269311
base_url_obj = AnyHttpUrl(base_url)

0 commit comments

Comments
 (0)