fix(mcp): make MCP Apps render in browser clients (basic-host, claude.ai)#17
Conversation
….ai) MCP Apps rendering was shipped in KLA-360 but never actually worked end-to- end from a browser-based host — the tool meta declared _meta.ui.resourceUri, the resource served the HTML with the right MIME, but the client never got far enough to fetch and render it. Five separate handshake/CORS/transport bugs all had to be fixed for basic-host to render the dashboard. Confirmed working via the reference basic-host (github.com/modelcontextprotocol/ ext-apps/examples/basic-host): the JumpCloud dashboard renders with stat cards, MFA ring, OS bars, and connectivity segments as intended. Five changes: 1. Advertise the io.modelcontextprotocol/ui extension in server capabilities. Per the client-matrix spec, extensions are opt-in; clients skip MCP Apps rendering entirely if the server doesn't declare support, even when tool defs carry _meta.ui.resourceUri. 2. Stateless: true on the Streamable HTTP handler. Browser EventSource objects cannot send custom headers, so the TS SDK client cannot include Mcp-Session-Id on the long-lived GET SSE stream. Strict session validation in the Go SDK rejected the GET; stateless mode sidesteps this. Trade-off: no server→client requests, but MCP Apps only use client-initiated tool calls and resource reads. 3. Bypass CrossOriginProtection on /mcp when CORSOrigin is set. The Go SDK uses Go 1.25 net/http.CrossOriginProtection which rejects cross-origin requests via Sec-Fetch-Site with 403 "cross-origin request detected", independent of DisableLocalhostProtection. Gated behind explicit CORS config so the default remains safe. 4. Wildcard Access-Control-Allow-Headers. The TS SDK sends Mcp-Protocol-Version (and likely more custom headers over time); chasing each new one led to silent ERR_FAILED in the browser. Wildcard is pragmatic since CORS is already opted-in when this path runs. 5. Expose Mcp-Session-Id via Access-Control-Expose-Headers so browser clients can read the session ID from the initialize response. Also adds a brief security note to `jc mcp serve` help text recommending --api-key when exposing the HTTP transport through a tunnel, since the http mode is deliberately permissive for browser clients. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bugbot flagged (high severity): the help text advertised --api-key for the
http transport but the code passed an empty APIKey to SSEConfig, so users
following the security guidance would have ended up with an unauthenticated
server they believed was protected.
- http case now reads config.APIKey() and passes it through, matching the
sse case's behavior (but auth stays optional for http, not required, so
basic-host and local MCP Apps dev still work without a key)
- Startup log now discloses auth state and warns when binding a
non-loopback address without auth (tunnel-like exposure)
- Help text reworded to point at the actual ways to configure the key
('jc auth login', JC_API_KEY env var, --api-key global flag) rather than
vaguely saying "use --api-key"
Three regression tests added:
- TestHTTP_AuthRejectsUnauthenticated — no header → 401 when key configured
- TestHTTP_AuthAcceptsCorrectKey — correct x-api-key → 200
- TestHTTP_NoAuthWhenNoKey — permissive default preserved for local dev
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed the Bugbot high-severity finding in 2b58387:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 2b58387. Configure here.
…t fix) The previous commit wired config.APIKey() unconditionally into the http transport's SSEConfig. Users who had run 'jc auth login' (i.e. essentially everyone) then had an HTTP server that required x-api-key on every request — silently breaking basic-host and MCP Apps local testing, which was the whole reason the http transport exists. - Add --require-auth boolean flag (default off) - When off: no auth, preserving the local-dev path - When on: read config.APIKey(), refuse to start if missing, enforce on every request via the existing authMiddleware - Startup log discloses which mode is active - Help text documents the flag with a tunnel-exposure example The Bugbot finding (help text promised auth that the code didn't wire through) is still addressed — auth IS now wire-able — it's just an explicit opt-in instead of an invisible side-effect of auth config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up to the Bugbot fix: the previous commit wired
Startup log discloses the mode. The three regression tests still hold: request without auth is accepted when off, rejected with 401 when on, accepted when on with matching key. |
Access-Control-Allow-Headers: * does not cover Authorization per the Fetch spec — it's special-cased and must be listed explicitly. Browser clients using Authorization: Bearer (e.g., a custom connector configured with a bearer token) would fail the preflight despite authMiddleware accepting that header in the actual request. Value is now '*, Authorization': wildcard still covers Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, x-api-key, and anything else the TS SDK decides to add; Authorization is listed explicitly. Existing TestSSE_CORSHeaders test (wildcard OR x-api-key match) continues to pass with the new value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed the Bugbot medium finding in a8a3d0f: Access-Control-Allow-Headers is now |

Summary
The five fixes
io.modelcontextprotocol/uiextension in initialize capabilitiesServerCapabilities.AddExtension("io.modelcontextprotocol/ui", ...)EventSourcecan't send custom headers → TS SDK's GET SSE stream failed 400 "GET requires Mcp-Session-Id"StreamableHTTPOptions.Stateless: truehttp.CrossOriginProtection)AddInsecureBypassPattern("/mcp"), only when CORSOrigin is setnet::ERR_FAILEDbecause request carriedMcp-Protocol-Versionheader not inAccess-Control-Allow-Headers*on Allow-Headers when CORS is configuredMcp-Session-Idfrom the initialize responseAccess-Control-Expose-HeadersEach on its own was a silent failure with a different symptom. The HAR captures made it tractable.
Security note
The `http` transport is deliberately permissive after these changes (wide-open CORS, cross-origin checks disabled) so browser MCP clients work. The help text now explicitly recommends `--api-key` when exposing the server through a tunnel, since the auth middleware is the only remaining gate against anonymous tool calls.
Stateless mode means server→client requests are rejected, but MCP Apps are client-initiated (tool call + resource read) so this doesn't regress the intended use.
Test plan
Related
🤖 Generated with Claude Code
Note
Medium Risk
Modifies the Streamable HTTP transport to be more permissive for browser clients (stateless mode, cross-origin bypass, broader CORS), which could increase exposure if operators bind publicly without enabling auth. Adds an explicit
--require-authgate and new tests to reduce the chance of accidentally running unauthenticated.Overview
Fixes browser-based MCP Apps rendering by advertising the
io.modelcontextprotocol/uiextension in server capabilities and adjusting Streamable HTTP behavior to work withEventSourceand cross-origin requests.Streamable HTTP now runs in stateless mode, conditionally bypasses Go’s cross-origin protection on
/mcpwhen CORS is enabled, and expands CORS headers (methods, wildcard allow-headers plus explicitAuthorization, and exposesMcp-Session-Id).Adds
jc mcp serve --require-authto optionally enforce API-key auth for thehttptransport (and refuse startup without a configured key), emits warnings for non-loopback unauthenticated binds, and adds regression tests for HTTP auth plus updated CORS/header assertions.Reviewed by Cursor Bugbot for commit a8a3d0f. Bugbot is set up for automated code reviews on this repo. Configure here.