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:
2026-06-25 12:45:59 +00:00
parent 12b60ee8c7
commit 289c6b9300
4 changed files with 42 additions and 51 deletions
+19 -23
View File
@@ -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]: