Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
tags:
- '*-keycardai'
- '*-keycardai-oauth'
- '*-keycardai-starlette'
- '*-keycardai-mcp'
- '*-keycardai-mcp-fastmcp'
- '*-keycardai-agents'
Expand Down
9 changes: 6 additions & 3 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ build:
# Run tests for all packages
test: build
just test-package oauth
just test-package starlette
just test-package mcp
just test-package mcp-fastmcp

Expand All @@ -21,11 +22,13 @@ test-package PACKAGE:
test-file PACKAGE FILE:
cd packages/{{PACKAGE}} && uv run --extra test pytest tests/{{FILE}} -v

# Run tests with coverage enforcement
# Note: mcp package has lower threshold due to optional client integrations (CrewAI, LangChain, etc.)
# Run tests with coverage enforcement. mcp sits at 60% because well-tested server
# primitives moved to oauth/starlette, leaving the under-tested client integrations
# (CrewAI, LangChain, OpenAI) as the dominant share of what remains.
test-coverage: build
cd packages/oauth && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=70
cd packages/mcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=65
cd packages/starlette && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=55
cd packages/mcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=60
cd packages/mcp-fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=70

check:
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Keycard", email = "support@keycard.ai" }]
dependencies = [
"keycardai-oauth>=0.7.0",
"keycardai-oauth>=0.9.0",
"keycardai-starlette>=0.1.0",
"mcp>=1.13.1",
"pydantic>=2.11.7",
"httpx>=0.27.2",
"starlette>=0.47.3",
"nanoid>=2.0.0",
"aiohttp>=3.11.11",
"aiosqlite>=0.20.0",
Expand Down
10 changes: 6 additions & 4 deletions packages/mcp/src/keycardai/mcp/server/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from .metadata import (
InferredProtectedResourceMetadata,
"""Backward-compatible re-export from keycardai.starlette.handlers."""

from keycardai.starlette.handlers.metadata import (
ProtectedResourceMetadata as InferredProtectedResourceMetadata,
authorization_server_metadata,
protected_resource_metadata,
)

__all__ = [
"protected_resource_metadata",
"authorization_server_metadata",
"InferredProtectedResourceMetadata",
"authorization_server_metadata",
"protected_resource_metadata",
]
32 changes: 5 additions & 27 deletions packages/mcp/src/keycardai/mcp/server/handlers/jwks.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,9 @@
"""JWKS endpoint handler for OAuth authentication.
"""JWKS endpoint handler.

This module provides the JWKS (JSON Web Key Set) endpoint implementation
that serves the public keys used for JWT token verification.
Re-exported from keycardai.starlette.handlers.jwks for backward compatibility.
Canonical import: ``from keycardai.starlette.handlers.jwks import jwks_endpoint``
"""

from collections.abc import Callable
from keycardai.starlette.handlers.jwks import jwks_endpoint

from starlette.requests import Request
from starlette.responses import JSONResponse

from keycardai.oauth.types import JsonWebKeySet


def jwks_endpoint(jwks: JsonWebKeySet) -> Callable:
"""Create a JWKS endpoint that serves the provided JSON Web Key Set.

Args:
jwks: JSON Web Key Set to serve at this endpoint

Returns:
Callable endpoint that serves the JWKS data
"""
def wrapper(request: Request) -> JSONResponse:
return JSONResponse(
content=jwks.model_dump(exclude_none=True),
status_code=200,
headers={"Content-Type": "application/json"}
)

return wrapper
__all__ = ["jwks_endpoint"]
174 changes: 34 additions & 140 deletions packages/mcp/src/keycardai/mcp/server/handlers/metadata.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,28 @@
import json
from collections.abc import Callable
from dataclasses import dataclass

import httpx
from mcp.shared.auth import ProtectedResourceMetadata
from pydantic import AnyHttpUrl, Field
from starlette.requests import Request
from starlette.responses import Response

from keycardai.oauth.types.oauth import GrantType, TokenEndpointAuthMethod

from ..shared.starlette import get_base_url


class InferredProtectedResourceMetadata(ProtectedResourceMetadata):
"""Extended ProtectedResourceMetadata that allows resource to be inferred from request."""
resource: AnyHttpUrl | None = Field(default=None) # Override to make it optional
client_id: str | None = Field(default=None)
client_name: str | None = Field(default=None)
redirect_uris: list[AnyHttpUrl] | None = Field(default=None)
token_endpoint_auth_method: TokenEndpointAuthMethod | None = Field(default=None)
grant_types: list[GrantType] | None = Field(default=None)
jwks_uri: AnyHttpUrl | None = Field(default=None)

@dataclass
class AuthorizationServerMetadata:
base_url: str


def _is_authorization_server_zone_scoped(authorization_server_urls: AnyHttpUrl) -> bool:
"""OAuth metadata handlers.

Re-exported from keycardai.starlette.handlers.metadata for backward compatibility.
Canonical import: ``from keycardai.starlette.handlers.metadata import ...``
"""

from keycardai.starlette.handlers.metadata import (
ProtectedResourceMetadata as InferredProtectedResourceMetadata,
_create_jwks_uri,
_create_resource_url,
_create_zone_scoped_authorization_server_url,
_get_zone_id_from_path,
_remove_authorization_server_prefix,
_remove_well_known_prefix,
authorization_server_metadata,
protected_resource_metadata,
)


# Not in starlette — was only in MCP's version. Provide it here for test compat.
def _is_authorization_server_zone_scoped(authorization_server_urls) -> bool:
if len(authorization_server_urls) != 1:
return False
return len(authorization_server_urls[0].host.split(".")) == 3

def _get_zone_id_from_path(path: str) -> str | None:
path = path.lstrip("/").rstrip("/")
zone_id = path.split("/")[0]
if zone_id == "" or zone_id == "/":
return None
return zone_id

def _remove_well_known_prefix(path: str) -> str:
prefix = ".well-known/oauth-protected-resource"
path = path.lstrip("/").rstrip("/")
if path.startswith(prefix):
return path[len(prefix):]
return path

def _create_zone_scoped_authorization_server_url(zone_id: str, authorization_server_url: AnyHttpUrl) -> AnyHttpUrl:
port_part = f":{authorization_server_url.port}" if authorization_server_url.port else ""
url = f"{authorization_server_url.scheme}://{zone_id}.{authorization_server_url.host}{port_part}"
return AnyHttpUrl(url)

def _strip_zone_id_from_path(zone_id: str, path: str) -> str:
path = path.lstrip("/").rstrip("/")
Expand All @@ -59,94 +31,16 @@ def _strip_zone_id_from_path(zone_id: str, path: str) -> str:
return path


def _create_resource_url(base_url: str | AnyHttpUrl, path: str) -> AnyHttpUrl:
base_url_str = str(base_url).rstrip("/")
if path and not path.startswith("/"):
path = "/" + path
url = f"{base_url_str}{path}".rstrip("/")
if url.endswith("://") or (path == "/" and not url.endswith("/")):
url += "/"
return AnyHttpUrl(url)

def _remove_authorization_server_prefix(path: str) -> str:
"""Remove the /.well-known/oauth-authorization-server prefix from the path."""
auth_server_prefix = "/.well-known/oauth-authorization-server"
if path.startswith(auth_server_prefix):
return path[len(auth_server_prefix):]
return path

def _create_jwks_uri(base_url: str) -> AnyHttpUrl:
return AnyHttpUrl(f"{base_url.rstrip('/')}/.well-known/jwks.json")

def protected_resource_metadata(metadata: InferredProtectedResourceMetadata, enable_multi_zone: bool = False) -> Callable:
def wrapper(request: Request) -> Response:
# Create a copy of the metadata to avoid mutating the original
request_metadata = metadata.model_copy(deep=True)
path = _remove_well_known_prefix(request.url.path)

# Get proxy-aware base URL for correct scheme handling
base_url = get_base_url(request)

if enable_multi_zone:
zone_id = _get_zone_id_from_path(path)
if zone_id:
request_metadata.authorization_servers = [ _create_zone_scoped_authorization_server_url(zone_id, request_metadata.authorization_servers[0]) ]

request_metadata.resource = _create_resource_url(base_url, path)
request_metadata.jwks_uri = _create_jwks_uri(base_url)
# Resource URL serves as client_id for private_key_jwt auth (each resource is its own OAuth client)
request_metadata.client_id = str(request_metadata.resource)
request_metadata.client_name = "MCP Server"
request_metadata.token_endpoint_auth_method = TokenEndpointAuthMethod.PRIVATE_KEY_JWT
request_metadata.grant_types = [GrantType.CLIENT_CREDENTIALS]


mcp_version = request.headers.get("mcp-protocol-version")
# TODO: what is the reason for this?
if mcp_version == "2025-03-26":
json["authorization_servers"] = [ base_url ]
return Response(content=request_metadata.model_dump_json(exclude_none=True), status_code=200)
return wrapper

def authorization_server_metadata(issuer: str, enable_multi_zone: bool = False) -> Callable:
def wrapper(request: Request) -> Response:
try:
actual_issuer = issuer
path = _remove_authorization_server_prefix(request.url.path)

if enable_multi_zone:
zone_id = _get_zone_id_from_path(path)
if zone_id:
actual_issuer = str(_create_zone_scoped_authorization_server_url(zone_id, AnyHttpUrl(issuer)))

# fetch the authorization server for the zone
with httpx.Client() as client:
# Ensure no double slashes by removing trailing slash from actual_issuer
issuer_url = str(actual_issuer).rstrip('/')
resp = client.get(f"{issuer_url}/.well-known/oauth-authorization-server")
resp.raise_for_status()
authorization_server_metadata = resp.json()
authorization_server_metadata["authorization_endpoint"] = f"{authorization_server_metadata['authorization_endpoint']}"
return Response(content=json.dumps(authorization_server_metadata), status_code=200)
except httpx.HTTPStatusError as e:
# Return the same status code as the upstream server
# This includes 404 for invalid zone_id/non-existent servers
error_message = {
"error": f"Upstream authorization server returned {e.response.status_code}: {e.response.text}",
"type": "upstream_error",
"url": str(e.request.url)
}
return Response(content=json.dumps(error_message), status_code=e.response.status_code)
except (httpx.ConnectError, httpx.TimeoutException) as e:
# Network connectivity issues - return 503 Service Unavailable
error_message = {
"error": f"Unable to connect to authorization server: {str(e)}",
"type": "connectivity_error",
"url": f"{actual_issuer}/.well-known/oauth-authorization-server"
}
return Response(content=json.dumps(error_message), status_code=503)
except Exception as e:
# All other errors are server configuration issues - return 500
error_message = {"error": str(e), "type": type(e).__name__}
return Response(content=json.dumps(error_message), status_code=500)
return wrapper
__all__ = [
"InferredProtectedResourceMetadata",
"authorization_server_metadata",
"protected_resource_metadata",
"_create_resource_url",
"_create_zone_scoped_authorization_server_url",
"_get_zone_id_from_path",
"_remove_well_known_prefix",
"_remove_authorization_server_prefix",
"_create_jwks_uri",
"_is_authorization_server_zone_scoped",
"_strip_zone_id_from_path",
]
8 changes: 4 additions & 4 deletions packages/mcp/src/keycardai/mcp/server/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .bearer import BearerAuthMiddleware
"""Backward-compatible re-export from keycardai.starlette.middleware."""

__all__ = [
"BearerAuthMiddleware",
]
from keycardai.starlette.middleware import BearerAuthMiddleware

__all__ = ["BearerAuthMiddleware"]
Loading
Loading