1use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, Mutex};
33
34use super::{json_escape, json_rpc, McpContent, McpServer, ToolAnnotations};
35use crate::http_client::Client;
36
37const PROTOCOL_VERSION: &str = "2025-11-25";
39
40const MODERN_VERSION: &str = "2026-07-28";
42
43pub const SEPARATOR: &str = "__";
45
46const MAX_LIST_PAGES: usize = 100;
48
49#[derive(Clone)]
51pub struct Upstream {
52 prefix: String,
53 url: String,
54 headers: Vec<(String, String)>,
55 caller_headers: Vec<(String, CallerHeader)>,
57 timeout_ms: u64,
58}
59
60type CallerHeader = Arc<dyn Fn(Option<&str>) -> Option<String> + Send + Sync>;
63
64impl std::fmt::Debug for Upstream {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("Upstream")
68 .field("prefix", &self.prefix)
69 .field("url", &self.url)
70 .field("headers", &self.headers.iter().map(|(k, _)| k).collect::<Vec<_>>())
71 .field("caller_headers", &self.caller_headers.iter().map(|(k, _)| k).collect::<Vec<_>>())
72 .field("timeout_ms", &self.timeout_ms)
73 .finish()
74 }
75}
76
77impl Upstream {
78 pub fn new(prefix: impl Into<String>, url: impl Into<String>) -> Self {
82 Upstream { prefix: prefix.into(), url: url.into(), headers: Vec::new(), caller_headers: Vec::new(), timeout_ms: 30_000 }
83 }
84
85 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
88 self.headers.push((name.into(), value.into()));
89 self
90 }
91
92 pub fn header_for<F>(mut self, name: impl Into<String>, value: F) -> Self
100 where
101 F: Fn(Option<&str>) -> Option<String> + Send + Sync + 'static,
102 {
103 self.caller_headers.push((name.into(), Arc::new(value)));
104 self
105 }
106
107 pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
109 self.timeout_ms = timeout_ms;
110 self
111 }
112}
113
114#[derive(Clone)]
118pub struct OpenApiUpstream {
119 prefix: String,
120 spec: String,
121 base_url: String,
122 headers: Vec<(String, String)>,
123 include: Vec<String>,
124 exclude: Vec<String>,
125 timeout_ms: u64,
126}
127
128impl OpenApiUpstream {
129 pub fn new(prefix: impl Into<String>, spec: impl Into<String>, base_url: impl Into<String>) -> Self {
133 OpenApiUpstream { prefix: prefix.into(), spec: spec.into(), base_url: base_url.into().trim_end_matches('/').to_string(), headers: Vec::new(), include: Vec::new(), exclude: Vec::new(), timeout_ms: 30_000 }
134 }
135
136 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
139 self.headers.push((name.into(), value.into()));
140 self
141 }
142
143 pub fn include<I: IntoIterator<Item = S>, S: Into<String>>(mut self, ids: I) -> Self {
145 self.include = ids.into_iter().map(Into::into).collect();
146 self
147 }
148
149 pub fn exclude<I: IntoIterator<Item = S>, S: Into<String>>(mut self, ids: I) -> Self {
151 self.exclude = ids.into_iter().map(Into::into).collect();
152 self
153 }
154
155 pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
157 self.timeout_ms = timeout_ms;
158 self
159 }
160
161 fn client(&self) -> crate::http_client::Client {
162 crate::http_client::Client::new().max_redirects(0)
164 }
165
166 fn load_spec(&self) -> Result<String, String> {
167 if self.spec.starts_with("http://") || self.spec.starts_with("https://") {
168 let client = self.client();
169 let mut request = client.get(&self.spec).timeout_ms(self.timeout_ms);
170 for (name, value) in &self.headers {
171 request = request.header(name, value);
172 }
173 let response = request.send().map_err(|e| format!("fetching {}: {e}", self.spec))?;
174 if !response.is_success() {
175 return Err(format!("fetching {}: HTTP {}", self.spec, response.status()));
176 }
177 response.text().map_err(|e| e.to_string())
178 } else {
179 std::fs::read_to_string(&self.spec).map_err(|e| format!("reading {}: {e}", self.spec))
180 }
181 }
182
183 fn call(&self, tool: &crate::mcp::openapi_tools::OpenApiTool, arguments: &str) -> Result<McpContent, String> {
185 const MAX_RESULT: usize = 1 << 20;
186 let call = tool.request(arguments)?;
187 let url = format!("{}{}", self.base_url, call.path_and_query);
188 let client = self.client();
189 let mut request = client.request(&call.method, &url).timeout_ms(self.timeout_ms);
190 for (name, value) in call.headers.iter().chain(self.headers.iter()) {
191 request = request.header(name, value);
192 }
193 if let Some(body) = call.body {
194 request = request.body_json(&body);
195 }
196 let response = request.send().map_err(|e| format!("{} {}: {e}", call.method, call.path_and_query))?;
197 let mut text = response.text().unwrap_or_default();
198 if text.len() > MAX_RESULT {
199 let mut cut = MAX_RESULT;
200 while !text.is_char_boundary(cut) {
201 cut -= 1;
202 }
203 text.truncate(cut);
204 text.push_str("…(truncated)");
205 }
206 if !response.is_success() {
207 return Err(format!("HTTP {}: {text}", response.status()));
208 }
209 let is_json = response.header("content-type").is_some_and(|t| t.contains("json"));
210 Ok(match (is_json, tool.output_schema.is_some()) {
211 (true, true) if text.trim_start().starts_with('{') => McpContent::structured(text),
212 (true, _) => McpContent::json(text),
213 _ => McpContent::text(text),
214 })
215 }
216}
217
218pub struct McpGateway {
220 name: String,
221 version: String,
222 upstreams: Vec<Upstream>,
223 apis: Vec<OpenApiUpstream>,
224 rules: Vec<(String, Vec<String>)>,
225 refresh: Option<std::time::Duration>,
226 limits: Vec<ToolLimit>,
227 audit: bool,
228}
229
230struct ToolLimit {
232 pattern: String,
233 max_calls: u32,
234 window_secs: u64,
235 limiter: crate::rate_limit::RateLimiter,
236}
237
238pub const DEFAULT_REFRESH: std::time::Duration = std::time::Duration::from_secs(300);
240
241impl McpGateway {
242 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
244 McpGateway { name: name.into(), version: version.into(), upstreams: Vec::new(), apis: Vec::new(), rules: Vec::new(), refresh: Some(DEFAULT_REFRESH), limits: Vec::new(), audit: false }
245 }
246
247 pub fn limit(mut self, tools: impl Into<String>, max_calls: u32, window_secs: u64) -> Self {
253 self.limits.push(ToolLimit { pattern: tools.into(), max_calls, window_secs, limiter: crate::rate_limit::RateLimiter::new(max_calls, window_secs) });
254 self
255 }
256
257 pub fn audit_log(mut self, on: bool) -> Self {
262 self.audit = on;
263 self
264 }
265
266 pub fn refresh_every(mut self, every: Option<std::time::Duration>) -> Self {
270 self.refresh = every;
271 self
272 }
273
274 pub fn openapi(mut self, api: OpenApiUpstream) -> Self {
277 self.apis.push(api);
278 self
279 }
280
281 pub fn upstream(mut self, upstream: Upstream) -> Self {
283 self.upstreams.push(upstream);
284 self
285 }
286
287 pub fn allow<I, S>(mut self, identity: impl Into<String>, tools: I) -> Self
292 where
293 I: IntoIterator<Item = S>,
294 S: Into<String>,
295 {
296 self.rules.push((identity.into(), tools.into_iter().map(Into::into).collect()));
297 self
298 }
299
300 pub fn build(self) -> Result<McpServer, String> {
304 let mut prefixes: Vec<&str> = Vec::new();
305 for prefix in self.upstreams.iter().map(|u| u.prefix.as_str()).chain(self.apis.iter().map(|a| a.prefix.as_str())) {
306 if !valid_prefix(prefix) {
307 return Err(format!("upstream prefix \"{prefix}\": use letters, digits, '-' and single '_'"));
308 }
309 if prefixes.contains(&prefix) {
310 return Err(format!("upstream prefix \"{prefix}\" is used twice"));
311 }
312 prefixes.push(prefix);
313 }
314 for api in &self.apis {
315 if !(api.base_url.starts_with("http://") || api.base_url.starts_with("https://")) {
316 return Err(format!("{}: base_url {:?} isn't an http(s) URL", api.prefix, api.base_url));
317 }
318 }
319
320 let rules = Arc::new(self.rules);
321 let tool_rules = Arc::clone(&rules);
322 let mut server = McpServer::new(self.name, self.version).tool_filter(move |ctx, tool| {
323 let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub"));
324 identity.is_some_and(|identity| allowed(&tool_rules, &identity, tool))
325 });
326 if self.audit {
327 server = server.after_tool_call(|ctx, call| {
328 let sub = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub")).unwrap_or_default();
329 let upstream = call.tool.split_once(SEPARATOR).map_or("", |(prefix, _)| prefix);
330 let outcome = match call.outcome {
331 crate::mcp::ToolCallOutcome::Ok => "ok",
332 crate::mcp::ToolCallOutcome::ToolError => "tool_error",
333 crate::mcp::ToolCallOutcome::Refused => "refused",
334 crate::mcp::ToolCallOutcome::Denied => "denied",
335 };
336 let line = audit_line(&sub, call.tool, upstream, outcome, call.duration);
337 crate::log::access_sink::write_line(|out| out.extend_from_slice(line.as_bytes()));
338 });
339 }
340 if !self.limits.is_empty() {
341 let limits = self.limits;
342 server = server.before_tool_call(move |ctx, tool| {
343 let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub")).unwrap_or_default();
344 for limit in limits.iter().filter(|l| matches_pattern(&l.pattern, tool)) {
345 let key = format!("{identity}\u{0}{tool}");
346 if !limit.limiter.check(&key) {
347 return Err(format!("Rate limited: {tool} allows {} calls per {} s; retry in {} s", limit.max_calls, limit.window_secs, limit.limiter.retry_after(&key).max(1)));
348 }
349 }
350 Ok(())
351 });
352 }
353 let resource_owner: Arc<Mutex<std::collections::HashMap<String, String>>> = Arc::new(Mutex::new(std::collections::HashMap::new()));
357 {
358 let rules = Arc::clone(&rules);
359 let owners = Arc::clone(&resource_owner);
360 server = server.resource_filter(move |ctx, uri| {
361 let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub"));
362 let owner = owners.lock().ok().and_then(|o| o.get(uri).cloned());
363 match (identity, owner) {
364 (Some(identity), Some(prefix)) => allowed(&rules, &identity, &format!("{prefix}{SEPARATOR}{uri}")),
365 (_, None) => true, (None, Some(_)) => false,
367 }
368 });
369 }
370 {
371 let rules = Arc::clone(&rules);
372 server = server.prompt_filter(move |ctx, name| {
373 let identity = ctx.auth_claims.as_deref().and_then(|claims| top_level_str(claims, "sub"));
374 identity.is_some_and(|identity| allowed(&rules, &identity, name))
375 });
376 }
377 for api in self.apis {
378 let spec = api.load_spec().map_err(|e| format!("{}: {e}", api.prefix))?;
379 let options = crate::mcp::openapi_tools::ToolOptions { prefix: Some(api.prefix.clone()), include: api.include.clone(), exclude: api.exclude.clone() };
380 let found = crate::mcp::openapi_tools::tools(&spec, &options).map_err(|e| format!("{}: {e}", api.prefix))?;
381 let api = Arc::new(api);
382 for tool in found {
383 let (name, description, input) = (tool.name.clone(), tool.description.clone(), tool.input_schema.clone());
384 let (output, annotations) = (tool.output_schema.clone(), tool.annotations);
385 let (api, tool) = (Arc::clone(&api), Arc::new(tool));
386 server = server.tool_with_context(&name, &description, &input, move |_ctx, args| api.call(&tool, args));
387 if let Some(schema) = output {
388 server = server.tool_output_schema(&name, &schema);
389 }
390 server = server.tool_annotations(&name, annotations);
391 }
392 }
393 let mut federated = Vec::new();
394 for upstream in self.upstreams {
395 let connection = Arc::new(Connection::new(upstream));
396 let mut known = std::collections::HashMap::new();
397 for tool in connection.list_tools()? {
398 known.insert(tool.name.clone(), tool.fingerprint());
399 server = register(server, &connection, tool);
400 }
401 let listed = |method: &str, field: &str| {
404 connection.list_all(method, field).unwrap_or_else(|e| {
405 eprintln!("mcp gateway: not federating {field}: {e}");
406 Vec::new()
407 })
408 };
409 for prompt in listed("prompts/list", "prompts") {
410 server = register_prompt(server, &connection, &prompt);
411 }
412 for (method, field, key) in [("resources/list", "resources", "uri"), ("resources/templates/list", "resourceTemplates", "uriTemplate")] {
413 for resource in listed(method, field) {
414 let Some(uri) = top_level_str(&resource, key) else { continue };
415 {
416 let mut owners = resource_owner.lock().map_err(|_| "resource lock poisoned".to_string())?;
417 if owners.contains_key(&uri) {
418 eprintln!("mcp gateway: {} also publishes {uri}; keeping the first", connection.name());
419 continue;
420 }
421 owners.insert(uri.clone(), connection.upstream.prefix.clone());
422 }
423 server = register_resource(server, &connection, &uri, &resource);
424 }
425 }
426 federated.push((connection, known));
427 }
428 if let Some(every) = self.refresh {
429 let live = server.clone();
430 let spawned = std::thread::Builder::new().name("rws-mcp-gateway-refresh".to_string()).spawn(move || loop {
431 std::thread::sleep(every);
432 for (connection, known) in federated.iter_mut() {
433 refresh(&live, connection, known);
434 }
435 });
436 if let Err(e) = spawned {
437 eprintln!("mcp gateway: tool list refresh not started: {e}");
438 }
439 }
440 Ok(server)
441 }
442}
443
444fn refresh(server: &McpServer, connection: &Arc<Connection>, known: &mut std::collections::HashMap<String, String>) {
447 let tools = match connection.list_tools() {
448 Ok(tools) => tools,
449 Err(e) => {
450 eprintln!("mcp gateway: keeping {}'s tools: {e}", connection.name());
451 return;
452 }
453 };
454 let prefix = &connection.upstream.prefix;
455 let mut changed = false;
456 let mut seen = std::collections::HashSet::new();
457 for tool in tools {
458 seen.insert(tool.name.clone());
459 let fingerprint = tool.fingerprint();
460 if known.get(&tool.name) == Some(&fingerprint) {
461 continue;
462 }
463 let exposed = format!("{prefix}{SEPARATOR}{}", tool.name);
464 if known.contains_key(&tool.name) {
465 server.remove_tool(&exposed);
466 }
467 known.insert(tool.name.clone(), fingerprint);
468 let _ = register(server.clone(), connection, tool);
469 changed = true;
470 }
471 let gone: Vec<String> = known.keys().filter(|name| !seen.contains(*name)).cloned().collect();
472 for name in gone {
473 known.remove(&name);
474 server.remove_tool(&format!("{prefix}{SEPARATOR}{name}"));
475 changed = true;
476 }
477 if changed {
478 server.notify("notifications/tools/list_changed", None);
479 }
480}
481
482#[derive(Clone)]
484struct UpstreamTool {
485 name: String,
486 description: String,
487 title: Option<String>,
488 input_schema: String,
489 output_schema: Option<String>,
490 annotations: Option<ToolAnnotations>,
491}
492
493impl UpstreamTool {
494 fn fingerprint(&self) -> String {
496 format!("{}\u{0}{}\u{0}{:?}\u{0}{}\u{0}{:?}\u{0}{:?}", self.name, self.description, self.title, self.input_schema, self.output_schema, self.annotations)
497 }
498}
499
500fn register(server: McpServer, connection: &Arc<Connection>, tool: UpstreamTool) -> McpServer {
501 let exposed = format!("{}{SEPARATOR}{}", connection.upstream.prefix, tool.name);
502 let forward = Arc::clone(connection);
503 let upstream_name = tool.name.clone();
504 let mut server = server.tool_with_context(&exposed, &tool.description, &tool.input_schema, move |ctx, args| {
505 forward.call_tool(&upstream_name, args, ctx.auth_claims.as_deref()).map(McpContent::raw_result)
506 });
507 if let Some(title) = tool.title {
508 server = server.tool_title(&exposed, title);
509 }
510 if let Some(schema) = tool.output_schema {
511 server = server.tool_output_schema(&exposed, &schema);
512 }
513 if let Some(annotations) = tool.annotations {
514 server = server.tool_annotations(&exposed, annotations);
515 }
516 server
517}
518
519fn register_prompt(server: McpServer, connection: &Arc<Connection>, prompt: &str) -> McpServer {
520 let Some(name) = top_level_str(prompt, "name") else { return server };
521 let exposed = format!("{}{SEPARATOR}{name}", connection.upstream.prefix);
522 let description = top_level_str(prompt, "description").unwrap_or_default();
523 let args = top_level_raw(prompt, "arguments")
524 .map(|list| {
525 json_rpc::split_array_elements(&list)
526 .into_iter()
527 .filter_map(|arg| {
528 let name = top_level_str(&arg, "name")?;
529 let description = top_level_str(&arg, "description").unwrap_or_default();
530 let required = top_level_raw(&arg, "required").as_deref() == Some("true");
531 Some(if required { super::PromptArgDef::required(name, description) } else { super::PromptArgDef::optional(name, description) })
532 })
533 .collect()
534 })
535 .unwrap_or_default();
536 let forward = Arc::clone(connection);
537 server.prompt_with_args(&exposed, &description, args, move |arguments| {
538 let params = format!(r#"{{"name":"{}","arguments":{}}}"#, json_escape(&name), if arguments.trim().is_empty() { "{}" } else { arguments });
539 let result = forward.request("prompts/get", ¶ms).map_err(|e| format!("{}: {e}", forward.upstream.prefix))?;
540 let messages = top_level_raw(&result, "messages").ok_or_else(|| format!("{}: prompts/get without messages", forward.upstream.prefix))?;
541 Ok(json_rpc::split_array_elements(&messages)
542 .into_iter()
543 .filter_map(|message| {
544 let role = if top_level_str(&message, "role").as_deref() == Some("assistant") { "assistant" } else { "user" };
545 let content = top_level_raw(&message, "content").filter(|c| c.starts_with('{'))?;
546 Some(super::PromptMessage { role, content: McpContent::raw_block(content) })
547 })
548 .collect())
549 })
550}
551
552fn register_resource(server: McpServer, connection: &Arc<Connection>, uri: &str, resource: &str) -> McpServer {
553 let name = top_level_str(resource, "name").unwrap_or_else(|| uri.to_string());
554 let description = top_level_str(resource, "description").unwrap_or_default();
555 let forward = Arc::clone(connection);
556 server.resource(uri, &name, &description, move |requested| {
557 let params = format!(r#"{{"uri":"{}"}}"#, json_escape(requested));
558 forward.request("resources/read", ¶ms).map(McpContent::raw_result).map_err(|e| format!("{}: {e}", forward.upstream.prefix))
559 })
560}
561
562fn valid_prefix(prefix: &str) -> bool {
563 !prefix.is_empty()
564 && !prefix.contains(SEPARATOR)
565 && !prefix.ends_with('_')
566 && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
567}
568
569fn allowed(rules: &[(String, Vec<String>)], identity: &str, tool: &str) -> bool {
571 rules
572 .iter()
573 .filter(|(who, _)| who == identity || who == "*")
574 .flat_map(|(_, patterns)| patterns)
575 .any(|pattern| matches_pattern(pattern, tool))
576}
577
578fn audit_line(sub: &str, tool: &str, upstream: &str, outcome: &str, duration: std::time::Duration) -> String {
580 let mut time = String::new();
581 crate::log::iso8601_now(|iso| time.push_str(iso));
582 format!(
583 r#"{{"time":"{time}","event":"mcp_tool_call","sub":"{}","tool":"{}","upstream":"{}","outcome":"{outcome}","duration_ms":{:.3}}}"#,
584 json_escape(sub),
585 json_escape(tool),
586 json_escape(upstream),
587 duration.as_secs_f64() * 1000.0
588 )
589}
590
591fn matches_pattern(pattern: &str, tool: &str) -> bool {
593 match pattern.strip_suffix('*') {
594 Some(prefix) => tool.starts_with(prefix),
595 None => pattern == tool,
596 }
597}
598
599fn top_level_str(json: &str, key: &str) -> Option<String> {
601 let raw = top_level_raw(json, key)?;
602 raw.starts_with('"').then(|| json_rpc::extract_str(&format!(r#"{{"v":{raw}}}"#), "v")).flatten()
603}
604
605fn top_level_raw(json: &str, key: &str) -> Option<String> {
607 json_rpc::object_members(json)?.into_iter().find(|(k, _)| k == key).map(|(_, v)| v)
608}
609
610struct Connection {
612 upstream: Upstream,
613 client: Client,
614 session: Mutex<Option<Session>>,
616 next_id: AtomicU64,
617}
618
619#[derive(Clone)]
620struct Session {
621 id: Option<String>,
622 version: String,
623 modern: bool,
625}
626
627impl Connection {
628 fn new(upstream: Upstream) -> Self {
629 let client = Client::new().timeout_ms(upstream.timeout_ms).max_redirects(0);
630 Connection { upstream, client, session: Mutex::new(None), next_id: AtomicU64::new(1) }
631 }
632
633 fn name(&self) -> String {
634 format!("upstream {} ({})", self.upstream.prefix, self.upstream.url)
635 }
636
637 fn list_tools(&self) -> Result<Vec<UpstreamTool>, String> {
639 let mut tools = Vec::new();
640 let mut cursor: Option<String> = None;
641 for _ in 0..MAX_LIST_PAGES {
642 let params = match &cursor {
643 Some(cursor) => format!(r#"{{"cursor":"{}"}}"#, json_escape(cursor)),
644 None => "{}".to_string(),
645 };
646 let result = self.request("tools/list", ¶ms).map_err(|e| format!("{}: {e}", self.name()))?;
647 let list = top_level_raw(&result, "tools").ok_or_else(|| format!("{}: tools/list without tools", self.name()))?;
648 for tool in json_rpc::split_array_elements(&list) {
649 tools.push(parse_tool(&tool).ok_or_else(|| format!("{}: malformed tool {tool}", self.name()))?);
650 }
651 cursor = top_level_str(&result, "nextCursor");
652 if cursor.is_none() {
653 return Ok(tools);
654 }
655 }
656 Err(format!("{}: more than {MAX_LIST_PAGES} pages of tools", self.name()))
657 }
658
659 fn list_all(&self, method: &str, field: &str) -> Result<Vec<String>, String> {
662 let mut items = Vec::new();
663 let mut cursor: Option<String> = None;
664 for _ in 0..MAX_LIST_PAGES {
665 let params = match &cursor {
666 Some(cursor) => format!(r#"{{"cursor":"{}"}}"#, json_escape(cursor)),
667 None => "{}".to_string(),
668 };
669 let result = match self.request(method, ¶ms) {
670 Ok(result) => result,
671 Err(e) if e.contains("not found") || e.contains("Unknown method") || e.contains("-32601") || e.contains("answered HTTP 404") => return Ok(items),
673 Err(e) => return Err(format!("{}: {method}: {e}", self.name())),
674 };
675 let Some(list) = top_level_raw(&result, field) else { return Ok(items) };
676 items.extend(json_rpc::split_array_elements(&list));
677 cursor = top_level_str(&result, "nextCursor");
678 if cursor.is_none() {
679 return Ok(items);
680 }
681 }
682 Err(format!("{}: more than {MAX_LIST_PAGES} pages of {field}", self.name()))
683 }
684
685 fn call_tool(&self, name: &str, arguments: &str, claims: Option<&str>) -> Result<String, String> {
687 let params = format!(r#"{{"name":"{}","arguments":{arguments}}}"#, json_escape(name));
688 let caller: Vec<(String, String)> = self
689 .upstream
690 .caller_headers
691 .iter()
692 .filter_map(|(header, value)| value(claims).map(|v| (header.clone(), v)))
693 .collect();
694 let result = self.request_as("tools/call", ¶ms, &caller).map_err(|e| format!("{}: {e}", self.upstream.prefix))?;
695 if top_level_str(&result, "resultType").as_deref() == Some("input_required") {
696 return Err(format!("{}: the tool asked for more input (2026-07-28 multi round-trip), which the gateway doesn't relay", self.upstream.prefix));
697 }
698 Ok(without_modern_fields(&result))
699 }
700
701 fn request(&self, method: &str, params: &str) -> Result<String, String> {
704 self.request_as(method, params, &[])
705 }
706
707 fn request_as(&self, method: &str, params: &str, caller: &[(String, String)]) -> Result<String, String> {
709 let session = self.session()?;
710 match self.send(&session, method, params, caller)? {
711 Reply::Result(result) => Ok(result),
712 Reply::SessionGone => {
713 self.forget(&session);
714 let session = self.session()?;
715 match self.send(&session, method, params, caller)? {
716 Reply::Result(result) => Ok(result),
717 Reply::SessionGone => Err("session not found twice in a row".to_string()),
718 }
719 }
720 }
721 }
722
723 fn session(&self) -> Result<Session, String> {
724 let mut guard = self.session.lock().map_err(|_| "session lock poisoned".to_string())?;
725 if let Some(session) = guard.as_ref() {
726 return Ok(session.clone());
727 }
728 let session = self.initialize()?;
729 *guard = Some(session.clone());
730 Ok(session)
731 }
732
733 fn forget(&self, stale: &Session) {
734 if let Ok(mut guard) = self.session.lock() {
735 if guard.as_ref().is_some_and(|current| current.id == stale.id) {
736 *guard = None;
737 }
738 }
739 }
740
741 fn initialize(&self) -> Result<Session, String> {
744 match self.initialize_legacy() {
745 Ok(session) => Ok(session),
746 Err(legacy) => self.discover().map_err(|modern| format!("{legacy}; as {MODERN_VERSION}: {modern}")),
747 }
748 }
749
750 fn discover(&self) -> Result<Session, String> {
751 let session = Session { id: None, version: MODERN_VERSION.to_string(), modern: true };
752 match self.send(&session, "server/discover", "{}", &[])? {
753 Reply::Result(result) if result.contains(MODERN_VERSION) => Ok(session),
754 Reply::Result(_) => Err(format!("server/discover doesn't list {MODERN_VERSION}")),
755 Reply::SessionGone => Err("server/discover answered 404".to_string()),
756 }
757 }
758
759 fn initialize_legacy(&self) -> Result<Session, String> {
760 let params = format!(
761 r#"{{"protocolVersion":"{PROTOCOL_VERSION}","capabilities":{{}},"clientInfo":{{"name":"rws-mcp-gateway","version":"{}"}}}}"#,
762 env!("CARGO_PKG_VERSION"),
763 );
764 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
765 let response = self.post(None, PROTOCOL_VERSION, &[], &envelope(id, "initialize", ¶ms))?;
766 if !(200..300).contains(&response.status()) {
767 return Err(format!("initialize answered HTTP {}", response.status()));
768 }
769 let session_id = response.header("mcp-session-id").map(str::to_string);
770 let result = parse_reply(response.header("content-type").unwrap_or(""), response.bytes(), id)?;
771 let version = top_level_str(&result, "protocolVersion").unwrap_or_else(|| PROTOCOL_VERSION.to_string());
772 let session = Session { id: session_id, version, modern: false };
773 let initialized = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
774 self.post(session.id.as_deref(), &session.version, &[], initialized)?;
775 Ok(session)
776 }
777
778 fn send(&self, session: &Session, method: &str, params: &str, caller: &[(String, String)]) -> Result<Reply, String> {
779 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
780 let response = if session.modern {
781 let mut headers: Vec<(&str, String)> = vec![("Mcp-Method", method.to_string())];
782 let named = match method {
783 "tools/call" | "prompts/get" => top_level_str(params, "name"),
784 "resources/read" => top_level_str(params, "uri"),
785 _ => None,
786 };
787 if let Some(name) = named {
788 headers.push(("Mcp-Name", name));
789 }
790 headers.extend(caller.iter().map(|(k, v)| (k.as_str(), v.clone())));
791 self.post(None, &session.version, &headers, &envelope(id, method, &with_modern_meta(params)))?
792 } else {
793 let headers: Vec<(&str, String)> = caller.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
794 self.post(session.id.as_deref(), &session.version, &headers, &envelope(id, method, params))?
795 };
796 if response.status() == 404 && session.id.is_some() {
797 return Ok(Reply::SessionGone);
798 }
799 if !(200..300).contains(&response.status()) {
800 return Err(format!("answered HTTP {}", response.status()));
801 }
802 parse_reply(response.header("content-type").unwrap_or(""), response.bytes(), id).map(Reply::Result)
803 }
804
805 fn post(&self, session_id: Option<&str>, version: &str, extra: &[(&str, String)], body: &str) -> Result<crate::http_client::Response, String> {
807 let mut request = self
808 .client
809 .post(&self.upstream.url)
810 .header("Content-Type", "application/json")
811 .header("Accept", "application/json, text/event-stream")
812 .header("MCP-Protocol-Version", version);
813 if let Some(id) = session_id {
814 request = request.header("Mcp-Session-Id", id);
815 }
816 for (name, value) in extra {
817 request = request.header(*name, value);
818 }
819 for (name, value) in &self.upstream.headers {
820 request = request.header(name, value);
821 }
822 request.body(body.as_bytes().to_vec()).send().map_err(|e| format!("unreachable: {e}"))
823 }
824}
825
826enum Reply {
827 Result(String),
828 SessionGone,
829}
830
831fn without_modern_fields(result: &str) -> String {
835 let Some(members) = json_rpc::object_members(result) else { return result.to_string() };
836 if !members.iter().any(|(k, _)| matches!(k.as_str(), "resultType" | "ttlMs" | "cacheScope")) {
837 return result.to_string();
838 }
839 let kept: Vec<String> = members
840 .into_iter()
841 .filter(|(k, _)| !matches!(k.as_str(), "resultType" | "ttlMs" | "cacheScope"))
842 .map(|(k, v)| format!(r#""{}":{v}"#, json_escape(&k)))
843 .collect();
844 format!("{{{}}}", kept.join(","))
845}
846
847fn with_modern_meta(params: &str) -> String {
850 let meta = format!(
851 r#""_meta":{{"io.modelcontextprotocol/protocolVersion":"{MODERN_VERSION}","io.modelcontextprotocol/clientInfo":{{"name":"rws-mcp-gateway","version":"{}"}},"io.modelcontextprotocol/clientCapabilities":{{}}}}"#,
852 env!("CARGO_PKG_VERSION"),
853 );
854 let inner = params.trim().strip_prefix('{').and_then(|p| p.strip_suffix('}')).unwrap_or("").trim();
855 if inner.is_empty() { format!("{{{meta}}}") } else { format!("{{{inner},{meta}}}") }
858}
859
860fn envelope(id: u64, method: &str, params: &str) -> String {
861 format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
862}
863
864fn parse_tool(json: &str) -> Option<UpstreamTool> {
865 let members = json_rpc::object_members(json)?;
866 let raw = |key: &str| members.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
867 let string = |key: &str| top_level_str(json, key);
868 let name = string("name")?;
869 let input_schema = raw("inputSchema").filter(|schema| schema.starts_with('{'))?;
870 let annotations = raw("annotations").filter(|a| a.starts_with('{')).map(|a| {
871 let hint = |key: &str| top_level_raw(&a, key).and_then(|v| v.parse::<bool>().ok());
872 ToolAnnotations {
873 read_only_hint: hint("readOnlyHint"),
874 destructive_hint: hint("destructiveHint"),
875 idempotent_hint: hint("idempotentHint"),
876 open_world_hint: hint("openWorldHint"),
877 }
878 });
879 Some(UpstreamTool {
880 name,
881 description: string("description").unwrap_or_default(),
882 title: string("title"),
883 input_schema,
884 output_schema: raw("outputSchema").filter(|schema| schema.starts_with('{')),
885 annotations,
886 })
887}
888
889#[doc(hidden)]
893pub fn parse_reply(content_type: &str, body: &[u8], id: u64) -> Result<String, String> {
894 let body = std::str::from_utf8(body).map_err(|_| "reply is not UTF-8".to_string())?;
895 let media_type = content_type.split(';').next().unwrap_or("").trim();
896 let id = id.to_string();
897 if media_type.eq_ignore_ascii_case("text/event-stream") {
898 let mut data = String::new();
899 for line in body.lines().chain(std::iter::once("")) {
900 if let Some(value) = line.strip_prefix("data:") {
901 if !data.is_empty() {
902 data.push('\n');
903 }
904 data.push_str(value.strip_prefix(' ').unwrap_or(value));
905 } else if line.is_empty() && !data.is_empty() {
906 let event = std::mem::take(&mut data);
907 if top_level_raw(&event, "id").as_deref() == Some(id.as_str()) && top_level_raw(&event, "method").is_none() {
908 return response_result(&event);
909 }
910 }
911 }
912 Err("the event stream ended without a response".to_string())
913 } else {
914 if top_level_raw(body, "id").as_deref() != Some(id.as_str()) {
915 return Err("reply is not the response to this request".to_string());
916 }
917 response_result(body)
918 }
919}
920
921fn response_result(response: &str) -> Result<String, String> {
922 if let Some(result) = top_level_raw(response, "result") {
923 return Ok(result);
924 }
925 let error = top_level_raw(response, "error").ok_or_else(|| "response has neither result nor error".to_string())?;
926 Err(top_level_str(&error, "message").unwrap_or(error))
927}
928
929#[doc(hidden)]
931pub mod fuzzing {
932 pub fn tools(result: &str) -> usize {
934 let Some(list) = super::top_level_raw(result, "tools") else { return 0 };
935 super::json_rpc::split_array_elements(&list).iter().filter_map(|tool| super::parse_tool(tool)).count()
936 }
937}
938
939#[cfg(test)]
940mod tests;