Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e83804
WIP
EricGustin Nov 7, 2025
beb871d
Haha it actually kinda works
EricGustin Nov 7, 2025
7e6e56c
Protected HTTP server with tool-level auth working
EricGustin Nov 9, 2025
59699f3
Generalize remote oauth provider and some cleanup
EricGustin Nov 14, 2025
9ca3bba
Intermediary commit
EricGustin Nov 15, 2025
58b13c0
Add auth env var configuration
EricGustin Nov 17, 2025
0c99777
Use python-jose instead of pyjwt
EricGustin Nov 17, 2025
b96b635
multi-algorithm, multi-issuer, client_id extraction
EricGustin Nov 17, 2025
0c8932c
Merge branch 'main' into ericgustin/front-door-auth
EricGustin Nov 20, 2025
428dcc5
Support 1 or more AS
EricGustin Nov 22, 2025
0fd488d
Solve envvar chicken & egg problem
EricGustin Nov 23, 2025
172acfc
Merge branch 'main' into ericgustin/front-door-auth
EricGustin Nov 24, 2025
86bbc6d
Cleanup part 1
EricGustin Nov 24, 2025
078c81f
Cleanup Part 2
EricGustin Nov 25, 2025
ae81885
Module wide renaming
EricGustin Nov 25, 2025
5a5f98c
Add logs
EricGustin Dec 2, 2025
1c49833
Track ResourceServerValidator type
EricGustin Dec 2, 2025
1b311f0
Use keycloak as an example
EricGustin Dec 2, 2025
570053d
Update some examples
EricGustin Dec 3, 2025
810d337
Fix bug
EricGustin Dec 3, 2025
c696d76
401 response
EricGustin Dec 4, 2025
b0a26c2
Update resource metadata url construction
EricGustin Dec 4, 2025
6d9c1ca
Resolve merge conflicts
EricGustin Dec 5, 2025
3c120af
Address comments
EricGustin Dec 5, 2025
0b16721
Make protected resource API response more robust
EricGustin Dec 5, 2025
fe4f418
Merge branch 'main' into ericgustin/front-door-auth
EricGustin Dec 10, 2025
571f666
Address comments
EricGustin Dec 11, 2025
5c0056b
Rename ResourceServer --> ResourceServerAuth
EricGustin Dec 11, 2025
070443d
Bump arcade_mcp_server version
EricGustin Dec 11, 2025
d1116cd
Words
EricGustin Dec 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions examples/mcp_servers/authorization/.dockerignore
Original file line number Diff line number Diff line change
@@ -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 changes: 45 additions & 0 deletions examples/mcp_servers/authorization/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 changes: 93 additions & 0 deletions examples/mcp_servers/authorization/docker/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions examples/mcp_servers/authorization/docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably out of scope of this PR, but I've generally seen this as PROXY_URL or something for when the server is behind a proxy of any kind.

45 changes: 45 additions & 0 deletions examples/mcp_servers/authorization/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
16 changes: 16 additions & 0 deletions examples/mcp_servers/authorization/src/authorization/.env.example
Original file line number Diff line number Diff line change
@@ -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
}
}
]'
Comment thread
EricGustin marked this conversation as resolved.

# Tool Secrets
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"
Empty file.
108 changes: 108 additions & 0 deletions examples/mcp_servers/authorization/src/authorization/server.py
Original file line number Diff line number Diff line change
@@ -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 (
AccessTokenValidationOptions,
AuthorizationServerEntry,
ResourceServer,
)

# Option 1: Single authorization server example
resource_server = ResourceServer(
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",
validation_options=AccessTokenValidationOptions(verify_aud=False),
Comment thread
EricGustin marked this conversation as resolved.
Outdated
),
],
)

# Option 2: Multiple authorization servers with different keys (e.g., multi-IdP)
# resource_server = ResourceServer(
# 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",
# ),
# 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",
# validation_options=AccessTokenValidationOptions(verify_aud=False),
# )
# ],
# )

# Option 3: Authoriation 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",
# "validation_options": {
# "verify_aud": false
# }
# }
# ]'
# ```
# resource_server = ResourceServer()

app = MCPApp(name="authorization", version="1.0.0", log_level="DEBUG", auth=resource_server)
Comment thread
EricGustin marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nitty nit: It feels weird to pass a ResourceServer to auth, because what I'm building here (the MCP server) is a resource server. Maybe ResourceServerAuthorization as a class name instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not nitty at all! I'm going to go with ResourceServerAuth



@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)
2 changes: 1 addition & 1 deletion libs/arcade-mcp-server/arcade_mcp_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
# Integrated Factory and Runner
"create_arcade_mcp",
"run_arcade_mcp",
# Re-exported TDK functionality
# Re-exported from TDK functionality
"tool",
]

Expand Down
5 changes: 5 additions & 0 deletions libs/arcade-mcp-server/arcade_mcp_server/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ToolContext,
)

from arcade_mcp_server.resource_server.base import ResourceOwner
from arcade_mcp_server.types import (
AudioContent,
CallToolParams,
Expand Down Expand Up @@ -124,6 +125,7 @@ def __init__(
server: Any,
session: Any | None = None,
request_id: str | None = None,
resource_owner: ResourceOwner | None = None,
):
"""Initialize context with server reference."""
super().__init__()
Expand All @@ -133,6 +135,9 @@ def __init__(
self._notification_queue: set[str] = set()
self._request_id: str | None = request_id

# Resource owner from front-door auth (if the server is protected)
self._resource_owner: ResourceOwner | None = resource_owner

# Namespaced adapters
self._log = Logs(self)
self._progress = Progress(self)
Expand Down
Loading