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
64 changes: 58 additions & 6 deletions packages/fastmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ pip install keycardai-fastmcp
## Quick Start

```python
from fastmcp import FastMCP, Context
from keycardai.fastmcp import AuthProvider
from fastmcp import FastMCP
from keycardai.fastmcp import AccessContext, AuthProvider

auth_provider = AuthProvider(
zone_id="abc1234",
Expand All @@ -28,13 +28,65 @@ auth_provider = AuthProvider(
mcp = FastMCP("My Server", auth=auth_provider.get_remote_auth_provider())

@mcp.tool()
@auth_provider.grant("https://api.example.com")
async def call_external_api(ctx: Context, query: str):
keycardai = await ctx.get_state("keycardai")
token = keycardai.access("https://api.example.com").access_token
async def call_external_api(
query: str,
access: AccessContext = auth_provider.grant("https://api.example.com"),
):
token = access.access("https://api.example.com").access_token
return f"Results for {query} (token starts with {token[:8]})"
```

Declaring the grant as a typed parameter default injects the populated
`AccessContext` per request; the parameter never appears in the tool's input
schema. Exchange failures are recorded on the `AccessContext` (check
`access.has_errors()` / `access.get_errors()`), never raised. Granting
multiple resources is all-or-nothing: if any exchange fails, the context
carries that resource's error and no tokens.

If you lint with flake8-bugbear or Ruff's `B008` rule (function call in
argument default), exempt your tool modules: the call-in-default is the
intended spelling here, the same pattern as FastAPI's `Depends`. In
`pyproject.toml`:

```toml
[tool.ruff.lint.per-file-ignores]
"src/my_server/tools/*.py" = ["B008"]
```

### Migrating from the decorator form

The decorator form (`@auth_provider.grant(...)` above the tool) still works
from the same object. Reading the result via `ctx.get_state("keycardai")` is
deprecated and emits a `DeprecationWarning`; helpers that only hold the
FastMCP `Context` can use `await AccessContext.from_context(ctx)` instead.

The warning fires once per tool, at decoration time (module import). If your
test or CI setup escalates warnings to errors (`-W error`,
`filterwarnings = ["error"]` in pytest config), importing a server module
that still uses the old form will raise instead of warn. Either migrate the
tools to the injected-parameter form, or allow this warning explicitly:

```toml
filterwarnings = ["error", 'default:Tool .* uses the grant decorator:DeprecationWarning']
```

## Testing

Fake delegated access without patching internals:

```python
from keycardai.fastmcp.testing import mock_access_context

with mock_access_context(access_token="fake_token"):
... # grants resolve to an AccessContext serving fake_token
```

The bare `access_token` form serves the token for any resource, so it will
not catch a mistyped resource URL in an `access(...)` call. Pass
`resource_tokens={...}` when the test should enforce which resources the
tool reads; resources outside the dict raise `ResourceAccessError`, matching
production.

## Migration from `keycardai-mcp-fastmcp`

The old package keeps working: `from keycardai.mcp.integrations.fastmcp import AuthProvider`
Expand Down
4 changes: 2 additions & 2 deletions packages/fastmcp/examples/delegated_access/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# GitHub API Integration with Keycard Delegated Access

A complete example demonstrating how to use the `@grant` decorator for token exchange, enabling your MCP server to access external APIs (GitHub) on behalf of authenticated users.
A complete example demonstrating how to declare a grant as a typed tool parameter for token exchange, enabling your MCP server to access external APIs (GitHub) on behalf of authenticated users.

## Why Keycard?

Expand Down Expand Up @@ -150,7 +150,7 @@ User MCP Server Keycard GitHub
```

1. User authenticates to your MCP server via Keycard
2. When a tool with `@grant` is called, Keycard exchanges the user's token
2. When a tool declaring a grant parameter is called, Keycard exchanges the user's token
3. The exchanged token has the scopes configured for the external resource
4. Your server uses this token to call GitHub API on behalf of the user

Expand Down
52 changes: 25 additions & 27 deletions packages/fastmcp/examples/delegated_access/main.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
"""GitHub API Integration with Keycard Delegated Access.

This example demonstrates how to use the @grant decorator to request
token exchange for accessing external APIs (GitHub) on behalf of
This example demonstrates how to declare a grant as a typed tool parameter
to request token exchange for accessing external APIs (GitHub) on behalf of
authenticated users.

Key concepts demonstrated:
- AuthProvider setup with ClientSecret credentials
- @grant decorator for requesting token exchange
- AccessContext for accessing exchanged tokens
- auth_provider.grant(...) as a typed parameter default for token exchange
- AccessContext injected into the tool, hidden from the tool's input schema
- Comprehensive error handling patterns
"""

import os

import httpx
from fastmcp import Context, FastMCP
from fastmcp import FastMCP

from keycardai.fastmcp import AccessContext, AuthProvider, ClientSecret

Expand All @@ -41,31 +41,29 @@


@mcp.tool()
@auth_provider.grant("https://api.github.com")
async def get_github_user(ctx: Context) -> dict:
async def get_github_user(
access: AccessContext = auth_provider.grant("https://api.github.com"),
) -> dict:
"""Get the authenticated GitHub user's profile.

Demonstrates:
- Basic @grant decorator usage
- Basic grant-as-parameter usage
- Error checking with has_errors()
- Token access via AccessContext
- Token access via the injected AccessContext

Args:
ctx: FastMCP context with Keycard authentication state
access: Injected access context holding the exchanged GitHub token

Returns:
User profile data or error details
"""
# Get access context from FastMCP context namespace
access_context: AccessContext = await ctx.get_state("keycardai")

# Check for any errors (global or resource-specific)
if access_context.has_errors():
errors = access_context.get_errors()
if access.has_errors():
errors = access.get_errors()
return {"error": "Token exchange failed", "details": errors}

# Get the exchanged token for GitHub API
token = access_context.access("https://api.github.com").access_token
token = access.access("https://api.github.com").access_token

# Call GitHub API with delegated token
async with httpx.AsyncClient() as client:
Expand Down Expand Up @@ -94,37 +92,37 @@ async def get_github_user(ctx: Context) -> dict:


@mcp.tool()
@auth_provider.grant("https://api.github.com")
async def list_github_repos(ctx: Context, per_page: int = 5) -> dict:
async def list_github_repos(
per_page: int = 5,
access: AccessContext = auth_provider.grant("https://api.github.com"),
) -> dict:
"""List the authenticated user's GitHub repositories.

Demonstrates:
- Resource-specific error checking with has_resource_error()
- Getting resource-specific errors with get_resource_error()
- Parameterized API calls
- Mixing regular tool parameters with an injected grant

Args:
ctx: FastMCP context with Keycard authentication state
per_page: Number of repositories to return (default: 5)
access: Injected access context holding the exchanged GitHub token

Returns:
List of repositories or error details
"""
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"):
resource_errors = access_context.get_resource_error("https://api.github.com")
if access.has_resource_error("https://api.github.com"):
resource_errors = access.get_resource_error("https://api.github.com")
return {
"message": "Token exchange failed for GitHub API",
"details": resource_errors,
}

# Check for global errors (e.g., no auth token available)
if access_context.has_error():
return {"error": "Global token error", "details": access_context.get_error()}
if access.has_error():
return {"error": "Global token error", "details": access.get_error()}

token = access_context.access("https://api.github.com").access_token
token = access.access("https://api.github.com").access_token

async with httpx.AsyncClient() as client:
response = await client.get(
Expand Down
2 changes: 1 addition & 1 deletion packages/fastmcp/examples/delegated_access/pyproject.toml
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-fastmcp",
"fastmcp>=3.0.0",
"fastmcp>=3.1.0",
"httpx>=0.27.0,<1.0.0",
]

Expand Down
2 changes: 1 addition & 1 deletion packages/fastmcp/examples/hello_world_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Connect to your server using any MCP-compatible client (e.g., Cursor, Claude Des

## Delegated Access

For accessing external APIs on behalf of users using the `@grant` decorator, see the [Delegated Access Example](../delegated_access/).
For accessing external APIs on behalf of users with an injected grant parameter, see the [Delegated Access Example](../delegated_access/).

## Environment Variables Reference

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-fastmcp",
"fastmcp>=3.0.0",
"fastmcp>=3.1.0",
]

[tool.uv.sources]
Expand Down
8 changes: 6 additions & 2 deletions packages/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>=3.0.0",
"fastmcp>=3.1.0",
"keycardai-mcp>=0.15.0",
]
keywords = ["fastmcp", "mcp", "model-context-protocol", "oauth", "token-exchange", "authentication", "keycard"]
Expand Down Expand Up @@ -84,7 +84,11 @@ ignore = [
isort = { combine-as-imports = true, known-first-party = ["keycardai"] }

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["T20"]
# B008: auth_provider.grant(...) is a dependency declaration (like
# fastapi.Depends): calling it in a parameter default is the intended
# injection spelling for tools.
"tests/**/*.py" = ["T20", "B008"]
"examples/**/*.py" = ["B008"]

[tool.mypy]
strict = true
Expand Down
45 changes: 31 additions & 14 deletions packages/fastmcp/src/keycardai/fastmcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
and FastMCP servers, enabling secure authentication and authorization.

Components:
- AuthProvider: Keycard authentication provider with RemoteAuthProvider creation and grant decorator
- AccessContext: Context object for accessing delegated tokens (used in FastMCP Context namespace)
- AuthProvider: Keycard authentication provider with RemoteAuthProvider creation and grant dependency
- AccessContext: Typed context object for accessing delegated tokens, injected into tool parameters
- Application credentials: ClientSecret, WebIdentity, EKSWorkloadIdentity for different authentication scenarios
- Auth strategies: BasicAuth, MultiZoneBasicAuth, NoneAuth for HTTP client authentication

GrantDependency is the return type of AuthProvider.grant(); it is exported for
type annotations only and is never constructed directly. Testing seams live in
keycardai.fastmcp.testing (mock_access_context is also re-exported here for
backward compatibility).

Re-export Guide:
Local definitions (primary API): AuthProvider, AccessContext
From keycardai.mcp.server.auth: ApplicationCredential, ClientSecret, EKSWorkloadIdentity, WebIdentity
Expand All @@ -19,8 +24,8 @@

Basic Usage:

from fastmcp import FastMCP, Context
from keycardai.fastmcp import AuthProvider
from fastmcp import FastMCP
from keycardai.fastmcp import AccessContext, AuthProvider

# Create authentication provider
auth_provider = AuthProvider(
Expand All @@ -33,11 +38,13 @@
auth = auth_provider.get_remote_auth_provider()
mcp = FastMCP("My Server", auth=auth)

# Use grant decorator for token exchange
# Declare the grant as a typed tool parameter for token exchange
@mcp.tool()
@auth_provider.grant("https://api.example.com")
async def call_external_api(ctx: Context, query: str):
token = (await ctx.get_state("keycardai")).access("https://api.example.com").access_token
async def call_external_api(
query: str,
access: AccessContext = auth_provider.grant("https://api.example.com"),
):
token = access.access("https://api.example.com").access_token
# Use token to call external API
return f"Results for {query}"

Expand All @@ -55,11 +62,14 @@ async def call_external_api(ctx: Context, query: str):

# Multiple resource access
@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):
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
async def sync_calendar_to_drive(
access: AccessContext = auth_provider.grant([
"https://www.googleapis.com/calendar/v3",
"https://www.googleapis.com/drive/v3",
]),
):
calendar_token = access.access("https://www.googleapis.com/calendar/v3").access_token
drive_token = access.access("https://www.googleapis.com/drive/v3").access_token
# Use both tokens for cross-service operations
return "Sync completed"

Expand Down Expand Up @@ -107,13 +117,20 @@ async def sync_calendar_to_drive(ctx: Context):
NoneAuth,
)

from .provider import AccessContext, AuthProvider
from .provider import (
AccessContext,
AuthProvider,
GrantDependency,
)
from .testing import mock_access_context

__all__ = [
# === Primary API (Local Definitions) ===
"AuthProvider",
"AccessContext",
# === Typing Support ===
# Return type of AuthProvider.grant(); exported for annotations, never constructed directly
"GrantDependency",
# === Application Credentials (re-exported from keycardai.mcp.server.auth) ===
"ApplicationCredential",
"ClientSecret",
Expand Down
Loading
Loading