Skip to content

fix(mcp): make MCP Apps render in browser clients (basic-host, claude.ai)#17

Merged
jklaassenjc merged 4 commits into
mainfrom
juergen/mcp-apps-browser-compat
Apr 28, 2026
Merged

fix(mcp): make MCP Apps render in browser clients (basic-host, claude.ai)#17
jklaassenjc merged 4 commits into
mainfrom
juergen/mcp-apps-browser-compat

Conversation

@jklaassenjc
Copy link
Copy Markdown
Collaborator

@jklaassenjc jklaassenjc commented Apr 24, 2026

Summary

  • MCP Apps (shipped in KLA-360) was returning tool data fine but the UI never rendered when driven from a browser-based host
  • Five independent bugs in the handshake / CORS / transport chain all had to land together for the iframe render to kick in
  • Confirmed end-to-end against the reference basic-host: the JumpCloud dashboard renders correctly with stat cards, MFA ring, OS bars, and connectivity segments

The five fixes

# Problem Fix
1 Client skipped MCP Apps render because server never declared the io.modelcontextprotocol/ui extension in initialize capabilities ServerCapabilities.AddExtension("io.modelcontextprotocol/ui", ...)
2 Browser EventSource can't send custom headers → TS SDK's GET SSE stream failed 400 "GET requires Mcp-Session-Id" StreamableHTTPOptions.Stateless: true
3 Cross-origin POSTs got 403 "cross-origin request detected from Sec-Fetch-Site" (Go 1.25 http.CrossOriginProtection) AddInsecureBypassPattern("/mcp"), only when CORSOrigin is set
4 Subsequent POSTs got net::ERR_FAILED because request carried Mcp-Protocol-Version header not in Access-Control-Allow-Headers Wildcard * on Allow-Headers when CORS is configured
5 Clients couldn't read Mcp-Session-Id from the initialize response Added it to Access-Control-Expose-Headers

Each 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

  • `go test ./...` — new/updated assertions for extension capability and CORS header shape
  • `go vet ./...` — clean
  • Basic-host end-to-end: dashboard renders with live JumpCloud data (screenshot on request)
  • claude.ai custom connector end-to-end — connector now reaches the server without 403/ERR_FAILED, but whether claude.ai renders MCP Apps for custom connectors appears to be a separate rollout on Anthropic's side
  • Claude Desktop - same situation as claude.ai

Related

  • KLA-360 — original MCP Apps ticket (shipped but the handshake was broken for browser hosts)

🤖 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-auth gate and new tests to reduce the chance of accidentally running unauthenticated.

Overview
Fixes browser-based MCP Apps rendering by advertising the io.modelcontextprotocol/ui extension in server capabilities and adjusting Streamable HTTP behavior to work with EventSource and cross-origin requests.

Streamable HTTP now runs in stateless mode, conditionally bypasses Go’s cross-origin protection on /mcp when CORS is enabled, and expands CORS headers (methods, wildcard allow-headers plus explicit Authorization, and exposes Mcp-Session-Id).

Adds jc mcp serve --require-auth to optionally enforce API-key auth for the http transport (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.

….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>
Comment thread internal/cmd/mcp.go
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>
@jklaassenjc
Copy link
Copy Markdown
Collaborator Author

Addressed the Bugbot high-severity finding in 2b58387:

  • http transport now reads config.APIKey() and passes it through to SSEConfig, mirroring the sse case
  • Startup log discloses auth state; warns when binding a non-loopback address without auth
  • Help text updated to point at the actual configuration paths ('jc auth login', JC_API_KEY, or --api-key global flag) rather than vaguely suggesting --api-key
  • 3 regression tests: rejects without auth, accepts with correct key, stays permissive when no key configured (preserves local dev flow)

Stacked PRs #18 and #19 rebased onto the updated base.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 2b58387. Configure here.

Comment thread internal/mcp/server.go Outdated
…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>
@jklaassenjc
Copy link
Copy Markdown
Collaborator Author

Follow-up to the Bugbot fix: the previous commit wired config.APIKey() into the http transport's SSEConfig unconditionally, which silently required auth whenever anyone had run jc auth login — breaking the basic-host / local MCP Apps dev path the transport was meant for.

8833f9e gates auth behind an explicit --require-auth flag:

  • Off by default → local dev works, basic-host connects without credentials
  • On (jc mcp serve --transport http --require-auth) → refuses to start without a configured API key, enforces x-api-key / Authorization: Bearer on every request

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.

Stacks #18 and #19 rebased on the updated base.

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>
@jklaassenjc
Copy link
Copy Markdown
Collaborator Author

Addressed the Bugbot medium finding in a8a3d0f: Access-Control-Allow-Headers is now *, Authorization since * doesn't cover the Authorization header per the Fetch spec. Bearer-token clients (custom connectors configured with a bearer) can now pass preflight.

@jklaassenjc jklaassenjc merged commit f710c3f into main Apr 28, 2026
5 of 6 checks passed
@jklaassenjc jklaassenjc deleted the juergen/mcp-apps-browser-compat branch April 28, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants