Skip to content

Commit 3a515ea

Browse files
committed
Allow user-provided OAuth URL for LLM discovery
1 parent fd13290 commit 3a515ea

3 files changed

Lines changed: 82 additions & 2 deletions

File tree

dash/_configs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def load_dash_env_vars():
3535
"DASH_MCP_ENABLED",
3636
"DASH_MCP_PATH",
3737
"DASH_MCP_EXPOSE_DOCSTRINGS",
38+
"DASH_MCP_AUTHORIZATION_SERVER",
3839
"HOST",
3940
"PORT",
4041
)

dash/dash.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ def __init__( # pylint: disable=too-many-statements
486486
enable_mcp: Optional[bool] = None,
487487
mcp_path: Optional[str] = None,
488488
mcp_expose_docstrings: Optional[bool] = None,
489+
mcp_authorization_server: Optional[str] = None,
489490
**obsolete,
490491
):
491492

@@ -605,6 +606,9 @@ def __init__( # pylint: disable=too-many-statements
605606
self._mcp_path = (
606607
_mcp_path.lstrip("/") if isinstance(_mcp_path, str) else _mcp_path
607608
)
609+
self._mcp_authorization_server = get_combined_config(
610+
"mcp_authorization_server", mcp_authorization_server
611+
)
608612

609613
# list of dependencies - this one is used by the back end for dispatching
610614
self.callback_map: dict = {}
@@ -833,7 +837,11 @@ def _setup_routes(self):
833837
)
834838

835839
try:
836-
enable_mcp_server(self, self._mcp_path)
840+
enable_mcp_server(
841+
self,
842+
self._mcp_path,
843+
mcp_authorization_server=self._mcp_authorization_server,
844+
)
837845
except Exception as e: # pylint: disable=broad-exception-caught
838846
self._enable_mcp = False
839847
self.logger.warning(

dash/mcp/_server.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import json
1010
import logging
1111
import uuid
12+
from functools import reduce
1213
from typing import TYPE_CHECKING, Any
14+
from urllib.parse import urljoin
1315

1416
from flask import Response, request
1517
from mcp.types import (
@@ -47,7 +49,73 @@
4749
logger = logging.getLogger(__name__)
4850

4951

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:
51119
"""Add MCP routes to a Dash/Flask app."""
52120

53121
app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS)
@@ -191,6 +259,9 @@ def _handle_delete() -> Response:
191259
mcp_path, with_app_context_factory(mcp_handler, app), ["GET", "POST", "DELETE"]
192260
)
193261

262+
if mcp_authorization_server:
263+
_setup_mcp_oauth(app, mcp_path, mcp_authorization_server)
264+
194265
logger.info(
195266
"MCP routes registered at %s%s",
196267
app.config.routes_pathname_prefix,

0 commit comments

Comments
 (0)