Skip to main content

rust_web_server/mcp/
gateway.rs

1//! A minimal MCP gateway: one endpoint in front of several upstream MCP
2//! servers.
3//!
4//! [`McpGateway::build`] connects to each [`Upstream`], lists its tools and
5//! registers each one as `<prefix>__<tool>` on an ordinary [`McpServer`];
6//! calling it forwards the call to that upstream. The gateway's caller
7//! authenticates to the gateway (put [`McpServer::require_bearer_tokens`] or
8//! [`McpServer::require_oauth`] on the server `build` returns); upstreams
9//! get only the headers configured for them — the caller's token is never
10//! passed on. [`McpGateway::allow`] decides which caller (the `sub` of
11//! [`McpContext::auth_claims`](super::McpContext::auth_claims)) sees which tools, and nothing is allowed
12//! unless a rule says so.
13//!
14//! Upstream prompts are federated as `<prefix>__<name>` and resources under
15//! their own URIs (the first upstream to publish a URI keeps it), both
16//! listed at `build` and filtered by the same rules as tools (a resource
17//! counts as `<prefix>__<uri>`).
18//!
19//! Tools are listed at `build` and again every [`McpGateway::refresh_every`]
20//! (5 minutes by default): new upstream tools appear, removed ones go, and
21//! the gateway's clients get `notifications/tools/list_changed`. Between
22//! refreshes `tools/list` is answered from the gateway's own copy.
23//!
24//! Each upstream speaks the protocol it supports: the gateway opens a
25//! 2025-11-25 session, and when an upstream refuses `initialize` it talks to
26//! it the 2026-07-28 way instead, statelessly (`server/discover` first, then
27//! every request carrying its version and capabilities in `_meta` and the
28//! `Mcp-Method`/`Mcp-Name` headers). Clients of either era reach upstreams of
29//! either era.
30
31use 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
37/// The protocol version the gateway asks upstreams for.
38const PROTOCOL_VERSION: &str = "2025-11-25";
39
40/// The stateless protocol, for upstreams that refuse `initialize`.
41const MODERN_VERSION: &str = "2026-07-28";
42
43/// Separates an upstream's prefix from its tool names.
44pub const SEPARATOR: &str = "__";
45
46/// `tools/list` pages followed per upstream before giving up.
47const MAX_LIST_PAGES: usize = 100;
48
49/// An upstream MCP server (Streamable HTTP).
50#[derive(Clone)]
51pub struct Upstream {
52    prefix: String,
53    url: String,
54    headers: Vec<(String, String)>,
55    /// Headers computed per tool call from the caller's claims.
56    caller_headers: Vec<(String, CallerHeader)>,
57    timeout_ms: u64,
58}
59
60/// [`Upstream::header_for`]: the caller's verified claims (JSON, `None`
61/// without an identity) → a header value, or `None` to send none.
62type 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        // Header values are credentials: names only.
67        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    /// The server at `url` (its MCP endpoint, e.g. `https://mcp.example.com/mcp`),
79    /// whose tools appear as `<prefix>__<tool>`. `prefix` is letters, digits,
80    /// `-` and single `_`s.
81    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    /// Send this header to the upstream on every request — its credentials,
86    /// e.g. `.header("Authorization", "Bearer <upstream token>")`.
87    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    /// Send header `name` on each tool call with a value computed from the
93    /// caller's verified claims (the JSON of
94    /// [`McpContext::auth_claims`](super::McpContext::auth_claims), `None`
95    /// for a caller without one) — e.g. each user's own upstream token,
96    /// looked up by `sub`, so the upstream acts as that user. `None` from
97    /// `value` sends no header. Only `tools/call` carries a caller; listing
98    /// and prompts/resources use the static [`Self::header`]s.
99    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    /// Give up on an upstream request after this long (default 30 s).
108    pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
109        self.timeout_ms = timeout_ms;
110        self
111    }
112}
113
114/// An HTTP API whose OpenAPI operations become gateway tools
115/// ([`McpGateway::openapi`], 18.19.0): each call is an HTTP request to
116/// `base_url`.
117#[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    /// Tools `<prefix>__<operation>` from the OpenAPI document at `spec`
130    /// (an `http(s)://` URL or a file path), calling `base_url` (scheme,
131    /// host and optional path prefix: requests never go anywhere else).
132    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    /// Send this header with every request (and the spec fetch): the API's
137    /// own credentials, never the caller's.
138    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    /// operationIds to expose (default: every `GET`).
144    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    /// operationIds never to expose.
150    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    /// Give up on a request after this long (default 30 s).
156    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        // No redirects: a call must not be bounced to another host.
163        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    /// Call the API for one tool call.
184    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
218/// Builds a gateway [`McpServer`]; see the [module docs](self).
219pub 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
230/// [`McpGateway::limit`]: calls of the tools matching `pattern`, per caller.
231struct ToolLimit {
232    pattern: String,
233    max_calls: u32,
234    window_secs: u64,
235    limiter: crate::rate_limit::RateLimiter,
236}
237
238/// How often upstream tool lists are re-read by default.
239pub const DEFAULT_REFRESH: std::time::Duration = std::time::Duration::from_secs(300);
240
241impl McpGateway {
242    /// A gateway announcing itself as `name`/`version`.
243    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    /// Allow each caller (each `sub`) at most `max_calls` calls per
248    /// `window_secs` of the tools matching `tools` (a full name, a prefix
249    /// ending in `*`, or `*`), counted per tool. Over the limit a call
250    /// answers a tool error saying when to retry. Limits add up: a call
251    /// must pass every one that matches. Counted in this process.
252    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    /// Write one JSON line per `tools/call` to the access log (stdout, or
258    /// `RWS_CONFIG_ACCESS_LOG`): time, caller (`sub`), tool, upstream,
259    /// outcome (`ok`, `tool_error`, `refused` by a limit, `denied` by the
260    /// ACL) and duration. Arguments and results are not logged.
261    pub fn audit_log(mut self, on: bool) -> Self {
262        self.audit = on;
263        self
264    }
265
266    /// Re-read every upstream's tools this often (default
267    /// [`DEFAULT_REFRESH`]); `None` lists them only at `build`. An upstream
268    /// that can't be reached keeps the tools it had.
269    pub fn refresh_every(mut self, every: Option<std::time::Duration>) -> Self {
270        self.refresh = every;
271        self
272    }
273
274    /// Expose an HTTP API's OpenAPI operations as tools (`<prefix>__<id>`);
275    /// the document is read at [`build`](Self::build).
276    pub fn openapi(mut self, api: OpenApiUpstream) -> Self {
277        self.apis.push(api);
278        self
279    }
280
281    /// Federate `upstream`'s tools.
282    pub fn upstream(mut self, upstream: Upstream) -> Self {
283        self.upstreams.push(upstream);
284        self
285    }
286
287    /// Let `identity` see and call the tools matching `tools`: a full name
288    /// (`github__search_issues`), a prefix ending in `*` (`github__*`), or
289    /// `*` for all. `identity` is the caller's `sub` claim; `*` means any
290    /// identified caller. Rules add up; a caller no rule names gets no tools.
291    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    /// Connect to every upstream, list its tools and return the gateway.
301    /// Fails, naming the upstream, if one can't be reached or answers
302    /// something that isn't MCP. Add authentication to the result.
303    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        // Resources and prompts: listed at build, ACL'd like tools (a
354        // resource as "<prefix>__<uri>"). The first upstream to publish a
355        // URI keeps it.
356        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, // not federated: the gateway's own
366                    (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            // Best effort: an upstream's tools matter most, so a broken
402            // prompts or resources list is logged, not fatal.
403            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
444/// Re-list `connection`'s tools and apply the difference to `server`:
445/// register new or changed tools, remove gone ones, then tell clients.
446fn 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/// One upstream tool, as `tools/list` described it.
483#[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    /// Changes when anything the gateway republishes changes.
495    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", &params).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", &params).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
569/// Does a rule for `identity` (or `*`) match `tool`?
570fn 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
578/// One audit log line (without the newline).
579fn 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
591/// A full tool name, a prefix ending in `*`, or `*`.
592fn 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
599/// The string member `key` of the top level of JSON object `json`.
600fn 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
605/// The raw JSON of member `key` of the top level of JSON object `json`.
606fn 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
610/// The gateway's session with one upstream.
611struct Connection {
612    upstream: Upstream,
613    client: Client,
614    /// `(Mcp-Session-Id, negotiated protocol version)`, once initialized.
615    session: Mutex<Option<Session>>,
616    next_id: AtomicU64,
617}
618
619#[derive(Clone)]
620struct Session {
621    id: Option<String>,
622    version: String,
623    /// 2026-07-28: no session; `_meta` and headers on every request.
624    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    /// Every tool, following `nextCursor`.
638    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", &params).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    /// Every item of a paginated list method (`prompts/list`, ...), as raw
660    /// JSON objects; `Ok(empty)` when the upstream doesn't have the method.
661    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, &params) {
670                Ok(result) => result,
671                // No such capability: nothing to federate.
672                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    /// Calls `name` upstream: the raw `tools/call` result, or why it failed.
686    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", &params, &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    /// Sends one request in the current session (initializing first if
702    /// needed, and once more if the upstream has forgotten the session).
703    fn request(&self, method: &str, params: &str) -> Result<String, String> {
704        self.request_as(method, params, &[])
705    }
706
707    /// [`Self::request`] with headers for this one call (the caller's).
708    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    /// A 2025-11-25 session, or, when the upstream refuses that, the
742    /// stateless 2026-07-28 protocol after a `server/discover`.
743    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", &params))?;
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    /// One POST to the upstream, with its own headers only.
806    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
831/// A 2026-07-28 result without the fields the gateway's own server adds for
832/// its client (`resultType`, cache hints): passed through, they would
833/// appear twice, or reach a 2025 client that doesn't know them.
834fn 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
847/// `params` with the 2026-07-28 `_meta` (version, client info, no client
848/// capabilities: the gateway answers no server-to-client requests).
849fn 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    // After the request's own members: some JSON readers take the first
856    // `name` at any depth, which would be clientInfo's.
857    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/// The `result` of the JSON-RPC response with `id` in an upstream reply —
890/// a JSON body, or an SSE stream whose events may include notifications and
891/// requests before the response. A JSON-RPC `error` becomes its message.
892#[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/// Entry points for `fuzz_api`.
930#[doc(hidden)]
931pub mod fuzzing {
932    /// The tools a `tools/list` result describes.
933    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;