refactor: public OAuth MCP at /mcp (readonly path is alias)
- OAuth read+write on /mcp; API key on /mcp still full internal tools (LAN) - /readonly/mcp and /oauth/mcp remain OAuth aliases - OAuth metadata and connector_url point to /mcp - README + Traefik template: route Host without blocking /mcp
This commit is contained in:
+19
-23
@@ -45,13 +45,14 @@ WRITE_MCP_TOOLS = {
|
||||
"sync_to_project",
|
||||
}
|
||||
|
||||
# Per-request OAuth scopes for the unified public MCP connector (/readonly/mcp).
|
||||
# Per-request OAuth scopes for the public MCP connector (/mcp).
|
||||
_oauth_mcp_ctx: contextvars.ContextVar[dict] = contextvars.ContextVar(
|
||||
"oauth_mcp_ctx",
|
||||
default={"has_read": False, "has_write": False, "user_id": "oauth"},
|
||||
)
|
||||
|
||||
PUBLIC_OAUTH_MCP_PATHS = frozenset({"/readonly/mcp", "/oauth/mcp"})
|
||||
# Streamable HTTP MCP paths. Canonical public URL is /mcp; aliases for older configs.
|
||||
MCP_STREAMABLE_PATHS = frozenset({"/mcp", "/readonly/mcp", "/oauth/mcp"})
|
||||
|
||||
|
||||
def _b64url_sha256(value: str) -> str:
|
||||
@@ -1320,7 +1321,7 @@ class HTTPServer:
|
||||
self._conn.commit()
|
||||
issuer = (self.cfg.oauth_issuer or "").rstrip("/") or "https://ctxd.cubecraftcreations.com"
|
||||
out = {k: v for k, v in client.items()}
|
||||
out["connector_url"] = f"{issuer}/readonly/mcp"
|
||||
out["connector_url"] = f"{issuer}/mcp"
|
||||
out["authorization_server"] = issuer
|
||||
return (201, {"Content-Type": "application/json"}, json.dumps(out))
|
||||
|
||||
@@ -1433,7 +1434,7 @@ class CombinedApp:
|
||||
|
||||
if method == "GET" and path.startswith("/.well-known/oauth-protected-resource"):
|
||||
payload = {
|
||||
"resource": f"{base}/readonly/mcp",
|
||||
"resource": f"{base}/mcp",
|
||||
"authorization_servers": [issuer],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{base}/",
|
||||
@@ -1630,17 +1631,24 @@ class CombinedApp:
|
||||
await self._serve_oauth(scope, receive, send, oauth_body)
|
||||
return
|
||||
|
||||
# Public OAuth MCP (Streamable HTTP) — single connector for read + write.
|
||||
if path in PUBLIC_OAUTH_MCP_PATHS:
|
||||
# Streamable HTTP MCP — /mcp (OAuth read+write or API key full access on /mcp only).
|
||||
if path in MCP_STREAMABLE_PATHS:
|
||||
token = _request_token()
|
||||
ok, ctx = self._public_mcp_auth_context(token)
|
||||
if not ok:
|
||||
await _auth_error()
|
||||
if path == "/mcp" and self.cfg.api_key and token == self.cfg.api_key:
|
||||
await self._serve_streamable_mcp(
|
||||
scope, receive, send,
|
||||
self.mcp_app, self._mcp_init_opts,
|
||||
surface_name="internal",
|
||||
)
|
||||
return
|
||||
await self._serve_oauth_mcp_streamable(scope, receive, send, ctx)
|
||||
ok, ctx = self._public_mcp_auth_context(token)
|
||||
if ok:
|
||||
await self._serve_oauth_mcp_streamable(scope, receive, send, ctx)
|
||||
return
|
||||
await _auth_error()
|
||||
return
|
||||
|
||||
# Legacy write-only SSE (same tools as unified connector; prefer /readonly/mcp).
|
||||
# Legacy SSE write path (prefer /mcp Streamable HTTP).
|
||||
# Note: Using SSE instead of Streamable HTTP because MCP SDK 1.28's
|
||||
# Streamable HTTP transport has a race condition where EventSourceResponse
|
||||
# is killed by the task group before sending headers.
|
||||
@@ -1662,18 +1670,6 @@ class CombinedApp:
|
||||
await self._serve_write_mcp_sse(scope, receive, send, ctx)
|
||||
return
|
||||
|
||||
# Internal full MCP (Streamable HTTP) — shared API key only.
|
||||
if path == "/mcp":
|
||||
token = _request_token()
|
||||
if self.cfg.auth_enabled and token != self.cfg.api_key:
|
||||
await _auth_error()
|
||||
return
|
||||
await self._serve_streamable_mcp(
|
||||
scope, receive, send,
|
||||
self.mcp_app, self._mcp_init_opts,
|
||||
surface_name="internal",
|
||||
)
|
||||
return
|
||||
token = _request_token()
|
||||
|
||||
def _resolve_http_auth(tok: str) -> tuple[bool, dict]:
|
||||
|
||||
Reference in New Issue
Block a user