1313
1414import asyncio
1515import logging
16+ import secrets
17+ from base64 import b64decode , b64encode
1618
1719import click
1820from pydantic import AnyHttpUrl , BaseModel
21+ from pydantic_settings import BaseSettings , SettingsConfigDict
1922from starlette .applications import Starlette
2023from starlette .endpoints import HTTPEndpoint
2124from starlette .requests import Request
2225from starlette .responses import JSONResponse , Response
2326from starlette .routing import Route
27+ from starlette .types import Receive , Scope , Send
2428from uvicorn import Config , Server
2529
2630from 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
2932from 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
3235logger = 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
3855class 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
0 commit comments