Skip to main content

soma_provider_adapters/
gateway.rs

1//! Thin, product-neutral projections onto `soma-gateway`, the canonical
2//! upstream-MCP connection/routing engine (plan section "Upstream MCP
3//! decision").
4//!
5//! Two independent pieces live here:
6//!
7//! - [`UpstreamMcpProvider`]: a per-manifest ad-hoc "connect to one upstream
8//!   MCP server and proxy a tool call" provider, migrated from the
9//!   soma-service `mcp` provider kind that predated `soma-gateway`.
10//! - [`project_gateway_action_catalog`]: a pure function that projects
11//!   `soma-gateway`'s own admin action catalog
12//!   (`soma_gateway::gateway::catalog::GatewayActionCatalog`) into a
13//!   `soma_provider_core::ProviderCatalog`, so a host can expose gateway
14//!   administration as a drop-in-shaped provider surface.
15//!
16//! ## Deviation: transport is not yet pooled through `soma-mcp-client`
17//!
18//! `UpstreamMcpProvider` still opens its own per-call `rmcp` session with
19//! the raw `TokioChildProcess` / `StreamableHttpClientTransport` transports
20//! rather than routing through `soma-mcp-client`'s pooled `UpstreamPool`.
21//! That would be the fuller reconciliation the plan calls for (acceptance:
22//! "no second upstream MCP transport stack"), but it is a genuinely
23//! cross-cutting change, not a mechanical swap:
24//!
25//! - `UpstreamPool`/`UpstreamConfig` (`crates/shared/mcp/client/src/config.rs`)
26//!   currently support only a single `bearer_token_env` for HTTP auth, while
27//!   this provider's manifest contract (`provider.meta.mcp.http.headers`)
28//!   supports arbitrary, `${VAR}`-interpolated custom headers with no test
29//!   coverage proving that capability is unused. Narrowing it silently to
30//!   single-bearer-token auth risks a real, undetectable behavior regression.
31//! - `UpstreamConfig::validate()` runs `SpawnGuard` command validation and a
32//!   restricted `name` character set that this provider's manifest-driven
33//!   stdio commands have never been checked against; migrating without
34//!   reconciling those rules risks rejecting previously-working manifests.
35//! - `UpstreamConfig` has no per-upstream timeout field equivalent to
36//!   `provider.meta.mcp.timeout_ms`.
37//! - `UpstreamPool` is a registered, stateful pool (`register_config` then
38//!   `ensure_connected`/`call_tool`); this provider is stateless per
39//!   `ProviderCall` today and has no "register once" phase to hook into.
40//!
41//! Tracked as its own scoped follow-up: bead `rmcp-template-fnz0`. This
42//! adapter is still a real improvement over the pre-PR10 state: it is
43//! physically consolidated into the shared, product-neutral adapters crate
44//! (no soma-service dependency), and it is the only upstream-MCP transport
45//! implementation outside `soma-mcp-client`/`soma-gateway` in the workspace.
46
47use std::{collections::HashMap, process::Stdio, sync::Arc, time::Duration};
48
49use async_trait::async_trait;
50use reqwest::header::{HeaderName, HeaderValue};
51use rmcp::{
52    model::CallToolRequestParams,
53    transport::{
54        streamable_http_client::StreamableHttpClientTransportConfig, ConfigureCommandExt,
55        StreamableHttpClientTransport, TokioChildProcess,
56    },
57    ServiceExt,
58};
59use serde_json::{json, Map, Value};
60use soma_provider_core::{
61    Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
62};
63use tokio::{io::AsyncReadExt, process::Command};
64
65#[derive(Clone)]
66pub struct UpstreamMcpProvider {
67    catalog: ProviderCatalog,
68}
69
70impl UpstreamMcpProvider {
71    pub fn new(catalog: ProviderCatalog) -> Self {
72        Self { catalog }
73    }
74
75    pub fn arc(catalog: ProviderCatalog) -> Arc<Self> {
76        Arc::new(Self::new(catalog))
77    }
78}
79
80#[async_trait]
81impl Provider for UpstreamMcpProvider {
82    fn catalog(&self) -> ProviderCatalog {
83        self.catalog.clone()
84    }
85
86    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
87        let runtime = McpRuntime::from_catalog(&self.catalog, &call)?;
88        let tool = self.tool(&call)?;
89        let upstream = UpstreamTool::from_tool(tool, &call);
90        let params = upstream.params(call.params.clone());
91        let timeout = runtime.timeout();
92
93        let fut = async {
94            match &runtime.transport {
95                McpTransport::Stdio(stdio) => {
96                    call_stdio(&self.catalog, &call, stdio, &upstream, params).await
97                }
98                McpTransport::Http(http) => {
99                    call_http(&self.catalog, &call, http, &upstream, params).await
100                }
101            }
102        };
103
104        let result = tokio::time::timeout(timeout, fut).await.map_err(|_| {
105            ProviderError::new(
106                "mcp_provider_timeout",
107                &self.catalog.provider.name,
108                Some(call.action.clone()),
109                format!("upstream MCP tool call exceeded {}ms", timeout.as_millis()),
110                "Increase provider.meta.mcp.timeout_ms or fix the upstream MCP server.",
111            )
112        })??;
113
114        Ok(ProviderOutput::json(
115            result
116                .structured_content
117                .unwrap_or_else(|| json!({ "content": result.content })),
118        ))
119    }
120}
121
122impl UpstreamMcpProvider {
123    fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
124        self.catalog
125            .tools
126            .iter()
127            .find(|tool| tool.name == call.action)
128            .ok_or_else(|| {
129                ProviderError::validation(
130                    &self.catalog.provider.name,
131                    &call.action,
132                    "unknown_mcp_action",
133                    format!("MCP provider has no action `{}`", call.action),
134                )
135            })
136    }
137}
138
139async fn call_stdio(
140    catalog: &ProviderCatalog,
141    call: &ProviderCall,
142    runtime: &McpStdioRuntime,
143    upstream: &UpstreamTool,
144    params: Map<String, Value>,
145) -> Result<rmcp::model::CallToolResult, ProviderError> {
146    // Keep stderr piped (rather than the previous `Stdio::null()`) so a
147    // failed spawn/handshake/call can be diagnosed — see `attach_stderr`.
148    let (transport, stderr) =
149        TokioChildProcess::builder(Command::new(&runtime.command).configure(|cmd| {
150            cmd.args(&runtime.args)
151                .env_clear()
152                .envs(runtime.env.iter().map(|(key, value)| (key, value)))
153                .stderr(Stdio::piped());
154            if let Some(cwd) = &runtime.cwd {
155                cmd.current_dir(cwd);
156            }
157        }))
158        .spawn()
159        .map_err(|error| {
160            ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
161        })?;
162    let service = match ().serve(transport).await {
163        Ok(service) => service,
164        Err(error) => {
165            let provider_error =
166                ProviderError::execution(&catalog.provider.name, call.action.clone(), error);
167            return Err(attach_stderr(provider_error, stderr).await);
168        }
169    };
170    let result = service
171        .call_tool(CallToolRequestParams::new(upstream.name.clone()).with_arguments(params))
172        .await;
173    let result = match result {
174        Ok(result) => Ok(result),
175        Err(error) => {
176            let provider_error =
177                ProviderError::execution(&catalog.provider.name, call.action.clone(), error);
178            Err(attach_stderr(provider_error, stderr).await)
179        }
180    };
181    if let Err(error) = service.cancel().await {
182        tracing::debug!(
183            provider = %catalog.provider.name,
184            action = %call.action,
185            error = %error,
186            "failed to cancel upstream MCP stdio session cleanly"
187        );
188    }
189    result
190}
191
192/// Best-effort attaches whatever the child has written to stderr as private
193/// (server-log-only, never returned to the MCP client — see
194/// `ProviderError::private_diagnostics`) diagnostics on an already-built
195/// error. Bounded in both time (the child may still be alive with an idle,
196/// non-EOF stderr pipe) and size, so a chatty or hung upstream can't stall or
197/// balloon a single failed call.
198async fn attach_stderr(
199    error: ProviderError,
200    stderr: Option<impl tokio::io::AsyncRead + Unpin>,
201) -> ProviderError {
202    const MAX_STDERR_BYTES: usize = 8 * 1024;
203    const READ_BUDGET: Duration = Duration::from_millis(200);
204
205    let Some(mut stderr) = stderr else {
206        return error;
207    };
208    let mut buffer = Vec::new();
209    let _ = tokio::time::timeout(
210        READ_BUDGET,
211        tokio::io::AsyncReadExt::take(&mut stderr, MAX_STDERR_BYTES as u64)
212            .read_to_end(&mut buffer),
213    )
214    .await;
215    let text = String::from_utf8_lossy(&buffer).trim().to_owned();
216    if text.is_empty() {
217        error
218    } else {
219        error.with_private_diagnostics(format!("upstream stderr: {text}"))
220    }
221}
222
223/// rmcp's streamable HTTP transport (reqwest 0.13) panics when the process has
224/// no rustls crypto provider installed; install ring once, tolerating a
225/// provider some embedder installed earlier.
226fn ensure_rustls_crypto_provider() {
227    static INSTALL: std::sync::Once = std::sync::Once::new();
228    INSTALL.call_once(|| {
229        let _ = rustls::crypto::ring::default_provider().install_default();
230    });
231}
232
233async fn call_http(
234    catalog: &ProviderCatalog,
235    call: &ProviderCall,
236    runtime: &McpHttpRuntime,
237    upstream: &UpstreamTool,
238    params: Map<String, Value>,
239) -> Result<rmcp::model::CallToolResult, ProviderError> {
240    ensure_rustls_crypto_provider();
241    let mut config = StreamableHttpClientTransportConfig::with_uri(runtime.url.clone());
242    if !runtime.headers.is_empty() {
243        config = config.custom_headers(runtime.headers.clone());
244    }
245    let transport = StreamableHttpClientTransport::from_config(config);
246    let service = ().serve(transport).await.map_err(|error| {
247        ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
248    })?;
249    let result = service
250        .call_tool(CallToolRequestParams::new(upstream.name.clone()).with_arguments(params))
251        .await
252        .map_err(|error| {
253            ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
254        });
255    if let Err(error) = service.cancel().await {
256        tracing::debug!(
257            provider = %catalog.provider.name,
258            action = %call.action,
259            error = %error,
260            "failed to cancel upstream MCP http session cleanly"
261        );
262    }
263    result
264}
265
266struct McpRuntime {
267    transport: McpTransport,
268    timeout_ms: u64,
269}
270
271enum McpTransportKind {
272    Stdio,
273    Http,
274}
275
276enum McpTransport {
277    Stdio(McpStdioRuntime),
278    Http(McpHttpRuntime),
279}
280
281struct McpStdioRuntime {
282    command: String,
283    args: Vec<String>,
284    cwd: Option<String>,
285    env: Vec<(String, String)>,
286}
287
288struct McpHttpRuntime {
289    url: String,
290    headers: HashMap<HeaderName, HeaderValue>,
291}
292
293impl McpRuntime {
294    fn from_catalog(catalog: &ProviderCatalog, call: &ProviderCall) -> Result<Self, ProviderError> {
295        let meta = catalog
296            .meta
297            .get("mcp")
298            .or_else(|| catalog.meta.get("runtime"))
299            .ok_or_else(|| {
300                ProviderError::validation(
301                    &catalog.provider.name,
302                    &call.action,
303                    "missing_mcp_runtime",
304                    "MCP provider requires provider.meta.mcp runtime config",
305                )
306            })?;
307        let timeout_ms = meta
308            .get("timeout_ms")
309            .and_then(Value::as_u64)
310            .unwrap_or(10_000);
311        let transport = transport_kind(meta).map_err(|message| {
312            ProviderError::validation(
313                &catalog.provider.name,
314                &call.action,
315                "invalid_mcp_transport",
316                message,
317            )
318        })?;
319        match transport {
320            McpTransportKind::Http => Ok(Self {
321                transport: McpTransport::Http(McpHttpRuntime::from_meta(meta, catalog, call)?),
322                timeout_ms,
323            }),
324            McpTransportKind::Stdio => {
325                let stdio = meta.get("stdio").unwrap_or(meta);
326                let timeout_ms = meta
327                    .get("timeout_ms")
328                    .or_else(|| stdio.get("timeout_ms"))
329                    .and_then(Value::as_u64)
330                    .unwrap_or(timeout_ms);
331                Ok(Self {
332                    transport: McpTransport::Stdio(McpStdioRuntime::from_meta(
333                        stdio, catalog, call,
334                    )?),
335                    timeout_ms,
336                })
337            }
338        }
339    }
340
341    fn timeout(&self) -> Duration {
342        Duration::from_millis(self.timeout_ms)
343    }
344}
345
346impl McpStdioRuntime {
347    fn from_meta(
348        stdio: &Value,
349        catalog: &ProviderCatalog,
350        call: &ProviderCall,
351    ) -> Result<Self, ProviderError> {
352        let command = stdio
353            .get("command")
354            .and_then(Value::as_str)
355            .filter(|value| !value.is_empty())
356            .ok_or_else(|| {
357                ProviderError::validation(
358                    &catalog.provider.name,
359                    &call.action,
360                    "missing_mcp_command",
361                    "MCP provider stdio runtime requires command",
362                )
363            })?
364            .to_owned();
365        let args = string_array(stdio.get("args")).map_err(|message| {
366            ProviderError::validation(
367                &catalog.provider.name,
368                &call.action,
369                "invalid_mcp_args",
370                message,
371            )
372        })?;
373        let cwd = stdio
374            .get("cwd")
375            .and_then(Value::as_str)
376            .map(ToOwned::to_owned);
377        let env = env_pairs(stdio.get("env")).map_err(|message| {
378            ProviderError::validation(
379                &catalog.provider.name,
380                &call.action,
381                "invalid_mcp_env",
382                message,
383            )
384        })?;
385        Ok(Self {
386            command,
387            args,
388            cwd,
389            env,
390        })
391    }
392}
393
394impl McpHttpRuntime {
395    fn from_meta(
396        meta: &Value,
397        catalog: &ProviderCatalog,
398        call: &ProviderCall,
399    ) -> Result<Self, ProviderError> {
400        let http = meta.get("http").unwrap_or(meta);
401        let url = http
402            .get("url")
403            .or_else(|| meta.get("url"))
404            .and_then(Value::as_str)
405            .filter(|value| !value.is_empty())
406            .ok_or_else(|| {
407                ProviderError::validation(
408                    &catalog.provider.name,
409                    &call.action,
410                    "missing_mcp_url",
411                    "MCP provider HTTP runtime requires url",
412                )
413            })?
414            .to_owned();
415        validate_http_url(&url).map_err(|message| {
416            ProviderError::validation(
417                &catalog.provider.name,
418                &call.action,
419                "invalid_mcp_url",
420                message,
421            )
422        })?;
423        let headers = header_pairs(http.get("headers").or_else(|| meta.get("headers"))).map_err(
424            |message| {
425                ProviderError::validation(
426                    &catalog.provider.name,
427                    &call.action,
428                    "invalid_mcp_headers",
429                    message,
430                )
431            },
432        )?;
433        Ok(Self { url, headers })
434    }
435}
436
437fn transport_kind(meta: &Value) -> Result<McpTransportKind, String> {
438    let explicit = meta
439        .get("transport")
440        .and_then(Value::as_str)
441        .map(|value| value.to_ascii_lowercase());
442    let has_url = meta.get("url").and_then(Value::as_str).is_some()
443        || meta
444            .get("http")
445            .and_then(|http| http.get("url"))
446            .and_then(Value::as_str)
447            .is_some();
448    let has_stdio =
449        meta.get("stdio").is_some() || meta.get("command").and_then(Value::as_str).is_some();
450
451    match explicit.as_deref() {
452        Some("http" | "streamable-http" | "streamable_http") => {
453            if !has_url {
454                return Err("transport=http requires url".to_owned());
455            }
456            Ok(McpTransportKind::Http)
457        }
458        Some("stdio") => {
459            if !has_stdio {
460                return Err("transport=stdio requires stdio.command or command".to_owned());
461            }
462            Ok(McpTransportKind::Stdio)
463        }
464        Some(other) => Err(format!("unsupported MCP transport `{other}`")),
465        None if has_url => Ok(McpTransportKind::Http),
466        None => Ok(McpTransportKind::Stdio),
467    }
468}
469
470fn validate_http_url(value: &str) -> Result<(), String> {
471    let parsed = url::Url::parse(value).map_err(|error| format!("url is invalid: {error}"))?;
472    match parsed.scheme() {
473        "http" | "https" => {}
474        scheme => return Err(format!("url scheme `{scheme}` is not supported")),
475    }
476    if parsed.host_str().is_none() {
477        return Err("url must include a host".to_owned());
478    }
479    Ok(())
480}
481
482struct UpstreamTool {
483    name: String,
484    static_args: Map<String, Value>,
485}
486
487impl UpstreamTool {
488    fn from_tool(tool: &ProviderTool, call: &ProviderCall) -> Self {
489        let meta = tool.meta.get("mcp");
490        let name = meta
491            .and_then(|value| value.get("upstream_tool"))
492            .and_then(Value::as_str)
493            .unwrap_or(&tool.name)
494            .to_owned();
495        let static_args = meta
496            .and_then(|value| value.get("static_args"))
497            .and_then(Value::as_object)
498            .cloned()
499            .unwrap_or_default();
500        tracing::debug!(
501            provider_action = %call.action,
502            upstream_tool = %name,
503            "proxying MCP provider tool call"
504        );
505        Self { name, static_args }
506    }
507
508    /// Merges the caller's params with this tool's manifest-declared
509    /// `static_args`. `static_args` are a pin, not a default: they are
510    /// applied *after* the caller's params so a manifest can restrict which
511    /// upstream action/argument a drop-in tool reaches (e.g. pinning
512    /// `action: "echo"` on a generic upstream tool) without a caller being
513    /// able to override it by supplying the same key. Any previous version
514    /// of this method that applied `static_args` first and let caller params
515    /// win on key collision inverted this contract.
516    fn params(&self, call_params: Value) -> Map<String, Value> {
517        let mut params = match call_params {
518            Value::Object(map) => map,
519            _ => Map::new(),
520        };
521        params.extend(self.static_args.clone());
522        params
523    }
524}
525
526fn string_array(value: Option<&Value>) -> Result<Vec<String>, String> {
527    let Some(value) = value else {
528        return Ok(Vec::new());
529    };
530    let Some(values) = value.as_array() else {
531        return Err("args must be an array of strings".to_owned());
532    };
533    values
534        .iter()
535        .map(|value| {
536            value
537                .as_str()
538                .map(ToOwned::to_owned)
539                .ok_or_else(|| "args must be an array of strings".to_owned())
540        })
541        .collect()
542}
543
544fn env_pairs(value: Option<&Value>) -> Result<Vec<(String, String)>, String> {
545    let Some(value) = value else {
546        return Ok(Vec::new());
547    };
548    let Some(values) = value.as_object() else {
549        return Err("env must be an object of string values".to_owned());
550    };
551    values
552        .iter()
553        .map(|(key, value)| {
554            value
555                .as_str()
556                .map(|value| (key.clone(), value.to_owned()))
557                .ok_or_else(|| "env must be an object of string values".to_owned())
558        })
559        .collect()
560}
561
562fn header_pairs(value: Option<&Value>) -> Result<HashMap<HeaderName, HeaderValue>, String> {
563    let Some(value) = value else {
564        return Ok(HashMap::new());
565    };
566    let Some(values) = value.as_object() else {
567        return Err("headers must be an object of string values".to_owned());
568    };
569    values
570        .iter()
571        .map(|(key, value)| {
572            let name = HeaderName::from_bytes(key.as_bytes())
573                .map_err(|error| format!("header `{key}` is invalid: {error}"))?;
574            let raw_value = value
575                .as_str()
576                .ok_or_else(|| "headers must be an object of string values".to_owned())?;
577            let expanded = expand_env_templates(raw_value)?;
578            let header_value = HeaderValue::from_str(&expanded)
579                .map_err(|error| format!("header `{key}` value is invalid: {error}"))?;
580            Ok((name, header_value))
581        })
582        .collect()
583}
584
585fn expand_env_templates(value: &str) -> Result<String, String> {
586    let mut output = String::with_capacity(value.len());
587    let mut rest = value;
588    while let Some(start) = rest.find("${") {
589        let (prefix, after_start) = rest.split_at(start);
590        output.push_str(prefix);
591        let after_start = &after_start[2..];
592        let Some(end) = after_start.find('}') else {
593            return Err("header env interpolation has an unterminated ${VAR}".to_owned());
594        };
595        let (name, after_end) = after_start.split_at(end);
596        if name.is_empty() {
597            return Err("header env interpolation requires a variable name".to_owned());
598        }
599        let env_value = std::env::var(name)
600            .map_err(|_| format!("header references missing environment variable `{name}`"))?;
601        output.push_str(&env_value);
602        rest = &after_end[1..];
603    }
604    output.push_str(rest);
605    Ok(output)
606}
607
608/// Projects `soma-gateway`'s own admin action catalog into a
609/// `soma_provider_core::ProviderCatalog`, so a host can advertise gateway
610/// administration (list/add/remove/reload upstreams, OAuth lifecycle, ...)
611/// through the same drop-in-provider-shaped surface as every other adapter
612/// in this crate.
613///
614/// This is a pure catalog *projection*, not a wired dispatcher: this crate
615/// has no `call()` implementation that dispatches through a live
616/// `soma_gateway` manager instance — `crates/soma/runtime` constructs the
617/// live `GatewayProductState`/`GatewayManager` and `crates/soma/integrations`
618/// wires it to `SomaApplication`'s gateway port, but neither routes back
619/// through this projection. Wiring this catalog to a live dispatcher is
620/// deferred to whichever product integration crate needs a drop-in-provider
621/// view of gateway administration (tracked as a PR10 follow-up) — see the
622/// module-level deviation notes.
623///
624/// Returns `Err` if `provider_id` is not a valid `ProviderId` (lowercase,
625/// `[a-z0-9-_]`, no leading/trailing/doubled separators) rather than
626/// panicking — this is a `pub fn` in a shared library crate and `provider_id`
627/// may come from caller-supplied configuration, not only compile-time
628/// literals.
629pub fn project_gateway_action_catalog(
630    provider_id: impl Into<String>,
631    title: impl Into<String>,
632    actions: &soma_gateway::gateway::catalog::GatewayActionCatalog,
633) -> Result<ProviderCatalog, soma_provider_core::ProviderIdError> {
634    let tools = actions
635        .list()
636        .into_iter()
637        .map(|action| {
638            let mut tool = ProviderTool::new(
639                action.name,
640                format!(
641                    "Gateway administration action `{}`{}.",
642                    action.name,
643                    if action.admin_required {
644                        " (admin only)"
645                    } else {
646                        ""
647                    }
648                ),
649                json!({"type": "object", "additionalProperties": true}),
650            );
651            tool.destructive = action.destructive;
652            tool.requires_admin = action.admin_required;
653            tool.meta = json!({
654                "gateway": {
655                    "discovery": action.discovery,
656                    "spawn_validation_required": action.spawn_validation_required,
657                }
658            });
659            tool
660        })
661        .collect();
662
663    let mut manifest = soma_provider_core::ProviderManifest::new(
664        soma_provider_core::ProviderId::new(provider_id.into())?,
665        title,
666        soma_gateway::VERSION,
667    );
668    manifest.tools = tools;
669    Ok(manifest)
670}
671
672#[cfg(test)]
673#[path = "gateway_tests.rs"]
674mod tests;