Skip to content

Commit 9ace317

Browse files
Merge pull request #15 from keycardai/feat/client-factory
Feat/client factory
2 parents e7e21c8 + 9ee8469 commit 9ace317

14 files changed

Lines changed: 1555 additions & 207 deletions

File tree

justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ build:
1111
test: build
1212
just test-package oauth
1313
just test-package mcp
14+
just test-package mcp-fastmcp
1415

1516
# Run tests for a specific package
1617
test-package PACKAGE:

packages/mcp-fastmcp/README.md

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ A Python package that provides seamless integration between KeyCard and FastMCP
44

55
## Installation
66

7+
```bash
8+
uv add keycardai-mcp-fastmcp
9+
```
10+
11+
or
12+
713
```bash
814
pip install keycardai-mcp-fastmcp
915
```
@@ -15,7 +21,7 @@ Add KeyCard authentication to your existing FastMCP server:
1521
### Install the Package
1622

1723
```bash
18-
pip install keycardai-mcp-fastmcp
24+
uv add keycardai-mcp-fastmcp
1925
```
2026

2127
### Get Your KeyCard Zone ID
@@ -35,7 +41,7 @@ from keycardai.mcp.integrations.fastmcp import AuthProvider
3541
auth_provider = AuthProvider(
3642
zone_id="your-zone-id", # Get this from keycard.ai
3743
mcp_server_name="My Secure FastMCP Server",
38-
mcp_server_url="http://127.0.0.1:8000/"
44+
mcp_base_url="http://127.0.0.1:8000/" # Note: trailing slash will be added automatically
3945
)
4046

4147
# Get the RemoteAuthProvider for FastMCP
@@ -48,12 +54,42 @@ mcp = FastMCP("My Secure FastMCP Server", auth=auth)
4854
def hello_world(name: str) -> str:
4955
return f"Hello, {name}!"
5056

57+
if __name__ == "__main__":
58+
mcp.run(transport="streamable-http")
59+
```
60+
61+
### Add access delegation to tool calls
62+
63+
```python
64+
from fastmcp import FastMCP, Context
65+
from keycardai.mcp.integrations.fastmcp import AuthProvider, AccessContext
66+
67+
# Configure KeyCard authentication (recommended: use zone_id)
68+
auth_provider = AuthProvider(
69+
zone_id="your-zone-id", # Get this from keycard.ai
70+
mcp_server_name="My Secure FastMCP Server",
71+
mcp_base_url="http://127.0.0.1:8000/" # Note: trailing slash will be added automatically
72+
)
73+
74+
# Get the RemoteAuthProvider for FastMCP
75+
auth = auth_provider.get_remote_auth_provider()
76+
77+
# Create authenticated FastMCP server
78+
mcp = FastMCP("My Secure FastMCP Server", auth=auth)
79+
5180
# Example with token exchange for external API access
5281
@mcp.tool()
5382
@auth_provider.grant("https://api.example.com")
5483
def call_external_api(ctx: Context, query: str) -> str:
84+
# Get access context to check token exchange status
85+
access_context: AccessContext = ctx.get_state("keycardai")
86+
87+
# Check for errors before accessing token
88+
if access_context.has_errors():
89+
return f"Error: Failed to obtain access token - {access_context.get_errors()}"
90+
5591
# Access delegated token through context namespace
56-
token = ctx.get_state("keycardai").access("https://api.example.com").access_token
92+
token = access_context.access("https://api.example.com").access_token
5793
# Use token to call external API
5894
return f"Results for {query}"
5995

@@ -63,6 +99,138 @@ if __name__ == "__main__":
6399

64100
### 🎉 Your FastMCP server is now protected with KeyCard authentication! 🎉
65101

102+
## Working with AccessContext
103+
104+
When using the `@grant()` decorator, tokens are made available through the `AccessContext` object. This object provides robust error handling and status checking for token exchange operations.
105+
106+
The `@grant()` decorator avoids raising exceptions. Instead, it exposes error information via associated metadata.
107+
You can check if the context encountered errors by calling the `has_errors()` method.
108+
109+
### Basic Usage
110+
111+
```python
112+
from keycardai.mcp.integrations.fastmcp import AccessContext
113+
114+
@mcp.tool()
115+
@auth_provider.grant("https://api.example.com")
116+
def my_tool(ctx: Context, user_id: str) -> str:
117+
# Get the access context
118+
access_context: AccessContext = ctx.get_state("keycardai")
119+
120+
# Always check for errors first
121+
if access_context.has_errors():
122+
# Handle the error case
123+
errors = access_context.get_errors()
124+
return f"Authentication failed: {errors}"
125+
126+
# Access the token for the specific resource
127+
token = access_context.access("https://api.example.com").access_token
128+
129+
# Use the token in your API calls
130+
headers = {"Authorization": f"Bearer {token}"}
131+
# Make your API request...
132+
return f"Success for user {user_id}"
133+
```
134+
135+
### Multiple Resources
136+
137+
You can request tokens for multiple resources in a single decorator:
138+
139+
```python
140+
@mcp.tool()
141+
@auth_provider.grant(["https://api.example.com", "https://other-api.com"])
142+
def multi_resource_tool(ctx: Context) -> str:
143+
access_context: AccessContext = ctx.get_state("keycardai")
144+
145+
# Check overall status
146+
status = access_context.get_status() # "success", "partial_error", or "error"
147+
148+
if status == "error":
149+
# Global error - no tokens available
150+
return f"Global error: {access_context.get_error()}"
151+
152+
elif status == "partial_error":
153+
# Some resources succeeded, others failed
154+
successful = access_context.get_successful_resources()
155+
failed = access_context.get_failed_resources()
156+
157+
# Work with successful resources only
158+
for resource in successful:
159+
token = access_context.access(resource).access_token
160+
# Use token...
161+
162+
return f"Partial success: {len(successful)} succeeded, {len(failed)} failed"
163+
164+
else: # status == "success"
165+
# All resources succeeded
166+
token1 = access_context.access("https://api.example.com").access_token
167+
token2 = access_context.access("https://other-api.com").access_token
168+
# Use both tokens...
169+
return "All resources accessed successfully"
170+
```
171+
172+
### Error Handling Methods
173+
174+
The `AccessContext` provides several methods for checking errors:
175+
176+
```python
177+
# Check if there are any errors (global or resource-specific)
178+
if access_context.has_errors():
179+
# Handle any error case
180+
181+
# Check for global errors only
182+
if access_context.has_error():
183+
global_error = access_context.get_error()
184+
185+
# Check for specific resource errors
186+
if access_context.has_resource_error("https://api.example.com"):
187+
resource_error = access_context.get_resource_errors("https://api.example.com")
188+
189+
# Get all errors (global + resource-specific)
190+
all_errors = access_context.get_errors()
191+
192+
# Get status summary
193+
status = access_context.get_status() # "success", "partial_error", or "error"
194+
195+
# Get lists of successful/failed resources
196+
successful_resources = access_context.get_successful_resources()
197+
failed_resources = access_context.get_failed_resources()
198+
```
199+
200+
## Important Configuration Notes
201+
202+
### URL Slash Requirement
203+
204+
⚠️ **Important**: The `mcp_base_url` parameter will automatically have a trailing slash (`/`) appended if not present. This is required for proper JWT audience validation with FastMCP.
205+
206+
**When configuring your KeyCard Resource**, ensure the resource URL in your KeyCard zone settings matches exactly, including the trailing slash:
207+
208+
```python
209+
# This configuration...
210+
auth_provider = AuthProvider(
211+
zone_id="your-zone-id",
212+
mcp_base_url="http://localhost:8000" # No trailing slash
213+
)
214+
215+
# Will become "http://localhost:8000/" internally
216+
# So your KeyCard Resource must be configured as: http://localhost:8000/
217+
```
218+
219+
### Client Credentials for Token Exchange
220+
221+
To enable token exchange (required for the `@grant` decorator), provide client credentials:
222+
223+
```python
224+
from keycardai.oauth.http.auth import BasicAuth
225+
226+
auth_provider = AuthProvider(
227+
zone_id="your-zone-id",
228+
mcp_server_name="My FastMCP Service",
229+
mcp_base_url="http://localhost:8000/",
230+
auth=BasicAuth("your_client_id", "your_client_secret")
231+
)
232+
```
233+
66234
## Examples
67235

68236
For complete examples and advanced usage patterns, see our [documentation](https://docs.keycard.ai).

packages/mcp-fastmcp/pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ classifiers = [
3030
"License :: OSI Approved :: MIT License",
3131
]
3232

33+
[project.optional-dependencies]
34+
test = [
35+
"pytest>=8.4.1",
36+
"pytest-asyncio>=1.1.0",
37+
]
38+
3339
[project.urls]
3440
Homepage = "https://github.com/keycardai/python-sdk"
3541
Repository = "https://github.com/keycardai/python-sdk"
@@ -54,6 +60,9 @@ url = "https://test.pypi.org/simple/"
5460
publish-url = "https://test.pypi.org/legacy/"
5561
explicit = true
5662

63+
[tool.uv.sources]
64+
keycardai-oauth = { workspace = true }
65+
5766
[tool.hatch.build.targets.wheel]
5867
packages = ["src/keycardai"]
5968

@@ -105,6 +114,11 @@ exclude_also = [
105114
testpaths = ["tests"]
106115
addopts = "-ra -q"
107116

117+
[dependency-groups]
118+
dev = [
119+
"pytest-cov>=6.2.1",
120+
]
121+
108122
[tool.commitizen]
109123
name = "cz_customize"
110124
version = "0.4.1"

packages/mcp-fastmcp/src/keycardai/mcp/integrations/fastmcp/__init__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,54 @@ async def sync_calendar_to_drive(ctx: Context):
7474
NoneAuth,
7575
)
7676

77+
from .exceptions import (
78+
# Convenience aliases
79+
AccessError,
80+
# Specific exceptions
81+
AuthProviderConfigurationError,
82+
ClientInitializationError,
83+
ConfigurationError,
84+
DecoratorError,
85+
DiscoveryError,
86+
ExchangeError,
87+
# Base exception
88+
FastMCPIntegrationError,
89+
InitializationError,
90+
JWKSValidationError,
91+
ResourceAccessError,
92+
TokenExchangeError,
93+
ZoneDiscoveryError,
94+
)
7795
from .provider import AccessContext, AuthProvider
7896

7997
__all__ = [
98+
# Core classes
8099
"AuthProvider",
81100
"AccessContext",
101+
82102
# Auth strategies
83103
"AuthStrategy",
84104
"BasicAuth",
85105
"MultiZoneBasicAuth",
86106
"NoneAuth",
107+
108+
# Exceptions - Base
109+
"FastMCPIntegrationError",
110+
111+
# Exceptions - Specific
112+
"AuthProviderConfigurationError",
113+
"ClientInitializationError",
114+
"DecoratorError",
115+
"JWKSValidationError",
116+
"ResourceAccessError",
117+
"TokenExchangeError",
118+
"ZoneDiscoveryError",
119+
120+
# Exceptions - Convenience aliases
121+
"AccessError",
122+
"ConfigurationError",
123+
"DecoratorError",
124+
"DiscoveryError",
125+
"ExchangeError",
126+
"InitializationError",
87127
]

0 commit comments

Comments
 (0)