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::{ClientHandler, RoleClient};
52use rmcp::{
53    model::{
54        CallToolRequestParams, CallToolResponse, CallToolResult, ClientCapabilities, ClientInfo,
55        GetTaskParams, Implementation, ProtocolVersion, TaskPayload,
56    },
57    service::{ClientLifecycleMode, ClientServiceExt, RunningService},
58    transport::{
59        ConfigureCommandExt, StreamableHttpClientTransport, TokioChildProcess,
60        streamable_http_client::StreamableHttpClientTransportConfig,
61    },
62};
63use serde_json::{Map, Value, json};
64use soma_provider_core::{
65    Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
66};
67use tokio::{io::AsyncReadExt, process::Command};
68
69#[derive(Clone, Copy, Debug, Default)]
70struct ProviderMcpClientHandler;
71
72impl ClientHandler for ProviderMcpClientHandler {
73    fn get_info(&self) -> ClientInfo {
74        ClientInfo::new(
75            ClientCapabilities::builder().enable_tasks().build(),
76            Implementation::new("soma-provider-mcp-client", env!("CARGO_PKG_VERSION")),
77        )
78        .with_protocol_version(ProtocolVersion::LATEST)
79    }
80}
81
82fn upstream_lifecycle() -> ClientLifecycleMode {
83    ClientLifecycleMode::Auto {
84        preferred_versions: ProtocolVersion::KNOWN_VERSIONS
85            .iter()
86            .rev()
87            .cloned()
88            .collect(),
89        legacy_version: Some(ProtocolVersion::LATEST),
90    }
91}
92
93#[derive(Clone)]
94pub struct UpstreamMcpProvider {
95    catalog: ProviderCatalog,
96}
97
98impl UpstreamMcpProvider {
99    pub fn new(catalog: ProviderCatalog) -> Self {
100        Self { catalog }
101    }
102
103    pub fn arc(catalog: ProviderCatalog) -> Arc<Self> {
104        Arc::new(Self::new(catalog))
105    }
106}
107
108#[async_trait]
109impl Provider for UpstreamMcpProvider {
110    fn catalog(&self) -> ProviderCatalog {
111        self.catalog.clone()
112    }
113
114    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
115        let runtime = McpRuntime::from_catalog(&self.catalog, &call)?;
116        let tool = self.tool(&call)?;
117        let upstream = UpstreamTool::from_tool(tool, &call);
118        let params = upstream.params(call.params.clone());
119        let timeout = runtime.timeout();
120
121        let fut = async {
122            match &runtime.transport {
123                McpTransport::Stdio(stdio) => {
124                    call_stdio(&self.catalog, &call, stdio, &upstream, params).await
125                }
126                McpTransport::Http(http) => {
127                    call_http(&self.catalog, &call, http, &upstream, params).await
128                }
129            }
130        };
131
132        let result = tokio::time::timeout(timeout, fut).await.map_err(|_| {
133            ProviderError::new(
134                "mcp_provider_timeout",
135                &self.catalog.provider.name,
136                Some(call.action.clone()),
137                format!("upstream MCP tool call exceeded {}ms", timeout.as_millis()),
138                "Increase provider.meta.mcp.timeout_ms or fix the upstream MCP server.",
139            )
140        })??;
141
142        Ok(ProviderOutput::json(
143            result
144                .structured_content
145                .unwrap_or_else(|| json!({ "content": result.content })),
146        ))
147    }
148}
149
150impl UpstreamMcpProvider {
151    fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
152        self.catalog
153            .tools
154            .iter()
155            .find(|tool| tool.name == call.action)
156            .ok_or_else(|| {
157                ProviderError::validation(
158                    &self.catalog.provider.name,
159                    &call.action,
160                    "unknown_mcp_action",
161                    format!("MCP provider has no action `{}`", call.action),
162                )
163            })
164    }
165}
166
167async fn call_stdio(
168    catalog: &ProviderCatalog,
169    call: &ProviderCall,
170    runtime: &McpStdioRuntime,
171    upstream: &UpstreamTool,
172    params: Map<String, Value>,
173) -> Result<rmcp::model::CallToolResult, ProviderError> {
174    // Keep stderr piped (rather than the previous `Stdio::null()`) so a
175    // failed spawn/handshake/call can be diagnosed — see `attach_stderr`.
176    let (transport, stderr) =
177        TokioChildProcess::builder(Command::new(&runtime.command).configure(|cmd| {
178            cmd.args(&runtime.args)
179                .env_clear()
180                .envs(runtime.env.iter().map(|(key, value)| (key, value)))
181                .stderr(Stdio::piped());
182            if let Some(cwd) = &runtime.cwd {
183                cmd.current_dir(cwd);
184            }
185        }))
186        .spawn()
187        .map_err(|error| {
188            ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
189        })?;
190    let service = match ProviderMcpClientHandler
191        .serve_with_lifecycle(transport, upstream_lifecycle())
192        .await
193    {
194        Ok(service) => service,
195        Err(error) => {
196            let provider_error =
197                ProviderError::execution(&catalog.provider.name, call.action.clone(), error);
198            return Err(attach_stderr(provider_error, stderr).await);
199        }
200    };
201    let result = call_provider_tool(&service, catalog, call, upstream, params).await;
202    let result = match result {
203        Ok(result) => Ok(result),
204        Err(error) => {
205            let provider_error =
206                ProviderError::execution(&catalog.provider.name, call.action.clone(), error);
207            Err(attach_stderr(provider_error, stderr).await)
208        }
209    };
210    if let Err(error) = service.cancel().await {
211        tracing::debug!(
212            provider = %catalog.provider.name,
213            action = %call.action,
214            error = %error,
215            "failed to cancel upstream MCP stdio session cleanly"
216        );
217    }
218    result
219}
220
221async fn call_provider_tool(
222    service: &RunningService<RoleClient, ProviderMcpClientHandler>,
223    catalog: &ProviderCatalog,
224    call: &ProviderCall,
225    upstream: &UpstreamTool,
226    params: Map<String, Value>,
227) -> Result<CallToolResult, ProviderError> {
228    let response = service
229        .call_tool_once(CallToolRequestParams::new(upstream.name.clone()).with_arguments(params))
230        .await
231        .map_err(|error| {
232            ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
233        })?;
234    match response {
235        CallToolResponse::Complete(result) => Ok(result),
236        CallToolResponse::InputRequired(_) => Err(provider_interaction_error(
237            catalog,
238            call,
239            "mcp_provider_input_required",
240            "upstream MCP tool requires interactive client input",
241            "Invoke this MCP server through a client that can fulfill elicitation, sampling, or roots requests.",
242        )),
243        CallToolResponse::Task(task) => {
244            poll_provider_task(service, catalog, call, task.task.task_id).await
245        }
246        _ => Err(provider_interaction_error(
247            catalog,
248            call,
249            "mcp_provider_unknown_result",
250            "upstream MCP tool returned an unsupported result variant",
251            "Update Soma and the upstream MCP server to compatible protocol versions.",
252        )),
253    }
254}
255
256async fn poll_provider_task(
257    service: &RunningService<RoleClient, ProviderMcpClientHandler>,
258    catalog: &ProviderCatalog,
259    call: &ProviderCall,
260    task_id: String,
261) -> Result<CallToolResult, ProviderError> {
262    loop {
263        let task = service
264            .get_task(GetTaskParams::new(task_id.clone()))
265            .await
266            .map_err(|error| {
267                ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
268            })?;
269        let poll_interval_ms = task.task.task.poll_interval_ms.unwrap_or(100).max(10);
270        match task.task.payload {
271            TaskPayload::Working => {
272                tokio::time::sleep(Duration::from_millis(poll_interval_ms)).await;
273            }
274            TaskPayload::Completed { result } => {
275                return serde_json::from_value(Value::Object(result)).map_err(|error| {
276                    ProviderError::new(
277                        "mcp_provider_invalid_task_result",
278                        &catalog.provider.name,
279                        Some(call.action.clone()),
280                        format!("completed MCP task returned an invalid tool result: {error}"),
281                        "Update the upstream MCP server to return a valid CallToolResult.",
282                    )
283                });
284            }
285            TaskPayload::InputRequired { .. } => {
286                return Err(provider_interaction_error(
287                    catalog,
288                    call,
289                    "mcp_provider_task_input_required",
290                    "upstream MCP task requires interactive client input",
291                    "Invoke this MCP server through a client that can answer task input requests.",
292                ));
293            }
294            TaskPayload::Failed { error } => {
295                return Err(ProviderError::new(
296                    "mcp_provider_task_failed",
297                    &catalog.provider.name,
298                    Some(call.action.clone()),
299                    format!("upstream MCP task failed: {}", Value::Object(error)),
300                    "Inspect the upstream MCP server logs and task error payload.",
301                ));
302            }
303            TaskPayload::Cancelled => {
304                return Err(provider_interaction_error(
305                    catalog,
306                    call,
307                    "mcp_provider_task_cancelled",
308                    "upstream MCP task was cancelled",
309                    "Retry the tool call if cancellation was not intentional.",
310                ));
311            }
312            _ => {
313                return Err(provider_interaction_error(
314                    catalog,
315                    call,
316                    "mcp_provider_unknown_task_status",
317                    "upstream MCP task returned an unsupported status",
318                    "Update Soma and the upstream MCP server to compatible protocol versions.",
319                ));
320            }
321        }
322    }
323}
324
325fn provider_interaction_error(
326    catalog: &ProviderCatalog,
327    call: &ProviderCall,
328    code: &'static str,
329    message: &'static str,
330    remediation: &'static str,
331) -> ProviderError {
332    ProviderError::new(
333        code,
334        &catalog.provider.name,
335        Some(call.action.clone()),
336        message,
337        remediation,
338    )
339}
340
341/// Best-effort attaches whatever the child has written to stderr as private
342/// (server-log-only, never returned to the MCP client — see
343/// `ProviderError::private_diagnostics`) diagnostics on an already-built
344/// error. Bounded in both time (the child may still be alive with an idle,
345/// non-EOF stderr pipe) and size, so a chatty or hung upstream can't stall or
346/// balloon a single failed call.
347async fn attach_stderr(
348    error: ProviderError,
349    stderr: Option<impl tokio::io::AsyncRead + Unpin>,
350) -> ProviderError {
351    const MAX_STDERR_BYTES: usize = 8 * 1024;
352    const READ_BUDGET: Duration = Duration::from_millis(200);
353
354    let Some(mut stderr) = stderr else {
355        return error;
356    };
357    let mut buffer = Vec::new();
358    let _ = tokio::time::timeout(
359        READ_BUDGET,
360        tokio::io::AsyncReadExt::take(&mut stderr, MAX_STDERR_BYTES as u64)
361            .read_to_end(&mut buffer),
362    )
363    .await;
364    let text = String::from_utf8_lossy(&buffer).trim().to_owned();
365    if text.is_empty() {
366        error
367    } else {
368        error.with_private_diagnostics(format!("upstream stderr: {text}"))
369    }
370}
371
372/// rmcp's streamable HTTP transport (reqwest 0.13) panics when the process has
373/// no rustls crypto provider installed; install ring once, tolerating a
374/// provider some embedder installed earlier.
375fn ensure_rustls_crypto_provider() {
376    static INSTALL: std::sync::Once = std::sync::Once::new();
377    INSTALL.call_once(|| {
378        let _ = rustls::crypto::ring::default_provider().install_default();
379    });
380}
381
382async fn call_http(
383    catalog: &ProviderCatalog,
384    call: &ProviderCall,
385    runtime: &McpHttpRuntime,
386    upstream: &UpstreamTool,
387    params: Map<String, Value>,
388) -> Result<rmcp::model::CallToolResult, ProviderError> {
389    ensure_rustls_crypto_provider();
390    let mut config = StreamableHttpClientTransportConfig::with_uri(runtime.url.clone());
391    if !runtime.headers.is_empty() {
392        config = config.custom_headers(runtime.headers.clone());
393    }
394    let transport = StreamableHttpClientTransport::from_config(config);
395    let service = ProviderMcpClientHandler
396        .serve_with_lifecycle(transport, upstream_lifecycle())
397        .await
398        .map_err(|error| {
399            ProviderError::execution(&catalog.provider.name, call.action.clone(), error)
400        })?;
401    let result = call_provider_tool(&service, catalog, call, upstream, params).await;
402    if let Err(error) = service.cancel().await {
403        tracing::debug!(
404            provider = %catalog.provider.name,
405            action = %call.action,
406            error = %error,
407            "failed to cancel upstream MCP http session cleanly"
408        );
409    }
410    result
411}
412
413struct McpRuntime {
414    transport: McpTransport,
415    timeout_ms: u64,
416}
417
418enum McpTransportKind {
419    Stdio,
420    Http,
421}
422
423enum McpTransport {
424    Stdio(McpStdioRuntime),
425    Http(McpHttpRuntime),
426}
427
428struct McpStdioRuntime {
429    command: String,
430    args: Vec<String>,
431    cwd: Option<String>,
432    env: Vec<(String, String)>,
433}
434
435struct McpHttpRuntime {
436    url: String,
437    headers: HashMap<HeaderName, HeaderValue>,
438}
439
440impl McpRuntime {
441    fn from_catalog(catalog: &ProviderCatalog, call: &ProviderCall) -> Result<Self, ProviderError> {
442        let meta = catalog
443            .meta
444            .get("mcp")
445            .or_else(|| catalog.meta.get("runtime"))
446            .ok_or_else(|| {
447                ProviderError::validation(
448                    &catalog.provider.name,
449                    &call.action,
450                    "missing_mcp_runtime",
451                    "MCP provider requires provider.meta.mcp runtime config",
452                )
453            })?;
454        let timeout_ms = meta
455            .get("timeout_ms")
456            .and_then(Value::as_u64)
457            .unwrap_or(10_000);
458        let transport = transport_kind(meta).map_err(|message| {
459            ProviderError::validation(
460                &catalog.provider.name,
461                &call.action,
462                "invalid_mcp_transport",
463                message,
464            )
465        })?;
466        match transport {
467            McpTransportKind::Http => Ok(Self {
468                transport: McpTransport::Http(McpHttpRuntime::from_meta(meta, catalog, call)?),
469                timeout_ms,
470            }),
471            McpTransportKind::Stdio => {
472                let stdio = meta.get("stdio").unwrap_or(meta);
473                let timeout_ms = meta
474                    .get("timeout_ms")
475                    .or_else(|| stdio.get("timeout_ms"))
476                    .and_then(Value::as_u64)
477                    .unwrap_or(timeout_ms);
478                Ok(Self {
479                    transport: McpTransport::Stdio(McpStdioRuntime::from_meta(
480                        stdio, catalog, call,
481                    )?),
482                    timeout_ms,
483                })
484            }
485        }
486    }
487
488    fn timeout(&self) -> Duration {
489        Duration::from_millis(self.timeout_ms)
490    }
491}
492
493impl McpStdioRuntime {
494    fn from_meta(
495        stdio: &Value,
496        catalog: &ProviderCatalog,
497        call: &ProviderCall,
498    ) -> Result<Self, ProviderError> {
499        let command = stdio
500            .get("command")
501            .and_then(Value::as_str)
502            .filter(|value| !value.is_empty())
503            .ok_or_else(|| {
504                ProviderError::validation(
505                    &catalog.provider.name,
506                    &call.action,
507                    "missing_mcp_command",
508                    "MCP provider stdio runtime requires command",
509                )
510            })?
511            .to_owned();
512        let args = string_array(stdio.get("args")).map_err(|message| {
513            ProviderError::validation(
514                &catalog.provider.name,
515                &call.action,
516                "invalid_mcp_args",
517                message,
518            )
519        })?;
520        let cwd = stdio
521            .get("cwd")
522            .and_then(Value::as_str)
523            .map(ToOwned::to_owned);
524        let env = env_pairs(stdio.get("env")).map_err(|message| {
525            ProviderError::validation(
526                &catalog.provider.name,
527                &call.action,
528                "invalid_mcp_env",
529                message,
530            )
531        })?;
532        Ok(Self {
533            command,
534            args,
535            cwd,
536            env,
537        })
538    }
539}
540
541impl McpHttpRuntime {
542    fn from_meta(
543        meta: &Value,
544        catalog: &ProviderCatalog,
545        call: &ProviderCall,
546    ) -> Result<Self, ProviderError> {
547        let http = meta.get("http").unwrap_or(meta);
548        let url = http
549            .get("url")
550            .or_else(|| meta.get("url"))
551            .and_then(Value::as_str)
552            .filter(|value| !value.is_empty())
553            .ok_or_else(|| {
554                ProviderError::validation(
555                    &catalog.provider.name,
556                    &call.action,
557                    "missing_mcp_url",
558                    "MCP provider HTTP runtime requires url",
559                )
560            })?
561            .to_owned();
562        validate_http_url(&url).map_err(|message| {
563            ProviderError::validation(
564                &catalog.provider.name,
565                &call.action,
566                "invalid_mcp_url",
567                message,
568            )
569        })?;
570        let headers = header_pairs(http.get("headers").or_else(|| meta.get("headers"))).map_err(
571            |message| {
572                ProviderError::validation(
573                    &catalog.provider.name,
574                    &call.action,
575                    "invalid_mcp_headers",
576                    message,
577                )
578            },
579        )?;
580        Ok(Self { url, headers })
581    }
582}
583
584fn transport_kind(meta: &Value) -> Result<McpTransportKind, String> {
585    let explicit = meta
586        .get("transport")
587        .and_then(Value::as_str)
588        .map(|value| value.to_ascii_lowercase());
589    let has_url = meta.get("url").and_then(Value::as_str).is_some()
590        || meta
591            .get("http")
592            .and_then(|http| http.get("url"))
593            .and_then(Value::as_str)
594            .is_some();
595    let has_stdio =
596        meta.get("stdio").is_some() || meta.get("command").and_then(Value::as_str).is_some();
597
598    match explicit.as_deref() {
599        Some("http" | "streamable-http" | "streamable_http") => {
600            if !has_url {
601                return Err("transport=http requires url".to_owned());
602            }
603            Ok(McpTransportKind::Http)
604        }
605        Some("stdio") => {
606            if !has_stdio {
607                return Err("transport=stdio requires stdio.command or command".to_owned());
608            }
609            Ok(McpTransportKind::Stdio)
610        }
611        Some(other) => Err(format!("unsupported MCP transport `{other}`")),
612        None if has_url => Ok(McpTransportKind::Http),
613        None => Ok(McpTransportKind::Stdio),
614    }
615}
616
617fn validate_http_url(value: &str) -> Result<(), String> {
618    let parsed = url::Url::parse(value).map_err(|error| format!("url is invalid: {error}"))?;
619    match parsed.scheme() {
620        "http" | "https" => {}
621        scheme => return Err(format!("url scheme `{scheme}` is not supported")),
622    }
623    if parsed.host_str().is_none() {
624        return Err("url must include a host".to_owned());
625    }
626    Ok(())
627}
628
629struct UpstreamTool {
630    name: String,
631    static_args: Map<String, Value>,
632}
633
634impl UpstreamTool {
635    fn from_tool(tool: &ProviderTool, call: &ProviderCall) -> Self {
636        let meta = tool.meta.get("mcp");
637        let name = meta
638            .and_then(|value| value.get("upstream_tool"))
639            .and_then(Value::as_str)
640            .unwrap_or(&tool.name)
641            .to_owned();
642        let static_args = meta
643            .and_then(|value| value.get("static_args"))
644            .and_then(Value::as_object)
645            .cloned()
646            .unwrap_or_default();
647        tracing::debug!(
648            provider_action = %call.action,
649            upstream_tool = %name,
650            "proxying MCP provider tool call"
651        );
652        Self { name, static_args }
653    }
654
655    /// Merges the caller's params with this tool's manifest-declared
656    /// `static_args`. `static_args` are a pin, not a default: they are
657    /// applied *after* the caller's params so a manifest can restrict which
658    /// upstream action/argument a drop-in tool reaches (e.g. pinning
659    /// `action: "echo"` on a generic upstream tool) without a caller being
660    /// able to override it by supplying the same key. Any previous version
661    /// of this method that applied `static_args` first and let caller params
662    /// win on key collision inverted this contract.
663    fn params(&self, call_params: Value) -> Map<String, Value> {
664        let mut params = match call_params {
665            Value::Object(map) => map,
666            _ => Map::new(),
667        };
668        params.extend(self.static_args.clone());
669        params
670    }
671}
672
673fn string_array(value: Option<&Value>) -> Result<Vec<String>, String> {
674    let Some(value) = value else {
675        return Ok(Vec::new());
676    };
677    let Some(values) = value.as_array() else {
678        return Err("args must be an array of strings".to_owned());
679    };
680    values
681        .iter()
682        .map(|value| {
683            value
684                .as_str()
685                .map(ToOwned::to_owned)
686                .ok_or_else(|| "args must be an array of strings".to_owned())
687        })
688        .collect()
689}
690
691fn env_pairs(value: Option<&Value>) -> Result<Vec<(String, String)>, String> {
692    let Some(value) = value else {
693        return Ok(Vec::new());
694    };
695    let Some(values) = value.as_object() else {
696        return Err("env must be an object of string values".to_owned());
697    };
698    values
699        .iter()
700        .map(|(key, value)| {
701            value
702                .as_str()
703                .map(|value| (key.clone(), value.to_owned()))
704                .ok_or_else(|| "env must be an object of string values".to_owned())
705        })
706        .collect()
707}
708
709fn header_pairs(value: Option<&Value>) -> Result<HashMap<HeaderName, HeaderValue>, String> {
710    let Some(value) = value else {
711        return Ok(HashMap::new());
712    };
713    let Some(values) = value.as_object() else {
714        return Err("headers must be an object of string values".to_owned());
715    };
716    values
717        .iter()
718        .map(|(key, value)| {
719            let name = HeaderName::from_bytes(key.as_bytes())
720                .map_err(|error| format!("header `{key}` is invalid: {error}"))?;
721            let raw_value = value
722                .as_str()
723                .ok_or_else(|| "headers must be an object of string values".to_owned())?;
724            let expanded = expand_env_templates(raw_value)?;
725            let header_value = HeaderValue::from_str(&expanded)
726                .map_err(|error| format!("header `{key}` value is invalid: {error}"))?;
727            Ok((name, header_value))
728        })
729        .collect()
730}
731
732fn expand_env_templates(value: &str) -> Result<String, String> {
733    let mut output = String::with_capacity(value.len());
734    let mut rest = value;
735    while let Some(start) = rest.find("${") {
736        let (prefix, after_start) = rest.split_at(start);
737        output.push_str(prefix);
738        let after_start = &after_start[2..];
739        let Some(end) = after_start.find('}') else {
740            return Err("header env interpolation has an unterminated ${VAR}".to_owned());
741        };
742        let (name, after_end) = after_start.split_at(end);
743        if name.is_empty() {
744            return Err("header env interpolation requires a variable name".to_owned());
745        }
746        let env_value = std::env::var(name)
747            .map_err(|_| format!("header references missing environment variable `{name}`"))?;
748        output.push_str(&env_value);
749        rest = &after_end[1..];
750    }
751    output.push_str(rest);
752    Ok(output)
753}
754
755/// Projects `soma-gateway`'s own admin action catalog into a
756/// `soma_provider_core::ProviderCatalog`, so a host can advertise gateway
757/// administration (list/add/remove/reload upstreams, OAuth lifecycle, ...)
758/// through the same drop-in-provider-shaped surface as every other adapter
759/// in this crate.
760///
761/// This is a pure catalog *projection*, not a wired dispatcher: this crate
762/// has no `call()` implementation that dispatches through a live
763/// `soma_gateway` manager instance — `crates/soma/runtime` constructs the
764/// live `GatewayProductState`/`GatewayManager` and `crates/soma/integrations`
765/// wires it to `SomaApplication`'s gateway port, but neither routes back
766/// through this projection. Wiring this catalog to a live dispatcher is
767/// deferred to whichever product integration crate needs a drop-in-provider
768/// view of gateway administration (tracked as a PR10 follow-up) — see the
769/// module-level deviation notes.
770///
771/// Returns `Err` if `provider_id` is not a valid `ProviderId` (lowercase,
772/// `[a-z0-9-_]`, no leading/trailing/doubled separators) rather than
773/// panicking — this is a `pub fn` in a shared library crate and `provider_id`
774/// may come from caller-supplied configuration, not only compile-time
775/// literals.
776pub fn project_gateway_action_catalog(
777    provider_id: impl Into<String>,
778    title: impl Into<String>,
779    actions: &soma_gateway::gateway::catalog::GatewayActionCatalog,
780) -> Result<ProviderCatalog, soma_provider_core::ProviderIdError> {
781    let tools = actions
782        .list()
783        .into_iter()
784        .map(|action| {
785            let mut tool = ProviderTool::new(
786                action.name,
787                format!(
788                    "Gateway administration action `{}`{}.",
789                    action.name,
790                    if action.admin_required {
791                        " (admin only)"
792                    } else {
793                        ""
794                    }
795                ),
796                json!({"type": "object", "additionalProperties": true}),
797            );
798            tool.destructive = action.destructive;
799            tool.requires_admin = action.admin_required;
800            tool.meta = json!({
801                "gateway": {
802                    "discovery": action.discovery,
803                    "spawn_validation_required": action.spawn_validation_required,
804                }
805            });
806            tool
807        })
808        .collect();
809
810    let mut manifest = soma_provider_core::ProviderManifest::new(
811        soma_provider_core::ProviderId::new(provider_id.into())?,
812        title,
813        soma_gateway::VERSION,
814    );
815    manifest.tools = tools;
816    Ok(manifest)
817}
818
819#[cfg(test)]
820#[path = "gateway_tests.rs"]
821mod tests;