10
10
from pydantic_settings import BaseSettings , SettingsConfigDict
11
11
from starlette .exceptions import HTTPException
12
12
from starlette .requests import Request
13
- from starlette .responses import JSONResponse , RedirectResponse , Response
13
+ from starlette .responses import JSONResponse , RedirectResponse , Response , HTMLResponse
14
+ from dataclasses import dataclass
15
+
14
16
15
17
from mcp .server .auth .middleware .auth_context import get_access_token
16
18
from mcp .server .auth .provider import (
25
27
from mcp .server .fastmcp .server import FastMCP
26
28
from mcp .shared ._httpx_utils import create_mcp_http_client
27
29
from mcp .shared .auth import OAuthClientInformationFull , OAuthToken
30
+ from urllib .parse import urlencode
28
31
29
32
logger = logging .getLogger (__name__ )
30
33
@@ -108,16 +111,25 @@ async def authorize(
108
111
"client_id" : client .client_id ,
109
112
}
110
113
111
- # Build GitHub authorization URL
112
- auth_url = (
113
- f"{ self .settings .github_auth_url } "
114
- f"?client_id={ self .settings .github_client_id } "
115
- f"&redirect_uri={ self .settings .github_callback_path } "
116
- f"&scope={ self .settings .github_scope } "
117
- f"&state={ state } "
118
- )
114
+ # Return our custom consent endpoint, which will then redirect to Github
115
+
116
+ # Extract scopes - use default MCP scope if none provided
117
+ scopes = params .scopes or [self .settings .mcp_scope ]
118
+ scopes_string = " " .join (scopes ) if isinstance (scopes , list ) else str (scopes )
119
+
120
+ consent_params = {
121
+ "client_id" : client .client_id ,
122
+ "redirect_uri" : str (params .redirect_uri ),
123
+ "state" : state ,
124
+ "scopes" : scopes_string ,
125
+ "code_challenge" : params .code_challenge or "" ,
126
+ "response_type" : "code"
127
+ }
128
+
129
+ consent_url = f"{ self .settings .server_url } consent?{ urlencode (consent_params )} "
130
+ print (f"[DEBUGG] { consent_url } { state } " )
119
131
120
- return auth_url
132
+ return consent_url
121
133
122
134
async def handle_github_callback (self , code : str , state : str ) -> str :
123
135
"""Handle GitHub OAuth callback."""
@@ -265,6 +277,224 @@ async def revoke_token(
265
277
del self .tokens [token ]
266
278
267
279
280
+ @dataclass
281
+ class ConsentHandler :
282
+ provider : OAuthAuthorizationServerProvider [Any , Any , Any ]
283
+ settings : ServerSettings
284
+
285
+ async def handle (self , request : Request ) -> Response :
286
+ # This handles both showing the consent form (GET) and processing consent (POST)
287
+ if request .method == "GET" :
288
+ # Show consent form
289
+ return await self ._show_consent_form (request )
290
+ elif request .method == "POST" :
291
+ # Process consent
292
+ return await self ._process_consent (request )
293
+ else :
294
+ return HTMLResponse (status_code = 405 , content = "Method not allowed" )
295
+
296
+ async def _show_consent_form (self , request : Request ) -> HTMLResponse :
297
+ client_id = request .query_params .get ("client_id" , "" )
298
+ redirect_uri = request .query_params .get ("redirect_uri" , "" )
299
+ state = request .query_params .get ("state" , "" )
300
+ scopes = request .query_params .get ("scopes" , "" )
301
+ code_challenge = request .query_params .get ("code_challenge" , "" )
302
+ response_type = request .query_params .get ("response_type" , "" )
303
+
304
+ # Get client info to display client_name
305
+ client_name = client_id # Default to client_id if we can't get the client
306
+ if client_id :
307
+ client = await self .provider .get_client (client_id )
308
+ if client and hasattr (client , 'client_name' ):
309
+ client_name = client .client_name
310
+
311
+ # TODO: get this passed in
312
+ target_url = "/consent"
313
+
314
+ # Create a simple consent form
315
+
316
+ html_content = f"""
317
+ <!DOCTYPE html>
318
+ <html>
319
+ <head>
320
+ <title>Authorization Required</title>
321
+ <meta charset="utf-8">
322
+ <meta name="viewport" content="width=device-width, initial-scale=1">
323
+ <style>
324
+ body {{
325
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
326
+ display: flex;
327
+ justify-content: center;
328
+ align-items: center;
329
+ min-height: 100vh;
330
+ margin: 0;
331
+ padding: 20px;
332
+ background-color: #f5f5f5;
333
+ }}
334
+ .consent-form {{
335
+ background: white;
336
+ padding: 40px;
337
+ border-radius: 8px;
338
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
339
+ width: 100%;
340
+ max-width: 400px;
341
+ }}
342
+ h1 {{
343
+ margin: 0 0 20px 0;
344
+ font-size: 24px;
345
+ font-weight: 600;
346
+ }}
347
+ p {{
348
+ margin-bottom: 20px;
349
+ color: #666;
350
+ }}
351
+ .client-info {{
352
+ background: #f8f8f8;
353
+ padding: 15px;
354
+ border-radius: 4px;
355
+ margin-bottom: 20px;
356
+ }}
357
+ .scopes {{
358
+ margin-bottom: 20px;
359
+ }}
360
+ .scope-item {{
361
+ padding: 8px 0;
362
+ border-bottom: 1px solid #eee;
363
+ }}
364
+ .scope-item:last-child {{
365
+ border-bottom: none;
366
+ }}
367
+ .button-group {{
368
+ display: flex;
369
+ gap: 10px;
370
+ }}
371
+ button {{
372
+ flex: 1;
373
+ padding: 10px;
374
+ border: none;
375
+ border-radius: 4px;
376
+ cursor: pointer;
377
+ font-size: 16px;
378
+ }}
379
+ .approve {{
380
+ background: #0366d6;
381
+ color: white;
382
+ }}
383
+ .deny {{
384
+ background: #f6f8fa;
385
+ color: #24292e;
386
+ border: 1px solid #d1d5da;
387
+ }}
388
+ button:hover {{
389
+ opacity: 0.9;
390
+ }}
391
+ </style>
392
+ </head>
393
+ <body>
394
+ <div class="consent-form">
395
+ <h1>Authorization Request</h1>
396
+ <p>The application <strong>{ client_name } </strong> is requesting access to your resources.</p>
397
+
398
+ <div class="client-info">
399
+ <strong>Application Name:</strong> { client_name } <br>
400
+ <strong>Client ID:</strong> { client_id } <br>
401
+ <strong>Redirect URI:</strong> { redirect_uri }
402
+ </div>
403
+
404
+ <div class="scopes">
405
+ <strong>Requested Permissions:</strong>
406
+ { self ._format_scopes (scopes )}
407
+ </div>
408
+
409
+ <form method="POST" action="{ target_url } ">
410
+ <input type="hidden" name="client_id" value="{ client_id } ">
411
+ <input type="hidden" name="redirect_uri" value="{ redirect_uri } ">
412
+ <input type="hidden" name="state" value="{ state } ">
413
+ <input type="hidden" name="scopes" value="{ scopes } ">
414
+ <input type="hidden" name="code_challenge" value="{ code_challenge } ">
415
+ <input type="hidden" name="response_type" value="{ response_type } ">
416
+
417
+ <div class="button-group">
418
+ <button type="submit" name="action" value="approve" class="approve">Approve</button>
419
+ <button type="submit" name="action" value="deny" class="deny">Deny</button>
420
+ </div>
421
+ </form>
422
+ </div>
423
+ </body>
424
+ </html>
425
+ """
426
+ return HTMLResponse (content = html_content )
427
+
428
+ async def _process_consent (self , request : Request ) -> RedirectResponse | HTMLResponse :
429
+ form_data = await request .form ()
430
+ action = form_data .get ("action" )
431
+ state = form_data .get ("state" )
432
+
433
+ if action == "approve" :
434
+ # Grant consent and continue with authorization
435
+ client_id = form_data .get ("client_id" )
436
+ if client_id :
437
+ client = await self .provider .get_client (client_id )
438
+ if client :
439
+ # TODO: move this out of provider
440
+ await self .provider .grant_client_consent (client )
441
+
442
+
443
+ auth_url = (
444
+ f"{ self .settings .github_auth_url } "
445
+ f"?client_id={ self .settings .github_client_id } "
446
+ f"&redirect_uri={ self .settings .github_callback_path } "
447
+ f"&scope={ self .settings .github_scope } "
448
+ f"&state={ state } "
449
+ )
450
+
451
+ return RedirectResponse (
452
+ # TODO: get this passed in
453
+ url = auth_url ,
454
+ status_code = 302 ,
455
+ headers = {"Cache-Control" : "no-store" },
456
+ )
457
+ else :
458
+ # User denied consent
459
+ redirect_uri = form_data .get ("redirect_uri" )
460
+ state = form_data .get ("state" )
461
+
462
+ error_params = {
463
+ "error" : "access_denied" ,
464
+ "error_description" : "User denied the authorization request"
465
+ }
466
+ if state :
467
+ error_params ["state" ] = state
468
+
469
+ if redirect_uri :
470
+ return RedirectResponse (
471
+ url = f"{ redirect_uri } ?{ urlencode (error_params )} " ,
472
+ status_code = 302 ,
473
+ headers = {"Cache-Control" : "no-store" },
474
+ )
475
+ else :
476
+ return HTMLResponse (
477
+ status_code = 400 ,
478
+ content = f"Access denied: { error_params ['error_description' ]} "
479
+ )
480
+
481
+ def _format_scopes (self , scopes : str ) -> str :
482
+ if not scopes :
483
+ return "<p>No specific permissions requested</p>"
484
+
485
+ scope_list = scopes .split ()
486
+ if not scope_list :
487
+ return "<p>No specific permissions requested</p>"
488
+
489
+ scope_html = ""
490
+ for scope in scope_list :
491
+ scope_html += f'<div class="scope-item">{ scope } </div>'
492
+
493
+ return scope_html
494
+
495
+
496
+
497
+
268
498
def create_simple_mcp_server (settings : ServerSettings ) -> FastMCP :
269
499
"""Create a simple FastMCP server with GitHub OAuth."""
270
500
oauth_provider = SimpleGitHubOAuthProvider (settings )
@@ -275,9 +505,8 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
275
505
enabled = True ,
276
506
valid_scopes = [settings .mcp_scope ],
277
507
default_scopes = [settings .mcp_scope ],
278
- # Because we're redirecting to a different AS during our
279
- # main auth flow.
280
- client_consent_required = True
508
+ # Turning off consent since we'll handle it via custom endpoint
509
+ client_consent_required = False
281
510
),
282
511
required_scopes = [settings .mcp_scope ],
283
512
)
@@ -292,6 +521,12 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
292
521
auth = auth_settings ,
293
522
)
294
523
524
+ consent_handler = ConsentHandler (provider = oauth_provider , settings = settings )
525
+
526
+ @app .custom_route ("/consent" , methods = ["GET" , "POST" ])
527
+ async def example_consent_handler (request : Request ) -> Response :
528
+ return await consent_handler .handle (request )
529
+
295
530
@app .custom_route ("/github/callback" , methods = ["GET" ])
296
531
async def github_callback_handler (request : Request ) -> Response :
297
532
"""Handle GitHub OAuth callback."""
0 commit comments