Skip to main content

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}