-
Notifications
You must be signed in to change notification settings - Fork 100
Front-Door Auth #696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Front-Door Auth #696
Changes from 20 commits
9e83804
beb871d
7e6e56c
59699f3
9ca3bba
58b13c0
0c99777
b96b635
0c8932c
428dcc5
0fd488d
172acfc
86bbc6d
078c81f
ae81885
5a5f98c
1c49833
1b311f0
570053d
810d337
c696d76
b0a26c2
6d9c1ca
3c120af
0b16721
fe4f418
571f666
5c0056b
070443d
d1116cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 |
| 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. |
| 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 | ||
| 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 } |
| 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 | ||
| } | ||
| } | ||
| ]' | ||
|
EricGustin marked this conversation as resolved.
|
||
|
|
||
| # Tool Secrets | ||
| MY_SECRET_KEY="Your tools can have secrets injected at runtime!" | ||
| 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), | ||
|
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) | ||
|
EricGustin marked this conversation as resolved.
Outdated
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. very nitty nit: It feels weird to pass a
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not nitty at all! I'm going to go with |
||
|
|
||
|
|
||
| @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) | ||
There was a problem hiding this comment.
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_URLor something for when the server is behind a proxy of any kind.