1use 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 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
341async 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
372fn 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 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
755pub 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;