feat(mcp): custom HTTP headers per server for authenticated gateways

Closes #1454. Harvested from PR #1456 by @Oliver-ZPLiu.

Adds `pub headers: HashMap<String, String>` to McpServerConfig,
threaded through HttpTransport::new and StreamableHttpTransport so
every outbound POST applies the user-configured headers after the
fixed Accept / Content-Type framing. Mirrors the field shape that
Claude Code, Codex, and OpenCode already accept in their MCP
config formats — unblocks Hugging Face MCP, GitHub MCP, Atlassian
Rovo MCP, and any other Streamable HTTP gateway that needs a
Bearer token or API key.

Defense-in-depth filter (`is_safe_custom_header`) drops:
  * empty / whitespace-only keys (would surface as a reqwest
    builder error mid-send and abort the connection);
  * `Accept` / `Content-Type` duplicates (the MCP Streamable HTTP
    protocol negotiates on these exact values; a stray override
    would silently break tool discovery);
  * values containing ASCII CR or LF (response-splitting defense
    against a misbehaving proxy).

Skipped headers emit a `tracing::warn!` and the rest of the
request still goes out, so a single bad entry can't take down a
server.

Scope-limited vs PR #1456:
  * SSE legacy transport intentionally not threaded (follow-up).
    The PR didn't cover it; modern MCP servers are Streamable HTTP.
  * No env-var interpolation in v0.8.31 — headers are sent
    literally, matching the PR. Documented in the field doc so
    users know tokens pasted directly into mcp.json live there
    as plain text. `${VAR}` substitution is a follow-up.

8 new tests (the original PR shipped without any): config
round-trip with custom headers, empty headers omitted from
serialized output, accept-normal-auth, reject empty key, reject
CR/LF in value, reject Accept/Content-Type override (case-
insensitive), and StreamableHttpTransport stores headers.

Empty-headers MCP fixtures updated at 10 sites across mcp.rs and
main.rs to match the new struct shape.
This commit is contained in:
Hunter Bown
2026-05-11 22:12:19 -05:00
parent b4158dcc1b
commit 1f7cd9cc2f
3 changed files with 217 additions and 3 deletions
+15
View File
@@ -49,6 +49,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a 50 KB file in 16 KB slices instead of dragging the whole thing
into the conversation context on every turn. PDFs continue to use
`pages`; `start_line` / `max_lines` apply to text files only.
- **MCP HTTP servers accept custom headers** for authentication
(#1454, harvested from PR #1456 by **@Oliver-ZPLiu**). Mirrors the
`headers` field that Claude Code, Codex, and OpenCode already
accept in their MCP config — add e.g.
`"headers": { "Authorization": "Bearer ${HF_TOKEN}" }` under any
HTTP server entry in `~/.deepseek/mcp.json` and the headers are
sent on every Streamable HTTP request. Headers are sent
literally — env-var interpolation is a follow-up, so tokens
pasted directly into mcp.json live there as plain text. The
Streamable HTTP transport filters out empty keys, framing
overrides (`Accept`, `Content-Type`), and CR/LF in values
(response-splitting defense) so a single bad entry can't break
protocol negotiation or smuggle a header through a misbehaving
proxy. Stdio servers (`command`-based) and the legacy SSE
transport ignore the field; SSE coverage is a follow-up.
## [0.8.30] - 2026-05-11
+4
View File
@@ -940,6 +940,7 @@ fn mcp_template_json() -> Result<String> {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
},
);
serde_json::to_string_pretty(&cfg)
@@ -3328,6 +3329,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
},
);
save_mcp_config(&config_path, &cfg)?;
@@ -3413,6 +3415,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
},
);
save_mcp_config(&config_path, &cfg)?;
@@ -5214,6 +5217,7 @@ mod doctor_mcp_tests {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
}
}
+198 -3
View File
@@ -42,6 +42,36 @@ fn validate_mcp_config_path(path: &Path) -> Result<()> {
Ok(())
}
/// Predicate for [`StreamableHttpTransport::send`]'s custom-header pass.
///
/// We accept whatever reqwest's `HeaderName::try_from` /
/// `HeaderValue::try_from` would accept, but with three extra rules:
///
/// 1. Reject empty / whitespace-only keys — these would surface as a
/// request-builder error mid-send and abort the whole connection.
/// 2. Reject keys that duplicate the framing we already emit
/// (`Accept`, `Content-Type`). The MCP Streamable HTTP transport
/// relies on those exact values for protocol negotiation; a stray
/// user override could silently break tool discovery.
/// 3. Reject values containing ASCII CR or LF. reqwest already
/// rejects those, but the explicit check makes the failure path
/// visible (a `tracing::warn!` instead of an obscure
/// builder error) and documents the response-splitting
/// defense.
///
/// Returning `false` means "skip this header"; the rest of the
/// request still goes out.
fn is_safe_custom_header(key: &str, value: &str) -> bool {
let trimmed = key.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.eq_ignore_ascii_case("accept") || trimmed.eq_ignore_ascii_case("content-type") {
return false;
}
!value.contains('\r') && !value.contains('\n')
}
/// Mask a URL so any embedded credentials in the userinfo portion (e.g.
/// `https://user:secret@host`) are replaced with `***`. Failures fall back to
/// the original string so we don't lose context — we never want masking to
@@ -203,6 +233,30 @@ pub struct McpServerConfig {
pub enabled_tools: Vec<String>,
#[serde(default)]
pub disabled_tools: Vec<String>,
/// Extra HTTP headers sent with every request to this MCP server.
/// Only the HTTP transports (streamable HTTP today; SSE in a
/// follow-up) honor this — `command`-based stdio servers ignore it.
///
/// Mirrors the `headers` field that Claude Code, Codex, and
/// OpenCode already accept in their MCP config formats. Use it to
/// authenticate against gateways that require a Bearer token or
/// API key, e.g.:
///
/// ```jsonc
/// "huggingface": {
/// "url": "https://huggingface.co/api/mcp",
/// "headers": { "Authorization": "Bearer ${HF_TOKEN}" }
/// }
/// ```
///
/// Header keys and values are passed through as-is — we do not
/// substitute environment variables in v0.8.31. If you store a
/// real token here, the value lives in plain text in
/// `~/.deepseek/mcp.json`; treat that file with the same care
/// as any other secret-bearing config.
#[serde(default)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
}
fn default_enabled() -> bool {
@@ -496,6 +550,11 @@ enum HttpTransportMode {
struct StreamableHttpTransport {
client: reqwest::Client,
url: String,
/// Extra headers applied to every outbound POST. Populated from
/// [`McpServerConfig::headers`]; an empty map is the no-auth
/// default. See `apply_custom_headers` for the filtering pass that
/// runs before each request.
headers: HashMap<String, String>,
pending_messages: VecDeque<Vec<u8>>,
}
@@ -693,6 +752,7 @@ impl HttpTransport {
fn new(
client: reqwest::Client,
url: String,
headers: HashMap<String, String>,
cancel_token: tokio_util::sync::CancellationToken,
endpoint_timeout: Duration,
) -> Self {
@@ -700,6 +760,7 @@ impl HttpTransport {
mode: HttpTransportMode::Streamable(StreamableHttpTransport::new(
client.clone(),
url.clone(),
headers,
)),
client,
base_url: url,
@@ -756,20 +817,44 @@ impl McpTransport for HttpTransport {
}
impl StreamableHttpTransport {
fn new(client: reqwest::Client, url: String) -> Self {
fn new(client: reqwest::Client, url: String, headers: HashMap<String, String>) -> Self {
Self {
client,
url,
headers,
pending_messages: VecDeque::new(),
}
}
async fn send(&mut self, msg: Vec<u8>) -> std::result::Result<(), StreamableSendError> {
let response = self
let mut request = self
.client
.post(&self.url)
.header(ACCEPT, "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.header(CONTENT_TYPE, "application/json");
// Apply user-configured custom headers. Skip:
// * empty / whitespace-only keys (would produce reqwest builder
// errors mid-request and abort the whole connection);
// * keys that duplicate the framing we already set (`Accept`,
// `Content-Type`) so a stray entry can't break protocol
// negotiation;
// * values containing CR/LF, which would enable response-
// splitting style requests on a misbehaving proxy.
// reqwest itself rejects malformed header names/values; the
// duplicates and control-char filter is purely defense in
// depth.
for (key, value) in &self.headers {
if !is_safe_custom_header(key, value) {
tracing::warn!(
target: "mcp",
"skipping unsafe MCP header {:?} (empty/control-char/reserved)",
key
);
continue;
}
request = request.header(key.as_str(), value.as_str());
}
let response = request
.body(msg)
.send()
.await
@@ -1022,6 +1107,7 @@ impl McpConnection {
Box::new(HttpTransport::new(
client,
url.clone(),
config.headers.clone(),
cancel_token.clone(),
Duration::from_secs(connect_timeout_secs),
))
@@ -2256,6 +2342,7 @@ fn mcp_template_json() -> Result<String> {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
},
);
serde_json::to_string_pretty(&cfg).context("Failed to render MCP template JSON")
@@ -2307,6 +2394,7 @@ pub fn add_server_config(
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
},
);
save_config(path, &cfg)
@@ -2559,6 +2647,109 @@ mod tests {
assert_eq!(server.env.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn mcp_server_config_parses_custom_headers() {
let json = r#"{
"servers": {
"hf": {
"url": "https://example.invalid/mcp",
"headers": {
"Authorization": "Bearer tok",
"X-Org": "anthropic"
}
}
}
}"#;
let cfg: McpConfig = serde_json::from_str(json).unwrap();
let hf = cfg.servers.get("hf").expect("server present");
assert_eq!(
hf.headers.get("Authorization"),
Some(&"Bearer tok".to_string())
);
assert_eq!(hf.headers.get("X-Org"), Some(&"anthropic".to_string()));
}
#[test]
fn mcp_server_config_omits_headers_when_empty() {
// Empty headers map should not appear in the serialized output —
// older mcp.json files written before v0.8.31 must round-trip
// unchanged so a `mcp save` from a fresh install doesn't add
// dead keys.
let cfg = McpServerConfig {
command: Some("node".into()),
args: vec!["server.js".into()],
env: HashMap::new(),
url: None,
connect_timeout: None,
execute_timeout: None,
read_timeout: None,
disabled: false,
enabled: true,
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
};
let serialized = serde_json::to_string(&cfg).unwrap();
assert!(
!serialized.contains("\"headers\""),
"empty headers must be omitted: {serialized}"
);
}
#[test]
fn is_safe_custom_header_accepts_normal_auth_pairs() {
assert!(is_safe_custom_header("Authorization", "Bearer tok"));
assert!(is_safe_custom_header("X-Api-Key", "deadbeef"));
assert!(is_safe_custom_header("x-org", "anthropic"));
}
#[test]
fn is_safe_custom_header_rejects_empty_or_whitespace_key() {
assert!(!is_safe_custom_header("", "value"));
assert!(!is_safe_custom_header(" ", "value"));
}
#[test]
fn is_safe_custom_header_rejects_response_splitting_values() {
assert!(
!is_safe_custom_header("X-Foo", "abc\r\nSet-Cookie: evil=1"),
"CRLF in value must reject — response-splitting defense"
);
assert!(
!is_safe_custom_header("X-Foo", "abc\nbar"),
"bare LF in value must reject"
);
assert!(
!is_safe_custom_header("X-Foo", "abc\rbar"),
"bare CR in value must reject"
);
}
#[test]
fn is_safe_custom_header_rejects_protocol_framing_overrides() {
// The MCP Streamable HTTP transport relies on its own
// Accept / Content-Type values for protocol negotiation;
// a stray user override would silently break tool discovery.
assert!(!is_safe_custom_header("Accept", "text/plain"));
assert!(!is_safe_custom_header("accept", "text/plain"));
assert!(!is_safe_custom_header("Content-Type", "text/plain"));
assert!(!is_safe_custom_header("CONTENT-TYPE", "x/y"));
}
#[test]
fn streamable_http_transport_stores_headers() {
let client = reqwest::Client::new();
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer xyz".to_string());
let transport = StreamableHttpTransport::new(
client,
"https://example.invalid/mcp".to_string(),
headers.clone(),
);
assert_eq!(transport.headers, headers);
}
#[test]
fn test_mcp_config_parse_mcp_servers_alias_and_snapshot() {
let dir = tempfile::tempdir().unwrap();
@@ -2646,6 +2837,7 @@ mod tests {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
};
assert_eq!(server_with_override.effective_connect_timeout(&global), 20);
@@ -2755,6 +2947,7 @@ mod tests {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
}
}
@@ -2923,6 +3116,7 @@ mod tests {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
},
);
assert_ne!(
@@ -3158,6 +3352,7 @@ mod tests {
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
};
let conn = McpConnection::connect_with_policy(