Skip to content

confused deputy fix in example + supporting sdk changes #648

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

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
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
255 changes: 245 additions & 10 deletions examples/servers/simple-auth/mcp_simple_auth/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from starlette.responses import JSONResponse, RedirectResponse, Response, HTMLResponse

from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import (
Expand All @@ -25,6 +25,7 @@
from mcp.server.fastmcp.server import FastMCP
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from urllib.parse import urlencode

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -98,16 +99,23 @@ async def authorize(
"client_id": client.client_id,
}

# Build GitHub authorization URL
auth_url = (
f"{self.settings.github_auth_url}"
f"?client_id={self.settings.github_client_id}"
f"&redirect_uri={self.settings.github_callback_path}"
f"&scope={self.settings.github_scope}"
f"&state={state}"
)
# Return our custom consent endpoint, which will then redirect to Github

# Extract scopes - use default MCP scope if none provided
scopes = params.scopes or [self.settings.mcp_scope]
scopes_string = " ".join(scopes) if isinstance(scopes, list) else str(scopes)

consent_params = {
"client_id": client.client_id,
"redirect_uri": str(params.redirect_uri),
"state": state,
"scopes": scopes_string,
"code_challenge": params.code_challenge or "",
"response_type": "code"
}

return auth_url
consent_url = f"{self.settings.server_url}consent?{urlencode(consent_params)}"
return consent_url

async def handle_github_callback(self, code: str, state: str) -> str:
"""Handle GitHub OAuth callback."""
Expand Down Expand Up @@ -255,6 +263,226 @@ async def revoke_token(
del self.tokens[token]


class ConsentHandler:

def __init__(self, provider: SimpleGitHubOAuthProvider, settings: ServerSettings, path: str):
self.provider: SimpleGitHubOAuthProvider = provider
self.settings: ServerSettings = settings
self.client_consent: dict[str, bool] = {}
self.path = path

async def handle(self, request: Request) -> Response:
# This handles both showing the consent form (GET) and processing consent (POST)
if request.method == "GET":
# Show consent form
return await self._show_consent_form(request)
elif request.method == "POST":
# Process consent
return await self._process_consent(request)
else:
return HTMLResponse(status_code=405, content="Method not allowed")

async def _show_consent_form(self, request: Request) -> HTMLResponse:
client_id = request.query_params.get("client_id", "")
redirect_uri = request.query_params.get("redirect_uri", "")
# TODO: address csrf
state = request.query_params.get("state", "")
scopes = request.query_params.get("scopes", "")
code_challenge = request.query_params.get("code_challenge", "")
response_type = request.query_params.get("response_type", "")

# Get client info to display client_name
client_name = client_id # Default to client_id if we can't get the client
if client_id:
client = await self.provider.get_client(client_id)
if client and hasattr(client, 'client_name'):
client_name = client.client_name

target_url = self.path

# TODO: allow skipping consent if we've already approved this client ID

# Create a simple consent form
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>Authorization Required</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}}
.consent-form {{
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}}
h1 {{
margin: 0 0 20px 0;
font-size: 24px;
font-weight: 600;
}}
p {{
margin-bottom: 20px;
color: #666;
}}
.client-info {{
background: #f8f8f8;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}}
.scopes {{
margin-bottom: 20px;
}}
.scope-item {{
padding: 8px 0;
border-bottom: 1px solid #eee;
}}
.scope-item:last-child {{
border-bottom: none;
}}
.button-group {{
display: flex;
gap: 10px;
}}
button {{
flex: 1;
padding: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}}
.approve {{
background: #0366d6;
color: white;
}}
.deny {{
background: #f6f8fa;
color: #24292e;
border: 1px solid #d1d5da;
}}
button:hover {{
opacity: 0.9;
}}
</style>
</head>
<body>
<div class="consent-form">
<h1>Authorization Request</h1>
<p>The application <strong>{client_name}</strong> is requesting access to your resources.</p>

<div class="client-info">
<strong>Application Name:</strong> {client_name}<br>
<strong>Client ID:</strong> {client_id}<br>
<strong>Redirect URI:</strong> {redirect_uri}
</div>

<div class="scopes">
<strong>Requested Permissions:</strong>
{self._format_scopes(scopes)}
</div>

<form method="POST" action="{target_url}">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="scopes" value="{scopes}">
<input type="hidden" name="code_challenge" value="{code_challenge}">
<input type="hidden" name="response_type" value="{response_type}">

<div class="button-group">
<button type="submit" name="action" value="approve" class="approve">Approve</button>
<button type="submit" name="action" value="deny" class="deny">Deny</button>
</div>
</form>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content)

async def _process_consent(self, request: Request) -> RedirectResponse | HTMLResponse:
form_data = await request.form()
action = form_data.get("action")
state = form_data.get("state")

if action == "approve":
# Grant consent and continue with authorization
client_id = form_data.get("client_id")
if client_id:
client = await self.provider.get_client(client_id)
if client:
self.client_consent[client.client_id] = True

auth_url = (
f"{self.settings.github_auth_url}"
f"?client_id={self.settings.github_client_id}"
f"&redirect_uri={self.settings.github_callback_path}"
f"&scope={self.settings.github_scope}"
f"&state={state}"
)

return RedirectResponse(
# TODO: get this passed in
url=auth_url,
status_code=302,
headers={"Cache-Control": "no-store"},
)
else:
# User denied consent
redirect_uri = form_data.get("redirect_uri")
state = form_data.get("state")

error_params = {
"error": "access_denied",
"error_description": "User denied the authorization request"
}
if state:
error_params["state"] = state

if redirect_uri:
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(error_params)}",
status_code=302,
headers={"Cache-Control": "no-store"},
)
else:
return HTMLResponse(
status_code=400,
content=f"Access denied: {error_params['error_description']}"
)

def _format_scopes(self, scopes: str) -> str:
if not scopes:
return "<p>No specific permissions requested</p>"

scope_list = scopes.split()
if not scope_list:
return "<p>No specific permissions requested</p>"

scope_html = ""
for scope in scope_list:
scope_html += f'<div class="scope-item">{scope}</div>'

return scope_html




def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
"""Create a simple FastMCP server with GitHub OAuth."""
oauth_provider = SimpleGitHubOAuthProvider(settings)
Expand All @@ -279,6 +507,13 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
auth=auth_settings,
)

consent_path = "/consent"
consent_handler = ConsentHandler(provider=oauth_provider, settings=settings, path=consent_path)

@app.custom_route(consent_path, methods=["GET", "POST"])
async def example_consent_handler(request: Request) -> Response:
return await consent_handler.handle(request)

@app.custom_route("/github/callback", methods=["GET"])
async def github_callback_handler(request: Request) -> Response:
"""Handle GitHub OAuth callback."""
Expand Down
Loading