Skip to content

Commit bda4caf

Browse files
authored
Merge pull request #428 from apollographql/AIR-44
Add support for forwarding headers from MCP clients to GraphQL APIs
2 parents bc7abdd + ef457d7 commit bda4caf

File tree

13 files changed

+379
-33
lines changed

13 files changed

+379
-33
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
### Add support for forwarding headers from MCP clients to GraphQL APIs - @DaleSeo PR #428
2+
3+
Adds opt-in support for dynamic header forwarding, which enables metadata for A/B testing, feature flagging, geo information from CDNs, or internal instrumentation to be sent from MCP clients to downstream GraphQL APIs. It automatically blocks hop-by-hop headers according to the guidelines in [RFC 7230, section 6.1](https://datatracker.ietf.org/doc/html/rfc7230#section-6.1), and it only works with the Streamable HTTP transport.
4+
5+
You can configure using the `forward_headers` setting:
6+
7+
```yaml
8+
forward_headers:
9+
- x-tenant-id
10+
- x-experiment-id
11+
- x-geo-country
12+
```
13+
14+
Please note that this feature is not intended for passing through credentials as documented in the best practices page.

crates/apollo-mcp-server/src/auth/valid_token.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use url::Url;
1212
/// Note: This is used as a marker to ensure that we have validated this
1313
/// separately from just reading the header itself.
1414
#[derive(Clone, Debug, PartialEq)]
15-
pub(crate) struct ValidToken(pub(super) Authorization<Bearer>);
15+
pub(crate) struct ValidToken(pub(crate) Authorization<Bearer>);
1616

1717
impl Deref for ValidToken {
1818
type Target = Authorization<Bearer>;
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
use std::ops::Deref;
2+
use std::str::FromStr;
3+
4+
use headers::HeaderMapExt;
5+
use http::Extensions;
6+
use reqwest::header::{HeaderMap, HeaderName};
7+
8+
use crate::auth::ValidToken;
9+
10+
/// List of header names to forward from MCP clients to GraphQL API
11+
pub type ForwardHeaders = Vec<String>;
12+
13+
/// Build headers for a GraphQL request by combining static headers with forwarded headers
14+
pub fn build_request_headers(
15+
static_headers: &HeaderMap,
16+
forward_header_names: &ForwardHeaders,
17+
incoming_headers: &HeaderMap,
18+
extensions: &Extensions,
19+
disable_auth_token_passthrough: bool,
20+
) -> HeaderMap {
21+
// Starts with static headers
22+
let mut headers = static_headers.clone();
23+
24+
// Forward headers dynamically
25+
forward_headers(forward_header_names, incoming_headers, &mut headers);
26+
27+
// Optionally extract the validated token and propagate it to upstream servers if present
28+
if !disable_auth_token_passthrough && let Some(token) = extensions.get::<ValidToken>() {
29+
headers.typed_insert(token.deref().clone());
30+
}
31+
32+
// Forward the mcp-session-id header if present
33+
if let Some(session_id) = incoming_headers.get("mcp-session-id") {
34+
headers.insert("mcp-session-id", session_id.clone());
35+
}
36+
37+
headers
38+
}
39+
40+
/// Forward matching headers from incoming headers to outgoing headers
41+
fn forward_headers(names: &[String], incoming: &HeaderMap, outgoing: &mut HeaderMap) {
42+
for header in names {
43+
if let Ok(header_name) = HeaderName::from_str(header)
44+
&& let Some(value) = incoming.get(&header_name)
45+
// Hop-by-hop headers are blocked per RFC 7230: https://datatracker.ietf.org/doc/html/rfc7230#section-6.1
46+
&& !matches!(
47+
header_name.as_str().to_lowercase().as_str(),
48+
"connection"
49+
| "keep-alive"
50+
| "proxy-authenticate"
51+
| "proxy-authorization"
52+
| "te"
53+
| "trailers"
54+
| "transfer-encoding"
55+
| "upgrade"
56+
| "content-length"
57+
)
58+
{
59+
outgoing.insert(header_name, value.clone());
60+
}
61+
}
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
use headers::Authorization;
68+
use http::Extensions;
69+
use reqwest::header::HeaderValue;
70+
71+
use crate::auth::ValidToken;
72+
73+
#[test]
74+
fn test_build_request_headers_includes_static_headers() {
75+
let mut static_headers = HeaderMap::new();
76+
static_headers.insert("x-api-key", HeaderValue::from_static("static-key"));
77+
static_headers.insert("user-agent", HeaderValue::from_static("mcp-server"));
78+
79+
let forward_header_names = vec![];
80+
let incoming_headers = HeaderMap::new();
81+
let extensions = Extensions::new();
82+
83+
let result = build_request_headers(
84+
&static_headers,
85+
&forward_header_names,
86+
&incoming_headers,
87+
&extensions,
88+
false,
89+
);
90+
91+
assert_eq!(result.get("x-api-key").unwrap(), "static-key");
92+
assert_eq!(result.get("user-agent").unwrap(), "mcp-server");
93+
}
94+
95+
#[test]
96+
fn test_build_request_headers_forwards_configured_headers() {
97+
let static_headers = HeaderMap::new();
98+
let forward_header_names = vec!["x-tenant-id".to_string(), "x-trace-id".to_string()];
99+
100+
let mut incoming_headers = HeaderMap::new();
101+
incoming_headers.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
102+
incoming_headers.insert("x-trace-id", HeaderValue::from_static("trace-456"));
103+
incoming_headers.insert("other-header", HeaderValue::from_static("ignored"));
104+
105+
let extensions = Extensions::new();
106+
107+
let result = build_request_headers(
108+
&static_headers,
109+
&forward_header_names,
110+
&incoming_headers,
111+
&extensions,
112+
false,
113+
);
114+
115+
assert_eq!(result.get("x-tenant-id").unwrap(), "tenant-123");
116+
assert_eq!(result.get("x-trace-id").unwrap(), "trace-456");
117+
assert!(result.get("other-header").is_none());
118+
}
119+
120+
#[test]
121+
fn test_build_request_headers_adds_oauth_token_when_enabled() {
122+
let static_headers = HeaderMap::new();
123+
let forward_header_names = vec![];
124+
let incoming_headers = HeaderMap::new();
125+
126+
let mut extensions = Extensions::new();
127+
let token = ValidToken(Authorization::bearer("test-token").unwrap());
128+
extensions.insert(token);
129+
130+
let result = build_request_headers(
131+
&static_headers,
132+
&forward_header_names,
133+
&incoming_headers,
134+
&extensions,
135+
false,
136+
);
137+
138+
assert!(result.get("authorization").is_some());
139+
assert_eq!(result.get("authorization").unwrap(), "Bearer test-token");
140+
}
141+
142+
#[test]
143+
fn test_build_request_headers_skips_oauth_token_when_disabled() {
144+
let static_headers = HeaderMap::new();
145+
let forward_header_names = vec![];
146+
let incoming_headers = HeaderMap::new();
147+
148+
let mut extensions = Extensions::new();
149+
let token = ValidToken(Authorization::bearer("test-token").unwrap());
150+
extensions.insert(token);
151+
152+
let result = build_request_headers(
153+
&static_headers,
154+
&forward_header_names,
155+
&incoming_headers,
156+
&extensions,
157+
true,
158+
);
159+
160+
assert!(result.get("authorization").is_none());
161+
}
162+
163+
#[test]
164+
fn test_build_request_headers_forwards_mcp_session_id() {
165+
let static_headers = HeaderMap::new();
166+
let forward_header_names = vec![];
167+
168+
let mut incoming_headers = HeaderMap::new();
169+
incoming_headers.insert("mcp-session-id", HeaderValue::from_static("session-123"));
170+
171+
let extensions = Extensions::new();
172+
173+
let result = build_request_headers(
174+
&static_headers,
175+
&forward_header_names,
176+
&incoming_headers,
177+
&extensions,
178+
false,
179+
);
180+
181+
assert_eq!(result.get("mcp-session-id").unwrap(), "session-123");
182+
}
183+
184+
#[test]
185+
fn test_build_request_headers_combined_scenario() {
186+
// Static headers
187+
let mut static_headers = HeaderMap::new();
188+
static_headers.insert("x-api-key", HeaderValue::from_static("static-key"));
189+
190+
// Forward specific headers
191+
let forward_header_names = vec!["x-tenant-id".to_string()];
192+
193+
// Incoming headers
194+
let mut incoming_headers = HeaderMap::new();
195+
incoming_headers.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
196+
incoming_headers.insert("mcp-session-id", HeaderValue::from_static("session-456"));
197+
incoming_headers.insert(
198+
"ignored-header",
199+
HeaderValue::from_static("should-not-appear"),
200+
);
201+
202+
// OAuth token
203+
let mut extensions = Extensions::new();
204+
let token = ValidToken(Authorization::bearer("oauth-token").unwrap());
205+
extensions.insert(token);
206+
207+
let result = build_request_headers(
208+
&static_headers,
209+
&forward_header_names,
210+
&incoming_headers,
211+
&extensions,
212+
false,
213+
);
214+
215+
// Verify all parts combined correctly
216+
assert_eq!(result.get("x-api-key").unwrap(), "static-key");
217+
assert_eq!(result.get("x-tenant-id").unwrap(), "tenant-123");
218+
assert_eq!(result.get("mcp-session-id").unwrap(), "session-456");
219+
assert_eq!(result.get("authorization").unwrap(), "Bearer oauth-token");
220+
assert!(result.get("ignored-header").is_none());
221+
}
222+
223+
#[test]
224+
fn test_forward_headers_no_headers_by_default() {
225+
let names: Vec<String> = vec![];
226+
227+
let mut incoming = HeaderMap::new();
228+
incoming.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
229+
230+
let mut outgoing = HeaderMap::new();
231+
232+
forward_headers(&names, &incoming, &mut outgoing);
233+
234+
assert!(outgoing.is_empty());
235+
}
236+
237+
#[test]
238+
fn test_forward_headers_only_specific_headers() {
239+
let names = vec![
240+
"x-tenant-id".to_string(), // Multi-tenancy
241+
"x-trace-id".to_string(), // Distributed tracing
242+
"x-geo-country".to_string(), // Geo information from CDN
243+
"x-experiment-id".to_string(), // A/B testing
244+
"ai-client-name".to_string(), // Client identification
245+
];
246+
247+
let mut incoming = HeaderMap::new();
248+
incoming.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
249+
incoming.insert("x-trace-id", HeaderValue::from_static("trace-456"));
250+
incoming.insert("x-geo-country", HeaderValue::from_static("US"));
251+
incoming.insert("x-experiment-id", HeaderValue::from_static("exp-789"));
252+
incoming.insert("ai-client-name", HeaderValue::from_static("claude"));
253+
incoming.insert("other-header", HeaderValue::from_static("ignored"));
254+
255+
let mut outgoing = HeaderMap::new();
256+
257+
forward_headers(&names, &incoming, &mut outgoing);
258+
259+
assert_eq!(outgoing.get("x-tenant-id").unwrap(), "tenant-123");
260+
assert_eq!(outgoing.get("x-trace-id").unwrap(), "trace-456");
261+
assert_eq!(outgoing.get("x-geo-country").unwrap(), "US");
262+
assert_eq!(outgoing.get("x-experiment-id").unwrap(), "exp-789");
263+
assert_eq!(outgoing.get("ai-client-name").unwrap(), "claude");
264+
265+
assert!(outgoing.get("other-header").is_none());
266+
}
267+
268+
#[test]
269+
fn test_forward_headers_blocks_hop_by_hop_headers() {
270+
let names = vec!["connection".to_string(), "content-length".to_string()];
271+
272+
let mut incoming = HeaderMap::new();
273+
incoming.insert("connection", HeaderValue::from_static("keep-alive"));
274+
incoming.insert("content-length", HeaderValue::from_static("1234"));
275+
276+
let mut outgoing = HeaderMap::new();
277+
278+
forward_headers(&names, &incoming, &mut outgoing);
279+
280+
assert!(outgoing.get("connection").is_none());
281+
assert!(outgoing.get("content-length").is_none());
282+
}
283+
284+
#[test]
285+
fn test_forward_headers_case_insensitive_matching() {
286+
let names = vec!["X-Tenant-ID".to_string()];
287+
288+
let mut incoming = HeaderMap::new();
289+
incoming.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
290+
291+
let mut outgoing = HeaderMap::new();
292+
forward_headers(&names, &incoming, &mut outgoing);
293+
294+
assert_eq!(outgoing.get("x-tenant-id").unwrap(), "tenant-123");
295+
}
296+
}

crates/apollo-mcp-server/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod errors;
77
pub mod event;
88
mod explorer;
99
mod graphql;
10+
pub mod headers;
1011
pub mod health;
1112
mod introspection;
1213
pub mod json_schema;

crates/apollo-mcp-server/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ async fn main() -> anyhow::Result<()> {
115115
.endpoint(config.endpoint.into_inner())
116116
.maybe_explorer_graph_ref(explorer_graph_ref)
117117
.headers(config.headers)
118+
.forward_headers(config.forward_headers)
118119
.execute_introspection(config.introspection.execute.enabled)
119120
.validate_introspection(config.introspection.validate.enabled)
120121
.introspect_introspection(config.introspection.introspect.enabled)

crates/apollo-mcp-server/src/runtime.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ mod test {
240240
],
241241
},
242242
headers: {},
243+
forward_headers: [],
243244
health_check: HealthCheckConfig {
244245
enabled: false,
245246
path: "/health",

crates/apollo-mcp-server/src/runtime/config.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::path::PathBuf;
22

3-
use apollo_mcp_server::{cors::CorsConfig, health::HealthCheckConfig, server::Transport};
3+
use apollo_mcp_server::{
4+
cors::CorsConfig, headers::ForwardHeaders, health::HealthCheckConfig, server::Transport,
5+
};
46
use reqwest::header::HeaderMap;
57
use schemars::JsonSchema;
68
use serde::Deserialize;
@@ -33,6 +35,10 @@ pub struct Config {
3335
#[schemars(schema_with = "super::schemas::header_map")]
3436
pub headers: HeaderMap,
3537

38+
/// List of header names to forward from MCP client requests to GraphQL requests
39+
#[serde(default)]
40+
pub forward_headers: ForwardHeaders,
41+
3642
/// Health check configuration
3743
#[serde(default)]
3844
pub health_check: HealthCheckConfig,

crates/apollo-mcp-server/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::cors::CorsConfig;
1212
use crate::custom_scalar_map::CustomScalarMap;
1313
use crate::errors::ServerError;
1414
use crate::event::Event as ServerEvent;
15+
use crate::headers::ForwardHeaders;
1516
use crate::health::HealthCheckConfig;
1617
use crate::operations::{MutationMode, OperationSource};
1718

@@ -26,6 +27,7 @@ pub struct Server {
2627
operation_source: OperationSource,
2728
endpoint: Url,
2829
headers: HeaderMap,
30+
forward_headers: ForwardHeaders,
2931
execute_introspection: bool,
3032
validate_introspection: bool,
3133
introspect_introspection: bool,
@@ -111,6 +113,7 @@ impl Server {
111113
operation_source: OperationSource,
112114
endpoint: Url,
113115
headers: HeaderMap,
116+
forward_headers: ForwardHeaders,
114117
execute_introspection: bool,
115118
validate_introspection: bool,
116119
introspect_introspection: bool,
@@ -139,6 +142,7 @@ impl Server {
139142
operation_source,
140143
endpoint,
141144
headers,
145+
forward_headers,
142146
execute_introspection,
143147
validate_introspection,
144148
introspect_introspection,

0 commit comments

Comments
 (0)