Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
30 changes: 15 additions & 15 deletions examples/mcp_servers/authorization/src/authorization/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,46 @@
from arcade_mcp_server import Context, MCPApp
from arcade_mcp_server.auth import Reddit
from arcade_mcp_server.resource_server import (
AccessTokenValidationOptions,
AuthorizationServerEntry,
ResourceServer,
ResourceServerAuth,
)

# Option 1: Single authorization server example
resource_server = ResourceServer(
# Option 1: Single authorization server with custom audience
# Use expected_audiences when your auth server returns a different aud claim
Comment thread
EricGustin marked this conversation as resolved.
Outdated
# (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",
validation_options=AccessTokenValidationOptions(verify_aud=False),
expected_audiences=["your-authkit-client-id"], # Override expected aud claim

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.

nit: from what we were discussing yesterday it seems like in the WorkOS case it's an app or workspace ID, not a client ID right?

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.

So their naming of things is very bad, but it is the AuthKit's Client ID (which is closer to a workspace ID), not the registered client's ID. Both IDs are unfortunately prefixed with client_ lol

),
],
)

# Option 2: Multiple authorization servers with different keys (e.g., multi-IdP)
# resource_server = ResourceServer(
# resource_server_auth = ResourceServerAuth(
# canonical_url="http://127.0.0.1:8000/mcp",
# authorization_servers=[
# AuthorizationServerEntry( # WorkOS Authkit example configuration
# 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
# 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),
# expected_audiences=["your-keycloak-client-id"],
# )
# ],
# )

# Option 3: Authoriation via env vars (place in your .env file)
# 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='[
Expand All @@ -53,15 +55,13 @@
# "issuer": "https://your-workos.authkit.app",
# "jwks_uri": "https://your-workos.authkit.app/oauth2/jwks",
# "algorithm": "RS256",
# "validation_options": {
# "verify_aud": false
# }
# "expected_audiences": ["your-authkit-client-id"]
# }
# ]'
# ```
# resource_server = ResourceServer()
# resource_server_auth = ResourceServerAuth()

app = MCPApp(name="authorization", version="1.0.0", log_level="DEBUG", auth=resource_server)
app = MCPApp(name="authorization", version="1.0.0", log_level="DEBUG", auth=resource_server_auth)


@app.tool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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,
Expand Down Expand Up @@ -52,6 +55,10 @@ async def oauth_protected_resource() -> JSONResponse:

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(
Comment thread
EricGustin marked this conversation as resolved.
{"error": "Resource metadata not available"},
status_code=500,
Expand Down
10 changes: 8 additions & 2 deletions libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,15 @@ def run(
logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")

if transport in ["http", "streamable-http", "streamable"]:
logger.info(
f"Resource Server authentication enabled: {isinstance(self.resource_server_validator, ResourceServerValidator)}"
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()
Expand Down
88 changes: 47 additions & 41 deletions libs/arcade-mcp-server/arcade_mcp_server/resource_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ The MCP server acts as an OAuth 2.1 **Resource Server**, validating Bearer token

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 RFC 9728
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 whether authorization servers support Dynamic Client Registration (DCR) or not. That's the authorization server's concern. The MCP server simply validates tokens and advertises the AS URLs.
**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

`ResourceServer` supports environment variable configuration for production deployments. This is the **recommended approach for production**.
`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 parameters.
**Note:** `JWKSTokenValidator` does not support environment variables and requires explicit programmatic parameters to its initializer

### Supported Environment Variables

Expand All @@ -33,36 +33,38 @@ The `MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS` must be a JSON array of entry ob
- `issuer`: Expected token issuer
- `jwks_uri`: JWKS endpoint URL
- `algorithm`: (Optional) JWT algorithm, defaults to RS256
- `validation_options`: (Optional) dict with optional `verify_aud`, `verify_exp`, `verify_iat`, `verify_iss` flags. Defaults to all flags being True
- `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

**Environment variables take precedence over parameters:**
**Explicit parameters take precedence over environment variables:**

```python
from arcade_mcp_server import MCPApp
from arcade_mcp_server.resource_server import (
AccessTokenValidationOptions,
AuthorizationServerEntry,
ResourceServer,
ResourceServerAuth,
)

# Parameters are ignored if env vars are set
resource_server = ResourceServer(
canonical_url="http://127.0.0.1:8000/mcp", # overridden by env var
authorization_servers=[ # overridden by env var
# 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",
validation_options=AccessTokenValidationOptions(
verify_aud=False,
),
# 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)
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
Expand All @@ -72,15 +74,29 @@ app = MCPApp(name="Protected", auth=resource_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",
"validation_options": {
"verify_aud": false
}
"expected_audiences": ["my-client-id"]
}
]'
```
Expand All @@ -94,43 +110,33 @@ 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",
"validation_options": {
"verify_aud": false
}
"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",
"validation_options": {
"verify_aud": false
}
"jwks_uri": "https://auth.example.com/.well-known/jwks.json"
}
]'
```

#### Multiple Authorization Servers (Different Keys)

```bash
# Multi-IdP configuration
# 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",
"validation_options": {
"verify_aud": false
}
"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",
"validation_options": {
"verify_aud": false
}
"expected_audiences": ["my-keycloak-client-id"]
}
]'
```
Expand All @@ -140,16 +146,16 @@ MCP_RESOURCE_SERVER_AUTHORIZATION_SERVERS='[
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 enables it)
4. **Separate authorization layers** - Resource Server auth != tool OAuth (but building a protected server enables tool authorization)

## Vendor-Specific Implementations

The `ResourceServer` class is designed to be subclassed for vendor-specific implementations:
The `ResourceServerAuth` class is designed to be subclassed for vendor-specific implementations:

```python
# Future vendor-specific implementations
class ArcadeResourceServer(ResourceServer): ...
class WorkOSResourceServer(ResourceServer): ...
class Auth0ResourceServer(ResourceServer): ...
class DescopeResourceServer(ResourceServer): ...
# Your vendor-specific implementations
class ArcadeResourceServerAuth(ResourceServerAuth): ...
class WorkOSResourceServerAuth(ResourceServerAuth): ...
class Auth0ResourceServerAuth(ResourceServerAuth): ...
class DescopeResourceServerAuth(ResourceServerAuth): ...
```
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
)
from arcade_mcp_server.resource_server.validators import (
JWKSTokenValidator,
ResourceServer,
ResourceServerAuth,
)

__all__ = [
"AccessTokenValidationOptions",
"AuthorizationServerEntry",
"JWKSTokenValidator",
"ResourceServer",
"ResourceServerAuth",
]
22 changes: 16 additions & 6 deletions libs/arcade-mcp-server/arcade_mcp_server/resource_server/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ class AccessTokenValidationOptions(BaseModel):
Set to False to disable specific validations for authorization servers
that are not compliant with MCP.

Note: Token signature verification is always enabled and cannot be disabled.
Additionally, the subject (sub claim) must always be present in the token.
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(
Expand All @@ -26,14 +27,18 @@ class AccessTokenValidationOptions(BaseModel):
default=True,
description="Verify issued-at time (iat claim)",
)
verify_aud: bool = Field(
default=True,
description="Verify audience claim (aud 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
Expand Down Expand Up @@ -79,6 +84,11 @@ class AuthorizationServerEntry:
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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ class ResourceServerMiddleware:
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)

Example:
```python
from starlette.applications import Starlette

app = Starlette()
validator = JWKSTokenValidator(...)
app = ResourceServerMiddleware(app, validator, "https://mcp.example.com/mcp")
```
"""

def __init__(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
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
from arcade_mcp_server.resource_server.validators.resource_server import ResourceServer

__all__ = [
"JWKSTokenValidator",
"ResourceServer",
"ResourceServerAuth",
]
Loading