1use 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 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
192async 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
223fn 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 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
608pub 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;