From 1cfc2674c2cef3f81d6b88c2c9403d2c1789bd93 Mon Sep 17 00:00:00 2001 From: Juergen Klaassen Date: Fri, 24 Apr 2026 14:53:54 -0600 Subject: [PATCH 1/4] fix(mcp): make MCP Apps render in browser clients (basic-host, claude.ai) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/cmd/mcp.go | 10 ++++++++ internal/mcp/server.go | 49 ++++++++++++++++++++++++++++++++----- internal/mcp/server_test.go | 8 ++++++ internal/mcp/sse_test.go | 14 +++++++++-- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 2fa0174..75d858d 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -87,6 +87,16 @@ SSE Examples: jc mcp serve --transport sse --addr 0.0.0.0:8080 --tls-cert cert.pem --tls-key key.pem jc mcp serve --transport sse --cors-origin "https://app.example.com" +Streamable HTTP Examples (for Claude Desktop custom connectors and MCP Apps): + jc mcp serve --transport http + jc mcp serve --transport http --port 8090 + +Security: the http transport is stateless and permissive by default (wide-open +CORS, cross-origin checks disabled) so browser-based MCP clients like basic-host +and MCP Apps UIs can connect. When exposing the server via a tunnel (cloudflared, +ngrok, etc.), use --api-key to prevent unauthenticated tool calls from anyone +who discovers the URL. + Use JC_PROFILE environment variable to select which JumpCloud org to use.`, RunE: func(cmd *cobra.Command, args []string) error { // Use config values as defaults; CLI flags override. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index fc01cf4..25963be 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -69,6 +69,15 @@ func NewServer(opts Options) *Server { Level: slog.LevelInfo, })) + // Declare support for the MCP Apps extension so clients (Claude, VS Code + // Copilot, etc.) know to fetch ui:// resources and render them in a + // sandboxed iframe. Without this, the _meta.ui.resourceUri on tool + // definitions is silently ignored by the client per the MCP extension + // handshake rules. See: + // https://modelcontextprotocol.io/extensions/client-matrix + caps := &mcp.ServerCapabilities{} + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{}) + mcpServer := mcp.NewServer( &mcp.Implementation{ Name: "jc", @@ -77,6 +86,7 @@ func NewServer(opts Options) *Server { &mcp.ServerOptions{ Instructions: "JumpCloud CLI MCP server. Manage users, devices, groups, policies, commands, and more.", Logger: logger, + Capabilities: caps, }, ) @@ -228,13 +238,31 @@ func (s *Server) RunSSE(ctx context.Context, cfg SSEConfig) error { func (s *Server) RunStreamableHTTP(ctx context.Context, cfg SSEConfig) error { defer s.auditLog.close() - handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return s.mcpServer - }, &mcp.StreamableHTTPOptions{ + opts := &mcp.StreamableHTTPOptions{ // Disable DNS rebinding protection so tunneled requests (e.g. cloudflared) // with non-localhost Host headers are accepted. DisableLocalhostProtection: true, - }) + // Stateless skips Mcp-Session-Id validation. Browser clients cannot + // send custom headers on EventSource (the GET SSE stream), so strict + // session validation breaks basic-host, MCP Apps UIs, etc. The trade- + // off is that server→client requests are rejected, but MCP Apps only + // use client-initiated tool calls and resource reads. + Stateless: true, + } + // When CORS is explicitly configured, the operator wants browser clients + // to connect (basic-host, MCP Apps UIs, web dashboards). The SDK's default + // CrossOriginProtection rejects such requests via Sec-Fetch-Site with 403 + // "cross-origin request detected". Bypass the check on /mcp — that's the + // only path we mount, and CORS headers still enforce origin policy. + if cfg.CORSOrigin != "" { + cop := http.NewCrossOriginProtection() + cop.AddInsecureBypassPattern("/mcp") + opts.CrossOriginProtection = cop + } + + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return s.mcpServer + }, opts) var h http.Handler = handler if cfg.APIKey != "" { @@ -306,8 +334,17 @@ func (s *Server) authMiddleware(apiKey string, next http.Handler) http.Handler { func corsMiddleware(origin string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, x-api-key, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + // Streamable HTTP clients send several custom headers: Mcp-Session-Id, + // Mcp-Protocol-Version, Last-Event-ID. Rather than chase each new one + // the TS SDK starts sending, allow all headers when the operator has + // explicitly opted into CORS — this is already a permissive config. + // Content-Type, x-api-key, and Authorization are also accepted for + // auth flows when CORSOrigin is non-wildcard. + w.Header().Set("Access-Control-Allow-Headers", "*") + // Clients need to read Mcp-Session-Id from the initialize response + // to echo it back on subsequent requests. + w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 73859b1..253df97 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -142,6 +142,14 @@ func TestMCP_Initialize(t *testing.T) { if initResult.Capabilities.Resources == nil { t.Fatal("expected resources capability") } + // MCP Apps extension must be advertised for ui:// resources to render in + // supporting clients (Claude web/desktop, VS Code Copilot, etc.). Without + // this, clients skip the iframe render even though tool defs carry + // _meta.ui.resourceUri. + if _, ok := initResult.Capabilities.Extensions["io.modelcontextprotocol/ui"]; !ok { + t.Fatalf("expected 'io.modelcontextprotocol/ui' extension in capabilities; got %v", + initResult.Capabilities.Extensions) + } } func TestMCP_ListTools(t *testing.T) { diff --git a/internal/mcp/sse_test.go b/internal/mcp/sse_test.go index 988cfa7..849be68 100644 --- a/internal/mcp/sse_test.go +++ b/internal/mcp/sse_test.go @@ -333,8 +333,18 @@ func TestSSE_CORSHeaders(t *testing.T) { t.Errorf("expected CORS methods to include GET and POST, got %q", methods) } headers := resp.Header.Get("Access-Control-Allow-Headers") - if !strings.Contains(headers, "x-api-key") { - t.Errorf("expected CORS headers to include x-api-key, got %q", headers) + // Server sends "*" so all custom MCP headers (Mcp-Session-Id, + // Mcp-Protocol-Version, Last-Event-ID, x-api-key, etc.) are allowed + // without having to enumerate them and chase SDK additions. + if !strings.Contains(headers, "*") && !strings.Contains(headers, "x-api-key") { + t.Errorf("expected CORS headers to be wildcard or include x-api-key, got %q", headers) + } + if !strings.Contains(headers, "*") && !strings.Contains(headers, "Mcp-Session-Id") { + t.Errorf("expected CORS headers to be wildcard or include Mcp-Session-Id, got %q", headers) + } + exposed := resp.Header.Get("Access-Control-Expose-Headers") + if !strings.Contains(exposed, "Mcp-Session-Id") { + t.Errorf("expected Mcp-Session-Id in Access-Control-Expose-Headers so clients can read it from the initialize response, got %q", exposed) } } From 2b583879dc946e085f32e57dd5c90a907f6a3d60 Mon Sep 17 00:00:00 2001 From: Juergen Klaassen Date: Fri, 24 Apr 2026 15:23:24 -0600 Subject: [PATCH 2/4] fix(mcp): wire APIKey through HTTP transport (Bugbot PR #17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/cmd/mcp.go | 27 ++++++++-- internal/mcp/sse_test.go | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 75d858d..67f1a8b 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -94,8 +94,9 @@ Streamable HTTP Examples (for Claude Desktop custom connectors and MCP Apps): Security: the http transport is stateless and permissive by default (wide-open CORS, cross-origin checks disabled) so browser-based MCP clients like basic-host and MCP Apps UIs can connect. When exposing the server via a tunnel (cloudflared, -ngrok, etc.), use --api-key to prevent unauthenticated tool calls from anyone -who discovers the URL. +ngrok, etc.), configure an API key — via 'jc auth login', the JC_API_KEY env +var, or the --api-key global flag — so the auth middleware rejects +unauthenticated tool calls from anyone who discovers the URL. Use JC_PROFILE environment variable to select which JumpCloud org to use.`, RunE: func(cmd *cobra.Command, args []string) error { @@ -231,14 +232,32 @@ func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, case "http": // Streamable HTTP transport for Claude Desktop custom connectors and MCP Apps. - // No auth by default on loopback — designed for local use with cloudflared tunnels. + // Auth is optional here (unlike sse) so browser-based MCP clients like + // basic-host can connect during local development. When the operator has + // configured an API key (via `jc auth login`, JC_API_KEY, or --api-key), + // we pass it through so the server's auth middleware rejects unauth'd + // calls — critical when exposing via a cloudflared tunnel. listenAddr := resolveSSEAddr(addr, port) + apiKey := config.APIKey() - fmt.Fprintf(os.Stderr, "jc: starting MCP server on Streamable HTTP transport at http://%s/mcp\n", listenAddr) + scheme := "http" + fmt.Fprintf(os.Stderr, "jc: starting MCP server on Streamable HTTP transport at %s://%s/mcp\n", scheme, listenAddr) + if apiKey == "" { + // Warn if binding beyond loopback without auth — that's the + // combination a tunnel creates, too. + if host, _, err := net.SplitHostPort(listenAddr); err == nil { + if host != "127.0.0.1" && host != "::1" && host != "localhost" { + fmt.Fprintln(os.Stderr, "jc: WARNING: HTTP transport running without an API key. Anyone who reaches the server can call all tools.") + } + } + } else { + fmt.Fprintln(os.Stderr, "jc: HTTP transport requires x-api-key or Authorization: Bearer header for all requests.") + } return server.RunStreamableHTTP(ctx, mcp.SSEConfig{ Addr: listenAddr, CORSOrigin: "*", + APIKey: apiKey, }) default: diff --git a/internal/mcp/sse_test.go b/internal/mcp/sse_test.go index 849be68..0925c4d 100644 --- a/internal/mcp/sse_test.go +++ b/internal/mcp/sse_test.go @@ -597,3 +597,111 @@ func generateTestCert(t *testing.T) (certFile, keyFile string) { return certFile, keyFile } + +// startHTTPStreamServer mirrors startSSEServer but for the Streamable HTTP +// transport. Returns the base URL (including the /mcp path). +func startHTTPStreamServer(t *testing.T, cfg SSEConfig) (*Server, string) { + t.Helper() + setupTest(t) + + if cfg.Addr == "" { + cfg.Addr = ":0" + } + + server := NewServer(Options{ + RateLimit: 60, + AuditLogPath: filepath.Join(t.TempDir(), "audit.log"), + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + errCh := make(chan error, 1) + go func() { errCh <- server.RunStreamableHTTP(ctx, cfg) }() + + var addr net.Addr + for i := 0; i < 50; i++ { + addr = server.Listener() + if addr != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if addr == nil { + t.Fatal("HTTP stream server did not start listening") + } + + return server, "http://" + addr.String() + "/mcp" +} + +// TestHTTP_AuthRejectsUnauthenticated is the regression guard for the high- +// severity Bugbot finding: when APIKey is set on the http transport, requests +// without credentials must be rejected. +func TestHTTP_AuthRejectsUnauthenticated(t *testing.T) { + _, baseURL := startHTTPStreamServer(t, SSEConfig{ + APIKey: "test-key", + CORSOrigin: "*", + }) + + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`) + req, _ := http.NewRequest(http.MethodPost, baseURL, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401 Unauthorized without x-api-key, got %d", resp.StatusCode) + } +} + +// TestHTTP_AuthAcceptsCorrectKey confirms auth is actually checked, not bypassed. +func TestHTTP_AuthAcceptsCorrectKey(t *testing.T) { + _, baseURL := startHTTPStreamServer(t, SSEConfig{ + APIKey: "test-key", + CORSOrigin: "*", + }) + + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`) + req, _ := http.NewRequest(http.MethodPost, baseURL, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("x-api-key", "test-key") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 with correct x-api-key, got %d", resp.StatusCode) + } +} + +// TestHTTP_NoAuthWhenNoKey asserts the permissive default: no API key → any +// client can connect (needed for basic-host and local MCP Apps dev). +func TestHTTP_NoAuthWhenNoKey(t *testing.T) { + _, baseURL := startHTTPStreamServer(t, SSEConfig{ + CORSOrigin: "*", + }) + + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`) + req, _ := http.NewRequest(http.MethodPost, baseURL, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 without auth when no API key configured, got %d", resp.StatusCode) + } +} From 8833f9ede9423f5ccd837e70970e0d3243209f9e Mon Sep 17 00:00:00 2001 From: Juergen Klaassen Date: Fri, 24 Apr 2026 15:39:24 -0600 Subject: [PATCH 3/4] fix(mcp): gate http auth behind --require-auth (regression from Bugbot fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/cmd/mcp.go | 66 +++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 67f1a8b..e48894f 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -38,13 +38,14 @@ Configure in Claude Desktop: func newMcpServeCmd() *cobra.Command { var ( - rateLimit int - readOnly bool - transport string - addr string - port int - corsOrigin string - tlsCert string + rateLimit int + readOnly bool + transport string + addr string + port int + corsOrigin string + requireAuth bool + tlsCert string tlsKey string ) @@ -90,13 +91,15 @@ SSE Examples: Streamable HTTP Examples (for Claude Desktop custom connectors and MCP Apps): jc mcp serve --transport http jc mcp serve --transport http --port 8090 + jc mcp serve --transport http --require-auth (for tunnels) Security: the http transport is stateless and permissive by default (wide-open CORS, cross-origin checks disabled) so browser-based MCP clients like basic-host -and MCP Apps UIs can connect. When exposing the server via a tunnel (cloudflared, -ngrok, etc.), configure an API key — via 'jc auth login', the JC_API_KEY env -var, or the --api-key global flag — so the auth middleware rejects -unauthenticated tool calls from anyone who discovers the URL. +and MCP Apps UIs can connect without friction. When exposing the server via a +tunnel (cloudflared, ngrok, etc.), add --require-auth to enforce that every +request carries the configured JumpCloud API key via x-api-key or +Authorization: Bearer. --require-auth reads the key from 'jc auth login', +JC_API_KEY, or the --api-key global flag, and refuses to start without one. Use JC_PROFILE environment variable to select which JumpCloud org to use.`, RunE: func(cmd *cobra.Command, args []string) error { @@ -110,7 +113,7 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`, if !cmd.Flags().Changed("port") { port = config.MCPSSEPort() } - return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey) + return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey, requireAuth) }, } @@ -122,6 +125,7 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`, cmd.Flags().StringVar(&corsOrigin, "cors-origin", "", "Allowed CORS origin for SSE transport") cmd.Flags().StringVar(&tlsCert, "tls-cert", "", "TLS certificate file for SSE transport") cmd.Flags().StringVar(&tlsKey, "tls-key", "", "TLS private key file for SSE transport") + cmd.Flags().BoolVar(&requireAuth, "require-auth", false, "Require x-api-key / Authorization Bearer on every request (http transport). Off by default so local browser clients like basic-host can connect. Turn on when exposing via a tunnel.") return cmd } @@ -174,7 +178,7 @@ func resolveSSEAddr(addr string, port int) string { return addr } -func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string) error { +func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string, requireAuth bool) error { server := mcp.NewServer(mcp.Options{ RateLimit: rateLimit, ReadOnly: readOnly, @@ -231,33 +235,37 @@ func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, }) case "http": - // Streamable HTTP transport for Claude Desktop custom connectors and MCP Apps. - // Auth is optional here (unlike sse) so browser-based MCP clients like - // basic-host can connect during local development. When the operator has - // configured an API key (via `jc auth login`, JC_API_KEY, or --api-key), - // we pass it through so the server's auth middleware rejects unauth'd - // calls — critical when exposing via a cloudflared tunnel. + // Streamable HTTP transport for Claude Desktop custom connectors and + // MCP Apps. Auth is off by default so browser-based clients like + // basic-host can connect for local dev. Enable with --require-auth + // when exposing via a tunnel; the server then requires x-api-key or + // Authorization: Bearer matching the configured JumpCloud API key. listenAddr := resolveSSEAddr(addr, port) - apiKey := config.APIKey() - scheme := "http" - fmt.Fprintf(os.Stderr, "jc: starting MCP server on Streamable HTTP transport at %s://%s/mcp\n", scheme, listenAddr) - if apiKey == "" { - // Warn if binding beyond loopback without auth — that's the - // combination a tunnel creates, too. + var httpAPIKey string + if requireAuth { + httpAPIKey = config.APIKey() + if httpAPIKey == "" { + return fmt.Errorf("--require-auth needs an API key to enforce. Run 'jc auth login' or set JC_API_KEY, or drop --require-auth for local dev") + } + } + + fmt.Fprintf(os.Stderr, "jc: starting MCP server on Streamable HTTP transport at http://%s/mcp\n", listenAddr) + if httpAPIKey != "" { + fmt.Fprintln(os.Stderr, "jc: auth REQUIRED (x-api-key or Authorization: Bearer).") + } else { + // Warn on non-loopback binds without auth — effectively public. if host, _, err := net.SplitHostPort(listenAddr); err == nil { if host != "127.0.0.1" && host != "::1" && host != "localhost" { - fmt.Fprintln(os.Stderr, "jc: WARNING: HTTP transport running without an API key. Anyone who reaches the server can call all tools.") + fmt.Fprintln(os.Stderr, "jc: WARNING: bound to a non-loopback address without --require-auth. Anyone who reaches the server can call all tools.") } } - } else { - fmt.Fprintln(os.Stderr, "jc: HTTP transport requires x-api-key or Authorization: Bearer header for all requests.") } return server.RunStreamableHTTP(ctx, mcp.SSEConfig{ Addr: listenAddr, CORSOrigin: "*", - APIKey: apiKey, + APIKey: httpAPIKey, }) default: From a8a3d0f5811281127be2753bb5146d600b606d48 Mon Sep 17 00:00:00 2001 From: Juergen Klaassen Date: Fri, 24 Apr 2026 15:47:43 -0600 Subject: [PATCH 4/4] fix(mcp): allow Authorization in CORS preflight (Bugbot PR #17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/mcp/server.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 25963be..74ab80b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -336,12 +336,13 @@ func corsMiddleware(origin string, next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") // Streamable HTTP clients send several custom headers: Mcp-Session-Id, - // Mcp-Protocol-Version, Last-Event-ID. Rather than chase each new one - // the TS SDK starts sending, allow all headers when the operator has - // explicitly opted into CORS — this is already a permissive config. - // Content-Type, x-api-key, and Authorization are also accepted for - // auth flows when CORSOrigin is non-wildcard. - w.Header().Set("Access-Control-Allow-Headers", "*") + // Mcp-Protocol-Version, Last-Event-ID. The wildcard covers all of + // those plus x-api-key. Authorization must be listed explicitly — per + // the Fetch spec it's a special-cased header that wildcards don't + // cover (https://fetch.spec.whatwg.org/#cors-safelisted-request-header) + // and browser clients using Authorization: Bearer would otherwise fail + // the preflight even though authMiddleware accepts it. + w.Header().Set("Access-Control-Allow-Headers", "*, Authorization") // Clients need to read Mcp-Session-Id from the initialize response // to echo it back on subsequent requests. w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id")