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.