@cryptotaxi247 / netdata-1 / commits / da62bf578

web_client: allow MCP Streamable HTTP headers in CORS preflight (#22258)

* web_client: allow MCP Streamable HTTP headers in CORS preflight Browser-based MCP clients (e.g. the MCP TypeScript SDK over Streamable HTTP) send Mcp-Protocol-Version on every request after the initial handshake and Mcp-Session-Id once the server has assigned one. Netdata was not listing these in its OPTIONS preflight response, so the second POST from any browser origin other than the Agent's own UI failed: Access to fetch at 'https://.../mcp' has been blocked by CORS policy: Request header field mcp-protocol-version is not allowed by Access-Control-Allow-Headers in preflight response. The initialize POST succeeded (no protocol-version header yet), but notifications/initialized and everything after it was blocked, leaving the session in a half-open state. Changes to the OPTIONS preflight response: - Access-Control-Allow-Methods: add DELETE (MCP Streamable HTTP uses DELETE to terminate a session) - Access-Control-Allow-Headers: add authorization, mcp-protocol-version, mcp-session-id, last-event-id (authorization covers Bearer-token MCP servers; last-event-id covers SSE reconnection to a resumable stream) - Access-Control-Expose-Headers: Mcp-Session-Id (so browser clients can read the session id the server assigns on the initialize response) The WebSocket MCP transport is unaffected — WebSockets don't use CORS preflight. This only touches the HTTP path. Reproduction: connect any browser-based MCP client to a Netdata Agent/Parent, observe the 'Failed to fetch' on the second request. * web_client: move Access-Control-Expose-Headers out of the preflight branch Addresses review feedback on PR #22258. Access-Control-Expose-Headers is a directive for the *actual* response, not the OPTIONS preflight — browsers only consult it when deciding which response headers are readable from JavaScript. Putting it in the OPTIONS branch meant browser clients still could not read Mcp-Session-Id from real MCP responses. Moved the header into the non-OPTIONS response block alongside the other shared CORS headers (Allow-Origin, Allow-Credentials). * web_client: scope CORS widening to /mcp paths only Addresses review feedback on PR #22258 (Copilot). The previous version of this PR added MCP-specific request headers (mcp-protocol-version, mcp-session-id, last-event-id, authorization), the DELETE method, and Access-Control-Expose-Headers: Mcp-Session-Id to every CORS response — even for endpoints that have nothing to do with MCP. That broadens the cross-origin posture for the whole server unnecessarily. Scope the additions to requests whose URL path starts with /mcp (either the exact path, /mcp/<subpath>, or /mcp?<query>). For any other path, CORS behaviour is byte-identical to pre-PR master. Non-MCP endpoints see the original allowlist: GET, POST, OPTIONS and the original nine header names. MCP endpoints see the expanded list plus the Expose-Headers advertisement on the actual response. * web_client: also scope CORS widening to /sse (legacy MCP SSE endpoint) Netdata exposes MCP over two transport URLs on the Agent's HTTP port: /mcp — Streamable HTTP (with Accept-header-driven SSE fallback) /sse — legacy SSE-only endpoint (mcp_sse_handle_request) Both dispatch to MCP handlers from src/web/server/web_client.c. The previous commit only scoped the CORS widening to /mcp*, leaving browser clients connecting to the /sse endpoint with the narrower header allowlist and no Access-Control-Expose-Headers, which would have the same 'Failed to fetch' symptom this PR set out to fix. Extend the path test to cover both prefixes. Any third URL path happening to share those four leading characters is still rejected by the trailing-character check (must be end-of-string, '/', or '?'). * web_client: tighten MCP CORS comment and scope tests Addresses review feedback on PR #22258 (Copilot). Three small corrections: 1. Remove the '?' check in the /mcp path test — w->url_path_decoded is the decoded path only; the query string lives in w->url_query_string_decoded. The '?' branch was unreachable. 2. Drop DELETE from Access-Control-Allow-Methods for MCP endpoints. The current MCP HTTP and SSE handlers (mcp-http.c:106, mcp-sse.c:134) reject anything other than GET/POST with 405. Advertising DELETE would let browser clients pass preflight only to hit a 405 on the real request — misleading. When the handlers learn session teardown the allowlist can be expanded. 3. Remove 'See PR #22258' comment — PR numbers don't travel to forks and rot over time. The inline comment now carries the rationale without needing the external reference. Non-functional otherwise; the widened allowlist of request headers (authorization, mcp-protocol-version, mcp-session-id, last-event-id) and Access-Control-Expose-Headers: Mcp-Session-Id still apply only to /mcp and /sse URL prefixes. * web_client: classify MCP paths via a flag, not repeated string parsing Addresses review feedback on PR #22258 (Costa). Previously the CORS header builder re-parsed w->url_path_decoded on every response to determine whether the request was for /mcp or /sse. That duplicated work already implicit in the URL dispatcher and scattered the matching rule across two places. Introduce WEB_CLIENT_FLAG_PATH_IS_MCP (bit 29), set once in web_client_decode_path_and_query_string() — the single place where url_path_decoded is populated — and cleared there on every fresh request so keepalived connections re-classify correctly. The flag is intentionally *not* included in web_client_reset_path_flags, which runs between URL decoding and URL dispatch; including it would wipe it before the header builder could read it. The flag lives for the lifetime of a request's URL. web_client_build_http_header now just does: web_client_flag_check(w, WEB_CLIENT_FLAG_PATH_IS_MCP) replacing the inline 8-line memcmp sequence. The MCP URL dispatcher no longer needs to set the flag itself; the OPTIONS preflight now sees it for free (previously it had to re-derive the same information by re-parsing the path). * web_client: polish MCP CORS path — rename, dedup, teardown Addresses the latest round of Copilot review on PR #22258. 1. Rename local 'p'/'plen' to 'decoded_path'/'decoded_path_len' in web_client_decode_path_and_query_string(). Short one-letter names are fine in tight scopes, but clangd/reviewers flagged possible shadowing concerns and the new name reads better anyway. 2. Clear WEB_CLIENT_FLAG_PATH_IS_MCP in web_client_request_done(), not only in web_client_decode_path_and_query_string(). The decode path is the normal setter, but if a subsequent request on a keepalive connection fails validation before URL decoding runs (malformed request line, unsupported method), the flag from the previous request would have carried over. Clearing on teardown closes that window. 3. Deduplicate the OPTIONS preflight response. Methods, max-age, and the base Access-Control-Allow-Headers list are identical for MCP and non-MCP preflights; only the trailing MCP-specific header names differ. Emit the common prefix once, append the MCP suffix when is_mcp_path, then emit the common suffix. One string to maintain instead of two near-identical ones. No functional change from the previous commit.

Costa Tsaousis committed Apr 24, 2026 at 13:01 UTC da62bf57870385ce4ffcc8b3429fd12f6e7b447b
2 files changed +57 -2
src/web/server/web_client.c
+52 -2
@@ -274,6 +274,13 @@ void web_client_request_done(struct web_client *w) {
274 web_client_disable_tracking_required(w);
275 web_client_disable_keepalive(w);
276
277 + // Clear URL-derived flags between requests. PATH_IS_MCP is re-set
278 + // during the next URL decode; clearing it here makes sure a keepalive
279 + // connection cannot carry the previous request's classification into
280 + // a new request that fails before URL decoding runs (e.g., malformed
281 + // request line, unsupported method).
282 + web_client_flag_clear(w, WEB_CLIENT_FLAG_PATH_IS_MCP);
283 +
284 w->header_parse_tries = 0;
285 w->header_parse_last_size = 0;
286
@@ -860,6 +867,15 @@ void web_client_build_http_header(struct web_client *w) {
867 http_header_content_type(w->response.header_output, w->response.data->content_type);
868 }
869
870 + // MCP-specific CORS: widen the allowlist + advertise exposed
871 + // headers only for MCP transport endpoints (/mcp, /sse). The flag
872 + // is set once during URL decoding; see WEB_CLIENT_FLAG_PATH_IS_MCP.
873 + bool is_mcp_path = web_client_flag_check(w, WEB_CLIENT_FLAG_PATH_IS_MCP);
874 +
875 + if(is_mcp_path && w->mode != HTTP_REQUEST_MODE_OPTIONS)
876 + buffer_strcat(w->response.header_output,
877 + "Access-Control-Expose-Headers: Mcp-Session-Id\r\n");
878 +
879 if(unlikely(web_x_frame_options))
880 buffer_sprintf(w->response.header_output, "X-Frame-Options: %s\r\n", web_x_frame_options);
881
@@ -880,10 +896,25 @@ void web_client_build_http_header(struct web_client *w) {
896 }
897
898 if(w->mode == HTTP_REQUEST_MODE_OPTIONS) {
899 + // Methods, max-age, and the base header allowlist are identical
900 + // for every OPTIONS preflight. MCP preflights append the extra
901 + // request headers the SDK uses: mcp-protocol-version,
902 + // mcp-session-id, last-event-id (SSE resumption), authorization
903 + // (bearer tokens). DELETE is *not* advertised — the MCP handlers
904 + // currently return 405 for it, and advertising it would let the
905 + // preflight succeed only for the real request to fail. Add DELETE
906 + // here when the handlers learn session teardown.
907 buffer_strcat(w->response.header_output,
908 "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
885 - "Access-Control-Allow-Headers: accept, x-requested-with, origin, content-type, cookie, pragma, cache-control, x-auth-token, x-netdata-auth, x-transaction-id\r\n"
886 - "Access-Control-Max-Age: 1209600\r\n" // 86400 * 14
909 + "Access-Control-Allow-Headers: accept, x-requested-with, origin, content-type, cookie, pragma, cache-control, x-auth-token, x-netdata-auth, x-transaction-id");
910 +
911 + if(is_mcp_path)
912 + buffer_strcat(w->response.header_output,
913 + ", authorization, mcp-protocol-version, mcp-session-id, last-event-id");
914 +
915 + buffer_strcat(w->response.header_output,
916 + "\r\n"
917 + "Access-Control-Max-Age: 1209600\r\n" // 86400 * 14
918 );
919 }
920 else {
@@ -1822,6 +1853,11 @@ void web_client_decode_path_and_query_string(struct web_client *w, const char *p
1853 // do not overwrite this if it is already filled
1854 buffer_strcat(w->url_as_received, path_and_query_string);
1855
1856 + // PATH_IS_MCP is a function of the URL alone; clear and re-derive on
1857 + // every decode so keepalived connections reusing the same web_client
1858 + // for a different URL see a fresh value.
1859 + web_client_flag_clear(w, WEB_CLIENT_FLAG_PATH_IS_MCP);
1860 +
1861 if(w->mode == HTTP_REQUEST_MODE_STREAM) {
1862 // in stream mode, there is no path
1863
@@ -1850,6 +1886,20 @@ void web_client_decode_path_and_query_string(struct web_client *w, const char *p
1886 buffer_strcat(w->url_query_string_decoded, "");
1887 buffer_strcat(w->url_path_decoded, buffer);
1888 }
1889 +
1890 + // Classify path: set PATH_IS_MCP when the URL addresses one of
1891 + // Netdata's MCP transport endpoints (/mcp or /sse, and their
1892 + // subpaths). Done here — at URL-decoding time — so the flag is
1893 + // available later both to the URL dispatcher and to the response
1894 + // header builder, including for OPTIONS preflights which bypass
1895 + // the dispatcher. Matching requires a path-segment boundary so a
1896 + // hypothetical /mcpfoo does not leak through.
1897 + const char *decoded_path = buffer_tostring(w->url_path_decoded);
1898 + size_t decoded_path_len = buffer_strlen(w->url_path_decoded);
1899 + if(decoded_path_len >= 4
1900 + && (memcmp(decoded_path, "/mcp", 4) == 0 || memcmp(decoded_path, "/sse", 4) == 0)
1901 + && (decoded_path_len == 4 || decoded_path[4] == '/'))
1902 + web_client_flag_set(w, WEB_CLIENT_FLAG_PATH_IS_MCP);
1903 }
1904 }
1905
src/web/server/web_client.h
+5
@@ -70,9 +70,14 @@ typedef enum __attribute__((packed)) {
70 WEB_CLIENT_FLAG_ACCEPT_SSE = (1 << 26),
71 WEB_CLIENT_FLAG_ACCEPT_TEXT = (1 << 27),
72 WEB_CLIENT_FLAG_MCP_PREVIEW_KEY = (1 << 28), // Authorization header matched MCP preview key
73 + WEB_CLIENT_FLAG_PATH_IS_MCP = (1 << 29), // URL path is /mcp[/...] or /sse[/...] — set during URL decoding so it's also available for OPTIONS preflights (which skip the URL dispatcher)
74 } WEB_CLIENT_FLAGS;
75
76 #define WEB_CLIENT_FLAG_PATH_WITH_VERSION (WEB_CLIENT_FLAG_PATH_IS_V0|WEB_CLIENT_FLAG_PATH_IS_V1|WEB_CLIENT_FLAG_PATH_IS_V2|WEB_CLIENT_FLAG_PATH_IS_V3)
77 +// PATH_IS_MCP is intentionally *not* in the reset mask: it is set during
78 +// URL decoding, not during URL dispatch, so resetting it here (which runs
79 +// after decoding but before dispatch on POST/GET/etc.) would wipe it
80 +// before the response builder could read it.
81 #define web_client_reset_path_flags(w) (w)->flags &= ~(WEB_CLIENT_FLAG_PATH_WITH_VERSION|WEB_CLIENT_FLAG_PATH_HAS_TRAILING_SLASH|WEB_CLIENT_FLAG_PATH_HAS_FILE_EXTENSION)
82
83 #define web_client_flag_check(w, flag) ((w)->flags & (flag))