Front-Door Auth (#696)
# Valuable references for the reviewer: - Docs PR: https://github.com/ArcadeAI/docs/pull/583 - Implements Phase 1 of the following planning doc: https://linear.app/arcadedev/project/arcade-mcp-supports-mcp-auth-front-door-auth-7cbaa20cb054/overview https://github.com/user-attachments/assets/79ad43fd-f5e8-4793-a1dd-18b35acefdc3 # PR Description Adds OAuth 2.1 Resource Server authentication to arcade-mcp-server, enabling HTTP MCP servers to validate Bearer tokens on every request. This unlocks tool-level authorization and secrets support for HTTP servers. - Multiple authorization server support - Granular token validation options (verify_exp, verify_iat, verify_iss) - Environment variable configuration - OAuth discovery metadata endpoint (/.well-known/oauth-protected-resource) - Extracts sub claim from token as context.user_id - Lifts transport restrictions for tools requiring auth/secrets on HTTP when protected ```python from arcade_mcp_server import MCPApp from arcade_mcp_server.resource_server import ResourceServerAuth, AuthorizationServerEntry resource_server_auth = ResourceServerAuth( canonical_url="http://127.0.0.1:8000/mcp", authorization_servers=[ AuthorizationServerEntry( authorization_server_url="https://auth.example.com", issuer="https://auth.example.com", jwks_uri="https://auth.example.com/jwks", ) ], ) app = MCPApp(name="my_server", version="1.0.0", auth=resource_server_auth) ``` # Testing Beyond the comprehensive unit tests, I also manually tested end-to-end with WorkOS Authkit (DCR) and KeyCloak (non-DCR). # Future Work - CIMD support - An `ArcadeResourceServer` to make adding front-door auth super easy when using Arcade's Auth Server <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds OAuth 2.1 front-door auth (JWKS validation + OAuth discovery) and propagates user identity to tools, enabling auth/secret-requiring tools over HTTP. > > - **Authentication (Front-Door OAuth 2.1)** > - New `resource_server` module with `ResourceServerAuth` (multi-authorization-server, metadata) and `JWKSTokenValidator` (JWKS-based JWT validation) plus granular validation options. > - ASGI `ResourceServerMiddleware` validates Bearer tokens on every HTTP request and injects `resource_owner`. > - OAuth discovery endpoint via FastAPI router at `/.well-known/oauth-protected-resource[/<path>]`. > - **Integration** > - `MCPApp`/`worker` accept `auth`/`resource_server_validator`, mount middleware, expose discovery; logs accepted auth servers. > - HTTP transport (`http_streamable`) carries `SessionMessage` with `resource_owner` from request → session. > - `Context`/`Session`/`Server` plumb `resource_owner`; `Server` selects `user_id` preferring token `sub`. > - **Behavior Changes** > - HTTP transport restriction lifted for tools requiring `authorization`/`secrets` when request is authenticated; otherwise blocked with actionable error. > - **Configuration** > - Env-var based auth config via `MCP_RESOURCE_SERVER_*` in `MCPSettings.ResourceServerSettings`; `.env` auto-load. > - **Telemetry** > - Usage tracking records `resource_server_type` on server start. > - **Examples** > - New `examples/mcp_servers/authorization` sample server (HTTP auth, secrets, Reddit tool) with Docker setup. > - **Tests** > - Extensive unit tests for validators, middleware, env config, multi-AS, transport rules, and app integration. > - **Version** > - Bump `arcade-mcp-server` to `1.12.0`; minor docstring tweak in `__init__.py`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d1116cdcafb0c7cb8f91e66682eb1fbae380da31. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Resolves TOO-152
This commit is contained in:
parent
99c22f0ebb
commit
98fd13c4ed
31 changed files with 3132 additions and 57 deletions
33
examples/mcp_servers/authorization/.dockerignore
Normal file
33
examples/mcp_servers/authorization/.dockerignore
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Docker
|
||||
docker/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
45
examples/mcp_servers/authorization/docker/Dockerfile
Normal file
45
examples/mcp_servers/authorization/docker/Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Auto-detect package name from pyproject.toml
|
||||
# First try using Python's tomllib
|
||||
# Fallback to grep/sed for compatibility
|
||||
RUN PACKAGE_NAME=$(python3 -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); print(data['project']['name'])" 2>/dev/null || \
|
||||
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E "s/.*name\s*=\s*[\"']([^\"']+)[\"'].*/\1/" || \
|
||||
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E 's/.*name\s*=\s*([^ ]+).*/\1/') && \
|
||||
if [ -z "$PACKAGE_NAME" ]; then \
|
||||
echo "ERROR: Could not detect package name from pyproject.toml" && exit 1; \
|
||||
fi && \
|
||||
echo "Detected package: $PACKAGE_NAME" && \
|
||||
echo "$PACKAGE_NAME" > /tmp/package_name.txt
|
||||
|
||||
# Install dependencies
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 8001
|
||||
|
||||
# Run the server from src/<package>/server.py
|
||||
CMD PACKAGE_NAME=$(cat /tmp/package_name.txt) && \
|
||||
if [ -f "src/${PACKAGE_NAME}/server.py" ]; then \
|
||||
uv run src/${PACKAGE_NAME}/server.py; \
|
||||
else \
|
||||
echo "ERROR: Could not find server.py at src/${PACKAGE_NAME}/server.py" && \
|
||||
echo " Package detected: ${PACKAGE_NAME}" && \
|
||||
echo " Available directories in src/:" && \
|
||||
ls -la src/ 2>/dev/null || echo " src/ directory not found" && \
|
||||
exit 1; \
|
||||
fi
|
||||
93
examples/mcp_servers/authorization/docker/README.md
Normal file
93
examples/mcp_servers/authorization/docker/README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Docker Setup for MCP Servers
|
||||
|
||||
This directory contains a generalized Docker configuration template that can be used with any MCP server in this repository.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Copy the Docker files to your MCP server directory:**
|
||||
|
||||
```bash
|
||||
cp -r examples/docker-template/docker your-mcp-server/
|
||||
cp examples/docker-template/.dockerignore your-mcp-server/
|
||||
```
|
||||
|
||||
2. **Build and run:**
|
||||
|
||||
```bash
|
||||
cd your-mcp-server
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Package Detection
|
||||
|
||||
The Dockerfile uses the package name from `pyproject.toml` by reading the `[project] name` field. It expects your server file at `src/<package_name>/server.py` (where `<package_name>` is from `pyproject.toml`).
|
||||
|
||||
If the server file is not found at this location, then the build will fail with an error message showing the detected package name and available directories in `src/`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `ARCADE_SERVER_TRANSPORT`: The transport protocol to use
|
||||
- Default: `http`
|
||||
- Options: `http`, `stdio`
|
||||
- `ARCADE_SERVER_PORT`: The port to run the server on (internal)
|
||||
- Default: `8001`
|
||||
- `ARCADE_SERVER_HOST`: The host to bind to
|
||||
- Default: `0.0.0.0`
|
||||
|
||||
### Example: Simple MCP Server
|
||||
|
||||
```bash
|
||||
# From examples/mcp_servers/simple/
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
The server will run internally on port 8001 but be accessible externally on port 8080 (http://localhost:8080). This demonstrates front-door auth working when the canonical URL differs from the internal bind address.
|
||||
|
||||
You can customize the ports by editing `docker/docker-compose.yml` and changing:
|
||||
- The port mapping (e.g., "8080:8001")
|
||||
- The `ARCADE_SERVER_PORT` environment variable (internal port)
|
||||
- The `MCP_RESOURCE_SERVER_CANONICAL_URL` (external URL)
|
||||
## Building the Image
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
-f docker/Dockerfile \
|
||||
-t your-mcp-server \
|
||||
.
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8001 \
|
||||
-e ARCADE_SERVER_TRANSPORT=http \
|
||||
-e ARCADE_SERVER_HOST=0.0.0.0 \
|
||||
-e ARCADE_SERVER_PORT=8001 \
|
||||
your-mcp-server
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic package detection**: Reads package name from `pyproject.toml`
|
||||
- **Standard server location**: Expects server file at `src/<package>/server.py`
|
||||
- **Secure by default**: Runs as non-root user
|
||||
- **Arcade environment variable support**: Uses `ARCADE_SERVER_*` environment variables
|
||||
- **Environment-based config**: Easy customization via environment variables
|
||||
- **uv integration**: Uses uv for fast dependency management
|
||||
- **Lightweight**: Based on Python 3.11 Bookworm slim image with uv
|
||||
|
||||
## Connecting from Cursor
|
||||
|
||||
Add to your `~/.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
"your-server-name": {
|
||||
"name": "your-server-name",
|
||||
"type": "stream",
|
||||
"url": "http://localhost:8080/mcp"
|
||||
}
|
||||
```
|
||||
|
||||
Then restart Cursor to connect to the server.
|
||||
12
examples/mcp_servers/authorization/docker/docker-compose.yml
Normal file
12
examples/mcp_servers/authorization/docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
mcp-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "8080:8001" # External port 8080 maps to internal port 8001
|
||||
environment:
|
||||
- ARCADE_SERVER_TRANSPORT=http
|
||||
- ARCADE_SERVER_HOST=0.0.0.0
|
||||
- ARCADE_SERVER_PORT=8001
|
||||
- MCP_RESOURCE_SERVER_CANONICAL_URL=http://127.0.0.1:8080/mcp
|
||||
45
examples/mcp_servers/authorization/pyproject.toml
Normal file
45
examples/mcp_servers/authorization/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "authorization"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.8.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.5.2,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "authorization"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/authorization"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Server Auth environment variables
|
||||
MCP_RESOURCE_SERVER_CANONICAL_URL="http://127.0.0.1:8000/mcp"
|
||||
MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
|
||||
{
|
||||
"authorization_server_url": "https://your-workos.authkit.app",
|
||||
"issuer": "https://your-workos.authkit.app",
|
||||
"jwks_uri": "https://your-workos.authkit.app/oauth2/jwks",
|
||||
"algorithm": "RS256",
|
||||
"verify_options": {
|
||||
"verify_aud": false
|
||||
}
|
||||
}
|
||||
]'
|
||||
|
||||
# Tool Secrets
|
||||
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"
|
||||
108
examples/mcp_servers/authorization/src/authorization/server.py
Normal file
108
examples/mcp_servers/authorization/src/authorization/server.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""authorization MCP server"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from arcade_mcp_server import Context, MCPApp
|
||||
from arcade_mcp_server.auth import Reddit
|
||||
from arcade_mcp_server.resource_server import (
|
||||
AuthorizationServerEntry,
|
||||
ResourceServerAuth,
|
||||
)
|
||||
|
||||
# Option 1: Single authorization server with custom audience
|
||||
# Use expected_audiences when your auth server returns a non-standard audience (aud) claim
|
||||
# (e.g., client_id instead of canonical_url)
|
||||
resource_server_auth = ResourceServerAuth(
|
||||
canonical_url="http://127.0.0.1:8000/mcp",
|
||||
authorization_servers=[
|
||||
AuthorizationServerEntry( # WorkOS Authkit example configuration
|
||||
authorization_server_url="https://your-workos.authkit.app",
|
||||
issuer="https://your-workos.authkit.app",
|
||||
jwks_uri="https://your-workos.authkit.app/oauth2/jwks",
|
||||
expected_audiences=["your-authkit-client-id"], # Override expected aud claim
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Option 2: Multiple authorization servers with different keys (e.g., multi-IdP)
|
||||
# resource_server_auth = ResourceServerAuth(
|
||||
# canonical_url="http://127.0.0.1:8000/mcp",
|
||||
# authorization_servers=[
|
||||
# AuthorizationServerEntry( # WorkOS Authkit example configuration
|
||||
# authorization_server_url="https://your-workos.authkit.app",
|
||||
# issuer="https://your-workos.authkit.app",
|
||||
# jwks_uri="https://your-workos.authkit.app/oauth2/jwks",
|
||||
# expected_audiences=["your-authkit-client-id"],
|
||||
# ),
|
||||
# AuthorizationServerEntry( # Keycloak example configuration
|
||||
# authorization_server_url="http://localhost:8080/realms/mcp-test",
|
||||
# issuer="http://localhost:8080/realms/mcp-test",
|
||||
# jwks_uri="http://localhost:8080/realms/mcp-test/protocol/openid-connect/certs",
|
||||
# algorithm="RS256",
|
||||
# expected_audiences=["your-keycloak-client-id"],
|
||||
# )
|
||||
# ],
|
||||
# )
|
||||
|
||||
# Option 3: Authorization via env vars (place in your .env file)
|
||||
# ```bash
|
||||
# MCP_RESOURCE_SERVER_CANONICAL_URL=http://127.0.0.1:8000/mcp
|
||||
# MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
|
||||
# {
|
||||
# "authorization_server_url": "https://your-workos.authkit.app",
|
||||
# "issuer": "https://your-workos.authkit.app",
|
||||
# "jwks_uri": "https://your-workos.authkit.app/oauth2/jwks",
|
||||
# "algorithm": "RS256",
|
||||
# "expected_audiences": ["your-authkit-client-id"]
|
||||
# }
|
||||
# ]'
|
||||
# ```
|
||||
# resource_server_auth = ResourceServerAuth()
|
||||
|
||||
app = MCPApp(name="authorization", version="1.0.0", log_level="DEBUG", auth=resource_server_auth)
|
||||
|
||||
|
||||
@app.tool
|
||||
def greet(name: Annotated[str, "The name of the person to greet"]) -> str:
|
||||
"""Greet a person by name."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
@app.tool(requires_secrets=["MY_SECRET_KEY"])
|
||||
def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]:
|
||||
"""Reveal the last 4 characters of a secret"""
|
||||
try:
|
||||
secret = context.get_secret("MY_SECRET_KEY")
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
return "The last 4 characters of the secret are: " + secret[-4:]
|
||||
|
||||
|
||||
# To use this tool locally, you need to install the Arcade CLI (uv tool install arcade-mcp)
|
||||
# and then run 'arcade login' to authenticate.
|
||||
@app.tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_posts_in_subreddit(
|
||||
context: Context, subreddit: Annotated[str, "The name of the subreddit"]
|
||||
) -> dict:
|
||||
"""Get posts from a specific subreddit"""
|
||||
subreddit = subreddit.lower().replace("r/", "").replace(" ", "")
|
||||
oauth_token = context.get_auth_token_or_empty()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {oauth_token}",
|
||||
"User-Agent": "authorization-mcp-server",
|
||||
}
|
||||
params = {"limit": 5}
|
||||
url = f"https://oauth.reddit.com/r/{subreddit}/hot"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(transport="http", host="127.0.0.1", port=8000)
|
||||
|
|
@ -36,7 +36,7 @@ __all__ = [
|
|||
# Integrated Factory and Runner
|
||||
"create_arcade_mcp",
|
||||
"run_arcade_mcp",
|
||||
# Re-exported TDK functionality
|
||||
# Re-exported from TDK functionality
|
||||
"tool",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from arcade_core.schema import (
|
|||
ToolContext,
|
||||
)
|
||||
|
||||
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||
from arcade_mcp_server.types import (
|
||||
AudioContent,
|
||||
CallToolParams,
|
||||
|
|
@ -124,6 +125,7 @@ class Context(ToolContext):
|
|||
server: Any,
|
||||
session: Any | None = None,
|
||||
request_id: str | None = None,
|
||||
resource_owner: ResourceOwner | None = None,
|
||||
):
|
||||
"""Initialize context with server reference."""
|
||||
super().__init__()
|
||||
|
|
@ -133,6 +135,9 @@ class Context(ToolContext):
|
|||
self._notification_queue: set[str] = set()
|
||||
self._request_id: str | None = request_id
|
||||
|
||||
# Resource owner from front-door auth (if the server is protected)
|
||||
self._resource_owner: ResourceOwner | None = resource_owner
|
||||
|
||||
# Namespaced adapters
|
||||
self._log = Logs(self)
|
||||
self._progress = Progress(self)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
"""FastAPI routes for MCP Resource Server authorization endpoints.
|
||||
|
||||
The routes defined here enable MCP clients to discover authorization servers
|
||||
associated with this MCP server.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from arcade_mcp_server.resource_server.base import ResourceServerValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_auth_router(
|
||||
resource_server_validator: ResourceServerValidator,
|
||||
canonical_url: str | None,
|
||||
) -> APIRouter:
|
||||
"""Create FastAPI router with OAuth discovery endpoints.
|
||||
|
||||
The well-known URI is constructed by inserting the well-known path after the host.
|
||||
If the canonical URL has a path component, then it becomes a suffix on the well-known path.
|
||||
|
||||
For example:
|
||||
- canonical_url "https://example.com" -> "/.well-known/oauth-protected-resource"
|
||||
- canonical_url "https://example.com/mcp" -> "/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
Args:
|
||||
resource_server_validator: The resource server validator instance
|
||||
canonical_url: Canonical URL of the MCP server
|
||||
|
||||
Returns:
|
||||
APIRouter configured with OAuth discovery endpoints
|
||||
"""
|
||||
router = APIRouter(tags=["MCP Protocol"])
|
||||
|
||||
path_suffix = ""
|
||||
if canonical_url:
|
||||
parsed = urlparse(canonical_url)
|
||||
path_suffix = parsed.path
|
||||
|
||||
well_known_base = "/.well-known/oauth-protected-resource"
|
||||
well_known_path = f"{well_known_base}{path_suffix}"
|
||||
|
||||
async def oauth_protected_resource() -> JSONResponse:
|
||||
"""OAuth 2.0 Protected Resource Metadata (RFC 9728)"""
|
||||
if not canonical_url:
|
||||
return JSONResponse(
|
||||
{"error": "Server canonical URL not configured"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
metadata = resource_server_validator.get_resource_metadata()
|
||||
if metadata is None:
|
||||
logger.error(
|
||||
"Resource metadata unavailable for OAuth discovery endpoint. "
|
||||
"This is unexpected - the validator should provide metadata if OAuth discovery is enabled."
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": "Resource metadata not available"},
|
||||
status_code=500,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
metadata,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
)
|
||||
|
||||
# Register the well-known endpoint at the RFC 9728 compliant path
|
||||
router.add_api_route(
|
||||
well_known_path,
|
||||
oauth_protected_resource,
|
||||
methods=["GET"],
|
||||
name="oauth_protected_resource",
|
||||
)
|
||||
|
||||
# Also register at base path if there's a suffix for extra compatibility
|
||||
if path_suffix:
|
||||
router.add_api_route(
|
||||
well_known_base,
|
||||
oauth_protected_resource,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
return router
|
||||
|
|
@ -19,12 +19,12 @@ from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionErr
|
|||
from arcade_tdk.auth import ToolAuthorization
|
||||
from arcade_tdk.error_adapters import ErrorAdapter
|
||||
from arcade_tdk.tool import tool as tool_decorator
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
from watchfiles import watch
|
||||
|
||||
from arcade_mcp_server.exceptions import ServerError
|
||||
from arcade_mcp_server.logging_utils import intercept_standard_logging
|
||||
from arcade_mcp_server.resource_server.base import ResourceServerValidator
|
||||
from arcade_mcp_server.server import MCPServer
|
||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
||||
|
|
@ -75,6 +75,7 @@ class MCPApp:
|
|||
host: str = "127.0.0.1",
|
||||
port: int = 8000,
|
||||
reload: bool = False,
|
||||
auth: ResourceServerValidator | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
|
|
@ -90,6 +91,7 @@ class MCPApp:
|
|||
host: Host for transport
|
||||
port: Port for transport
|
||||
reload: Enable auto-reload for development
|
||||
auth: Resource Server validator for front-door authentication
|
||||
**kwargs: Additional server configuration
|
||||
"""
|
||||
self._name = self._validate_name(name)
|
||||
|
|
@ -97,6 +99,7 @@ class MCPApp:
|
|||
self.title = title or name
|
||||
self.instructions = instructions
|
||||
self.log_level = log_level
|
||||
self.resource_server_validator = auth
|
||||
self.server_kwargs = kwargs
|
||||
self.transport = transport
|
||||
self.host = host
|
||||
|
|
@ -123,7 +126,6 @@ class MCPApp:
|
|||
# Store the actual instructions that ended up in ServerSettings
|
||||
self.instructions = self._mcp_settings.server.instructions
|
||||
|
||||
self._load_env()
|
||||
if not logger._core.handlers: # type: ignore[attr-defined]
|
||||
self._setup_logging(transport == "stdio")
|
||||
|
||||
|
|
@ -193,13 +195,6 @@ class MCPApp:
|
|||
"""Runtime resources API: add/remove/list."""
|
||||
return _ResourcesAPI(self)
|
||||
|
||||
def _load_env(self) -> None:
|
||||
"""Load .env file from the current directory."""
|
||||
env_path = Path.cwd() / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=False)
|
||||
logger.info(f"Loaded environment from {env_path}")
|
||||
|
||||
def _setup_logging(self, stdio_mode: bool = False) -> None:
|
||||
logger.remove()
|
||||
|
||||
|
|
@ -313,6 +308,24 @@ class MCPApp:
|
|||
logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")
|
||||
|
||||
if transport in ["http", "streamable-http", "streamable"]:
|
||||
resource_server_auth_enabled = isinstance(
|
||||
self.resource_server_validator, ResourceServerValidator
|
||||
)
|
||||
if resource_server_auth_enabled:
|
||||
logger.info("Resource Server authentication is enabled. MCP routes are protected.")
|
||||
else:
|
||||
logger.warning(
|
||||
"Resource Server authentication is disabled. MCP routes are not protected, so tools requiring auth or secrets will fail."
|
||||
)
|
||||
if (
|
||||
isinstance(self.resource_server_validator, ResourceServerValidator)
|
||||
and self.resource_server_validator.supports_oauth_discovery()
|
||||
):
|
||||
metadata = self.resource_server_validator.get_resource_metadata()
|
||||
if metadata:
|
||||
auth_servers = metadata.get("authorization_servers", [])
|
||||
logger.info(f"Accepted authorization server(s): {', '.join(auth_servers)}")
|
||||
|
||||
if reload:
|
||||
self._run_with_reload(host, port)
|
||||
else:
|
||||
|
|
@ -326,6 +339,7 @@ class MCPApp:
|
|||
host=None,
|
||||
port=None,
|
||||
tool_count=len(self._catalog),
|
||||
resource_server_validator=self.resource_server_validator,
|
||||
)
|
||||
asyncio.run(
|
||||
run_stdio_server(
|
||||
|
|
@ -403,6 +417,7 @@ class MCPApp:
|
|||
catalog=self._catalog,
|
||||
mcp_settings=self._mcp_settings,
|
||||
debug=debug,
|
||||
resource_server_validator=self.resource_server_validator,
|
||||
**self.server_kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -412,6 +427,7 @@ class MCPApp:
|
|||
host=host,
|
||||
port=port,
|
||||
tool_count=len(self._catalog),
|
||||
resource_server_validator=self.resource_server_validator,
|
||||
)
|
||||
|
||||
asyncio.run(serve_with_force_quit(app=app, host=host, port=port, log_level=log_level))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
# MCP Resource Server Authentication
|
||||
|
||||
OAuth 2.1-compliant Resource Server authentication for securing HTTP-based MCP servers.
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP server acts as an OAuth 2.1 **Resource Server**, validating Bearer tokens on **every HTTP request** before processing MCP protocol messages. This enables:
|
||||
|
||||
1. **Secure HTTP Transport** - Protect your MCP server with OAuth 2.1
|
||||
2. **Tool-Level Authorization** - Enable tools requiring end-user OAuth on HTTP transport
|
||||
3. **OAuth Discovery** - MCP clients automatically discover authentication requirements via OAuth Protected Resource Metadata (RFC 9728)
|
||||
4. **User Context** - Tools receive authenticated resource owner identity from the Authorization Server
|
||||
|
||||
MCP servers can accept tokens from one or more authorization servers. Accepting tokens from multiple authorization servers supports scenarios like regional endpoints, multiple identity providers, or migrating between auth systems.
|
||||
|
||||
**Note:** The MCP server (Resource Server) doesn't need to know how MCP clients are registered with the Authorization Server (for example, Dynamic Client Registration, static client secrets, etc.) - that's the authorization server's concern. The MCP server simply validates tokens and advertises the AS URLs.
|
||||
|
||||
## Environment Variable Configuration
|
||||
|
||||
`ResourceServerAuth` supports environment variable configuration for production deployments. This is the **recommended approach for production**.
|
||||
|
||||
**Note:** `JWKSTokenValidator` does not support environment variables and requires explicit programmatic parameters to its initializer
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Environment Variable | Type | Description | Required |
|
||||
|---------------------|------|-------------|----------|
|
||||
| `MCP_RESOURCE_SERVER_CANONICAL_URL` | string | MCP server canonical URL | Yes |
|
||||
| `MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS` | JSON array | Authorization server entries | Yes |
|
||||
|
||||
The `MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS` must be a JSON array of entry objects. Each object should include:
|
||||
- `authorization_server_url`: Authorization server URL
|
||||
- `issuer`: Expected token issuer
|
||||
- `jwks_uri`: JWKS endpoint URL
|
||||
- `algorithm`: (Optional) JWT algorithm, defaults to RS256
|
||||
- `expected_audiences`: (Optional) list of expected audience claim values. If not provided, defaults to the canonical_url. Use this when your auth server returns a different aud claim (e.g., client_id).
|
||||
- `validation_options`: (Optional) dict with optional `verify_exp`, `verify_iat`, `verify_iss`, `verify_nbf`, and `leeway` (int, seconds). All verify flags default to True.
|
||||
|
||||
### Precedence Rules
|
||||
|
||||
**Explicit parameters take precedence over environment variables:**
|
||||
|
||||
```python
|
||||
from arcade_mcp_server import MCPApp
|
||||
from arcade_mcp_server.resource_server import (
|
||||
AuthorizationServerEntry,
|
||||
ResourceServerAuth,
|
||||
)
|
||||
|
||||
# Explicit parameters override env vars (if both are provided)
|
||||
resource_server_auth = ResourceServerAuth(
|
||||
canonical_url="http://127.0.0.1:8000/mcp", # used even if env var is set
|
||||
authorization_servers=[ # used even if env var is set
|
||||
AuthorizationServerEntry(
|
||||
authorization_server_url="https://your-workos.authkit.app",
|
||||
issuer="https://your-workos.authkit.app",
|
||||
jwks_uri="https://your-workos.authkit.app/oauth2/jwks",
|
||||
algorithm="RS256",
|
||||
# Override expected aud if auth server returns different audience (e.g., client_id)
|
||||
expected_audiences=["my-authkit-client-id"],
|
||||
)
|
||||
],
|
||||
)
|
||||
app = MCPApp(name="Protected", auth=resource_server_auth)
|
||||
|
||||
# If no parameters provided, env vars are used as fallback
|
||||
resource_server_auth = ResourceServerAuth() # Uses MCP_RESOURCE_SERVER_* env vars
|
||||
```
|
||||
|
||||
### Example .env File
|
||||
|
||||
#### Single Authorization Server
|
||||
|
||||
```bash
|
||||
# Resource Server Configuration
|
||||
MCP_RESOURCE_SERVER_CANONICAL_URL=https://mcp.example.com/mcp
|
||||
MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
|
||||
{
|
||||
"authorization_server_url": "https://auth.example.com",
|
||||
"issuer": "https://auth.example.com",
|
||||
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
|
||||
"algorithm": "RS256"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
#### Single Authorization Server (Custom Audience)
|
||||
|
||||
When your auth server returns a different `aud` claim (e.g., client_id instead of canonical URL):
|
||||
|
||||
```bash
|
||||
MCP_RESOURCE_SERVER_CANONICAL_URL=https://mcp.example.com/mcp
|
||||
MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
|
||||
{
|
||||
"authorization_server_url": "https://auth.example.com",
|
||||
"issuer": "https://auth.example.com",
|
||||
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
|
||||
"algorithm": "RS256",
|
||||
"expected_audiences": ["my-client-id"]
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
#### Multiple Authorization Servers (Shared Keys)
|
||||
|
||||
```bash
|
||||
# Regional endpoints with shared keys
|
||||
MCP_RESOURCE_SERVER_CANONICAL_URL=https://mcp.example.com/mcp
|
||||
MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
|
||||
{
|
||||
"authorization_server_url": "https://auth-us.example.com",
|
||||
"issuer": "https://auth.example.com",
|
||||
"jwks_uri": "https://auth.example.com/.well-known/jwks.json"
|
||||
},
|
||||
{
|
||||
"authorization_server_url": "https://auth-eu.example.com",
|
||||
"issuer": "https://auth.example.com",
|
||||
"jwks_uri": "https://auth.example.com/.well-known/jwks.json"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
#### Multiple Authorization Servers (Different Keys)
|
||||
|
||||
```bash
|
||||
# Multi-IdP configuration with custom audiences
|
||||
MCP_RESOURCE_SERVER_CANONICAL_URL=https://mcp.example.com/mcp
|
||||
MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
|
||||
{
|
||||
"authorization_server_url": "https://workos.authkit.app",
|
||||
"issuer": "https://workos.authkit.app",
|
||||
"jwks_uri": "https://workos.authkit.app/oauth2/jwks",
|
||||
"expected_audiences": ["my-workos-client-id"]
|
||||
},
|
||||
{
|
||||
"authorization_server_url": "http://localhost:8080/realms/mcp-test",
|
||||
"issuer": "http://localhost:8080/realms/mcp-test",
|
||||
"jwks_uri": "http://localhost:8080/realms/mcp-test/protocol/openid-connect/certs",
|
||||
"expected_audiences": ["my-keycloak-client-id"]
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Resource Server validates tokens** - Extracts user identity from validated token's `sub` claim
|
||||
2. **User ID flows to ToolContext** - Used for tool-level OAuth via Arcade platform
|
||||
3. **Transport restriction lifted** - HTTP is now safe for tools requiring auth/secrets
|
||||
4. **Separate authorization layers** - Resource Server auth != tool OAuth (but building a protected server enables tool authorization)
|
||||
|
||||
## Vendor-Specific Implementations
|
||||
|
||||
The `ResourceServerAuth` class is designed to be subclassed for vendor-specific implementations:
|
||||
|
||||
```python
|
||||
# Your vendor-specific implementations
|
||||
class ArcadeResourceServerAuth(ResourceServerAuth): ...
|
||||
class WorkOSResourceServerAuth(ResourceServerAuth): ...
|
||||
class Auth0ResourceServerAuth(ResourceServerAuth): ...
|
||||
class DescopeResourceServerAuth(ResourceServerAuth): ...
|
||||
```
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"""
|
||||
MCP Resource Server authentication.
|
||||
|
||||
This module provides OAuth 2.1 Resource Server capabilities for MCP servers.
|
||||
It enables MCP servers to validate Bearer tokens on every HTTP request
|
||||
before processing MCP messages.
|
||||
"""
|
||||
|
||||
from arcade_mcp_server.resource_server.base import (
|
||||
AccessTokenValidationOptions,
|
||||
AuthorizationServerEntry,
|
||||
)
|
||||
from arcade_mcp_server.resource_server.validators import (
|
||||
JWKSTokenValidator,
|
||||
ResourceServerAuth,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccessTokenValidationOptions",
|
||||
"AuthorizationServerEntry",
|
||||
"JWKSTokenValidator",
|
||||
"ResourceServerAuth",
|
||||
]
|
||||
168
libs/arcade-mcp-server/arcade_mcp_server/resource_server/base.py
Normal file
168
libs/arcade-mcp-server/arcade_mcp_server/resource_server/base.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Base classes for MCP Resource Server authentication."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccessTokenValidationOptions(BaseModel):
|
||||
"""Options for access token validation.
|
||||
|
||||
All validations are enabled by default for security.
|
||||
Set to False to disable specific validations for authorization servers
|
||||
that are not compliant with MCP.
|
||||
|
||||
Note: Token signature verification and audience validation are always enabled
|
||||
and cannot be disabled. Additionally, the subject (sub claim) must always be
|
||||
present in the token.
|
||||
"""
|
||||
|
||||
verify_exp: bool = Field(
|
||||
default=True,
|
||||
description="Verify token expiration (exp claim)",
|
||||
)
|
||||
verify_iat: bool = Field(
|
||||
default=True,
|
||||
description="Verify issued-at time (iat claim)",
|
||||
)
|
||||
verify_iss: bool = Field(
|
||||
default=True,
|
||||
description="Verify issuer claim (iss claim)",
|
||||
)
|
||||
verify_nbf: bool = Field(
|
||||
default=True,
|
||||
description="Verify not-before time (nbf claim). Rejects tokens used before their activation time.",
|
||||
)
|
||||
leeway: int = Field(
|
||||
default=0,
|
||||
description="Clock skew tolerance in seconds for exp/nbf validation. Recommended: 30-60 seconds.",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceOwner:
|
||||
"""User information extracted from validated access token.
|
||||
|
||||
This represents the authenticated resource owner (end-user) making requests
|
||||
to the MCP server. The user_id typically comes from the 'sub' (subject) claim
|
||||
in JWT tokens.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
"""User identifier from token (typically 'sub' claim)"""
|
||||
|
||||
client_id: str | None = None
|
||||
"""OAuth client identifier from 'client_id' or 'azp' claim"""
|
||||
|
||||
email: str | None = None
|
||||
"""User email if available in token claims"""
|
||||
|
||||
claims: dict[str, Any] = field(default_factory=dict)
|
||||
"""All claims from the validated token for advanced use cases"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthorizationServerEntry:
|
||||
"""Configuration entry for a single authorization server.
|
||||
|
||||
Each authorization server that can issue valid tokens for this
|
||||
MCP server (Resource Server) needs its own entry specifying how to
|
||||
verify tokens from that server.
|
||||
"""
|
||||
|
||||
authorization_server_url: str
|
||||
"""Authorization server URL for client discovery (RFC 9728)"""
|
||||
|
||||
issuer: str
|
||||
"""Expected issuer claim in JWT tokens from this server"""
|
||||
|
||||
jwks_uri: str
|
||||
"""JWKS endpoint to fetch public keys for token verification"""
|
||||
|
||||
algorithm: str = "RS256"
|
||||
"""JWT signature algorithm (RS256, ES256, PS256, etc.)"""
|
||||
|
||||
expected_audiences: list[str] | None = None
|
||||
"""Optional list of expected audience claims. If not provided,
|
||||
defaults to the MCP server's canonical_url. Use this when your
|
||||
authorization server returns a different aud claim (e.g., client_id)."""
|
||||
|
||||
validation_options: AccessTokenValidationOptions = field(
|
||||
default_factory=AccessTokenValidationOptions
|
||||
)
|
||||
"""Token validation options for this authorization server"""
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Base authentication error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TokenExpiredError(AuthenticationError):
|
||||
"""Token has expired."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTokenError(AuthenticationError):
|
||||
"""Token is invalid (signature, audience, issuer, etc.)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ResourceServerValidator(ABC):
|
||||
"""Base class for MCP Resource Server token validation.
|
||||
|
||||
An MCP server acts as an OAuth 2.1 Resource Server, validating Bearer tokens
|
||||
on every HTTP request. Implementations must validate tokens according to
|
||||
OAuth 2.1 Resource Server requirements, including:
|
||||
- Token signature verification
|
||||
- Expiration checking
|
||||
- Issuer validation
|
||||
- Audience validation
|
||||
|
||||
Tokens are validated on every request - no caching is permitted per MCP spec.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def validate_token(self, token: str) -> ResourceOwner:
|
||||
"""Validate bearer token and return authenticated resource owner info.
|
||||
|
||||
Must validate:
|
||||
- Token signature
|
||||
- Expiration
|
||||
- Issuer (matches expected authorization server)
|
||||
- Audience (matches this MCP server's canonical URL)
|
||||
|
||||
Args:
|
||||
token: Bearer token from Authorization header
|
||||
|
||||
Returns:
|
||||
ResourceOwner with user_id and claims
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: Token has expired
|
||||
InvalidTokenError: Token is invalid (signature, audience, issuer mismatch)
|
||||
AuthenticationError: Other validation errors
|
||||
"""
|
||||
pass
|
||||
|
||||
def supports_oauth_discovery(self) -> bool:
|
||||
"""Whether this validator supports OAuth discovery endpoints.
|
||||
|
||||
Returns True if the validator can serve OAuth 2.0 Protected Resource Metadata
|
||||
(RFC 9728) to enable MCP clients to discover authorization servers.
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_resource_metadata(self) -> dict[str, Any] | None:
|
||||
"""Return OAuth Protected Resource Metadata (RFC 9728) if supported.
|
||||
|
||||
Returns:
|
||||
Metadata dictionary with 'resource' and 'authorization_servers' fields,
|
||||
or None if discovery is not supported.
|
||||
"""
|
||||
return None
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"""ASGI middleware for MCP Resource Server authentication."""
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from arcade_mcp_server.resource_server.base import (
|
||||
AuthenticationError,
|
||||
InvalidTokenError,
|
||||
ResourceOwner,
|
||||
ResourceServerValidator,
|
||||
TokenExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class ResourceServerMiddleware:
|
||||
"""ASGI middleware that validates Bearer tokens on every HTTP request.
|
||||
|
||||
Validates tokens per MCP specification:
|
||||
- Checks Authorization header for Bearer token
|
||||
- Validates token on every request
|
||||
- Returns 401 with WWW-Authenticate header if authentication fails
|
||||
- Stores authenticated resource owner in scope for downstream use to lift
|
||||
tool-auth and tool-secrets restrictions
|
||||
|
||||
The WWW-Authenticate header includes:
|
||||
- resource_metadata URL for OAuth discovery (if validator supports it)
|
||||
- error and error_description for token validation failures (RFC 6750)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
validator: ResourceServerValidator,
|
||||
canonical_url: str | None,
|
||||
):
|
||||
"""Initialize the Resource Server middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application to wrap
|
||||
validator: Token validator for access token validation
|
||||
canonical_url: Canonical URL of this MCP server (for OAuth metadata).
|
||||
Required only for validators that support OAuth discovery.
|
||||
"""
|
||||
self.app = app
|
||||
self.validator = validator
|
||||
self.canonical_url = canonical_url
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""Process ASGI request with authentication.
|
||||
|
||||
For HTTP requests:
|
||||
1. Allow CORS preflight OPTIONS requests to pass through
|
||||
2. Extract Bearer token from Authorization header
|
||||
3. Validate token (on EVERY request - no caching)
|
||||
4. Store authenticated resource owner in scope
|
||||
5. Pass to wrapped app
|
||||
|
||||
For non-HTTP requests, pass through without auth.
|
||||
"""
|
||||
# Only process HTTP requests
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
|
||||
# Allow CORS preflight requests to pass through without authentication.
|
||||
# Browsers send OPTIONS requests without Authorization headers to check
|
||||
# if the cross-origin request is allowed before sending the actual request.
|
||||
if request.method == "OPTIONS":
|
||||
response = self._create_cors_preflight_response()
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
try:
|
||||
resource_owner = await self._authenticate_request(request)
|
||||
|
||||
# Store in scope for downstream usage & continue to app execution
|
||||
scope["resource_owner"] = resource_owner
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
except (TokenExpiredError, InvalidTokenError) as e:
|
||||
response = self._create_401_response(
|
||||
error="invalid_token",
|
||||
error_description=str(e),
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
|
||||
except AuthenticationError:
|
||||
response = self._create_401_response()
|
||||
await response(scope, receive, send)
|
||||
|
||||
async def _authenticate_request(self, request: Request) -> ResourceOwner:
|
||||
"""Extract and validate Bearer token from Authorization header.
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
ResourceOwner from validated token
|
||||
|
||||
Raises:
|
||||
AuthenticationError: No token or invalid format
|
||||
TokenExpiredError: Token has expired
|
||||
InvalidTokenError: Token signature/audience/issuer invalid
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
raise AuthenticationError("No Authorization header")
|
||||
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise AuthenticationError("Invalid Authorization header format.")
|
||||
|
||||
# Remove "Bearer " prefix
|
||||
token = auth_header[7:]
|
||||
|
||||
return await self.validator.validate_token(token)
|
||||
|
||||
def _build_metadata_url(self) -> str:
|
||||
"""Build the OAuth Protected Resource Metadata URL per RFC 9728.
|
||||
|
||||
For example, for a canonical_url of "https://example.com/mcp" the metadata URL is:
|
||||
"https://example.com/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
Returns:
|
||||
Metadata URL
|
||||
"""
|
||||
if not self.canonical_url:
|
||||
return ""
|
||||
|
||||
parsed = urlparse(self.canonical_url)
|
||||
# Insert well-known path after host, with resource path as suffix
|
||||
well_known_path = f"/.well-known/oauth-protected-resource{parsed.path}"
|
||||
return urlunparse((parsed.scheme, parsed.netloc, well_known_path, "", "", ""))
|
||||
|
||||
def _create_cors_preflight_response(self) -> Response:
|
||||
"""Create a CORS preflight response for OPTIONS requests.
|
||||
|
||||
Returns:
|
||||
Response with 204 status and CORS headers
|
||||
"""
|
||||
return Response(
|
||||
content=None,
|
||||
status_code=204,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, Accept",
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id",
|
||||
"Access-Control-Max-Age": "86400", # 24 hr
|
||||
},
|
||||
)
|
||||
|
||||
def _create_401_response(
|
||||
self,
|
||||
error: str | None = None,
|
||||
error_description: str | None = None,
|
||||
) -> Response:
|
||||
"""Create RFC 6750 + RFC 9728 compliant 401 response.
|
||||
|
||||
The WWW-Authenticate header format follows:
|
||||
- RFC 6750 (OAuth 2.0 Bearer Token Usage)
|
||||
- RFC 9728 (OAuth 2.0 Protected Resource Metadata)
|
||||
|
||||
Args:
|
||||
error: Error code (e.g., "invalid_token")
|
||||
error_description: Human-readable error description
|
||||
|
||||
Returns:
|
||||
Response with 401 status with WWW-Authenticate header
|
||||
"""
|
||||
www_auth_parts = []
|
||||
|
||||
# Add resource metadata URL if validator supports discovery (RFC 9728)
|
||||
if self.validator.supports_oauth_discovery() and self.canonical_url:
|
||||
metadata_url = self._build_metadata_url()
|
||||
www_auth_parts.append(f'resource_metadata="{metadata_url}"')
|
||||
|
||||
# Add error details if token validation failed (RFC 6750)
|
||||
if error:
|
||||
www_auth_parts.append(f'error="{error}"')
|
||||
if error_description:
|
||||
www_auth_parts.append(f'error_description="{error_description}"')
|
||||
|
||||
www_auth_value = "Bearer " + ", ".join(www_auth_parts)
|
||||
|
||||
return Response(
|
||||
content="Unauthorized",
|
||||
status_code=401,
|
||||
headers={
|
||||
"WWW-Authenticate": www_auth_value,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, Accept",
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id",
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
Token validator implementations for MCP Resource Servers.
|
||||
|
||||
Provides concrete implementations of ResourceServerValidator for different auth scenarios.
|
||||
"""
|
||||
|
||||
from arcade_mcp_server.resource_server.validators.auth import ResourceServerAuth
|
||||
from arcade_mcp_server.resource_server.validators.jwks import JWKSTokenValidator
|
||||
|
||||
__all__ = [
|
||||
"JWKSTokenValidator",
|
||||
"ResourceServerAuth",
|
||||
]
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
"""ResourceServerAuth implementation with OAuth discovery metadata support.
|
||||
|
||||
This module provides the base ResourceServerAuth class that validates JWT tokens
|
||||
from one or more authorization servers and provides OAuth 2.0 Protected Resource
|
||||
Metadata (RFC 9728) for discovery.
|
||||
|
||||
Vendor specific implementations (WorkOS, Auth0, Descope, etc.) should inherit
|
||||
from ResourceServerAuth.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from arcade_mcp_server.resource_server.base import (
|
||||
AuthenticationError,
|
||||
AuthorizationServerEntry,
|
||||
InvalidTokenError,
|
||||
ResourceOwner,
|
||||
ResourceServerValidator,
|
||||
TokenExpiredError,
|
||||
)
|
||||
from arcade_mcp_server.resource_server.validators.jwks import JWKSTokenValidator
|
||||
from arcade_mcp_server.settings import MCPSettings
|
||||
|
||||
|
||||
class ResourceServerAuth(ResourceServerValidator):
|
||||
"""OAuth 2.1 Resource Server with discovery metadata support.
|
||||
|
||||
This class implements the MCP server's role as an OAuth 2.1 Resource Server,
|
||||
validating JWT tokens from one or more authorization servers and providing
|
||||
OAuth 2.0 Protected Resource Metadata (RFC 9728) for discovery.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authorization_servers: list[AuthorizationServerEntry] | None = None,
|
||||
canonical_url: str | None = None,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
"""Initialize Resource Server.
|
||||
|
||||
Supports environment variable configuration via MCP_RESOURCE_SERVER_* variables.
|
||||
Explicit parameters take precedence over environment variables.
|
||||
|
||||
Args:
|
||||
authorization_servers: List of authorization server entries
|
||||
canonical_url: MCP server canonical URL (or MCP_RESOURCE_SERVER_CANONICAL_URL)
|
||||
cache_ttl: JWKS cache TTL in seconds
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields not provided via params or env vars
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Option 1: Use environment variables
|
||||
# Set MCP_RESOURCE_SERVER_CANONICAL_URL and MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS env vars
|
||||
resource_server_auth = ResourceServerAuth()
|
||||
|
||||
# Option 2: Single Authorization Server (aud claim matches canonical_url)
|
||||
resource_server_auth = ResourceServerAuth(
|
||||
canonical_url="https://mcp.example.com/mcp",
|
||||
authorization_servers=[
|
||||
AuthorizationServerEntry(
|
||||
authorization_server_url="https://auth.example.com",
|
||||
issuer="https://auth.example.com",
|
||||
jwks_uri="https://auth.example.com/jwks",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Option 3: Custom audience (when auth server returns different aud claim)
|
||||
resource_server_auth = ResourceServerAuth(
|
||||
canonical_url="https://mcp.example.com/mcp",
|
||||
authorization_servers=[
|
||||
AuthorizationServerEntry(
|
||||
authorization_server_url="https://workos.authkit.app",
|
||||
issuer="https://workos.authkit.app",
|
||||
jwks_uri="https://workos.authkit.app/oauth2/jwks",
|
||||
expected_audiences=["my-authkit-client-id"], # Override expected aud
|
||||
),
|
||||
AuthorizationServerEntry( # Keycloak example configuration
|
||||
authorization_server_url="http://localhost:8080/realms/mcp-test",
|
||||
issuer="http://localhost:8080/realms/mcp-test",
|
||||
jwks_uri="http://localhost:8080/realms/mcp-test/protocol/openid-connect/certs",
|
||||
algorithm="RS256",
|
||||
expected_audiences=["my-keycloak-client-id"],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
"""
|
||||
settings = MCPSettings.from_env()
|
||||
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# Explicit parameters take precedence over environment variables
|
||||
if canonical_url is not None:
|
||||
self.canonical_url = canonical_url
|
||||
elif settings.resource_server.canonical_url is not None:
|
||||
self.canonical_url = settings.resource_server.canonical_url
|
||||
else:
|
||||
raise ValueError(
|
||||
"'canonical_url' required (parameter or MCP_RESOURCE_SERVER_CANONICAL_URL environment variable)"
|
||||
)
|
||||
|
||||
if authorization_servers is not None:
|
||||
configs = authorization_servers
|
||||
elif settings.resource_server.authorization_servers:
|
||||
configs = settings.resource_server.to_authorization_server_entries()
|
||||
else:
|
||||
raise ValueError(
|
||||
"'authorization_servers' required (parameter or MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS environment variable)"
|
||||
)
|
||||
|
||||
self._validators = self._create_validators(configs)
|
||||
|
||||
self._resource_metadata = self._build_resource_metadata()
|
||||
|
||||
def _build_resource_metadata(self) -> dict[str, Any]:
|
||||
"""Build RFC 9728 Protected Resource Metadata
|
||||
|
||||
Returns:
|
||||
Dictionary containing resource metadata per RFC 9728
|
||||
"""
|
||||
return {
|
||||
"resource": self.canonical_url,
|
||||
"authorization_servers": list(self._validators.keys()),
|
||||
"bearer_methods_supported": ["header"],
|
||||
}
|
||||
|
||||
def _create_validators(
|
||||
self, entries: list[AuthorizationServerEntry]
|
||||
) -> dict[str, JWKSTokenValidator]:
|
||||
"""Create a mapping of authorization server URLs to their JWKSTokenValidator instances.
|
||||
|
||||
Args:
|
||||
entries: List of authorization server entries
|
||||
|
||||
Returns:
|
||||
Dictionary that maps authorization_server_url to its JWKSTokenValidator instance
|
||||
"""
|
||||
validators = {}
|
||||
|
||||
for entry in entries:
|
||||
# Use expected_audiences if provided, otherwise default to canonical_url
|
||||
audience = (
|
||||
entry.expected_audiences if entry.expected_audiences else [self.canonical_url]
|
||||
)
|
||||
validators[entry.authorization_server_url] = JWKSTokenValidator(
|
||||
jwks_uri=entry.jwks_uri,
|
||||
issuer=entry.issuer,
|
||||
audience=audience,
|
||||
algorithm=entry.algorithm,
|
||||
cache_ttl=self.cache_ttl,
|
||||
validation_options=entry.validation_options,
|
||||
)
|
||||
|
||||
return validators
|
||||
|
||||
async def validate_token(self, token: str) -> ResourceOwner:
|
||||
"""Validate the given token against each configured authorization server.
|
||||
|
||||
Tries each validator until one succeeds. If all fail, raises InvalidTokenError.
|
||||
|
||||
Error handling strategy:
|
||||
- TokenExpiredError: Raise immediately. If any validator raises this, the token
|
||||
is expired for all authorization servers (expiration is universal). No point
|
||||
trying other validators.
|
||||
- InvalidTokenError/AuthenticationError: Continue to next validator because another
|
||||
authorization server might accept the token. These errors indicate wrong issuer,
|
||||
audience, or signature mismatch.
|
||||
|
||||
Args:
|
||||
token: JWT Bearer token
|
||||
|
||||
Returns:
|
||||
ResourceOwner with user_id, client_id, and claims
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: Token has expired
|
||||
InvalidTokenError: Token signature, algorithm, audience, or issuer is invalid
|
||||
AuthenticationError: Other validation errors
|
||||
"""
|
||||
for validator in self._validators.values():
|
||||
try:
|
||||
return await validator.validate_token(token)
|
||||
except TokenExpiredError:
|
||||
raise
|
||||
except (InvalidTokenError, AuthenticationError):
|
||||
continue
|
||||
|
||||
raise InvalidTokenError("Token validation failed for all configured authorization servers")
|
||||
|
||||
def supports_oauth_discovery(self) -> bool:
|
||||
"""This Resource Server supports OAuth discovery."""
|
||||
return True
|
||||
|
||||
def get_resource_metadata(self) -> dict[str, Any]:
|
||||
"""Return RFC 9728 Protected Resource Metadata.
|
||||
|
||||
This metadata tells MCP clients:
|
||||
1. What resource this server protects (canonical URL)
|
||||
2. Which authorization server(s) can issue tokens for this resource
|
||||
3. Supported bearer token methods
|
||||
|
||||
Returns:
|
||||
Dictionary containing resource metadata per RFC 9728
|
||||
"""
|
||||
return self._resource_metadata
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
"""
|
||||
JWKS-based token validator for MCP Resource Servers.
|
||||
|
||||
Implements OAuth 2.1 Resource Server token validation using JWT with JWKS.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from jose import jwk, jwt
|
||||
|
||||
from arcade_mcp_server.resource_server.base import (
|
||||
AccessTokenValidationOptions,
|
||||
AuthenticationError,
|
||||
InvalidTokenError,
|
||||
ResourceOwner,
|
||||
ResourceServerValidator,
|
||||
TokenExpiredError,
|
||||
)
|
||||
|
||||
# Note: Only asymmetric algorithms supported
|
||||
SUPPORTED_ALGORITHMS = {
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
}
|
||||
|
||||
|
||||
class JWKSTokenValidator(ResourceServerValidator):
|
||||
"""JWKS-based JWT token validator for simple, explicit token validation.
|
||||
|
||||
This validator fetches public keys from a JWKS endpoint and validates
|
||||
JWT access tokens against them. Use this when you need direct control
|
||||
over token validation without OAuth discovery support.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jwks_uri: str,
|
||||
issuer: str | list[str],
|
||||
audience: str | list[str],
|
||||
algorithm: str = "RS256",
|
||||
cache_ttl: int = 3600,
|
||||
validation_options: AccessTokenValidationOptions | None = None,
|
||||
):
|
||||
"""Initialize JWKS token validator.
|
||||
|
||||
Args:
|
||||
jwks_uri: URL to fetch JWKS
|
||||
issuer: Token issuer or list of allowed issuers
|
||||
audience: Token audience or list of allowed audiences (typically your MCP server's canonical URL)
|
||||
algorithm: Signature algorithm. Default RS256.
|
||||
cache_ttl: JWKS cache TTL in seconds
|
||||
validation_options: Access token validation options
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields not provided or algorithm unsupported
|
||||
|
||||
Example:
|
||||
```python
|
||||
validator = JWKSTokenValidator(
|
||||
jwks_uri="https://auth.example.com/jwks",
|
||||
issuer="https://auth.example.com",
|
||||
audience="https://mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
# Multiple issuers
|
||||
validator = JWKSTokenValidator(
|
||||
jwks_uri="https://auth.example.com/jwks",
|
||||
issuer=["https://auth1.example.com", "https://auth2.example.com"],
|
||||
audience="https://mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
# Multiple audiences (e.g., URL migration)
|
||||
validator = JWKSTokenValidator(
|
||||
jwks_uri="https://auth.example.com/jwks",
|
||||
issuer="https://auth.example.com",
|
||||
audience=["https://old-mcp.example.com/mcp", "https://new-mcp.example.com/mcp"],
|
||||
)
|
||||
|
||||
# Different algorithm
|
||||
validator = JWKSTokenValidator(
|
||||
jwks_uri="https://auth.example.com/jwks",
|
||||
issuer="https://auth.example.com",
|
||||
audience="https://mcp.example.com/mcp",
|
||||
algorithm="ES256",
|
||||
)
|
||||
```
|
||||
"""
|
||||
if algorithm not in SUPPORTED_ALGORITHMS:
|
||||
raise ValueError(
|
||||
f"Unsupported algorithm '{algorithm}'. "
|
||||
f"Supported asymmetric algorithms: {', '.join(sorted(SUPPORTED_ALGORITHMS))}"
|
||||
)
|
||||
|
||||
if validation_options is None:
|
||||
validation_options = AccessTokenValidationOptions()
|
||||
|
||||
self.jwks_uri = jwks_uri
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.algorithm = algorithm
|
||||
self.validation_options = validation_options
|
||||
|
||||
self._cache_ttl = cache_ttl
|
||||
self._http_client = httpx.AsyncClient(timeout=10.0)
|
||||
self._jwks_cache: dict[str, Any] | None = None
|
||||
self._cache_timestamp: float = 0
|
||||
|
||||
async def _fetch_jwks(self) -> dict[str, Any]:
|
||||
"""Fetch JWKS with caching.
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If JWKS cannot be fetched
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Use cached JWKS if it's still valid
|
||||
if self._jwks_cache and (current_time - self._cache_timestamp) < self._cache_ttl:
|
||||
return self._jwks_cache
|
||||
|
||||
try:
|
||||
response = await self._http_client.get(self.jwks_uri)
|
||||
response.raise_for_status()
|
||||
self._jwks_cache = response.json()
|
||||
self._cache_timestamp = current_time
|
||||
except httpx.HTTPError as e:
|
||||
raise AuthenticationError(f"Failed to fetch JWKS: {e}") from e
|
||||
else:
|
||||
return self._jwks_cache
|
||||
|
||||
def _find_signing_key(self, jwks: dict[str, Any], token: str) -> Any:
|
||||
"""Find the signing key from JWKS that matches the token's kid.
|
||||
|
||||
Args:
|
||||
jwks: JSON Web Key Set
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
Signing key in PEM format
|
||||
|
||||
Raises:
|
||||
InvalidTokenError: If no matching key found or algorithm mismatch
|
||||
"""
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
token_alg = unverified_header.get("alg")
|
||||
|
||||
# Validate token algorithm matches configuration (prevent algorithm confusion)
|
||||
if token_alg and token_alg != self.algorithm:
|
||||
raise InvalidTokenError(
|
||||
f"Token algorithm '{token_alg}' doesn't match "
|
||||
f"configured algorithm '{self.algorithm}'"
|
||||
)
|
||||
|
||||
for key_data in jwks.get("keys", []):
|
||||
if key_data.get("kid") == kid:
|
||||
key_alg = key_data.get("alg")
|
||||
|
||||
if key_alg and key_alg != self.algorithm:
|
||||
raise InvalidTokenError(
|
||||
f"Key algorithm '{key_alg}' doesn't match "
|
||||
f"configured algorithm '{self.algorithm}'"
|
||||
)
|
||||
|
||||
key_obj = jwk.construct(key_data, algorithm=self.algorithm)
|
||||
return key_obj.to_pem().decode("utf-8")
|
||||
|
||||
raise InvalidTokenError("No matching key found in JWKS")
|
||||
|
||||
def _decode_token(self, token: str, signing_key: str) -> dict[str, Any]:
|
||||
"""Decode and verify the provided JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token
|
||||
signing_key: Public key in PEM format
|
||||
|
||||
Returns:
|
||||
Decoded token claims
|
||||
|
||||
Raises:
|
||||
jwt.ExpiredSignatureError: Token has expired
|
||||
jwt.JWTClaimsError: Token claims validation failed (audience/issuer mismatch)
|
||||
jwt.JWTError: Token is invalid
|
||||
"""
|
||||
decode_options = {
|
||||
"verify_signature": True, # Always verify signature. Cannot be disabled.
|
||||
"verify_exp": self.validation_options.verify_exp,
|
||||
"verify_iat": self.validation_options.verify_iat,
|
||||
"verify_nbf": self.validation_options.verify_nbf,
|
||||
"verify_aud": False, # Manual validation for multi-audience support
|
||||
"verify_iss": False, # Manual validation for multi-issuer support
|
||||
"leeway": self.validation_options.leeway,
|
||||
}
|
||||
|
||||
# Decode token once without aud/iss validation
|
||||
decoded = cast(
|
||||
dict[str, Any],
|
||||
jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=[self.algorithm],
|
||||
options=decode_options,
|
||||
),
|
||||
)
|
||||
|
||||
# Manually validate issuer (if flag is enabled)
|
||||
if self.validation_options.verify_iss:
|
||||
token_iss = decoded.get("iss")
|
||||
if isinstance(self.issuer, list):
|
||||
if token_iss not in self.issuer:
|
||||
raise InvalidTokenError(
|
||||
f"Token issuer '{token_iss}' not in allowed issuers: {self.issuer}"
|
||||
)
|
||||
else:
|
||||
if token_iss != self.issuer:
|
||||
raise InvalidTokenError(
|
||||
f"Token issuer '{token_iss}' doesn't match expected '{self.issuer}'"
|
||||
)
|
||||
|
||||
# Always validate audience
|
||||
token_aud = decoded.get("aud")
|
||||
token_audiences = [token_aud] if isinstance(token_aud, str) else (token_aud or [])
|
||||
expected_audiences = [self.audience] if isinstance(self.audience, str) else self.audience
|
||||
|
||||
# Token is valid if any of its aud values match any of our expected values
|
||||
if not (set(token_audiences) & set(expected_audiences)):
|
||||
raise InvalidTokenError(
|
||||
f"Token audience {token_aud} doesn't match expected {self.audience}"
|
||||
)
|
||||
|
||||
return decoded
|
||||
|
||||
def _extract_user_id(self, decoded: dict[str, Any]) -> str:
|
||||
"""Extract and validate user_id from decoded token.
|
||||
|
||||
Args:
|
||||
decoded: Decoded token claims
|
||||
|
||||
Returns:
|
||||
User ID from 'sub' claim
|
||||
|
||||
Raises:
|
||||
InvalidTokenError: If 'sub' claim is missing
|
||||
"""
|
||||
user_id = decoded.get("sub")
|
||||
if not user_id:
|
||||
raise InvalidTokenError("Token missing 'sub' claim")
|
||||
return cast(str, user_id)
|
||||
|
||||
def _extract_client_id(self, decoded: dict[str, Any]) -> str | None:
|
||||
"""Extract client ID from decoded token.
|
||||
|
||||
Args:
|
||||
decoded: Decoded token claims
|
||||
|
||||
Returns:
|
||||
Client identifier or "unknown" if no client claim found
|
||||
"""
|
||||
client_id = decoded.get("client_id") or decoded.get("azp") or "unknown"
|
||||
|
||||
return client_id
|
||||
|
||||
async def validate_token(self, token: str) -> ResourceOwner:
|
||||
"""Validate JWT and return authenticated resource owner.
|
||||
|
||||
Always validates (cannot be disabled):
|
||||
- Token signature using JWKS public key
|
||||
- Subject (sub claim) exists
|
||||
- Audience (aud claim) matches configured audience(s)
|
||||
|
||||
Optionally validates (controlled by validation_options, all default to True):
|
||||
- Expiration (exp claim) - verify_exp
|
||||
- Issued-at time (iat claim) - verify_iat
|
||||
- Not-before time (nbf claim) - verify_nbf
|
||||
- Issuer (iss claim) matches configured issuer(s) - verify_iss
|
||||
|
||||
Clock skew tolerance can be configured via validation_options.leeway (in seconds).
|
||||
|
||||
Args:
|
||||
token: JWT Bearer token
|
||||
|
||||
Returns:
|
||||
ResourceOwner with user_id, client_id, and claims
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: Token has expired
|
||||
InvalidTokenError: Token signature, algorithm, audience, or issuer is invalid
|
||||
AuthenticationError: Other validation errors
|
||||
"""
|
||||
try:
|
||||
jwks = await self._fetch_jwks()
|
||||
signing_key = self._find_signing_key(jwks, token)
|
||||
decoded = self._decode_token(token, signing_key)
|
||||
user_id = self._extract_user_id(decoded)
|
||||
client_id = self._extract_client_id(decoded)
|
||||
email = decoded.get("email")
|
||||
|
||||
return ResourceOwner(
|
||||
user_id=user_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
claims=decoded,
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise TokenExpiredError("Token has expired") from e
|
||||
except jwt.JWTClaimsError as e:
|
||||
raise InvalidTokenError(f"Token claims validation failed: {e}") from e
|
||||
except jwt.JWTError as e:
|
||||
raise InvalidTokenError(f"Invalid token: {e}") from e
|
||||
except (InvalidTokenError, TokenExpiredError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise AuthenticationError(f"Token validation failed: {e}") from e
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
await self._http_client.aclose()
|
||||
|
|
@ -38,6 +38,7 @@ from arcade_mcp_server.middleware import (
|
|||
Middleware,
|
||||
MiddlewareContext,
|
||||
)
|
||||
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||
from arcade_mcp_server.session import InitializationState, NotificationManager, ServerSession
|
||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||
from arcade_mcp_server.types import (
|
||||
|
|
@ -402,6 +403,7 @@ class MCPServer:
|
|||
self,
|
||||
message: Any,
|
||||
session: ServerSession | None = None,
|
||||
resource_owner: ResourceOwner | None = None,
|
||||
) -> MCPMessage | None:
|
||||
"""
|
||||
Handle an incoming message.
|
||||
|
|
@ -409,6 +411,7 @@ class MCPServer:
|
|||
Args:
|
||||
message: Message to handle
|
||||
session: Server session
|
||||
resource_owner: Authenticated resource owner from front-door auth
|
||||
|
||||
Returns:
|
||||
Response message or None
|
||||
|
|
@ -502,9 +505,13 @@ class MCPServer:
|
|||
|
||||
# Create request context
|
||||
context = (
|
||||
await session.create_request_context()
|
||||
await session.create_request_context(resource_owner=resource_owner)
|
||||
if session
|
||||
else Context(self, request_id=str(msg_id) if msg_id else None)
|
||||
else Context(
|
||||
self,
|
||||
request_id=str(msg_id) if msg_id else None,
|
||||
resource_owner=resource_owner,
|
||||
)
|
||||
)
|
||||
|
||||
# Set as current model context
|
||||
|
|
@ -678,7 +685,7 @@ class MCPServer:
|
|||
},
|
||||
)
|
||||
|
||||
async def _create_tool_context(
|
||||
def _create_tool_context(
|
||||
self, tool: MaterializedTool, session: ServerSession | None = None
|
||||
) -> ToolContext:
|
||||
"""Create a tool context from a tool definition and session"""
|
||||
|
|
@ -692,27 +699,53 @@ class MCPServer:
|
|||
elif secret.key in os.environ:
|
||||
tool_context.set_secret(secret.key, os.environ[secret.key])
|
||||
|
||||
# user_id selection
|
||||
env = (self.settings.arcade.environment or "").lower()
|
||||
user_id = self.settings.arcade.user_id
|
||||
|
||||
# If no user_id from env, try credentials file
|
||||
if not user_id:
|
||||
_, config_user_id = self._load_config_values()
|
||||
user_id = config_user_id
|
||||
|
||||
if user_id:
|
||||
tool_context.user_id = user_id
|
||||
logger.debug(f"Context user_id set: {user_id}")
|
||||
elif env in ("development", "dev", "local"):
|
||||
tool_context.user_id = session.session_id if session else None
|
||||
logger.debug(f"Context user_id set from session (dev env={env})")
|
||||
else:
|
||||
tool_context.user_id = session.session_id if session else None
|
||||
logger.debug("Context user_id set from session (non-dev env)")
|
||||
tool_context.user_id = self._select_user_id(session)
|
||||
|
||||
return tool_context
|
||||
|
||||
def _select_user_id(self, session: ServerSession | None = None) -> str | None:
|
||||
"""Select the user_id for the tool's context.
|
||||
|
||||
User ID selection priority:
|
||||
- Authenticated user from front-door auth
|
||||
- Configured user_id from settings
|
||||
- Configured user_id from credentials file
|
||||
- Use session ID if no other user_id is available
|
||||
|
||||
Args:
|
||||
session: Server session
|
||||
|
||||
Returns:
|
||||
User ID for the context
|
||||
"""
|
||||
env = (self.settings.arcade.environment or "").lower()
|
||||
|
||||
# First priority: resource owner from front-door auth (from current model context)
|
||||
mctx = get_current_model_context()
|
||||
if mctx is not None and hasattr(mctx, "_resource_owner") and mctx._resource_owner:
|
||||
user_id = mctx._resource_owner.user_id
|
||||
logger.debug(f"Context user_id set from Authorization Server 'sub' claim: {user_id}")
|
||||
return cast(str, user_id)
|
||||
|
||||
# Second priority: configured user_id from settings
|
||||
if (settings_user_id := self.settings.arcade.user_id) is not None:
|
||||
logger.debug(f"Context user_id set from settings: {settings_user_id}")
|
||||
return settings_user_id
|
||||
|
||||
# Third priority: configured user_id from credentials file
|
||||
_, config_user_id = self._load_config_values()
|
||||
if config_user_id:
|
||||
logger.debug(f"Context user_id set from credentials file: {config_user_id}")
|
||||
return config_user_id
|
||||
|
||||
# Fourth priority: use session ID if no other user_id is available
|
||||
if env in ("development", "dev", "local"):
|
||||
logger.debug(f"Context user_id set from session (dev env={env})")
|
||||
else:
|
||||
logger.debug("Context user_id set from session (non-dev env)")
|
||||
|
||||
return session.session_id if session else None
|
||||
|
||||
async def _check_and_warn_missing_secrets(self) -> None:
|
||||
"""
|
||||
Check for missing tool secrets and log warnings.
|
||||
|
|
@ -761,7 +794,7 @@ class MCPServer:
|
|||
tool = await self._tool_manager.get_tool(tool_name)
|
||||
|
||||
# Create tool context
|
||||
tool_context = await self._create_tool_context(tool, session)
|
||||
tool_context = self._create_tool_context(tool, session)
|
||||
|
||||
# Check restrictions for unauthenticated HTTP transport
|
||||
if transport_restriction_response := self._check_transport_restrictions(
|
||||
|
|
@ -906,13 +939,31 @@ class MCPServer:
|
|||
tool_name: str,
|
||||
session: ServerSession | None = None,
|
||||
) -> JSONRPCResponse[CallToolResult] | None:
|
||||
"""Check transport restrictions for tools requiring auth or secrets"""
|
||||
"""Check transport restrictions for tools requiring auth or secrets.
|
||||
|
||||
Tools requiring authorization or secrets are blocked on unauthenticated HTTP
|
||||
transport for security reasons. However, if the HTTP transport has front-door
|
||||
authentication enabled (resource_owner is present), these tools are allowed
|
||||
since we can safely identify the end-user and handle their authorization.
|
||||
"""
|
||||
# Check transport restrictions for tools requiring auth or secrets
|
||||
if session and session.init_options:
|
||||
transport_type = session.init_options.get("transport_type")
|
||||
if transport_type != "stdio":
|
||||
# Get resource_owner from current model context (set during handle_message)
|
||||
mctx = get_current_model_context()
|
||||
is_authenticated = (
|
||||
mctx is not None
|
||||
and hasattr(mctx, "_resource_owner")
|
||||
and mctx._resource_owner is not None
|
||||
)
|
||||
|
||||
requirements = tool.definition.requirements
|
||||
if requirements and (requirements.authorization or requirements.secrets):
|
||||
if (
|
||||
requirements
|
||||
and (requirements.authorization or requirements.secrets)
|
||||
and not is_authenticated
|
||||
):
|
||||
documentation_url = "https://docs.arcade.dev/en/home/compare-server-types"
|
||||
user_message = "✗ Unsupported transport\n\n"
|
||||
user_message += f" Tool '{tool_name}' cannot run over HTTP transport for security reasons.\n"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import anyio
|
|||
|
||||
from arcade_mcp_server.context import Context
|
||||
from arcade_mcp_server.exceptions import RequestError, SessionError
|
||||
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||
from arcade_mcp_server.types import (
|
||||
CancelledNotification,
|
||||
CancelledParams,
|
||||
|
|
@ -37,6 +38,7 @@ from arcade_mcp_server.types import (
|
|||
ProgressNotificationParams,
|
||||
PromptListChangedNotification,
|
||||
ResourceListChangedNotification,
|
||||
SessionMessage,
|
||||
ToolListChangedNotification,
|
||||
)
|
||||
|
||||
|
|
@ -361,11 +363,24 @@ class ServerSession:
|
|||
# Cancel any pending requests
|
||||
await self._cleanup_pending_requests()
|
||||
|
||||
async def _process_message(self, message: str) -> None:
|
||||
"""Process a single message."""
|
||||
async def _process_message(self, message: str | Any) -> None:
|
||||
"""Process a single message.
|
||||
|
||||
Args:
|
||||
message: Either a JSON string (stdio) or SessionMessage object (http)
|
||||
"""
|
||||
try:
|
||||
# Parse message
|
||||
data = json.loads(message)
|
||||
if isinstance(message, str):
|
||||
data = json.loads(message)
|
||||
resource_owner = None
|
||||
elif isinstance(message, SessionMessage):
|
||||
# We must keep exclude_none=True to avoid Pydantic union type coersion
|
||||
# when reconstructing models from dict (e.g., RequestId = str | int)
|
||||
data = message.message.model_dump(exclude_none=True)
|
||||
resource_owner = message.resource_owner
|
||||
else:
|
||||
logger.error(f"Unexpected message type: {type(message)}")
|
||||
return
|
||||
|
||||
# Check if it's a response to our request
|
||||
if "id" in data and "method" not in data:
|
||||
|
|
@ -377,7 +392,7 @@ class ServerSession:
|
|||
return
|
||||
|
||||
# Otherwise, process as incoming request
|
||||
response = await self.server.handle_message(data, self)
|
||||
response = await self.server.handle_message(data, self, resource_owner=resource_owner)
|
||||
|
||||
# Send response if any
|
||||
if response and self.write_stream:
|
||||
|
|
@ -646,9 +661,16 @@ class ServerSession:
|
|||
self._request_meta = None
|
||||
|
||||
# Context management
|
||||
async def create_request_context(self) -> Context:
|
||||
"""Create a context for the current request."""
|
||||
context = Context(self.server)
|
||||
async def create_request_context(self, resource_owner: ResourceOwner | None = None) -> Context:
|
||||
"""Create a context for the current request.
|
||||
|
||||
Args:
|
||||
resource_owner: The authenticated resource owner from front-door auth.
|
||||
"""
|
||||
context = Context(
|
||||
server=self.server,
|
||||
resource_owner=resource_owner,
|
||||
)
|
||||
context.set_session(self)
|
||||
self._current_context = context
|
||||
return context
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Provides Pydantic-based settings with validation and environment variable suppor
|
|||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
|
@ -93,6 +94,71 @@ class ServerSettings(BaseSettings):
|
|||
model_config = {"env_prefix": "MCP_SERVER_"}
|
||||
|
||||
|
||||
class ResourceServerSettings(BaseSettings):
|
||||
"""Settings for ResourceServer configuration via environment variables."""
|
||||
|
||||
canonical_url: str | None = Field(
|
||||
default=None,
|
||||
description="Canonical URL of this MCP server (e.g., https://mcp.example.com/mcp)",
|
||||
)
|
||||
authorization_servers: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="JSON array of authorization server entries."
|
||||
'Example: \'[{"authorization_server_url":"https://auth.example.com","issuer":"https://auth.example.com","jwks_uri":"https://auth.example.com/oauth2/jwks","algorithm":"RS256"}]\'',
|
||||
)
|
||||
|
||||
@field_validator("authorization_servers", mode="before")
|
||||
@classmethod
|
||||
def parse_authorization_servers(cls, v: Any) -> list[dict[str, Any]] | None:
|
||||
"""Parse JSON array from environment variable."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
if not isinstance(parsed, list):
|
||||
raise TypeError("authorization_servers must be a JSON array")
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in authorization_servers: {e}") from e
|
||||
else:
|
||||
return parsed
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return None
|
||||
|
||||
def to_authorization_server_entries(self) -> list[Any]:
|
||||
"""Convert settings to list of AuthorizationServerEntry objects."""
|
||||
if not self.authorization_servers:
|
||||
return []
|
||||
|
||||
from arcade_mcp_server.resource_server import (
|
||||
AccessTokenValidationOptions,
|
||||
AuthorizationServerEntry,
|
||||
)
|
||||
|
||||
return [
|
||||
AuthorizationServerEntry(
|
||||
authorization_server_url=config["authorization_server_url"],
|
||||
issuer=config["issuer"],
|
||||
jwks_uri=config["jwks_uri"],
|
||||
algorithm=config.get("algorithm", "RS256"),
|
||||
expected_audiences=config.get("expected_audiences"),
|
||||
validation_options=AccessTokenValidationOptions(
|
||||
verify_exp=config.get("validation_options", {}).get("verify_exp", True),
|
||||
verify_iat=config.get("validation_options", {}).get("verify_iat", True),
|
||||
verify_iss=config.get("validation_options", {}).get("verify_iss", True),
|
||||
verify_nbf=config.get("validation_options", {}).get("verify_nbf", True),
|
||||
leeway=config.get("validation_options", {}).get("leeway", 0),
|
||||
),
|
||||
)
|
||||
for config in self.authorization_servers
|
||||
]
|
||||
|
||||
model_config = {"env_prefix": "MCP_RESOURCE_SERVER_"}
|
||||
|
||||
|
||||
class MiddlewareSettings(BaseSettings):
|
||||
"""Middleware-related settings."""
|
||||
|
||||
|
|
@ -207,6 +273,10 @@ class MCPSettings(BaseSettings):
|
|||
default_factory=ServerSettings,
|
||||
description="Server settings",
|
||||
)
|
||||
resource_server: ResourceServerSettings = Field(
|
||||
default_factory=ResourceServerSettings,
|
||||
description="Server authentication settings",
|
||||
)
|
||||
middleware: MiddlewareSettings = Field(
|
||||
default_factory=MiddlewareSettings,
|
||||
description="Middleware settings",
|
||||
|
|
@ -236,7 +306,20 @@ class MCPSettings(BaseSettings):
|
|||
|
||||
@classmethod
|
||||
def from_env(cls) -> "MCPSettings":
|
||||
"""Create settings from environment variables."""
|
||||
"""Create settings from environment variables.
|
||||
|
||||
Automatically loads .env file from current directory if it exists,
|
||||
then creates settings from the combined environment.
|
||||
|
||||
The .env file is loaded with override=False, meaning existing
|
||||
environment variables take precedence. Multiple calls are safe
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_path = Path.cwd() / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=False)
|
||||
|
||||
return cls()
|
||||
|
||||
def tool_secrets(self) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -168,8 +168,8 @@ class HTTPStreamableTransport:
|
|||
self._terminated = False
|
||||
|
||||
# Streams for connection
|
||||
self._read_stream_writer: MemoryObjectSendStream[str | Exception] | None = None
|
||||
self._read_stream: MemoryObjectReceiveStream[str | Exception] | None = None
|
||||
self._read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] | None = None
|
||||
self._read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None
|
||||
self._write_stream: MemoryObjectSendStream[str | SessionMessage] | None = None
|
||||
self._write_stream_reader: MemoryObjectReceiveStream[str | SessionMessage] | None = None
|
||||
|
||||
|
|
@ -218,7 +218,13 @@ class HTTPStreamableTransport:
|
|||
headers: dict[str, str] | None = None,
|
||||
) -> Response:
|
||||
"""Create an error response."""
|
||||
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
|
||||
response_headers = {
|
||||
"Content-Type": CONTENT_TYPE_JSON,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, Mcp-Session-Id",
|
||||
"Access-Control-Expose-Headers": "Mcp-Session-Id",
|
||||
}
|
||||
if headers:
|
||||
response_headers.update(headers)
|
||||
|
||||
|
|
@ -406,13 +412,20 @@ class HTTPStreamableTransport:
|
|||
elif not await self._validate_request_headers(request, send):
|
||||
return
|
||||
|
||||
# Extract resource owner from scope (set by ASGI Resource Server middleware)
|
||||
resource_owner = request.scope.get("resource_owner")
|
||||
|
||||
# For notifications and responses, return 202 Accepted
|
||||
if not isinstance(message, JSONRPCRequest):
|
||||
response = self._create_json_response(None, HTTPStatus.ACCEPTED)
|
||||
await response(scope, receive, send)
|
||||
|
||||
# Process the message
|
||||
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
|
||||
session_message = SessionMessage(
|
||||
message=message,
|
||||
resource_owner=resource_owner,
|
||||
)
|
||||
await writer.send(session_message)
|
||||
return
|
||||
|
||||
# Handle requests
|
||||
|
|
@ -421,8 +434,11 @@ class HTTPStreamableTransport:
|
|||
request_stream_reader = self._request_streams[request_id][1]
|
||||
|
||||
if self.is_json_response_enabled:
|
||||
# JSON response mode
|
||||
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
|
||||
session_message = SessionMessage(
|
||||
message=message,
|
||||
resource_owner=resource_owner,
|
||||
)
|
||||
await writer.send(session_message)
|
||||
|
||||
try:
|
||||
response_message = None
|
||||
|
|
@ -490,7 +506,12 @@ class HTTPStreamableTransport:
|
|||
try:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(response, scope, receive, send)
|
||||
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
|
||||
# Send SessionMessage object
|
||||
session_message = SessionMessage(
|
||||
message=message,
|
||||
resource_owner=resource_owner,
|
||||
)
|
||||
await writer.send(session_message)
|
||||
except Exception:
|
||||
logger.exception("SSE response error")
|
||||
await sse_stream_writer.aclose()
|
||||
|
|
@ -742,7 +763,7 @@ class HTTPStreamableTransport:
|
|||
self,
|
||||
) -> AsyncIterator[
|
||||
tuple[
|
||||
MemoryObjectReceiveStream[str | Exception],
|
||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
MemoryObjectSendStream[str | SessionMessage],
|
||||
]
|
||||
]:
|
||||
|
|
@ -754,7 +775,9 @@ class HTTPStreamableTransport:
|
|||
stream identified by `GET_STREAM_KEY`).
|
||||
"""
|
||||
# Create memory streams with buffer
|
||||
read_stream_writer, read_stream = anyio.create_memory_object_stream[str | Exception](100)
|
||||
read_stream_writer, read_stream = anyio.create_memory_object_stream[
|
||||
SessionMessage | Exception
|
||||
](100)
|
||||
write_stream, write_stream_reader = anyio.create_memory_object_stream[str | SessionMessage](
|
||||
100
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from typing import Any, Generic, Literal, TypeAlias, TypeVar
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# JSON-RPC constants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
@ -91,9 +93,14 @@ class JSONRPCError(JSONRPCMessage):
|
|||
|
||||
@dataclass
|
||||
class SessionMessage:
|
||||
"""Wrapper for messages in transport sessions."""
|
||||
"""Wrapper for messages in transport sessions.
|
||||
|
||||
Carries both the MCP protocol message and optional authenticated user
|
||||
information from front-door authentication.
|
||||
"""
|
||||
|
||||
message: JSONRPCMessage
|
||||
resource_owner: ResourceOwner | None = None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
@ -660,7 +667,13 @@ MCPMessage = (
|
|||
JSONRPCRequest
|
||||
| JSONRPCResponse[Any]
|
||||
| JSONRPCError
|
||||
| InitializedNotification
|
||||
| CancelledNotification
|
||||
| ProgressNotification
|
||||
| LoggingMessageNotification
|
||||
| ResourceListChangedNotification
|
||||
| ResourceUpdatedNotification
|
||||
| PromptListChangedNotification
|
||||
| ToolListChangedNotification
|
||||
| RootsListChangedNotification
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ PROP_TOOL_COUNT = "tool_count"
|
|||
PROP_MCP_SERVER_VERSION = "arcade_mcp_server_version"
|
||||
PROP_IS_EXECUTION_SUCCESS = "is_execution_success"
|
||||
PROP_FAILURE_REASON = "failure_reason"
|
||||
PROP_RESOURCE_SERVER_TYPE = "resource_server_type"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import platform
|
|||
import sys
|
||||
import time
|
||||
from importlib import metadata
|
||||
from typing import Any
|
||||
|
||||
from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled
|
||||
from arcade_core.usage.constants import (
|
||||
|
|
@ -20,6 +21,7 @@ from arcade_mcp_server.usage.constants import (
|
|||
PROP_IS_EXECUTION_SUCCESS,
|
||||
PROP_MCP_SERVER_VERSION,
|
||||
PROP_PORT,
|
||||
PROP_RESOURCE_SERVER_TYPE,
|
||||
PROP_TOOL_COUNT,
|
||||
PROP_TRANSPORT,
|
||||
)
|
||||
|
|
@ -62,12 +64,27 @@ class ServerTracker:
|
|||
"""Get the distinct_id based on developer's authentication state"""
|
||||
return self.identity.get_distinct_id()
|
||||
|
||||
def _get_resource_server_type(self, resource_server_validator: Any) -> str:
|
||||
"""Get the class name of the resource server validator.
|
||||
|
||||
Args:
|
||||
resource_server_validator: The resource server validator instance or None
|
||||
|
||||
Returns:
|
||||
The class name of the validator, or "none" if no validator
|
||||
"""
|
||||
if resource_server_validator is None:
|
||||
return "none"
|
||||
|
||||
return str(resource_server_validator.__class__.__name__)
|
||||
|
||||
def track_server_start(
|
||||
self,
|
||||
transport: str,
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
tool_count: int,
|
||||
resource_server_validator: Any = None,
|
||||
) -> None:
|
||||
"""Track MCP server start event.
|
||||
|
||||
|
|
@ -76,6 +93,7 @@ class ServerTracker:
|
|||
host: The host address (None for stdio)
|
||||
port: The port number (None for stdio)
|
||||
tool_count: The number of tools available at server start
|
||||
resource_server_validator: The resource server validator instance (None if no auth)
|
||||
"""
|
||||
if not is_tracking_enabled():
|
||||
return
|
||||
|
|
@ -92,6 +110,7 @@ class ServerTracker:
|
|||
properties: dict[str, str | int | float] = {
|
||||
PROP_TRANSPORT: transport,
|
||||
PROP_TOOL_COUNT: tool_count,
|
||||
PROP_RESOURCE_SERVER_TYPE: self._get_resource_server_type(resource_server_validator),
|
||||
PROP_MCP_SERVER_VERSION: self.mcp_server_version,
|
||||
PROP_RUNTIME_LANGUAGE: "python",
|
||||
PROP_RUNTIME_VERSION: self.runtime_version,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ from starlette.requests import Request
|
|||
from starlette.responses import Response
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from arcade_mcp_server.fastapi.auth_routes import create_auth_router
|
||||
from arcade_mcp_server.fastapi.middleware import AddTrailingSlashToPathMiddleware
|
||||
from arcade_mcp_server.resource_server.base import ResourceServerValidator
|
||||
from arcade_mcp_server.resource_server.middleware import ResourceServerMiddleware
|
||||
from arcade_mcp_server.server import MCPServer
|
||||
from arcade_mcp_server.settings import MCPSettings
|
||||
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
|
||||
|
|
@ -120,6 +123,7 @@ def create_arcade_mcp(
|
|||
mcp_settings: MCPSettings | None = None,
|
||||
debug: bool = False,
|
||||
otel_enable: bool = False,
|
||||
resource_server_validator: ResourceServerValidator | None = None,
|
||||
**kwargs: Any,
|
||||
) -> FastAPI:
|
||||
"""
|
||||
|
|
@ -127,6 +131,14 @@ def create_arcade_mcp(
|
|||
and Arcade Worker endpoints if a secret is provided.
|
||||
|
||||
MCP is always enabled in this integrated application.
|
||||
|
||||
Args:
|
||||
catalog: Tool catalog for available tools
|
||||
mcp_settings: MCP configuration settings
|
||||
debug: Enable debug mode
|
||||
otel_enable: Enable OpenTelemetry
|
||||
resource_server_validator: Resource Server validator for front-door authentication
|
||||
**kwargs: Additional configuration options
|
||||
"""
|
||||
if mcp_settings is None:
|
||||
mcp_settings = MCPSettings.from_env()
|
||||
|
|
@ -178,6 +190,18 @@ def create_arcade_mcp(
|
|||
|
||||
app.add_middleware(AddTrailingSlashToPathMiddleware)
|
||||
|
||||
# Add OAuth discovery endpoint if auth is enabled
|
||||
if resource_server_validator and resource_server_validator.supports_oauth_discovery():
|
||||
canonical_url = getattr(resource_server_validator, "canonical_url", None)
|
||||
if not canonical_url:
|
||||
raise ValueError(
|
||||
"canonical_url must be set via parameter or "
|
||||
"MCP_RESOURCE_SERVER_CANONICAL_URL environment variable"
|
||||
)
|
||||
|
||||
auth_router = create_auth_router(resource_server_validator, canonical_url)
|
||||
app.include_router(auth_router)
|
||||
|
||||
# Worker endpoints
|
||||
if secret is not None:
|
||||
worker = FastAPIWorker(
|
||||
|
|
@ -201,8 +225,23 @@ def create_arcade_mcp(
|
|||
return
|
||||
await session_manager.handle_request(scope, receive, send)
|
||||
|
||||
# Mount the actual ASGI proxy to handle all /mcp requests
|
||||
app.mount("/mcp", _MCPASGIProxy(app), name="mcp-proxy")
|
||||
# Create MCP proxy and wrap with auth middleware if enabled
|
||||
mcp_proxy: Any = _MCPASGIProxy(app)
|
||||
if resource_server_validator:
|
||||
# Get canonical_url from validator if it supports OAuth discovery
|
||||
canonical_url = None
|
||||
if resource_server_validator.supports_oauth_discovery():
|
||||
canonical_url = getattr(resource_server_validator, "canonical_url", None)
|
||||
if not canonical_url:
|
||||
raise ValueError(
|
||||
"canonical_url must be set via parameter or "
|
||||
"MCP_RESOURCE_SERVER_CANONICAL_URL environment variable"
|
||||
)
|
||||
|
||||
mcp_proxy = ResourceServerMiddleware(mcp_proxy, resource_server_validator, canonical_url)
|
||||
|
||||
# Mount the ASGI proxy to handle all /mcp requests
|
||||
app.mount("/mcp", mcp_proxy, name="mcp-proxy")
|
||||
|
||||
# Customize OpenAPI to include MCP documentation
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "arcade-mcp-server"
|
||||
version = "1.11.2"
|
||||
version = "1.12.0"
|
||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Arcade.dev" }]
|
||||
|
|
@ -34,6 +34,8 @@ dependencies = [
|
|||
"anyio>=4.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic-settings>=2.10.1",
|
||||
"python-jose[cryptography]>=3.3.0,<4.0.0",
|
||||
"httpx>=0.27.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ class TestMCPApp:
|
|||
catalog=mcp_app._catalog,
|
||||
mcp_settings=mcp_app._mcp_settings,
|
||||
debug=False,
|
||||
resource_server_validator=mcp_app.resource_server_validator,
|
||||
)
|
||||
mock_serve.assert_called_once_with(
|
||||
app=mock_fastapi_app,
|
||||
|
|
@ -365,6 +366,7 @@ class TestMCPApp:
|
|||
catalog=mcp_app._catalog,
|
||||
mcp_settings=mcp_app._mcp_settings,
|
||||
debug=True,
|
||||
resource_server_validator=mcp_app.resource_server_validator,
|
||||
)
|
||||
mock_serve.assert_called_once_with(
|
||||
app=mock_fastapi_app,
|
||||
|
|
|
|||
1246
libs/tests/arcade_mcp_server/test_resource_server_auth.py
Normal file
1246
libs/tests/arcade_mcp_server/test_resource_server_auth.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -962,7 +962,6 @@ class TestMCPServer:
|
|||
"arguments": {"text": "test"},
|
||||
},
|
||||
)
|
||||
|
||||
response = await mcp_server._handle_call_tool(message, session=session)
|
||||
|
||||
assert isinstance(response, JSONRPCResponse)
|
||||
|
|
|
|||
Loading…
Reference in a new issue