Skip to content

Commit fe548e5

Browse files
committed
Implement simplified RS+AS token mapping without DCR
1 parent 1a5f104 commit fe548e5

File tree

8 files changed

+219
-54
lines changed

8 files changed

+219
-54
lines changed

examples/clients/simple-auth-client-client-credentials/mcp_simple_auth_client_client_credentials/main.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@
88

99
import asyncio
1010
import os
11-
import threading
12-
import time
13-
import webbrowser
1411
from datetime import timedelta
15-
from http.server import BaseHTTPRequestHandler, HTTPServer
1612
from typing import Any
17-
from urllib.parse import parse_qs, urlparse
1813

1914
from mcp.client.auth import OAuthClientProvider, TokenStorage
2015
from mcp.client.session import ClientSession
2116
from mcp.client.sse import sse_client
2217
from mcp.client.streamable_http import streamablehttp_client
2318
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
2419

20+
# Hardcoded credentials assuming a preconfigured client, to demonstrate
21+
# working with an AS that does not have DCR support
22+
MCP_CLIENT_ID = "0000000000000000000"
23+
MCP_CLIENT_SECRET = "aaaaaaaaaaaaaaaaaaa"
24+
2525

2626
class InMemoryTokenStorage(TokenStorage):
2727
"""Simple in-memory token storage implementation."""
@@ -66,18 +66,16 @@ async def connect(self):
6666
"grant_types": ["client_credentials"],
6767
"response_types": ["code"],
6868
"token_endpoint_auth_method": "client_secret_basic",
69-
"scope": "identify"
69+
"scope": "identify",
7070
}
7171

7272
# Create OAuth authentication handler using the new interface
7373
oauth_auth = OAuthClientProvider(
7474
server_url=self.server_url.replace("/mcp", ""),
75-
client_metadata=OAuthClientMetadata.model_validate(
76-
client_metadata_dict
77-
),
75+
client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
7876
storage=InMemoryTokenStorage(
79-
client_id=os.environ.get("MCP_DISCORD_CLIENT_ID"),
80-
client_secret=os.environ.get("MCP_DISCORD_CLIENT_SECRET"),
77+
client_id=MCP_CLIENT_ID,
78+
client_secret=MCP_CLIENT_SECRET,
8179
),
8280
)
8381
oauth_auth.context.client_info = OAuthClientInformationFull(
@@ -210,9 +208,7 @@ async def interactive_loop(self):
210208
await self.call_tool(tool_name, arguments)
211209

212210
else:
213-
print(
214-
"❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'"
215-
)
211+
print("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'")
216212

217213
except KeyboardInterrupt:
218214
print("\n\n👋 Goodbye!")

examples/servers/simple-auth-client-credentials/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# MCP OAuth Authentication Demo
22

3-
This example demonstrates OAuth 2.0 authentication with the Model Context Protocol as an OAuth 2.0 Resource Server using the `client_credentials` token exchange.
3+
This example demonstrates OAuth 2.0 authentication with the Model Context Protocol as an OAuth 2.0 Resource Server using the `client_credentials` token exchange, with
4+
an Authorization Server that does not support Dynamic Client Registration.
45

56
---
67

@@ -27,8 +28,8 @@ export MCP_DISCORD_CLIENT_SECRET="your_client_secret_here"
2728
### Step 1: Start Authorization Server
2829

2930
```bash
30-
# Navigate to the simple-auth directory
31-
cd examples/servers/simple-auth
31+
# Navigate to the simple-auth-client-credentials directory
32+
cd examples/servers/simple-auth-client-credentials
3233

3334
# Start Authorization Server on port 9000
3435
uv run mcp-simple-auth-as --port=9000
@@ -44,8 +45,8 @@ uv run mcp-simple-auth-as --port=9000
4445
### Step 2: Start Resource Server (MCP Server)
4546

4647
```bash
47-
# In another terminal, navigate to the simple-auth directory
48-
cd examples/servers/simple-auth
48+
# In another terminal, navigate to the simple-auth-client-credentials directory
49+
cd examples/servers/simple-auth-client-credentials
4950

5051
# Start Resource Server on port 8001, connected to Authorization Server
5152
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http

examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/auth_server.py

Lines changed: 181 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,43 @@
1313

1414
import asyncio
1515
import logging
16+
import secrets
17+
from base64 import b64decode, b64encode
1618

1719
import click
1820
from pydantic import AnyHttpUrl, BaseModel
21+
from pydantic_settings import BaseSettings, SettingsConfigDict
1922
from starlette.applications import Starlette
2023
from starlette.endpoints import HTTPEndpoint
2124
from starlette.requests import Request
2225
from starlette.responses import JSONResponse, Response
2326
from starlette.routing import Route
27+
from starlette.types import Receive, Scope, Send
2428
from uvicorn import Config, Server
2529

2630
from mcp.server.auth.handlers.metadata import MetadataHandler
27-
from mcp.server.auth.routes import cors_middleware, create_auth_routes
28-
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
31+
from mcp.server.auth.routes import cors_middleware
2932
from mcp.shared._httpx_utils import create_mcp_http_client
30-
from mcp.shared.auth import OAuthMetadata
33+
from mcp.shared.auth import OAuthMetadata, OAuthToken
3134

3235
logger = logging.getLogger(__name__)
3336

34-
API_BASE = "https://discord.com"
35-
API_ENDPOINT = f"{API_BASE}/api/v10"
37+
API_ENDPOINT = "https://discord.com/api/v10"
38+
39+
40+
class DiscordOAuthSettings(BaseSettings):
41+
"""Discord OAuth settings."""
42+
43+
model_config = SettingsConfigDict(env_prefix="MCP_")
44+
45+
# Discord OAuth settings - MUST be provided via environment variables
46+
discord_client_id: str | None = None
47+
discord_client_secret: str | None = None
48+
49+
# Discord OAuth URL
50+
discord_token_url: str = f"{API_ENDPOINT}/oauth2/token"
51+
52+
discord_scope: str = "identify"
3653

3754

3855
class AuthServerSettings(BaseModel):
@@ -43,35 +60,176 @@ class AuthServerSettings(BaseModel):
4360
port: int = 9000
4461
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
4562

46-
def create_authorization_server(server_settings: AuthServerSettings) -> Starlette:
63+
64+
# Hardcoded credentials assuming a preconfigured client, to demonstrate
65+
# working with an AS that does not have DCR support
66+
MCP_CLIENT_ID = "0000000000000000000"
67+
MCP_CLIENT_SECRET = "aaaaaaaaaaaaaaaaaaa"
68+
69+
# Map of MCP server tokens to Discord API tokens
70+
TOKEN_MAP: dict[str, str] = {}
71+
72+
73+
class TokenEndpoint(HTTPEndpoint):
74+
# Map of MCP client IDs to Discord client IDs
75+
client_map: dict[str, str] = {}
76+
client_credentials: dict[str, str] = {}
77+
78+
discord_client_credentials: dict[str, str] = {}
79+
80+
def __init__(self, scope: Scope, receive: Receive, send: Send):
81+
super().__init__(scope, receive, send)
82+
self.discord_settings = DiscordOAuthSettings()
83+
84+
assert self.discord_settings.discord_client_id is not None, "Discord client ID not set"
85+
assert self.discord_settings.discord_client_secret is not None, "Discord client secret not set"
86+
87+
# Assume a preconfigured client ID to demonstrate working with an AS that does not have DCR support
88+
self.client_map = {
89+
MCP_CLIENT_ID: self.discord_settings.discord_client_id,
90+
}
91+
self.client_credentials = {
92+
MCP_CLIENT_ID: MCP_CLIENT_SECRET,
93+
}
94+
self.discord_client_credentials = {
95+
self.discord_settings.discord_client_id: self.discord_settings.discord_client_secret,
96+
}
97+
98+
async def post(self, request: Request) -> Response:
99+
# Get client_id and client_secret from Basic auth header
100+
auth_header = request.headers.get("Authorization", "")
101+
if not auth_header.startswith("Basic "):
102+
return JSONResponse({"error": "Invalid authorization header"}, status_code=401)
103+
auth_header_encoded = auth_header.split(" ")[1]
104+
auth_header_raw = b64decode(auth_header_encoded).decode("utf-8")
105+
client_id, client_secret = auth_header_raw.split(":")
106+
107+
# Validate MCP client
108+
if client_id not in self.client_map:
109+
return JSONResponse({"error": "Invalid client"}, status_code=401)
110+
# Check if client secret matches
111+
if client_secret != self.client_credentials[client_id]:
112+
return JSONResponse({"error": "Invalid client secret"}, status_code=401)
113+
114+
# Get mapped credentials
115+
discord_client_id = self.client_map[client_id]
116+
discord_client_secret = self.discord_client_credentials[discord_client_id]
117+
118+
# Get request data (application/x-www-form-urlencoded)
119+
data = await request.form()
120+
121+
# Validate scopes
122+
scopes = str(data.get("scope", "")).split(" ")
123+
if not set(scopes).issubset(set(self.discord_settings.discord_scope.split(" "))):
124+
return JSONResponse({"error": "Invalid scope"}, status_code=400)
125+
126+
# Set credentials in HTTP client
127+
headers = {
128+
"Authorization": f"Basic {b64encode(f'{discord_client_id}:{discord_client_secret}'.encode()).decode()}"
129+
}
130+
131+
# Create HTTP client
132+
async with create_mcp_http_client() as http_client:
133+
# Forward request to Discord API
134+
method = getattr(http_client, request.method.lower())
135+
response = await method(self.discord_settings.discord_token_url, data=data, headers=headers)
136+
if response.status_code != 200:
137+
body = await response.aread()
138+
return Response(body, status_code=response.status_code, headers=response.headers)
139+
140+
# Generate MCP access token
141+
mcp_token = f"mcp_{secrets.token_hex(32)}"
142+
143+
# Store mapped access token
144+
TOKEN_MAP[mcp_token] = response.json()["access_token"]
145+
146+
# Return response
147+
return JSONResponse(
148+
OAuthToken(
149+
access_token=mcp_token,
150+
token_type="Bearer",
151+
expires_in=response.json()["expires_in"],
152+
scope=self.discord_settings.discord_scope,
153+
).model_dump(),
154+
status_code=response.status_code,
155+
)
156+
157+
158+
class DiscordAPIProxy(HTTPEndpoint):
159+
"""Proxy for Discord API."""
160+
161+
async def get(self, request: Request) -> Response:
162+
"""Proxy GET requests to Discord API."""
163+
return await self.handle(request)
164+
165+
async def post(self, request: Request) -> Response:
166+
"""Proxy POST requests to Discord API."""
167+
return await self.handle(request)
168+
169+
async def handle(self, request: Request) -> Response:
170+
"""Proxy requests to Discord API."""
171+
path = request.url.path[len("/discord") :]
172+
query = request.url.query
173+
174+
# Get access token from Authorization header
175+
access_token = request.headers.get("Authorization", "").split(" ")[1]
176+
if not access_token:
177+
return JSONResponse({"error": "Missing access token"}, status_code=401)
178+
179+
# Map access token to Discord access token
180+
access_token = TOKEN_MAP.get(access_token, None)
181+
if not access_token:
182+
return JSONResponse({"error": "Invalid access token"}, status_code=401)
183+
184+
# Set mapped access token in HTTP client
185+
headers = {"Authorization": f"Bearer {access_token}"}
186+
187+
# Create HTTP client
188+
async with create_mcp_http_client() as http_client:
189+
# Forward request to Discord API
190+
response = await http_client.get(f"{API_ENDPOINT}{path}?{query}", headers=headers)
191+
192+
# Return response
193+
return JSONResponse(response.json(), status_code=response.status_code)
194+
195+
196+
def create_authorization_server(
197+
server_settings: AuthServerSettings, discord_settings: DiscordOAuthSettings
198+
) -> Starlette:
47199
"""Create the Authorization Server application."""
48200

49201
routes = [
50202
# Create RFC 8414 authorization server metadata endpoint
51203
Route(
52204
"/.well-known/oauth-authorization-server",
53205
endpoint=cors_middleware(
54-
MetadataHandler(metadata=OAuthMetadata(
55-
issuer=server_settings.server_url,
56-
authorization_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/authorize"),
57-
token_endpoint=AnyHttpUrl(f"{API_ENDPOINT}/oauth2/token"),
58-
token_endpoint_auth_methods_supported=["client_secret_basic"],
59-
response_types_supported=["code"],
60-
grant_types_supported=["client_credentials"],
61-
scopes_supported=["identify"]
62-
)).handle,
206+
MetadataHandler(
207+
metadata=OAuthMetadata(
208+
issuer=server_settings.server_url,
209+
authorization_endpoint=AnyHttpUrl(f"{server_settings.server_url}authorize"),
210+
token_endpoint=AnyHttpUrl(f"{server_settings.server_url}token"),
211+
token_endpoint_auth_methods_supported=["client_secret_basic"],
212+
response_types_supported=["code"],
213+
grant_types_supported=["client_credentials"],
214+
scopes_supported=[discord_settings.discord_scope],
215+
)
216+
).handle,
63217
["GET", "OPTIONS"],
64218
),
65219
methods=["GET", "OPTIONS"],
66220
),
221+
# Create OAuth 2.0 token endpoint
222+
Route("/token", TokenEndpoint),
223+
# Create API proxy endpoint
224+
Route("/discord/{path:path}", DiscordAPIProxy),
67225
]
68226

69227
return Starlette(routes=routes)
70228

71229

72-
async def run_server(server_settings: AuthServerSettings):
230+
async def run_server(server_settings: AuthServerSettings, discord_settings: DiscordOAuthSettings):
73231
"""Run the Authorization Server."""
74-
auth_server = create_authorization_server(server_settings)
232+
auth_server = create_authorization_server(server_settings, discord_settings)
75233

76234
config = Config(
77235
auth_server,
@@ -86,7 +244,9 @@ async def run_server(server_settings: AuthServerSettings):
86244
logger.info("=" * 80)
87245
logger.info(f"Server URL: {server_settings.server_url}")
88246
logger.info("Endpoints:")
89-
logger.info(f" - OAuth Metadata: {server_settings.server_url}/.well-known/oauth-authorization-server")
247+
logger.info(f" - OAuth Metadata: {server_settings.server_url}.well-known/oauth-authorization-server")
248+
logger.info(f" - Token Exchange: {server_settings.server_url}token")
249+
logger.info(f" - Discord API Proxy: {server_settings.server_url}discord")
90250
logger.info("")
91251
logger.info("=" * 80)
92252

@@ -112,7 +272,9 @@ def main(port: int) -> int:
112272
server_url=AnyHttpUrl(server_url),
113273
)
114274

115-
asyncio.run(run_server(server_settings))
275+
discord_settings = DiscordOAuthSettings()
276+
277+
asyncio.run(run_server(server_settings, discord_settings))
116278
return 0
117279

118280

examples/servers/simple-auth-client-credentials/mcp_simple_auth_client_credentials/server.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919

2020
from .token_verifier import IntrospectionTokenVerifier
2121

22-
2322
logger = logging.getLogger(__name__)
2423

2524
API_ENDPOINT = "https://discord.com/api/v10"
2625

26+
2727
class ResourceServerSettings(BaseSettings):
2828
"""Settings for the MCP Resource Server."""
2929

@@ -36,8 +36,8 @@ class ResourceServerSettings(BaseSettings):
3636

3737
# Authorization Server settings
3838
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
39-
auth_server_introspection_endpoint: str = f"{API_ENDPOINT}/oauth2/@me"
40-
auth_server_discord_user_endpoint: str = f"{API_ENDPOINT}/users/@me"
39+
auth_server_introspection_endpoint: str = "http://localhost:9000/discord/oauth2/@me"
40+
auth_server_discord_user_endpoint: str = "http://localhost:9000/discord/users/@me"
4141

4242
# MCP settings
4343
mcp_scope: str = "identify"
@@ -151,8 +151,8 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
151151
port=port,
152152
server_url=AnyHttpUrl(server_url),
153153
auth_server_url=auth_server_url,
154-
auth_server_introspection_endpoint=f"{API_ENDPOINT}/oauth2/@me",
155-
auth_server_discord_user_endpoint=f"{API_ENDPOINT}/users/@me",
154+
auth_server_introspection_endpoint=f"{auth_server_url}discord/oauth2/@me",
155+
auth_server_discord_user_endpoint=f"{auth_server_url}discord/users/@me",
156156
)
157157
except ValueError as e:
158158
logger.error(f"Configuration error: {e}")
@@ -168,9 +168,9 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
168168
logger.info(f"🌐 Server URL: {settings.server_url}")
169169
logger.info(f"🔑 Authorization Server: {settings.auth_server_url}")
170170
logger.info("📋 Endpoints:")
171-
logger.info(f" ┌─ Protected Resource Metadata: {settings.server_url}/.well-known/oauth-protected-resource")
171+
logger.info(f" ┌─ Protected Resource Metadata: {settings.server_url}.well-known/oauth-protected-resource")
172172
mcp_path = "sse" if transport == "sse" else "mcp"
173-
logger.info(f" ├─ MCP Protocol: {settings.server_url}/{mcp_path}")
173+
logger.info(f" ├─ MCP Protocol: {settings.server_url}{mcp_path}")
174174
logger.info(f" └─ Token Introspection: {settings.auth_server_introspection_endpoint}")
175175
logger.info("")
176176
logger.info("🛠️ Available Tools:")

0 commit comments

Comments
 (0)