From 07a064c478387865cc1d5acbef64b0486ee191e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:49:27 +0000 Subject: [PATCH] fix(mcp): add CORS to /mcp so browser-based connectors can probe it Browser-based MCP clients (Grok/ChatGPT connector UIs) call /mcp via fetch(), but /mcp had no CORS: the OPTIONS preflight hit the bearer gate and returned 401, and the 401 responses carried no Access-Control-Allow- Origin and did not expose WWW-Authenticate, so the browser couldn't read the auth-discovery hint. Add a CORS layer as the OUTERMOST layer on the /mcp router so the preflight is answered (2xx) before the token gate, and the 401's WWW-Authenticate is exposed cross-origin. Authorization is listed explicitly in allow-headers (the `*` wildcard does not cover it per the Fetch spec); WWW-Authenticate and mcp-session-id are exposed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym --- src/main.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 83cbfea..7a0ca73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -952,12 +952,39 @@ async fn main() -> anyhow::Result<()> { // `route_layer` (not `layer`) applies the gate ONLY to matched /mcp routes, so // unmatched paths fall through to a 404 instead of the token gate's 401 — and // the `/oauth/*` and `/.well-known/*` routes stay exempt from the bearer check. + // + // Browser-based MCP clients (e.g. the Grok/ChatGPT connector UIs) call `/mcp` + // via `fetch()`, so it needs CORS — applied as the OUTERMOST layer so the + // OPTIONS preflight is answered (2xx) BEFORE the bearer gate, and the 401's + // `WWW-Authenticate` (the auth-discovery hint) is exposed cross-origin. + // `Authorization` must be listed explicitly: the `*` wildcard for + // `Access-Control-Allow-Headers` does NOT cover it per the Fetch spec. + let mcp_cors = tower_http::cors::CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::OPTIONS, + ]) + .allow_headers([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + axum::http::header::ACCEPT, + axum::http::HeaderName::from_static("mcp-protocol-version"), + axum::http::HeaderName::from_static("mcp-session-id"), + axum::http::HeaderName::from_static("last-event-id"), + ]) + .expose_headers([ + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderName::from_static("mcp-session-id"), + ]); let protected_mcp = axum::Router::new() .nest_service("/mcp", mcp) .route_layer(axum::middleware::from_fn_with_state( store.clone(), auth::require_token, - )); + )) + .layer(mcp_cors); // OAuth authorization-server + discovery endpoints (CORS-open for clients). let cors = tower_http::cors::CorsLayer::new()