From 469f494b25cbd3f1ae533f9884b9a38f1427fdf7 Mon Sep 17 00:00:00 2001 From: Lang-Akshay Date: Sun, 26 Jul 2026 14:20:47 +0100 Subject: [PATCH 1/4] feat: per-backend header passthrough/add/remove (#4544) Forward downstream request headers to backend MCP connections via apply_header_config, applied in order: passthrough (copy named headers from the downstream request) -> add (static inject/override) -> remove (strip). The gateway-managed HOST header is never affected by config. Adds add_headers / remove_headers to BackendMCPGateway (passthrough_headers already existed). Signed-off-by: Lang-Akshay --- .../src/user_store.rs | 7 ++ .../src/gateway/mcp_service/initialization.rs | 109 +++++++++++++++++- .../tests/support/list_tools_gateway.rs | 2 + .../tests/support/plugin_gateway.rs | 2 + 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/contextforge-gateway-rs-apis/src/user_store.rs b/crates/contextforge-gateway-rs-apis/src/user_store.rs index f4dc1d4..9cb48f3 100644 --- a/crates/contextforge-gateway-rs-apis/src/user_store.rs +++ b/crates/contextforge-gateway-rs-apis/src/user_store.rs @@ -28,7 +28,14 @@ pub struct BackendMCPGateway { pub name: String, pub url: url::Url, pub transport: Transport, + /// Header names copied from the downstream request onto the upstream connection. pub passthrough_headers: Vec, + /// Static headers injected onto the upstream connection (override passthrough). + #[serde(default)] + pub add_headers: HashMap, + /// Header names stripped from the upstream connection (applied last). + #[serde(default)] + pub remove_headers: Vec, pub allowed_tool_names: Vec, #[serde(default)] pub tool_name_aliases: HashMap, diff --git a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs index 460237c..0da1ec8 100644 --- a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs @@ -1,5 +1,7 @@ use std::{collections::HashMap, sync::Arc}; +use contextforge_gateway_rs_apis::user_store::BackendMCPGateway; +use http::request::Parts; use rmcp::{ ErrorData, RoleClient, RoleServer, ServiceExt, model::{ErrorCode, Implementation, InitializeRequestParams, InitializeResult, ServerCapabilities}, @@ -41,6 +43,7 @@ where }; let namespace_identifiers = virtual_host.backends.len() > 1; + let downstream_headers = cx.extensions.get::().map(|parts| parts.headers.clone()); let tasks: Vec<_> = virtual_host .backends .iter() @@ -53,6 +56,8 @@ where mcp_service.plugin_runtime.clone(), ); let backend_url = backend.url.clone(); + let backend_cfg = backend.clone(); + let downstream_headers = downstream_headers.clone(); let downstream_session_id = downstream_session_id.clone(); Box::pin(async move { @@ -73,6 +78,8 @@ where } } + apply_header_config(&mut headers, &backend_cfg, downstream_headers.as_ref()); + let config = StreamableHttpClientTransportConfig::with_uri(backend_url.to_string()).custom_headers(headers); let transport = StreamableHttpClientTransport::with_client(client, config); @@ -170,11 +177,52 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option add +/// (inject/override static headers) -> remove (strip named headers). The +/// gateway-managed `HOST` header is never affected by config. +/// ponytail: single-value per name; a repeated downstream header keeps its first value. +fn apply_header_config( + headers: &mut HashMap, + backend: &BackendMCPGateway, + downstream: Option<&http::HeaderMap>, +) { + if let Some(downstream) = downstream { + for name in &backend.passthrough_headers { + let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) else { continue }; + if name == http::header::HOST { + continue; + } + if let Some(value) = downstream.get(&name) { + headers.insert(name, value.clone()); + } + } + } + for (name, value) in &backend.add_headers { + let (Ok(name), Ok(value)) = (http::HeaderName::from_bytes(name.as_bytes()), http::HeaderValue::from_str(value)) + else { + continue; + }; + if name == http::header::HOST { + continue; + } + headers.insert(name, value); + } + for name in &backend.remove_headers { + let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) else { continue }; + if name == http::header::HOST { + continue; + } + headers.remove(&name); + } +} + #[cfg(test)] mod tests { - use rmcp::model::ServerCapabilities; + use contextforge_gateway_rs_apis::user_store::Transport; - use super::merge_and_build_capabilities; + use super::*; #[test] fn merge_and_build_capabilities_only_advertises_upstream_capabilities() { @@ -189,4 +237,61 @@ mod tests { assert!(capabilities.resources.is_some()); assert!(capabilities.prompts.is_none()); } + + fn backend(passthrough: &[&str], add: &[(&str, &str)], remove: &[&str]) -> BackendMCPGateway { + BackendMCPGateway { + name: "b".into(), + url: "https://upstream.example/mcp".parse().unwrap(), + transport: Transport::default(), + passthrough_headers: passthrough.iter().map(|s| (*s).to_owned()).collect(), + add_headers: add.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(), + remove_headers: remove.iter().map(|s| (*s).to_owned()).collect(), + allowed_tool_names: vec![], + tool_name_aliases: HashMap::new(), + allowed_resource_names: vec![], + allowed_prompt_names: vec![], + } + } + + fn downstream(pairs: &[(&str, &str)]) -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for (k, v) in pairs { + map.insert(http::HeaderName::from_bytes(k.as_bytes()).unwrap(), http::HeaderValue::from_str(v).unwrap()); + } + map + } + + #[test] + fn pass_add_remove_and_host_protected() { + let mut headers = HashMap::new(); + // Gateway sets HOST before config runs; config must never touch it. + headers.insert(http::header::HOST, http::HeaderValue::from_static("upstream.example")); + + let ds = downstream(&[("Authorization", "Bearer downstream"), ("X-Drop", "1"), ("Host", "downstream.example")]); + let cfg = backend( + &["authorization", "host", "x-drop"], + &[("X-Add", "added"), ("Authorization", "Bearer override")], + &["x-drop"], + ); + + apply_header_config(&mut headers, &cfg, Some(&ds)); + + // add overrides passthrough + assert_eq!(headers[&http::header::AUTHORIZATION], "Bearer override"); + // static add present + assert_eq!(headers[&http::HeaderName::from_static("x-add")], "added"); + // remove wins last + assert!(!headers.contains_key(&http::HeaderName::from_static("x-drop"))); + // gateway HOST untouched by passthrough of downstream Host + assert_eq!(headers[&http::header::HOST], "upstream.example"); + } + + #[test] + fn no_downstream_headers_still_applies_add_remove() { + let mut headers = HashMap::new(); + let cfg = backend(&["authorization"], &[("X-Add", "added")], &[]); + apply_header_config(&mut headers, &cfg, None); + assert_eq!(headers[&http::HeaderName::from_static("x-add")], "added"); + assert!(!headers.contains_key(&http::header::AUTHORIZATION)); + } } diff --git a/crates/contextforge-gateway-rs-lib/tests/support/list_tools_gateway.rs b/crates/contextforge-gateway-rs-lib/tests/support/list_tools_gateway.rs index 556b5e3..b7062a0 100644 --- a/crates/contextforge-gateway-rs-lib/tests/support/list_tools_gateway.rs +++ b/crates/contextforge-gateway-rs-lib/tests/support/list_tools_gateway.rs @@ -217,6 +217,8 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap Date: Mon, 27 Jul 2026 15:08:24 +0100 Subject: [PATCH 2/4] fix: protect hop-by-hop and RMCP-reserved headers from passthrough config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single HOST check in apply_header_config with is_protected_header, which also blocks hop-by-hop headers (RFC 7230 §6.1: Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailers, Transfer-Encoding, Upgrade) and RMCP transport-reserved headers (Mcp-Session-Id, Accept, Last-Event-Id). These are silently skipped across all three phases (passthrough, add, remove) so config can't corrupt the upstream transport. Also document add_headers/remove_headers and session-scoped propagation in the book, and add the two new fields to the user_config schema. Signed-off-by: Lang-Akshay --- .../src/gateway/mcp_service/initialization.rs | 89 +++++++++++++++++-- .../src/backend-connections-and-transports.md | 13 +-- docs/book/src/runtime-configuration.md | 12 ++- schemas/user_config.json | 17 ++++ 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs index 0da1ec8..b8b1b53 100644 --- a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs @@ -43,6 +43,13 @@ where }; let namespace_identifiers = virtual_host.backends.len() > 1; + // SESSION-SCOPED: downstream headers are snapshotted here and baked into + // the StreamableHttpClientTransportConfig for the lifetime of the backend + // transport. Post-initialize calls (tools, resources, prompts) reuse these + // headers. True per-request propagation requires either per-request transport + // reconstruction or an SDK-level per-call header injection API on RunningService. + // When the stateless path (MCP 2026-07-28, SEP-2575/SEP-2567) is implemented, + // transports will be per-request and headers will naturally be request-scoped. let downstream_headers = cx.extensions.get::().map(|parts| parts.headers.clone()); let tasks: Vec<_> = virtual_host .backends @@ -180,8 +187,14 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option add -/// (inject/override static headers) -> remove (strip named headers). The -/// gateway-managed `HOST` header is never affected by config. +/// (inject/override static headers) -> remove (strip named headers). +/// +/// Protected headers are silently skipped in every phase: +/// - Gateway-managed: `Host` (set from backend URL before this runs) +/// - Hop-by-hop (RFC 7230 §6.1): `Connection`, `Keep-Alive`, `Proxy-Authenticate`, +/// `Proxy-Authorization`, `TE`, `Trailers`, `Transfer-Encoding`, `Upgrade` +/// - RMCP transport-reserved: `Mcp-Session-Id`, `Accept`, `Last-Event-Id` +/// /// ponytail: single-value per name; a repeated downstream header keeps its first value. fn apply_header_config( headers: &mut HashMap, @@ -191,7 +204,7 @@ fn apply_header_config( if let Some(downstream) = downstream { for name in &backend.passthrough_headers { let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) else { continue }; - if name == http::header::HOST { + if is_protected_header(&name) { continue; } if let Some(value) = downstream.get(&name) { @@ -204,20 +217,43 @@ fn apply_header_config( else { continue; }; - if name == http::header::HOST { + if is_protected_header(&name) { continue; } headers.insert(name, value); } for name in &backend.remove_headers { let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) else { continue }; - if name == http::header::HOST { + if is_protected_header(&name) { continue; } headers.remove(&name); } } +/// Returns `true` for headers that config must never touch: +/// gateway-managed `Host`, HTTP/1.1 hop-by-hop headers (RFC 7230 §6.1), and +/// RMCP transport-reserved headers (`Mcp-Session-Id`, `Accept`, `Last-Event-Id`). +fn is_protected_header(name: &http::HeaderName) -> bool { + const PROTECTED: &[&str] = &[ + "host", + // hop-by-hop (RFC 7230 §6.1) + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + // RMCP transport-reserved + "mcp-session-id", + "accept", + "last-event-id", + ]; + PROTECTED.iter().any(|&p| name.as_str().eq_ignore_ascii_case(p)) +} + #[cfg(test)] mod tests { use contextforge_gateway_rs_apis::user_store::Transport; @@ -294,4 +330,47 @@ mod tests { assert_eq!(headers[&http::HeaderName::from_static("x-add")], "added"); assert!(!headers.contains_key(&http::header::AUTHORIZATION)); } + + #[test] + fn hop_by_hop_headers_cannot_be_passed_through() { + let mut headers = HashMap::new(); + let ds = downstream(&[ + ("Connection", "keep-alive"), + ("Transfer-Encoding", "chunked"), + ("Upgrade", "websocket"), + ("Keep-Alive", "timeout=5"), + ("TE", "trailers"), + ("Trailers", "X-Foo"), + ("Proxy-Authorization", "Basic abc"), + ("Proxy-Authenticate", "Basic realm=x"), + ("X-Custom", "value"), + ]); + let cfg = backend( + &["connection", "transfer-encoding", "upgrade", "keep-alive", "te", "trailers", + "proxy-authorization", "proxy-authenticate", "x-custom"], + &[], + &[], + ); + apply_header_config(&mut headers, &cfg, Some(&ds)); + // Only the non-hop-by-hop header gets through + assert_eq!(headers[&http::HeaderName::from_static("x-custom")], "value"); + for blocked in &["connection", "transfer-encoding", "upgrade", "keep-alive", "te", + "trailers", "proxy-authorization", "proxy-authenticate"] { + assert!(!headers.contains_key(&http::HeaderName::from_static(blocked)), + "{blocked} must be blocked"); + } + } + + #[test] + fn rmcp_reserved_headers_cannot_be_passed_through_or_added() { + let mut headers = HashMap::new(); + let ds = downstream(&[("Mcp-Session-Id", "sess123"), ("Accept", "text/html"), ("Last-Event-Id", "42")]); + let cfg = backend( + &["mcp-session-id", "accept", "last-event-id"], + &[("Mcp-Session-Id", "injected"), ("Accept", "text/html")], + &[], + ); + apply_header_config(&mut headers, &cfg, Some(&ds)); + assert!(headers.is_empty(), "no RMCP-reserved header must reach the upstream config"); + } } diff --git a/docs/book/src/backend-connections-and-transports.md b/docs/book/src/backend-connections-and-transports.md index 3328068..8b97ff9 100644 --- a/docs/book/src/backend-connections-and-transports.md +++ b/docs/book/src/backend-connections-and-transports.md @@ -82,9 +82,12 @@ VirtualHost.backends -> store running service in BackendTransports ``` -For HTTPS backend URLs, the gateway also sets a custom `Host` header from the -backend URL host and optional port. HTTP backend URLs do not get this custom -header in the current code. +For HTTPS backend URLs, the gateway sets a `Host` header from the backend URL host and optional port. +After that, `apply_header_config` runs the backend's header config in order: passthrough named downstream +headers, inject static `add_headers`, then strip `remove_headers`. Hop-by-hop (Connection, Keep-Alive, +TE, Transfer-Encoding, Trailers, Upgrade, Proxy-Authenticate, Proxy-Authorization) and RMCP-reserved +(Mcp-Session-Id, Accept, Last-Event-Id) headers are silently skipped in all three phases; the +gateway-managed `Host` is likewise protected and never altered by config. Backend connection failures are not fatal to the whole initialize call. The gateway stores the backend entry with no running service, so list calls can @@ -121,9 +124,9 @@ but it is not the final shape for every backend-specific decision. | --- | --- | --- | | Downstream TLS certificate | Process config. | Process config. It belongs to the gateway listener. | | Upstream trust bundle and mTLS identity | Process config. | Runtime config per backend or referenced secret material. | -| Backend auth headers | Not applied from `UserConfig` yet. | Runtime config per backend. | +| Backend auth headers | Delegated to `passthrough_headers` / `add_headers` / `remove_headers` in `BackendMCPGateway`. | — | | Backend transport type | Model field exists, not routed yet. | Runtime config per backend. | -| Header pass-through policy | Model field exists, not enforced yet. | Runtime config per backend or route policy. | +| Header pass-through policy | Implemented via `passthrough_headers`, `add_headers`, `remove_headers` on `BackendMCPGateway`. Headers are session-scoped (snapshotted at initialize); request-scoped propagation is future work. | — | The boundary to preserve is simple: listener code should not know Redis schema, MCP routing code should not know Redis command details, and backend transport diff --git a/docs/book/src/runtime-configuration.md b/docs/book/src/runtime-configuration.md index 80279c5..e8c660a 100644 --- a/docs/book/src/runtime-configuration.md +++ b/docs/book/src/runtime-configuration.md @@ -66,12 +66,21 @@ but the distinction should stay explicit: | `BackendMCPGateway.url` | Required. Used to build the upstream `StreamableHttpClientTransport`. | | `BackendMCPGateway.name` | Present in the model. Current routing uses the backend map key, not this field, as the namespace. | | `transport` | Present in the model. Current upstream code always builds a streamable HTTP client transport. | -| `passthrough_headers` | Present in the model. Current MCP routing does not apply header pass-through policy from this field. | +| `passthrough_headers` | Applied during `initialize`: named downstream request headers are copied onto the upstream connection header map. Hop-by-hop and RMCP-reserved headers are silently skipped. Propagation is session-scoped — headers are snapshotted from the initialize request; see note below. | +| `add_headers` | Static `{name: value}` headers injected onto the upstream connection after passthrough (override passthrough values). Hop-by-hop and RMCP-reserved headers are silently skipped. | +| `remove_headers` | Header names stripped from the upstream connection after add (applied last). Hop-by-hop and RMCP-reserved headers are silently skipped. | | `allowed_tool_names` | Present in the model. Current list/call routing does not enforce it. | | `tool_name_aliases` | Optional exact `{downstream_alias: upstream_original}` mapping used by tool list/call routing before the single-versus-multi-backend fallback. | | `allowed_resource_names` | Present in the model. Current resource routing does not enforce it. | | `allowed_prompt_names` | Present in the model. Current prompt routing does not enforce it. | +> **Note — session-scoped header propagation:** `passthrough_headers` values are snapshotted from the +> downstream `initialize` request and baked into the backend transport connection for the lifetime of the +> session. Post-initialize requests (tool calls, list calls, etc.) reuse those headers. True +> request-scoped propagation requires either per-request transport reconstruction or SDK support for +> per-request header injection; this is the correct path as MCP moves toward stateless operation +> (SEP-2575, SEP-2567). + The current route selection is: ```text @@ -89,7 +98,6 @@ Expected config growth beyond the current fields: - route selection across multiple MCP endpoints - principal/virtual-host filters for tools, resources, and prompts - backend auth/TLS material references -- request/response header pass/add/remove rules - plugin/CPEX hook settings - pagination/SSE behavior where protocol handling needs config - future A2A and LLM routing/provider settings diff --git a/schemas/user_config.json b/schemas/user_config.json index d253640..348bfd5 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -42,11 +42,28 @@ "$ref": "#/$defs/Transport" }, "passthrough_headers": { + "description": "Header names copied from the downstream request onto the upstream connection.", "type": "array", "items": { "type": "string" } }, + "add_headers": { + "description": "Static headers injected onto the upstream connection (override passthrough).", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "remove_headers": { + "description": "Header names stripped from the upstream connection (applied last).", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, "allowed_tool_names": { "type": "array", "items": { From bfc2a02b671da7971bf11c8e0798ddec07257aed Mon Sep 17 00:00:00 2001 From: Lang-Akshay Date: Mon, 27 Jul 2026 15:11:17 +0100 Subject: [PATCH 3/4] fixup: fmt Signed-off-by: Lang-Akshay --- .../src/gateway/mcp_service/initialization.rs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs index b8b1b53..169840c 100644 --- a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs @@ -346,18 +346,34 @@ mod tests { ("X-Custom", "value"), ]); let cfg = backend( - &["connection", "transfer-encoding", "upgrade", "keep-alive", "te", "trailers", - "proxy-authorization", "proxy-authenticate", "x-custom"], + &[ + "connection", + "transfer-encoding", + "upgrade", + "keep-alive", + "te", + "trailers", + "proxy-authorization", + "proxy-authenticate", + "x-custom", + ], &[], &[], ); apply_header_config(&mut headers, &cfg, Some(&ds)); // Only the non-hop-by-hop header gets through assert_eq!(headers[&http::HeaderName::from_static("x-custom")], "value"); - for blocked in &["connection", "transfer-encoding", "upgrade", "keep-alive", "te", - "trailers", "proxy-authorization", "proxy-authenticate"] { - assert!(!headers.contains_key(&http::HeaderName::from_static(blocked)), - "{blocked} must be blocked"); + for blocked in &[ + "connection", + "transfer-encoding", + "upgrade", + "keep-alive", + "te", + "trailers", + "proxy-authorization", + "proxy-authenticate", + ] { + assert!(!headers.contains_key(&http::HeaderName::from_static(blocked)), "{blocked} must be blocked"); } } From b35dbf9ebc97be5246ed3ea6da9b3bd3ead43b96 Mon Sep 17 00:00:00 2001 From: Lang-Akshay Date: Tue, 28 Jul 2026 13:09:26 +0100 Subject: [PATCH 4/4] fix: added missing content-type, content-lenth, proxy-connection & trailer Signed-off-by: Lang-Akshay --- .../src/gateway/mcp_service/initialization.rs | 41 +++++++++++++++++-- .../src/backend-connections-and-transports.md | 9 ++-- docs/book/src/runtime-configuration.md | 6 +-- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs index 169840c..8409c32 100644 --- a/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-gateway-rs-lib/src/gateway/mcp_service/initialization.rs @@ -191,8 +191,10 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option bool { const PROTECTED: &[&str] = &[ "host", + // body-framing: gateway owns these; forwarding corrupts framing or enables encoding-dispatch bypass + "content-length", + "content-type", // hop-by-hop (RFC 7230 §6.1) "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", + "trailer", "trailers", "transfer-encoding", "upgrade", + // non-standard hop-by-hop; must not cross gateway boundary + "proxy-connection", // RMCP transport-reserved "mcp-session-id", "accept", @@ -389,4 +400,28 @@ mod tests { apply_header_config(&mut headers, &cfg, Some(&ds)); assert!(headers.is_empty(), "no RMCP-reserved header must reach the upstream config"); } + + #[test] + fn body_framing_and_connection_management_headers_cannot_be_forwarded() { + let mut headers = HashMap::new(); + let ds = downstream(&[ + ("Content-Type", "text/plain"), + ("Content-Length", "42"), + ("Proxy-Connection", "keep-alive"), + ("Trailer", "X-Checksum"), + ("X-Custom", "ok"), + ]); + let cfg = backend( + &["content-type", "content-length", "proxy-connection", "trailer", "x-custom"], + &[("Content-Length", "99")], + &[], + ); + apply_header_config(&mut headers, &cfg, Some(&ds)); + // legitimate header passes through + assert_eq!(headers[&http::HeaderName::from_static("x-custom")], "ok"); + // blocked in both passthrough and add phases + for blocked in &["content-type", "content-length", "proxy-connection", "trailer"] { + assert!(!headers.contains_key(&http::HeaderName::from_static(blocked)), "{blocked} must be blocked"); + } + } } diff --git a/docs/book/src/backend-connections-and-transports.md b/docs/book/src/backend-connections-and-transports.md index 8b97ff9..fb91f67 100644 --- a/docs/book/src/backend-connections-and-transports.md +++ b/docs/book/src/backend-connections-and-transports.md @@ -84,10 +84,11 @@ VirtualHost.backends For HTTPS backend URLs, the gateway sets a `Host` header from the backend URL host and optional port. After that, `apply_header_config` runs the backend's header config in order: passthrough named downstream -headers, inject static `add_headers`, then strip `remove_headers`. Hop-by-hop (Connection, Keep-Alive, -TE, Transfer-Encoding, Trailers, Upgrade, Proxy-Authenticate, Proxy-Authorization) and RMCP-reserved -(Mcp-Session-Id, Accept, Last-Event-Id) headers are silently skipped in all three phases; the -gateway-managed `Host` is likewise protected and never altered by config. +headers, inject static `add_headers`, then strip `remove_headers`. Protected headers are silently skipped +in all three phases: body-framing (`Content-Length`, `Content-Type`), hop-by-hop (`Connection`, +`Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `Proxy-Connection`, `TE`, `Trailer`, +`Trailers`, `Transfer-Encoding`, `Upgrade`), and RMCP-reserved (`Mcp-Session-Id`, `Accept`, +`Last-Event-Id`). The gateway-managed `Host` is likewise protected and never altered by config. Backend connection failures are not fatal to the whole initialize call. The gateway stores the backend entry with no running service, so list calls can diff --git a/docs/book/src/runtime-configuration.md b/docs/book/src/runtime-configuration.md index e8c660a..3a6592f 100644 --- a/docs/book/src/runtime-configuration.md +++ b/docs/book/src/runtime-configuration.md @@ -66,9 +66,9 @@ but the distinction should stay explicit: | `BackendMCPGateway.url` | Required. Used to build the upstream `StreamableHttpClientTransport`. | | `BackendMCPGateway.name` | Present in the model. Current routing uses the backend map key, not this field, as the namespace. | | `transport` | Present in the model. Current upstream code always builds a streamable HTTP client transport. | -| `passthrough_headers` | Applied during `initialize`: named downstream request headers are copied onto the upstream connection header map. Hop-by-hop and RMCP-reserved headers are silently skipped. Propagation is session-scoped — headers are snapshotted from the initialize request; see note below. | -| `add_headers` | Static `{name: value}` headers injected onto the upstream connection after passthrough (override passthrough values). Hop-by-hop and RMCP-reserved headers are silently skipped. | -| `remove_headers` | Header names stripped from the upstream connection after add (applied last). Hop-by-hop and RMCP-reserved headers are silently skipped. | +| `passthrough_headers` | Applied during `initialize`: named downstream request headers are copied onto the upstream connection header map. Body-framing (`Content-Length`, `Content-Type`), hop-by-hop, non-standard hop-by-hop (`Proxy-Connection`), and RMCP-reserved headers are silently skipped. Propagation is session-scoped — headers are snapshotted from the initialize request; see note below. | +| `add_headers` | Static `{name: value}` headers injected onto the upstream connection after passthrough (override passthrough values). Body-framing, hop-by-hop, non-standard hop-by-hop (`Proxy-Connection`), and RMCP-reserved headers are silently skipped. | +| `remove_headers` | Header names stripped from the upstream connection after add (applied last). Body-framing, hop-by-hop, non-standard hop-by-hop (`Proxy-Connection`), and RMCP-reserved headers are silently skipped. | | `allowed_tool_names` | Present in the model. Current list/call routing does not enforce it. | | `tool_name_aliases` | Optional exact `{downstream_alias: upstream_original}` mapping used by tool list/call routing before the single-versus-multi-backend fallback. | | `allowed_resource_names` | Present in the model. Current resource routing does not enforce it. |