|
9 | 9 | import json |
10 | 10 | import logging |
11 | 11 | import uuid |
| 12 | +from functools import reduce |
12 | 13 | from typing import TYPE_CHECKING, Any |
| 14 | +from urllib.parse import urljoin |
13 | 15 |
|
14 | 16 | from flask import Response, request |
15 | 17 | from mcp.types import ( |
|
47 | 49 | logger = logging.getLogger(__name__) |
48 | 50 |
|
49 | 51 |
|
50 | | -def enable_mcp_server(app: Dash, mcp_path: str) -> None: |
| 52 | +def _url_from_path(*parts: str) -> str: |
| 53 | + """Build an absolute URL by joining path parts onto the current request origin. |
| 54 | +
|
| 55 | + Behind a reverse proxy, TLS terminates at the proxy so |
| 56 | + ``request.scheme`` reports HTTP even when the client connected |
| 57 | + over HTTPS. Use HTTPS unless running on localhost. |
| 58 | + """ |
| 59 | + host = request.host |
| 60 | + is_localhost = host.startswith("localhost") or host.startswith("127.0.0.1") |
| 61 | + scheme = "http" if is_localhost else "https" |
| 62 | + path = reduce(urljoin, parts, "/") |
| 63 | + return f"{scheme}://{host}{path}" |
| 64 | + |
| 65 | + |
| 66 | +def _setup_mcp_oauth(app: Dash, mcp_path: str, mcp_authorization_server: str) -> None: |
| 67 | + """Register OAuth metadata endpoint and auth gate for MCP. |
| 68 | +
|
| 69 | + Serves RFC 9728 Protected Resource Metadata so MCP clients can |
| 70 | + discover the authorization server, and returns 401 with |
| 71 | + WWW-Authenticate for unauthenticated requests to the MCP endpoint. |
| 72 | + """ |
| 73 | + well_known_path = urljoin("/.well-known/oauth-protected-resource/", mcp_path) |
| 74 | + |
| 75 | + def _serve_resource_metadata() -> Response: |
| 76 | + return Response( |
| 77 | + json.dumps( |
| 78 | + { |
| 79 | + "resource": _url_from_path( |
| 80 | + app.config.requests_pathname_prefix, mcp_path |
| 81 | + ), |
| 82 | + "authorization_servers": [mcp_authorization_server], |
| 83 | + "bearer_methods_supported": ["header"], |
| 84 | + } |
| 85 | + ), |
| 86 | + content_type="application/json", |
| 87 | + ) |
| 88 | + |
| 89 | + # pylint: disable-next=protected-access |
| 90 | + app._add_url(well_known_path.lstrip("/"), _serve_resource_metadata) |
| 91 | + |
| 92 | + @app.server.before_request |
| 93 | + def _mcp_require_auth(): |
| 94 | + if request.path != app.config.routes_pathname_prefix + mcp_path: |
| 95 | + return None |
| 96 | + auth_header = request.headers.get("Authorization", "") |
| 97 | + if auth_header.startswith("Bearer "): |
| 98 | + return None |
| 99 | + resource_metadata_url = _url_from_path(well_known_path) |
| 100 | + return Response( |
| 101 | + json.dumps({"error": "unauthorized"}), |
| 102 | + status=401, |
| 103 | + content_type="application/json", |
| 104 | + headers={ |
| 105 | + "WWW-Authenticate": ( |
| 106 | + f'Bearer resource_metadata="{resource_metadata_url}"' |
| 107 | + ), |
| 108 | + }, |
| 109 | + ) |
| 110 | + |
| 111 | + logger.info("MCP OAuth enabled, authorization server: %s", mcp_authorization_server) |
| 112 | + |
| 113 | + |
| 114 | +def enable_mcp_server( |
| 115 | + app: Dash, |
| 116 | + mcp_path: str, |
| 117 | + mcp_authorization_server: str | None = None, |
| 118 | +) -> None: |
51 | 119 | """Add MCP routes to a Dash/Flask app.""" |
52 | 120 |
|
53 | 121 | app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS) |
@@ -191,6 +259,9 @@ def _handle_delete() -> Response: |
191 | 259 | mcp_path, with_app_context_factory(mcp_handler, app), ["GET", "POST", "DELETE"] |
192 | 260 | ) |
193 | 261 |
|
| 262 | + if mcp_authorization_server: |
| 263 | + _setup_mcp_oauth(app, mcp_path, mcp_authorization_server) |
| 264 | + |
194 | 265 | logger.info( |
195 | 266 | "MCP routes registered at %s%s", |
196 | 267 | app.config.routes_pathname_prefix, |
|
0 commit comments