emperor_mcp/headers.rs
1//!
2//! Credential forwarding.
3//!
4//! ## How it flows
5//!
6//! 1. [`forward_headers_middleware`] runs as an axum layer in front of the
7//! streamable-HTTP MCP service. It copies a configured allow-list of
8//! inbound headers into a [`ForwardedHeaders`] value and inserts it into
9//! the request's `http::Extensions`.
10//! 2. The rmcp streamable-HTTP transport propagates request extensions into
11//! each `RequestContext`'s `extensions`.
12//! 3. A tool handler reads [`ForwardedHeaders`] back out of
13//! `request_context.extensions` (see [`ForwardedHeaders::from_request_context`])
14//! and forwards the relevant header(s) to the upstream call.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use axum::extract::Request;
20use axum::middleware::Next;
21use axum::response::Response;
22use http::HeaderMap;
23
24/// The set of inbound headers captured for forwarding to the upstream
25/// service, for one MCP request.
26///
27/// Stored in `http::Extensions` by [`forward_headers_middleware`] and read
28/// back from `RequestContext::extensions` inside tool handlers.
29#[derive(Debug, Clone, Default)]
30pub struct ForwardedHeaders {
31 /// Lower-cased header name -> value. Lower-casing keeps look-ups
32 /// case-insensitive (HTTP header names are case-insensitive).
33 values: HashMap<String, String>,
34}
35
36impl ForwardedHeaders {
37 /// Look up a forwarded header value by (case-insensitive) name.
38 pub fn get(&self, name: &str) -> Option<&str> {
39 self.values
40 .get(&name.to_ascii_lowercase())
41 .map(String::as_str)
42 }
43
44 /// Convenience: the `Authorization` header, if present.
45 pub fn authorization(&self) -> Option<&str> {
46 self.get("authorization")
47 }
48
49 /// True when no headers were forwarded (e.g. an unauthenticated probe).
50 pub fn is_empty(&self) -> bool {
51 self.values.is_empty()
52 }
53
54 /// Build from a raw [`HeaderMap`], keeping only the allow-listed names.
55 /// Names in `allow` are matched case-insensitively.
56 pub fn from_header_map(headers: &HeaderMap, allow: &[&str]) -> Self {
57 let mut values = HashMap::new();
58 for name in allow {
59 if let Some(v) = headers.get(*name).and_then(|v| v.to_str().ok()) {
60 values.insert(name.to_ascii_lowercase(), v.to_string());
61 }
62 }
63 Self { values }
64 }
65
66 /// Extract from an rmcp server `RequestContext` inside a tool handler.
67 ///
68 /// rmcp injects the HTTP request's [`http::request::Parts`] as a single
69 /// extension on the `RequestContext`, so the headers captured by
70 /// [`forward_headers_middleware`] live in `Parts.extensions` — NOT directly
71 /// on `ctx.extensions`. We therefore look both places: directly on the
72 /// context (in case a future rmcp merges them) and, failing that, through
73 /// the injected `Parts`.
74 ///
75 /// Returns `ForwardedHeaders::default()` (empty) when the middleware did
76 /// not run or no allow-listed headers were present, so callers can treat
77 /// "no credentials" uniformly.
78 pub fn from_request_context<R>(ctx: &rmcp::service::RequestContext<R>) -> Self
79 where
80 R: rmcp::service::ServiceRole,
81 {
82 // 1. Directly on the context extensions.
83 if let Some(fwd) = ctx.extensions.get::<ForwardedHeaders>() {
84 return fwd.clone();
85 }
86 // 2. Through the injected HTTP request Parts (the streamable-HTTP path).
87 if let Some(parts) = ctx.extensions.get::<http::request::Parts>()
88 && let Some(fwd) = parts.extensions.get::<ForwardedHeaders>()
89 {
90 return fwd.clone();
91 }
92 Self::default()
93 }
94}
95
96/// Configuration for [`forward_headers_middleware`]: which inbound header
97/// names should be captured and forwarded.
98#[derive(Debug, Clone)]
99pub struct ForwardConfig {
100 /// Allow-list of header names to forward (case-insensitive).
101 pub allow: Arc<Vec<String>>,
102}
103
104impl ForwardConfig {
105 /// Build from a list of header names.
106 pub fn new<I, S>(names: I) -> Self
107 where
108 I: IntoIterator<Item = S>,
109 S: Into<String>,
110 {
111 Self {
112 allow: Arc::new(names.into_iter().map(Into::into).collect()),
113 }
114 }
115
116 /// The common single-header case: just `Authorization`.
117 pub fn authorization_only() -> Self {
118 Self::new(["Authorization"])
119 }
120}
121
122/// Axum middleware that captures the allow-listed inbound headers into a
123/// [`ForwardedHeaders`] extension on the request, so downstream MCP tool
124/// handlers can forward them to the upstream service.
125///
126/// Mount it on the router that serves the MCP service:
127///
128/// ```ignore
129/// let app = axum::Router::new()
130/// .nest_service("/mcp", mcp_service)
131/// .layer(axum::middleware::from_fn_with_state(
132/// ForwardConfig::authorization_only(),
133/// forward_headers_middleware,
134/// ));
135/// ```
136pub async fn forward_headers_middleware(
137 axum::extract::State(config): axum::extract::State<ForwardConfig>,
138 mut request: Request,
139 next: Next,
140) -> Response {
141 let allow: Vec<&str> = config.allow.iter().map(String::as_str).collect();
142 let forwarded = ForwardedHeaders::from_header_map(request.headers(), &allow);
143 request.extensions_mut().insert(forwarded);
144 next.run(request).await
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn header_map(pairs: &[(&str, &str)]) -> HeaderMap {
152 let mut m = HeaderMap::new();
153 for (k, v) in pairs {
154 m.insert(
155 http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
156 http::HeaderValue::from_str(v).unwrap(),
157 );
158 }
159 m
160 }
161
162 #[test]
163 fn captures_only_allowlisted_headers() {
164 let headers = header_map(&[
165 ("Authorization", "Bearer abc"),
166 ("X-Other", "nope"),
167 ("X-Upstream-User", "alice"),
168 ]);
169 let fwd =
170 ForwardedHeaders::from_header_map(&headers, &["Authorization", "X-Upstream-User"]);
171 assert_eq!(fwd.authorization(), Some("Bearer abc"));
172 assert_eq!(fwd.get("x-upstream-user"), Some("alice"));
173 assert_eq!(
174 fwd.get("X-Other"),
175 None,
176 "non-allowlisted header must be dropped"
177 );
178 }
179
180 #[test]
181 fn lookup_is_case_insensitive() {
182 let headers = header_map(&[("Authorization", "Bearer abc")]);
183 let fwd = ForwardedHeaders::from_header_map(&headers, &["authorization"]);
184 assert_eq!(fwd.get("AUTHORIZATION"), Some("Bearer abc"));
185 assert_eq!(fwd.get("Authorization"), Some("Bearer abc"));
186 }
187
188 #[test]
189 fn empty_when_no_allowlisted_headers_present() {
190 let headers = header_map(&[("X-Random", "v")]);
191 let fwd = ForwardedHeaders::from_header_map(&headers, &["Authorization"]);
192 assert!(fwd.is_empty());
193 assert_eq!(fwd.authorization(), None);
194 }
195}