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
|
# Integrated Factory and Runner
|
||||||
"create_arcade_mcp",
|
"create_arcade_mcp",
|
||||||
"run_arcade_mcp",
|
"run_arcade_mcp",
|
||||||
# Re-exported TDK functionality
|
# Re-exported from TDK functionality
|
||||||
"tool",
|
"tool",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from arcade_core.schema import (
|
||||||
ToolContext,
|
ToolContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||||
from arcade_mcp_server.types import (
|
from arcade_mcp_server.types import (
|
||||||
AudioContent,
|
AudioContent,
|
||||||
CallToolParams,
|
CallToolParams,
|
||||||
|
|
@ -124,6 +125,7 @@ class Context(ToolContext):
|
||||||
server: Any,
|
server: Any,
|
||||||
session: Any | None = None,
|
session: Any | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
|
resource_owner: ResourceOwner | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize context with server reference."""
|
"""Initialize context with server reference."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -133,6 +135,9 @@ class Context(ToolContext):
|
||||||
self._notification_queue: set[str] = set()
|
self._notification_queue: set[str] = set()
|
||||||
self._request_id: str | None = request_id
|
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
|
# Namespaced adapters
|
||||||
self._log = Logs(self)
|
self._log = Logs(self)
|
||||||
self._progress = Progress(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.auth import ToolAuthorization
|
||||||
from arcade_tdk.error_adapters import ErrorAdapter
|
from arcade_tdk.error_adapters import ErrorAdapter
|
||||||
from arcade_tdk.tool import tool as tool_decorator
|
from arcade_tdk.tool import tool as tool_decorator
|
||||||
from dotenv import load_dotenv
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from watchfiles import watch
|
from watchfiles import watch
|
||||||
|
|
||||||
from arcade_mcp_server.exceptions import ServerError
|
from arcade_mcp_server.exceptions import ServerError
|
||||||
from arcade_mcp_server.logging_utils import intercept_standard_logging
|
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.server import MCPServer
|
||||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||||
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
||||||
|
|
@ -75,6 +75,7 @@ class MCPApp:
|
||||||
host: str = "127.0.0.1",
|
host: str = "127.0.0.1",
|
||||||
port: int = 8000,
|
port: int = 8000,
|
||||||
reload: bool = False,
|
reload: bool = False,
|
||||||
|
auth: ResourceServerValidator | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -90,6 +91,7 @@ class MCPApp:
|
||||||
host: Host for transport
|
host: Host for transport
|
||||||
port: Port for transport
|
port: Port for transport
|
||||||
reload: Enable auto-reload for development
|
reload: Enable auto-reload for development
|
||||||
|
auth: Resource Server validator for front-door authentication
|
||||||
**kwargs: Additional server configuration
|
**kwargs: Additional server configuration
|
||||||
"""
|
"""
|
||||||
self._name = self._validate_name(name)
|
self._name = self._validate_name(name)
|
||||||
|
|
@ -97,6 +99,7 @@ class MCPApp:
|
||||||
self.title = title or name
|
self.title = title or name
|
||||||
self.instructions = instructions
|
self.instructions = instructions
|
||||||
self.log_level = log_level
|
self.log_level = log_level
|
||||||
|
self.resource_server_validator = auth
|
||||||
self.server_kwargs = kwargs
|
self.server_kwargs = kwargs
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.host = host
|
self.host = host
|
||||||
|
|
@ -123,7 +126,6 @@ class MCPApp:
|
||||||
# Store the actual instructions that ended up in ServerSettings
|
# Store the actual instructions that ended up in ServerSettings
|
||||||
self.instructions = self._mcp_settings.server.instructions
|
self.instructions = self._mcp_settings.server.instructions
|
||||||
|
|
||||||
self._load_env()
|
|
||||||
if not logger._core.handlers: # type: ignore[attr-defined]
|
if not logger._core.handlers: # type: ignore[attr-defined]
|
||||||
self._setup_logging(transport == "stdio")
|
self._setup_logging(transport == "stdio")
|
||||||
|
|
||||||
|
|
@ -193,13 +195,6 @@ class MCPApp:
|
||||||
"""Runtime resources API: add/remove/list."""
|
"""Runtime resources API: add/remove/list."""
|
||||||
return _ResourcesAPI(self)
|
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:
|
def _setup_logging(self, stdio_mode: bool = False) -> None:
|
||||||
logger.remove()
|
logger.remove()
|
||||||
|
|
||||||
|
|
@ -313,6 +308,24 @@ class MCPApp:
|
||||||
logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")
|
logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")
|
||||||
|
|
||||||
if transport in ["http", "streamable-http", "streamable"]:
|
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:
|
if reload:
|
||||||
self._run_with_reload(host, port)
|
self._run_with_reload(host, port)
|
||||||
else:
|
else:
|
||||||
|
|
@ -326,6 +339,7 @@ class MCPApp:
|
||||||
host=None,
|
host=None,
|
||||||
port=None,
|
port=None,
|
||||||
tool_count=len(self._catalog),
|
tool_count=len(self._catalog),
|
||||||
|
resource_server_validator=self.resource_server_validator,
|
||||||
)
|
)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
run_stdio_server(
|
run_stdio_server(
|
||||||
|
|
@ -403,6 +417,7 @@ class MCPApp:
|
||||||
catalog=self._catalog,
|
catalog=self._catalog,
|
||||||
mcp_settings=self._mcp_settings,
|
mcp_settings=self._mcp_settings,
|
||||||
debug=debug,
|
debug=debug,
|
||||||
|
resource_server_validator=self.resource_server_validator,
|
||||||
**self.server_kwargs,
|
**self.server_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -412,6 +427,7 @@ class MCPApp:
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
tool_count=len(self._catalog),
|
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))
|
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,
|
Middleware,
|
||||||
MiddlewareContext,
|
MiddlewareContext,
|
||||||
)
|
)
|
||||||
|
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||||
from arcade_mcp_server.session import InitializationState, NotificationManager, ServerSession
|
from arcade_mcp_server.session import InitializationState, NotificationManager, ServerSession
|
||||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||||
from arcade_mcp_server.types import (
|
from arcade_mcp_server.types import (
|
||||||
|
|
@ -402,6 +403,7 @@ class MCPServer:
|
||||||
self,
|
self,
|
||||||
message: Any,
|
message: Any,
|
||||||
session: ServerSession | None = None,
|
session: ServerSession | None = None,
|
||||||
|
resource_owner: ResourceOwner | None = None,
|
||||||
) -> MCPMessage | None:
|
) -> MCPMessage | None:
|
||||||
"""
|
"""
|
||||||
Handle an incoming message.
|
Handle an incoming message.
|
||||||
|
|
@ -409,6 +411,7 @@ class MCPServer:
|
||||||
Args:
|
Args:
|
||||||
message: Message to handle
|
message: Message to handle
|
||||||
session: Server session
|
session: Server session
|
||||||
|
resource_owner: Authenticated resource owner from front-door auth
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response message or None
|
Response message or None
|
||||||
|
|
@ -502,9 +505,13 @@ class MCPServer:
|
||||||
|
|
||||||
# Create request context
|
# Create request context
|
||||||
context = (
|
context = (
|
||||||
await session.create_request_context()
|
await session.create_request_context(resource_owner=resource_owner)
|
||||||
if session
|
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
|
# 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
|
self, tool: MaterializedTool, session: ServerSession | None = None
|
||||||
) -> ToolContext:
|
) -> ToolContext:
|
||||||
"""Create a tool context from a tool definition and session"""
|
"""Create a tool context from a tool definition and session"""
|
||||||
|
|
@ -692,27 +699,53 @@ class MCPServer:
|
||||||
elif secret.key in os.environ:
|
elif secret.key in os.environ:
|
||||||
tool_context.set_secret(secret.key, os.environ[secret.key])
|
tool_context.set_secret(secret.key, os.environ[secret.key])
|
||||||
|
|
||||||
# user_id selection
|
tool_context.user_id = self._select_user_id(session)
|
||||||
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)")
|
|
||||||
|
|
||||||
return tool_context
|
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:
|
async def _check_and_warn_missing_secrets(self) -> None:
|
||||||
"""
|
"""
|
||||||
Check for missing tool secrets and log warnings.
|
Check for missing tool secrets and log warnings.
|
||||||
|
|
@ -761,7 +794,7 @@ class MCPServer:
|
||||||
tool = await self._tool_manager.get_tool(tool_name)
|
tool = await self._tool_manager.get_tool(tool_name)
|
||||||
|
|
||||||
# Create tool context
|
# 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
|
# Check restrictions for unauthenticated HTTP transport
|
||||||
if transport_restriction_response := self._check_transport_restrictions(
|
if transport_restriction_response := self._check_transport_restrictions(
|
||||||
|
|
@ -906,13 +939,31 @@ class MCPServer:
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
session: ServerSession | None = None,
|
session: ServerSession | None = None,
|
||||||
) -> JSONRPCResponse[CallToolResult] | 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
|
# Check transport restrictions for tools requiring auth or secrets
|
||||||
if session and session.init_options:
|
if session and session.init_options:
|
||||||
transport_type = session.init_options.get("transport_type")
|
transport_type = session.init_options.get("transport_type")
|
||||||
if transport_type != "stdio":
|
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
|
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"
|
documentation_url = "https://docs.arcade.dev/en/home/compare-server-types"
|
||||||
user_message = "✗ Unsupported transport\n\n"
|
user_message = "✗ Unsupported transport\n\n"
|
||||||
user_message += f" Tool '{tool_name}' cannot run over HTTP transport for security reasons.\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.context import Context
|
||||||
from arcade_mcp_server.exceptions import RequestError, SessionError
|
from arcade_mcp_server.exceptions import RequestError, SessionError
|
||||||
|
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||||
from arcade_mcp_server.types import (
|
from arcade_mcp_server.types import (
|
||||||
CancelledNotification,
|
CancelledNotification,
|
||||||
CancelledParams,
|
CancelledParams,
|
||||||
|
|
@ -37,6 +38,7 @@ from arcade_mcp_server.types import (
|
||||||
ProgressNotificationParams,
|
ProgressNotificationParams,
|
||||||
PromptListChangedNotification,
|
PromptListChangedNotification,
|
||||||
ResourceListChangedNotification,
|
ResourceListChangedNotification,
|
||||||
|
SessionMessage,
|
||||||
ToolListChangedNotification,
|
ToolListChangedNotification,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -361,11 +363,24 @@ class ServerSession:
|
||||||
# Cancel any pending requests
|
# Cancel any pending requests
|
||||||
await self._cleanup_pending_requests()
|
await self._cleanup_pending_requests()
|
||||||
|
|
||||||
async def _process_message(self, message: str) -> None:
|
async def _process_message(self, message: str | Any) -> None:
|
||||||
"""Process a single message."""
|
"""Process a single message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Either a JSON string (stdio) or SessionMessage object (http)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse message
|
if isinstance(message, str):
|
||||||
data = json.loads(message)
|
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
|
# Check if it's a response to our request
|
||||||
if "id" in data and "method" not in data:
|
if "id" in data and "method" not in data:
|
||||||
|
|
@ -377,7 +392,7 @@ class ServerSession:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Otherwise, process as incoming request
|
# 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
|
# Send response if any
|
||||||
if response and self.write_stream:
|
if response and self.write_stream:
|
||||||
|
|
@ -646,9 +661,16 @@ class ServerSession:
|
||||||
self._request_meta = None
|
self._request_meta = None
|
||||||
|
|
||||||
# Context management
|
# Context management
|
||||||
async def create_request_context(self) -> Context:
|
async def create_request_context(self, resource_owner: ResourceOwner | None = None) -> Context:
|
||||||
"""Create a context for the current request."""
|
"""Create a context for the current request.
|
||||||
context = Context(self.server)
|
|
||||||
|
Args:
|
||||||
|
resource_owner: The authenticated resource owner from front-door auth.
|
||||||
|
"""
|
||||||
|
context = Context(
|
||||||
|
server=self.server,
|
||||||
|
resource_owner=resource_owner,
|
||||||
|
)
|
||||||
context.set_session(self)
|
context.set_session(self)
|
||||||
self._current_context = context
|
self._current_context = context
|
||||||
return context
|
return context
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Provides Pydantic-based settings with validation and environment variable suppor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
|
|
@ -93,6 +94,71 @@ class ServerSettings(BaseSettings):
|
||||||
model_config = {"env_prefix": "MCP_SERVER_"}
|
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):
|
class MiddlewareSettings(BaseSettings):
|
||||||
"""Middleware-related settings."""
|
"""Middleware-related settings."""
|
||||||
|
|
||||||
|
|
@ -207,6 +273,10 @@ class MCPSettings(BaseSettings):
|
||||||
default_factory=ServerSettings,
|
default_factory=ServerSettings,
|
||||||
description="Server settings",
|
description="Server settings",
|
||||||
)
|
)
|
||||||
|
resource_server: ResourceServerSettings = Field(
|
||||||
|
default_factory=ResourceServerSettings,
|
||||||
|
description="Server authentication settings",
|
||||||
|
)
|
||||||
middleware: MiddlewareSettings = Field(
|
middleware: MiddlewareSettings = Field(
|
||||||
default_factory=MiddlewareSettings,
|
default_factory=MiddlewareSettings,
|
||||||
description="Middleware settings",
|
description="Middleware settings",
|
||||||
|
|
@ -236,7 +306,20 @@ class MCPSettings(BaseSettings):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "MCPSettings":
|
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()
|
return cls()
|
||||||
|
|
||||||
def tool_secrets(self) -> dict[str, Any]:
|
def tool_secrets(self) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ class HTTPStreamableTransport:
|
||||||
self._terminated = False
|
self._terminated = False
|
||||||
|
|
||||||
# Streams for connection
|
# Streams for connection
|
||||||
self._read_stream_writer: MemoryObjectSendStream[str | Exception] | None = None
|
self._read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] | None = None
|
||||||
self._read_stream: MemoryObjectReceiveStream[str | Exception] | None = None
|
self._read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None
|
||||||
self._write_stream: MemoryObjectSendStream[str | SessionMessage] | None = None
|
self._write_stream: MemoryObjectSendStream[str | SessionMessage] | None = None
|
||||||
self._write_stream_reader: MemoryObjectReceiveStream[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,
|
headers: dict[str, str] | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Create an error 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:
|
if headers:
|
||||||
response_headers.update(headers)
|
response_headers.update(headers)
|
||||||
|
|
||||||
|
|
@ -406,13 +412,20 @@ class HTTPStreamableTransport:
|
||||||
elif not await self._validate_request_headers(request, send):
|
elif not await self._validate_request_headers(request, send):
|
||||||
return
|
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
|
# For notifications and responses, return 202 Accepted
|
||||||
if not isinstance(message, JSONRPCRequest):
|
if not isinstance(message, JSONRPCRequest):
|
||||||
response = self._create_json_response(None, HTTPStatus.ACCEPTED)
|
response = self._create_json_response(None, HTTPStatus.ACCEPTED)
|
||||||
await response(scope, receive, send)
|
await response(scope, receive, send)
|
||||||
|
|
||||||
# Process the message
|
# 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
|
return
|
||||||
|
|
||||||
# Handle requests
|
# Handle requests
|
||||||
|
|
@ -421,8 +434,11 @@ class HTTPStreamableTransport:
|
||||||
request_stream_reader = self._request_streams[request_id][1]
|
request_stream_reader = self._request_streams[request_id][1]
|
||||||
|
|
||||||
if self.is_json_response_enabled:
|
if self.is_json_response_enabled:
|
||||||
# JSON response mode
|
session_message = SessionMessage(
|
||||||
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
|
message=message,
|
||||||
|
resource_owner=resource_owner,
|
||||||
|
)
|
||||||
|
await writer.send(session_message)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_message = None
|
response_message = None
|
||||||
|
|
@ -490,7 +506,12 @@ class HTTPStreamableTransport:
|
||||||
try:
|
try:
|
||||||
async with anyio.create_task_group() as tg:
|
async with anyio.create_task_group() as tg:
|
||||||
tg.start_soon(response, scope, receive, send)
|
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:
|
except Exception:
|
||||||
logger.exception("SSE response error")
|
logger.exception("SSE response error")
|
||||||
await sse_stream_writer.aclose()
|
await sse_stream_writer.aclose()
|
||||||
|
|
@ -742,7 +763,7 @@ class HTTPStreamableTransport:
|
||||||
self,
|
self,
|
||||||
) -> AsyncIterator[
|
) -> AsyncIterator[
|
||||||
tuple[
|
tuple[
|
||||||
MemoryObjectReceiveStream[str | Exception],
|
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||||
MemoryObjectSendStream[str | SessionMessage],
|
MemoryObjectSendStream[str | SessionMessage],
|
||||||
]
|
]
|
||||||
]:
|
]:
|
||||||
|
|
@ -754,7 +775,9 @@ class HTTPStreamableTransport:
|
||||||
stream identified by `GET_STREAM_KEY`).
|
stream identified by `GET_STREAM_KEY`).
|
||||||
"""
|
"""
|
||||||
# Create memory streams with buffer
|
# 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](
|
write_stream, write_stream_reader = anyio.create_memory_object_stream[str | SessionMessage](
|
||||||
100
|
100
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from typing import Any, Generic, Literal, TypeAlias, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from arcade_mcp_server.resource_server.base import ResourceOwner
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# JSON-RPC constants
|
# JSON-RPC constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
@ -91,9 +93,14 @@ class JSONRPCError(JSONRPCMessage):
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SessionMessage:
|
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
|
message: JSONRPCMessage
|
||||||
|
resource_owner: ResourceOwner | None = None
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
@ -660,7 +667,13 @@ MCPMessage = (
|
||||||
JSONRPCRequest
|
JSONRPCRequest
|
||||||
| JSONRPCResponse[Any]
|
| JSONRPCResponse[Any]
|
||||||
| JSONRPCError
|
| JSONRPCError
|
||||||
|
| InitializedNotification
|
||||||
| CancelledNotification
|
| CancelledNotification
|
||||||
| ProgressNotification
|
| ProgressNotification
|
||||||
| LoggingMessageNotification
|
| LoggingMessageNotification
|
||||||
|
| ResourceListChangedNotification
|
||||||
|
| ResourceUpdatedNotification
|
||||||
|
| PromptListChangedNotification
|
||||||
|
| ToolListChangedNotification
|
||||||
|
| RootsListChangedNotification
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,4 @@ PROP_TOOL_COUNT = "tool_count"
|
||||||
PROP_MCP_SERVER_VERSION = "arcade_mcp_server_version"
|
PROP_MCP_SERVER_VERSION = "arcade_mcp_server_version"
|
||||||
PROP_IS_EXECUTION_SUCCESS = "is_execution_success"
|
PROP_IS_EXECUTION_SUCCESS = "is_execution_success"
|
||||||
PROP_FAILURE_REASON = "failure_reason"
|
PROP_FAILURE_REASON = "failure_reason"
|
||||||
|
PROP_RESOURCE_SERVER_TYPE = "resource_server_type"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import platform
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled
|
from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled
|
||||||
from arcade_core.usage.constants import (
|
from arcade_core.usage.constants import (
|
||||||
|
|
@ -20,6 +21,7 @@ from arcade_mcp_server.usage.constants import (
|
||||||
PROP_IS_EXECUTION_SUCCESS,
|
PROP_IS_EXECUTION_SUCCESS,
|
||||||
PROP_MCP_SERVER_VERSION,
|
PROP_MCP_SERVER_VERSION,
|
||||||
PROP_PORT,
|
PROP_PORT,
|
||||||
|
PROP_RESOURCE_SERVER_TYPE,
|
||||||
PROP_TOOL_COUNT,
|
PROP_TOOL_COUNT,
|
||||||
PROP_TRANSPORT,
|
PROP_TRANSPORT,
|
||||||
)
|
)
|
||||||
|
|
@ -62,12 +64,27 @@ class ServerTracker:
|
||||||
"""Get the distinct_id based on developer's authentication state"""
|
"""Get the distinct_id based on developer's authentication state"""
|
||||||
return self.identity.get_distinct_id()
|
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(
|
def track_server_start(
|
||||||
self,
|
self,
|
||||||
transport: str,
|
transport: str,
|
||||||
host: str | None,
|
host: str | None,
|
||||||
port: int | None,
|
port: int | None,
|
||||||
tool_count: int,
|
tool_count: int,
|
||||||
|
resource_server_validator: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Track MCP server start event.
|
"""Track MCP server start event.
|
||||||
|
|
||||||
|
|
@ -76,6 +93,7 @@ class ServerTracker:
|
||||||
host: The host address (None for stdio)
|
host: The host address (None for stdio)
|
||||||
port: The port number (None for stdio)
|
port: The port number (None for stdio)
|
||||||
tool_count: The number of tools available at server start
|
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():
|
if not is_tracking_enabled():
|
||||||
return
|
return
|
||||||
|
|
@ -92,6 +110,7 @@ class ServerTracker:
|
||||||
properties: dict[str, str | int | float] = {
|
properties: dict[str, str | int | float] = {
|
||||||
PROP_TRANSPORT: transport,
|
PROP_TRANSPORT: transport,
|
||||||
PROP_TOOL_COUNT: tool_count,
|
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_MCP_SERVER_VERSION: self.mcp_server_version,
|
||||||
PROP_RUNTIME_LANGUAGE: "python",
|
PROP_RUNTIME_LANGUAGE: "python",
|
||||||
PROP_RUNTIME_VERSION: self.runtime_version,
|
PROP_RUNTIME_VERSION: self.runtime_version,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ from starlette.requests import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from starlette.types import Receive, Scope, Send
|
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.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.server import MCPServer
|
||||||
from arcade_mcp_server.settings import MCPSettings
|
from arcade_mcp_server.settings import MCPSettings
|
||||||
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
|
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
|
||||||
|
|
@ -120,6 +123,7 @@ def create_arcade_mcp(
|
||||||
mcp_settings: MCPSettings | None = None,
|
mcp_settings: MCPSettings | None = None,
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
otel_enable: bool = False,
|
otel_enable: bool = False,
|
||||||
|
resource_server_validator: ResourceServerValidator | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
"""
|
"""
|
||||||
|
|
@ -127,6 +131,14 @@ def create_arcade_mcp(
|
||||||
and Arcade Worker endpoints if a secret is provided.
|
and Arcade Worker endpoints if a secret is provided.
|
||||||
|
|
||||||
MCP is always enabled in this integrated application.
|
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:
|
if mcp_settings is None:
|
||||||
mcp_settings = MCPSettings.from_env()
|
mcp_settings = MCPSettings.from_env()
|
||||||
|
|
@ -178,6 +190,18 @@ def create_arcade_mcp(
|
||||||
|
|
||||||
app.add_middleware(AddTrailingSlashToPathMiddleware)
|
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
|
# Worker endpoints
|
||||||
if secret is not None:
|
if secret is not None:
|
||||||
worker = FastAPIWorker(
|
worker = FastAPIWorker(
|
||||||
|
|
@ -201,8 +225,23 @@ def create_arcade_mcp(
|
||||||
return
|
return
|
||||||
await session_manager.handle_request(scope, receive, send)
|
await session_manager.handle_request(scope, receive, send)
|
||||||
|
|
||||||
# Mount the actual ASGI proxy to handle all /mcp requests
|
# Create MCP proxy and wrap with auth middleware if enabled
|
||||||
app.mount("/mcp", _MCPASGIProxy(app), name="mcp-proxy")
|
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
|
# Customize OpenAPI to include MCP documentation
|
||||||
def custom_openapi() -> dict[str, Any]:
|
def custom_openapi() -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp-server"
|
name = "arcade-mcp-server"
|
||||||
version = "1.11.2"
|
version = "1.12.0"
|
||||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Arcade.dev" }]
|
authors = [{ name = "Arcade.dev" }]
|
||||||
|
|
@ -34,6 +34,8 @@ dependencies = [
|
||||||
"anyio>=4.0.0",
|
"anyio>=4.0.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pydantic-settings>=2.10.1",
|
"pydantic-settings>=2.10.1",
|
||||||
|
"python-jose[cryptography]>=3.3.0,<4.0.0",
|
||||||
|
"httpx>=0.27.0,<1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,7 @@ class TestMCPApp:
|
||||||
catalog=mcp_app._catalog,
|
catalog=mcp_app._catalog,
|
||||||
mcp_settings=mcp_app._mcp_settings,
|
mcp_settings=mcp_app._mcp_settings,
|
||||||
debug=False,
|
debug=False,
|
||||||
|
resource_server_validator=mcp_app.resource_server_validator,
|
||||||
)
|
)
|
||||||
mock_serve.assert_called_once_with(
|
mock_serve.assert_called_once_with(
|
||||||
app=mock_fastapi_app,
|
app=mock_fastapi_app,
|
||||||
|
|
@ -365,6 +366,7 @@ class TestMCPApp:
|
||||||
catalog=mcp_app._catalog,
|
catalog=mcp_app._catalog,
|
||||||
mcp_settings=mcp_app._mcp_settings,
|
mcp_settings=mcp_app._mcp_settings,
|
||||||
debug=True,
|
debug=True,
|
||||||
|
resource_server_validator=mcp_app.resource_server_validator,
|
||||||
)
|
)
|
||||||
mock_serve.assert_called_once_with(
|
mock_serve.assert_called_once_with(
|
||||||
app=mock_fastapi_app,
|
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"},
|
"arguments": {"text": "test"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await mcp_server._handle_call_tool(message, session=session)
|
response = await mcp_server._handle_call_tool(message, session=session)
|
||||||
|
|
||||||
assert isinstance(response, JSONRPCResponse)
|
assert isinstance(response, JSONRPCResponse)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue