Add CORS support to the transparent MCP proxy#5588
Conversation
The transparent MCP proxy has no OPTIONS handler, so browser-based clients such as MCP Inspector get a 405 on the CORS preflight and the browser blocks the real request. This adds an opt-in CORS middleware wired as the outermost handler on the proxy, so preflights are answered before the method gate or backend can reject them. A new --allow-origins flag on 'thv proxy' enables it; with no flag the behaviour is unchanged. Origins support exact, scheme+host (any port), and wildcard matching. No Access-Control-Allow-Credentials is emitted, so a wildcard is never combined with credentials. Fixes stacklok#4297 Signed-off-by: Devam Shah <devamshah91@gmail.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5588 +/- ##
========================================
Coverage 70.01% 70.02%
========================================
Files 651 660 +9
Lines 66401 66903 +502
========================================
+ Hits 46492 46850 +358
- Misses 16533 16669 +136
- Partials 3376 3384 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
aponcedeleonch
left a comment
There was a problem hiding this comment.
Thanks for the contribution. Clean implementation, well-structured tests, and the security defaults (opt-in, no credentials header) are all correct.
| requestOrigin: "http://localhost:6274", | ||
| allowed: []string{"http://local"}, | ||
| expectedResult: "", | ||
| }, |
There was a problem hiding this comment.
suggestion: The entry + ":" colon boundary is the key guard here. It's what prevents http://localhost.evil.com from matching the entry http://localhost. That invariant isn't explicitly tested, though. Worth adding a case alongside the existing prefix tests:
{
name: "prefix match does not allow sibling hostname",
requestOrigin: "http://localhost.evil.com",
allowed: []string{"http://localhost"},
expectedResult: "",
},This documents the security property and protects against a future refactor accidentally weakening it.
| "Headers with secret values from ToolHive secrets manager (format: Name=secret-name, can be repeated)") | ||
|
|
||
| // CORS — disabled by default; opt in explicitly to avoid widening the attack surface | ||
| proxyCmd.Flags().StringArrayVar(&proxyCORSOrigins, "allow-origins", []string{}, |
There was a problem hiding this comment.
One thing to do before CI goes green: run task docs from the repo root to regenerate the CLI reference docs. Adding --allow-origins means the auto-generated content under docs/ is out of date, and the docs check will fail without it.
JAORMX
left a comment
There was a problem hiding this comment.
So, got a panel together on this one (spec, standards, security, architecture, MCP protocol, UX, devex) since CORS is one of those features that looks small but has a habit of biting you later. The security-sensitive ask got extra eyes.
Overall the shape is good. Opt-in, default-off, no credentials, origin matching that doesn't fall for the usual prefix-spoofing traps. The toggle you asked about is there and it works... --allow-origins is repeatable, empty by default, and there are guards at four layers so a deployment that handles CORS at a gateway is genuinely unaffected. That part's solid.
But there's one thing that'll break the headline use case, plus a few rough edges worth tightening before this merges.
Blocking: MCP-Protocol-Version is missing from the allowed headers
This one's the real problem. The MCP Streamable HTTP transport (2025-11-25) has clients send MCP-Protocol-Version on requests after initialize. ToolHive itself reads and validates it at pkg/transport/proxy/streamable/streamable_proxy.go:360... a bad version gets a 400. And pkg/telemetry/middleware.go:393 reads it too.
The catch: MCP-Protocol-Version is not a CORS-safelisted request header. So a spec-compliant browser client (MCP Inspector included) that sends it triggers a preflight, and Access-Control-Allow-Headers has to list it. Right now corsAllowedHeaders is "Content-Type, Accept, Mcp-Session-Id, Authorization" (cors.go:19), no protocol version. So the browser blocks the actual POST right after initialize. Which is exactly the scenario this PR exists to enable. Oops!
Fix:
corsAllowedHeaders = "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Authorization, Last-Event-ID"
corsExposedHeaders = "Mcp-Session-Id, MCP-Protocol-Version"Last-Event-ID is for SSE stream resumption (also not safelisted). MCP-Protocol-Version belongs in Expose-Headers too, so the browser can read the negotiated version back from the response.
Security findings (extra-focus pass)
The origin matching is sound against the attacks I worried about most. matchCORSOrigin (cors.go:85) uses a : boundary for the scheme+host prefix, so http://localhost does NOT match http://localhost.evil.com (the next char is ., not :). No CWE-942. Wildcard * is never paired with credentials (no Access-Control-Allow-Credentials is ever set, anywhere). Default-off is real. No CRLF response-splitting path (Go's http.Header.Set sanitizes, and the request parser terminates Origin at CRLF). Vary: Origin is set correctly with Add so it composes. Good.
One medium worth a decision: the CORS middleware is wired outermost (transparent_proxy.go:1242), so OPTIONS preflights are intercepted before the auth middleware runs. That's actually spec-conformant (the Fetch standard expects preflight to be unauthenticated, and the response leaks nothing sensitive, just static headers), so I'd call it acceptable. But it does mean an unauthenticated OPTIONS advertises Authorization in Access-Control-Allow-Headers, which tells a prober "this endpoint expects bearer tokens." Minor capability leak, not a hole. Worth a one-line comment noting preflight is intentionally unauthenticated.
Also: on a stateless server, preflight advertises GET, POST, DELETE, OPTIONS but the statelessMethodGate (transparent_proxy.go:1510) only allows POST, OPTIONS. So a browser preflights DELETE successfully, then the actual DELETE hits 405. Preflight/actual asymmetry... confusing for clients, not a security issue. Either make the advertised methods conditional on stateless, or accept the mismatch with a comment.
UX / toggle
The toggle design itself is right. But --help doesn't actually tell the gateway-deployment operator that doing nothing is the correct choice. The "disabled by default" note is in a code comment (proxy.go:163), not the help string. An operator running thv proxy --help sees the flag with no indication that omitting it leaves CORS off. One line in the help text would fix it:
CORS is disabled by default; omit this flag when CORS is handled by an upstream gateway.
Second thing: --allow-origins "" (empty string) is a surprising edge. It passes the len > 0 check (length is 1), activates the middleware, but matchCORSOrigin matches nothing... so you get OPTIONS preflights eaten (204 instead of 405) with no CORS headers set. Half-configured and confusing. Validate/reject empty entries, or filter them before the guard.
The thv run omission is a real UX gap too... thv run is the primary way operators expose MCP servers, and it has no --allow-origins. The plumbing (WithAllowedOrigins) exists, it's just not wired through buildProxyOptions (pkg/transport/http.go:431). The PR scopes it to thv proxy only, which keeps the diff small, but an operator will reasonably expect parity. At least note the limitation in the help text if it's a follow-up.
Architecture / devex
A few structural notes from the architect and devex passes:
NewTransparentProxyis now a thin delegator toNewTransparentProxyWithOptions, and after this PR it has zero non-test callers. Either deprecate it (it's dead weight) or commit to it as a stable alias. Right now it's boilerplate that has to stay in lockstep on every signature change (go-style "avoid parallel types that drift").- Origins aren't validated.
--allow-origins "localhost:6274"(no scheme) or"http://localhost:6274/"(trailing slash) silently never matches anything, and the deployer gets a broken browser experience with no startup signal. The go-style rule "Constructor Validation: Fail Loudly on Invalid Input" applies here... validate atWithAllowedOrigins/CORSand reject entries that aren't*or don't parse as a URL with scheme + host. CORSandWithAllowedOriginsretain the caller's slice without a defensive copy (cors.go:51,transparent_proxy.go:356). The closure lives for process lifetime. A one-lineslices.Cloneremoves the footgun.- The
CORSdoc comment should state the credentials constraint explicitly:Access-Control-Allow-Credentialsis never set, so cookie-based browser auth won't work regardless of origin matching. Deployers wiring browser auth via cookies will hit this with no pointer in the docs. - The triple empty-guard (in
CORS, inWithAllowedOrigins, inStart) is redundant. Pick one layer to own the contract.
Standards
Minor: cmd/thv/app/proxy.go:280 uses the mutable-variable-then-conditional-append pattern; go-style prefers an immediately-invoked anonymous function. The E2E omission is disclosed and acceptable for this scope (browser-preflight is impractical in the container E2E harness), and the thv run follow-up is a reasonable scope boundary.
Summary
The toggle is there and the security posture against the usual CORS attacks is solid. But MCP-Protocol-Version missing from the allowed headers breaks the browser client case this PR exists to fix, so that's a request-changes from me. Once that's in, the UX help-text disclosure, the empty-string validation, and the origin validation are the next ones I'd want before merge. The rest (stateless method mismatch, thv run parity, constructor cleanup, slice copy, doc comment) can land as follow-ups if you want to keep this tight.
| corsAllowedMethods = "GET, POST, DELETE, OPTIONS" | ||
|
|
||
| // corsAllowedHeaders lists request headers MCP clients may send. | ||
| corsAllowedHeaders = "Content-Type, Accept, Mcp-Session-Id, Authorization" |
There was a problem hiding this comment.
Ship-blocker: MCP-Protocol-Version is missing here. ToolHive reads and validates it at pkg/transport/proxy/streamable/streamable_proxy.go:360 (bad version = 400) and pkg/telemetry/middleware.go:393. It's not a CORS-safelisted header, so a spec-compliant browser client sending it triggers a preflight that this allowlist rejects... and the browser blocks the POST right after initialize. Which is the exact case this PR enables.
Add MCP-Protocol-Version (and Last-Event-ID for SSE resumption) to corsAllowedHeaders. Verified against the current branch.
| corsAllowedHeaders = "Content-Type, Accept, Mcp-Session-Id, Authorization" | ||
|
|
||
| // corsExposedHeaders lists response headers that browsers may read. | ||
| corsExposedHeaders = "Mcp-Session-Id, Content-Type" |
There was a problem hiding this comment.
MCP-Protocol-Version should be in Expose-Headers too, so a browser client can read the negotiated version back from the response. (Content-Type is already CORS-safelisted, so listing it here is harmless but redundant.)
|
|
||
| const ( | ||
| // corsAllowedMethods lists the HTTP methods the MCP proxy accepts. | ||
| corsAllowedMethods = "GET, POST, DELETE, OPTIONS" |
There was a problem hiding this comment.
On a stateless server, the statelessMethodGate (transparent_proxy.go:1510) only allows POST, OPTIONS, but preflight advertises GET, POST, DELETE, OPTIONS. So a browser preflights DELETE successfully, then the actual DELETE hits 405. Preflight/actual asymmetry... confusing for clients. Either make the advertised methods conditional on stateless, or note the mismatch with a comment.
| // All OPTIONS requests are handled directly (returning 204) when this middleware | ||
| // is active so that CORS preflights never reach the backend, which previously | ||
| // returned 405 Method Not Allowed. | ||
| func CORS(allowedOrigins []string) types.MiddlewareFunction { |
There was a problem hiding this comment.
Origins aren't validated. --allow-origins "localhost:6274" (no scheme) or "http://localhost:6274/" (trailing slash) silently never matches anything, and the deployer gets a broken browser experience with no startup signal. Per go-style "Constructor Validation: Fail Loudly on Invalid Input", reject entries that aren't * or don't parse as a URL with scheme + host.
Also: allowedOrigins is captured into the closure without a slices.Clone... the closure lives for process lifetime, so a caller mutating the backing array later would change matching at runtime. One-line slices.Clone removes the footgun.
| `Allowed CORS origins for the MCP proxy endpoint (repeatable). | ||
| Supported forms: | ||
| exact: http://localhost:6274 | ||
| scheme+host: http://localhost (matches any port on localhost) |
There was a problem hiding this comment.
The "disabled by default" note is in the code comment above, not the help string an operator sees in --help. An operator who handles CORS at a gateway gets no signal that doing nothing is the correct choice. One line in the help text would fix it:
CORS is disabled by default; omit this flag when CORS is handled by an upstream gateway.
Also watch the --allow-origins "" edge: it passes the len > 0 check (length 1), activates the middleware, but matchCORSOrigin matches nothing... so preflights get eaten (204 instead of 405) with no CORS headers. Half-configured and confusing. Validate/reject empty entries or filter them before the guard.
| return func(p *TransparentProxy) { | ||
| if len(origins) > 0 { | ||
| p.corsOrigins = origins | ||
| } |
There was a problem hiding this comment.
NewTransparentProxy now delegates to NewTransparentProxyWithOptions and has zero non-test callers after this PR. Either deprecate it (dead weight) or commit to it as a stable alias... right now it's boilerplate that has to stay in lockstep on every signature change (go-style "avoid parallel types that drift").
Also: WithAllowedOrigins silently no-ops on empty input and stores the caller's slice without a copy. Consider validating origins here (fail loudly on invalid) and slices.Clone-ing before storing.
… origin validation Resolves the maintainer panel feedback on the transparent MCP proxy CORS support: - Allow-Headers: add MCP-Protocol-Version so browser MCP clients can send the header ToolHive reads/validates on the request path (ship-blocker). - Expose-Headers: add MCP-Protocol-Version so clients can read the negotiated version; drop redundant Content-Type (already CORS-safelisted). - Stateless method mismatch: derive the advertised preflight methods from the same source of truth the request path enforces. statelessMethodGate and the CORS preflight now share statelessAllowedMethods / statefulAllowedMethods, so a stateless server advertises only "POST, OPTIONS". - Origin validation at startup: ValidateAndNormalizeOrigins rejects schemeless origins (can never match) and strips trailing slashes with a warning, instead of silently failing in the browser. - Help text: document that CORS is disabled by default and scheme/no-trailing- slash requirements on the --allow-origins flag. - NewTransparentProxy: document it as a committed stable positional-arg wrapper around NewTransparentProxyWithOptions (kept; widely used by tests). - Tests: colon-boundary invariant (evil-subdomain must not match), caller-method reflection, MCP-Protocol-Version header presence, origin validation cases, and preflight/method-gate consistency. - Regenerate docs/cli for the new --allow-origins flag. Signed-off-by: Devam Shah <devamshah91@gmail.com>
|
Thanks @JAORMX for convening the panel and @aponcedeleonch for the careful read — the feedback was precise and made this materially better. Pushed 1. Ship-blocker — 2. Expose-Headers. Added 3. Stateless method mismatch. Reconciled against the real source of truth. The advertised preflight methods are now derived from the same constants the request path enforces: 4. Origin validation at startup. New 5. Help text. The 6. 7. Colon-boundary invariant test. Added explicit cases to 8. Docs. Regenerated the CLI reference for the new flag ( Happy to adjust any of these — particularly the call on #6 if you'd prefer deprecation with a lint exclusion instead. |
|
Thanks for the quick turnaround on the feedback. Good progress addressing the feedback. Two open items from JAORMX's inline comments that weren't picked up in the latest commit:
CI is also red, so another commit is coming anyway. Once that's in, I'm fine to approve unless @JAORMX wants those two items resolved first before closing out the review. |
|
CI's still red on
And the two open items from before are still hanging:
Fix those three, re-run CI, and once it's green I'll approve. |
Summary
Add opt-in CORS support to the transparent MCP proxy so that browser-based clients (e.g. MCP Inspector) can connect without hitting a 405 on the OPTIONS preflight.
Fixes #4297
Problem / motivation
The transparent MCP proxy (
/mcpendpoint) has no OPTIONS handler. When a browser-based client such as MCP Inspector (http://localhost:6274) performs a CORS preflight before a real request, the server returns405 Method Not Allowedbecause Go'shttp.ServeMuxdoes not automatically handle OPTIONS. The browser then blocks the actual request withnet::ERR_FAILED/CORS policy: No 'Access-Control-Allow-Origin' header.This blocks every web-based MCP client from using the ToolHive proxy for local development and testing, exactly as reported in #4297.
Change
pkg/transport/middleware/cors.go— a newCORS(allowedOrigins []string) MiddlewareFunctionthat:Access-Control-Allow-Origin/Methods/Headers/Expose-Headers+Vary: Originon responses whoseOriginheader matches an allowed entry.http://localhost:6274), scheme+host prefix (http://localhost→ any port), and wildcard (*).allowedOriginsis empty — zero behaviour change by default.pkg/transport/proxy/transparent/transparent_proxy.go— adds acorsOriginsfield and aWithAllowedOrigins([]string) Option, and wires the middleware as the outermost handler inStart()(after the stateless method gate), so OPTIONS is intercepted before the gate or backend can reject it.cmd/thv/app/proxy.go— adds the--allow-originsflag tothv proxyand switches toNewTransparentProxyWithOptionsto pass the option.Does this introduce a user-facing change?
Yes. New optional flag on
thv proxy:No default value — CORS is disabled unless the flag is passed. Existing deployments are unaffected.
Security rationale
CORS is a browser-enforced same-origin mechanism (OWASP A05:2021 Security Misconfiguration, CWE-942 Permissive Cross-domain Policy). This implementation:
--allow-originsis explicitly passed.*with credentials — noAccess-Control-Allow-Credentials: trueheader is emitted, so a wildcard origin and credentials are never paired.*) — partial path/query components cannot inject forged headers; the scheme+host prefix match requires a:boundary, sohttp://localhostdoes not matchhttp://localhost.evil.com.Test plan
Unit tests added (
pkg/transport/middleware/cors_test.go): preflight handling, header injection, origin matching (exact/prefix/wildcard/negative), and no-op-when-disabled, plus table-drivenmatchCORSOrigincases.Manual:
thv proxy my-server --target-uri http://localhost:8080 --allow-origins http://localhost:6274, then point MCP Inspector at the proxy — the OPTIONS preflight returns 204 withAccess-Control-Allow-Origin: http://localhost:6274and the follow-up POST reaches the backend. Without--allow-origins, behaviour is unchanged.Notes for reviewers
--allow-originsis wired intothv proxyonly.thv run's internal transparent proxy would need the same option plumbed throughbuildProxyOptions; left as a focused follow-up to keep this PR small.