Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
### Current Limitations

- **Alpha Status**: All packages are in early development (`Development Status :: 3 - Alpha`). APIs may change between minor versions.
- **FastMCP 3.x Not Supported**: The `keycardai-mcp-fastmcp` package is pinned to FastMCP 2.x due to breaking async API changes in FastMCP 3.0 (see [PR #49](https://github.com/keycardai/python-sdk/pull/49)). Support for 3.x will be evaluated once the API stabilizes.
- **FastMCP 3.x Required**: The `keycardai-mcp-fastmcp` package requires FastMCP 3.0 or later. FastMCP 3.0 made `ctx.get_state()` and `ctx.set_state()` async; all tool functions using these calls must be `async def`.
- **MCP Protocol Version**: Tested against MCP protocol version as implemented by `mcp>=1.13.1`. Newer MCP protocol versions may introduce incompatibilities.

### Non-Goals
Expand All @@ -54,7 +54,7 @@

| Package | Dependency | Version Constraint | Rationale |
|---------|------------|-------------------|-----------|
| `keycardai-mcp-fastmcp` | `fastmcp` | `>=2.13.0,<3.0.0` | FastMCP 3.x has breaking async API changes. Constraint will be lifted when migration is complete. |
| `keycardai-mcp-fastmcp` | `fastmcp` | `>=3.0.0` | FastMCP 3.0+ required. `ctx.get_state()`/`ctx.set_state()` are now async. |
| All packages | `pydantic` | `>=2.11.7` | No upper bound - Pydantic 2.x maintains backward compatibility. |
| All packages | `httpx` | `>=0.27.2` | No upper bound - httpx follows semver. |
| `keycardai-mcp` | `mcp` | `>=1.13.1` | No upper bound - API is protocol-defined. |
Expand Down Expand Up @@ -205,7 +205,7 @@ mcp = FastMCP("My Server", auth=auth)
async def get_calendar_events(ctx: Context) -> dict:
"""Get the user's calendar events with delegated access."""
# Retrieve access context from FastMCP context
access_context: AccessContext = ctx.get_state("keycardai")
access_context: AccessContext = await ctx.get_state("keycardai")

if access_context.has_errors():
return {"error": f"Token exchange failed: {access_context.get_errors()}"}
Expand Down Expand Up @@ -262,7 +262,7 @@ async def get_calendar_events(access_ctx: AccessContext, ctx: Context) -> dict:
app = auth_provider.app(mcp)
```

> **Key difference:** In `keycardai-mcp`, the `@grant` decorator requires both `access_ctx: AccessContext` and `ctx: Context` as function parameters. In `keycardai-mcp-fastmcp`, `AccessContext` is retrieved from the FastMCP `Context` via `ctx.get_state("keycardai")`.
> **Key difference:** In `keycardai-mcp`, the `@grant` decorator requires both `access_ctx: AccessContext` and `ctx: Context` as function parameters. In `keycardai-mcp-fastmcp`, `AccessContext` is retrieved from the FastMCP `Context` via `await ctx.get_state("keycardai")`.

For complete delegated access examples with error handling patterns, see:
- [FastMCP delegated access example](packages/mcp-fastmcp/examples/delegated_access/)
Expand Down
4 changes: 2 additions & 2 deletions docs/concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The **`@grant` decorator** declares which external APIs a tool needs access to.
```python
@auth_provider.grant("https://www.googleapis.com/calendar/v3")
async def get_events(ctx: Context) -> dict:
access_context = ctx.get_state("keycardai")
access_context = await ctx.get_state("keycardai")
token = access_context.access("https://www.googleapis.com/calendar/v3").access_token
# Use token to call Google Calendar API
```
Expand All @@ -51,7 +51,7 @@ How you get the AccessContext depends on the package:

| Package | How to get AccessContext |
|---|---|
| `keycardai-mcp-fastmcp` | `ctx.get_state("keycardai")` |
| `keycardai-mcp-fastmcp` | `await ctx.get_state("keycardai")` |
| `keycardai-mcp` | Function parameter: `access_ctx: AccessContext` |

## Application Credentials
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/delegated-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Runnable examples with full setup instructions (including GitHub App configurati

| | FastMCP (`keycardai-mcp-fastmcp`) | Standard MCP (`keycardai-mcp`) |
|---|---|---|
| **AccessContext** | Retrieved from context: `ctx.get_state("keycardai")` | Injected as function parameter: `access_ctx: AccessContext` |
| **AccessContext** | Retrieved from context: `await 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
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/fastmcp-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The `@grant` decorator handles token exchange automatically. When a user calls a

1. Keycard exchanges the user's token for a scoped token targeting the external API
2. The exchanged token is stored in FastMCP's context state under the `"keycardai"` namespace
3. Your function retrieves it via `ctx.get_state("keycardai")`
3. Your function retrieves it via `await ctx.get_state("keycardai")`
4. You extract the token and call the external API

If the exchange fails, the error is set on the `AccessContext` — no exceptions are thrown. Always check `access_context.has_errors()` before using tokens.
Expand All @@ -41,8 +41,8 @@ If the exchange fails, the error is set on the `AccessContext` — no exceptions
In `keycardai-mcp-fastmcp`, `AccessContext` is **retrieved from context state**:

```
def my_tool(ctx: Context) -> dict:
access_context = ctx.get_state("keycardai")
async def my_tool(ctx: Context) -> dict:
access_context = await ctx.get_state("keycardai")
```

In `keycardai-mcp`, `AccessContext` is a **function parameter** injected by `@grant`:
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/mcp-server-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ def my_tool(access_ctx: AccessContext, ctx: Context) -> dict:
In `keycardai-mcp-fastmcp`, `AccessContext` is **retrieved from context state**:

```
def my_tool(ctx: Context) -> dict:
access_context = ctx.get_state("keycardai")
async def my_tool(ctx: Context) -> dict:
access_context = await ctx.get_state("keycardai")
```

## Get started
Expand Down
8 changes: 4 additions & 4 deletions docs/sdk/keycardai-mcp-integrations-fastmcp-__init__.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ Basic Usage:
# Use grant decorator for token exchange
@mcp.tool()
@auth_provider.grant("https://api.example.com")
def call_external_api(ctx: Context, query: str):
token = ctx.get_state("keycardai").access("https://api.example.com").access_token
async def call_external_api(ctx: Context, query: str):
token = (await ctx.get_state("keycardai")).access("https://api.example.com").access_token
# Use token to call external API
return f"Results for {query}"

Expand All @@ -57,8 +57,8 @@ Advanced Configuration:
@mcp.tool()
@auth_provider.grant(["https://www.googleapis.com/calendar/v3", "https://www.googleapis.com/drive/v3"])
async def sync_calendar_to_drive(ctx: Context):
calendar_token = ctx.get_state("keycardai").access("https://www.googleapis.com/calendar/v3").access_token
drive_token = ctx.get_state("keycardai").access("https://www.googleapis.com/drive/v3").access_token
calendar_token = (await ctx.get_state("keycardai")).access("https://www.googleapis.com/calendar/v3").access_token
drive_token = (await ctx.get_state("keycardai")).access("https://www.googleapis.com/drive/v3").access_token
# Use both tokens for cross-service operations
return "Sync completed"

Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/keycardai-mcp-integrations-fastmcp-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ Decorator for automatic delegated token exchange.

This decorator automates the OAuth token exchange process for accessing
external resources on behalf of authenticated users. It follows the FastMCP
Context namespace pattern, making tokens available through ctx.get_state("keycardai").
Context namespace pattern, making tokens available through await ctx.get_state("keycardai").

The returned value is an instance of AccessContext, which can be used to check the status of the token exchange

Expand Down
40 changes: 20 additions & 20 deletions packages/mcp-fastmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ mcp = FastMCP("My Secure FastMCP Server", auth=auth)
# Example with token exchange for external API access
@mcp.tool()
@auth_provider.grant("https://api.example.com")
def call_external_api(ctx: Context, query: str) -> str:
async def call_external_api(ctx: Context, query: str) -> str:
# Get access context to check token exchange status
access_context: AccessContext = ctx.get_state("keycardai")
access_context: AccessContext = await ctx.get_state("keycardai")

# Check for errors before accessing token
if access_context.has_errors():
Expand Down Expand Up @@ -151,9 +151,9 @@ from keycardai.mcp.integrations.fastmcp import AccessContext

@mcp.tool()
@auth_provider.grant("https://api.example.com")
def my_tool(ctx: Context, user_id: str) -> str:
async def my_tool(ctx: Context, user_id: str) -> str:
# Get the access context
access_context: AccessContext = ctx.get_state("keycardai")
access_context: AccessContext = await ctx.get_state("keycardai")

# Always check for errors first
if access_context.has_errors():
Expand All @@ -177,8 +177,8 @@ You can request tokens for multiple resources in a single decorator:
```python
@mcp.tool()
@auth_provider.grant(["https://api.example.com", "https://other-api.com"])
def multi_resource_tool(ctx: Context) -> str:
access_context: AccessContext = ctx.get_state("keycardai")
async def multi_resource_tool(ctx: Context) -> str:
access_context: AccessContext = await ctx.get_state("keycardai")

# Check overall status
status = access_context.get_status() # "success", "partial_error", or "error"
Expand Down Expand Up @@ -514,15 +514,15 @@ async def test_tool_with_default_token(auth_provider):

@mcp.tool()
@auth_provider.grant("https://api.example.com")
def call_external_api(ctx: Context, query: str) -> str:
access_context = ctx.get_state("keycardai")
async def call_external_api(ctx: Context, query: str) -> str:
access_context = await ctx.get_state("keycardai")

if access_context.has_errors():
return f"Error: {access_context.get_errors()}"

token = access_context.access("https://api.example.com").access_token
return f"API result for {query} with token {token}"

# Test with default token
with mock_access_context(): # Uses "test_access_token" by default
async with Client(mcp) as client:
Expand All @@ -544,11 +544,11 @@ async def test_tool_with_custom_token(auth_provider):

@mcp.tool()
@auth_provider.grant("https://api.example.com")
def call_external_api(ctx: Context, query: str) -> str:
access_context = ctx.get_state("keycardai")
async def call_external_api(ctx: Context, query: str) -> str:
access_context = await ctx.get_state("keycardai")
token = access_context.access("https://api.example.com").access_token
return f"API result for {query} with token {token}"

# Test with custom token
with mock_access_context(access_token="my_custom_token_123"):
async with Client(mcp) as client:
Expand All @@ -568,8 +568,8 @@ async def test_tool_with_resource_specific_tokens(auth_provider):

@mcp.tool()
@auth_provider.grant(["https://api.example.com", "https://calendar-api.com"])
def sync_data(ctx: Context) -> str:
access_context = ctx.get_state("keycardai")
async def sync_data(ctx: Context) -> str:
access_context = await ctx.get_state("keycardai")

api_token = access_context.access("https://api.example.com").access_token
calendar_token = access_context.access("https://calendar-api.com").access_token
Expand Down Expand Up @@ -601,8 +601,8 @@ async def test_tool_with_authentication_error(auth_provider):

@mcp.tool()
@auth_provider.grant("https://api.example.com")
def failing_tool(ctx: Context, query: str) -> str:
access_context = ctx.get_state("keycardai")
async def failing_tool(ctx: Context, query: str) -> str:
access_context = await ctx.get_state("keycardai")

# Always check for errors first
if access_context.has_errors():
Expand All @@ -628,8 +628,8 @@ async def test_tool_with_custom_error_message(auth_provider):

@mcp.tool()
@auth_provider.grant("https://api.example.com")
def error_handling_tool(ctx: Context) -> str:
access_context = ctx.get_state("keycardai")
async def error_handling_tool(ctx: Context) -> str:
access_context = await ctx.get_state("keycardai")

if access_context.has_errors():
return f"Error occurred: {access_context.get_errors()}"
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-fastmcp/examples/delegated_access/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async def get_github_user(ctx: Context) -> dict:
User profile data or error details
"""
# Get access context from FastMCP context namespace
access_context: AccessContext = ctx.get_state("keycardai")
access_context: AccessContext = await ctx.get_state("keycardai")

# Check for any errors (global or resource-specific)
if access_context.has_errors():
Expand Down Expand Up @@ -110,7 +110,7 @@ async def list_github_repos(ctx: Context, per_page: int = 5) -> dict:
Returns:
List of repositories or error details
"""
access_context: AccessContext = ctx.get_state("keycardai")
access_context: AccessContext = await ctx.get_state("keycardai")

# Check for resource-specific error (alternative to has_errors())
if access_context.has_resource_error("https://api.github.com"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"keycardai-mcp-fastmcp",
"fastmcp>=2.13.0,<3.0.0",
"fastmcp>=3.0.0",
"httpx>=0.27.0,<1.0.0",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"keycardai-mcp-fastmcp",
"fastmcp>=2.13.0,<3.0.0",
"fastmcp>=3.0.0",
]

[tool.uv.sources]
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-fastmcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dependencies = [
"pydantic-settings>=2.7.1",
"httpx>=0.27.2",
"keycardai-oauth>=0.7.0",
"fastmcp>=2.14.0,<3.0.0",
"fastmcp>=3.0.0",
"keycardai-mcp>=0.15.0",
]
keywords = ["fastmcp", "mcp", "model-context-protocol", "oauth", "token-exchange", "authentication", "keycard"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
# Use grant decorator for token exchange
@mcp.tool()
@auth_provider.grant("https://api.example.com")
def call_external_api(ctx: Context, query: str):
token = ctx.get_state("keycardai").access("https://api.example.com").access_token
async def call_external_api(ctx: Context, query: str):
token = (await ctx.get_state("keycardai")).access("https://api.example.com").access_token
# Use token to call external API
return f"Results for {query}"

Expand All @@ -57,8 +57,9 @@ def call_external_api(ctx: Context, query: str):
@mcp.tool()
@auth_provider.grant(["https://www.googleapis.com/calendar/v3", "https://www.googleapis.com/drive/v3"])
async def sync_calendar_to_drive(ctx: Context):
calendar_token = ctx.get_state("keycardai").access("https://www.googleapis.com/calendar/v3").access_token
drive_token = ctx.get_state("keycardai").access("https://www.googleapis.com/drive/v3").access_token
access_context = await ctx.get_state("keycardai")
calendar_token = access_context.access("https://www.googleapis.com/calendar/v3").access_token
drive_token = access_context.access("https://www.googleapis.com/drive/v3").access_token
# Use both tokens for cross-service operations
return "Sync completed"

Expand Down
Loading
Loading