use std::collections::HashMap; use async_trait::async_trait; use rmcp::model::ProtocolVersion; use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, ReadResourceRequestParams, ResourceContents, ServerNotification, SubscriptionFilter, Tool, }; use rmcp::service::{ClientLifecycleMode, ClientServiceExt, RunningService}; use rmcp::transport::TokioChildProcess; use rmcp::RoleClient; use serde_json::{Map, Value}; use tokio::process::Command; use tokio::sync::mpsc; use tokio::task::JoinHandle; use crate::resources::{ example_of, template_matches, ResourceContent, ResourceInfo, ResourceSource, ResourceWatch, WatchSignal, }; use crate::tools::{ToolCallOutput, ToolDefinition, ToolError, ToolExecutor}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct StdioServerConfig { pub command: String, pub args: Vec, } impl StdioServerConfig { pub fn parse(spec: &str) -> Result { let mut parts = spec.split_whitespace(); let command = parts .next() .ok_or_else(|| ToolError::new("empty --mcp-stdio command"))?; Ok(Self { command: command.to_string(), args: parts.map(str::to_string).collect(), }) } } /// A tool name declared by more than one server; the first server wins. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DuplicateTool { pub name: String, pub kept: String, pub ignored: String, } struct Server { label: String, client: RunningService, subscribable: bool, resources: Vec, } /// Calls tools and reads/watches resources on stdio MCP servers. Each tool /// name routes to exactly one server. Tool output is returned in full; calls /// run concurrently. pub struct McpToolExecutor { servers: Vec, definitions: Vec, routes: HashMap, duplicates: Vec, } impl McpToolExecutor { pub async fn connect_stdio(configs: Vec) -> Result { if configs.is_empty() { return Err(ToolError::new("no MCP stdio servers configured")); } let mut servers: Vec = Vec::new(); let mut definitions = Vec::new(); let mut routes: HashMap = HashMap::new(); let mut duplicates = Vec::new(); for config in configs { let client = connect_one(&config).await?; let tools = client.list_all_tools().await.map_err(|error| { ToolError::new(format!( "tools/list failed for {:?}: {error}", config.command )) })?; let index = servers.len(); for tool in tools { let definition = tool_definition(&tool); if let Some(&kept) = routes.get(&definition.name) { duplicates.push(DuplicateTool { name: definition.name.clone(), kept: servers[kept].label.clone(), ignored: config.command.clone(), }); continue; } routes.insert(definition.name.clone(), index); definitions.push(definition); } let capability = client .peer_info() .and_then(|info| info.capabilities.resources.clone()); let subscribable = capability .as_ref() .is_some_and(|resources| resources.subscribe == Some(true)); let resources = match capability { Some(_) => list_server_resources(&client, &config.command, subscribable).await, None => Vec::new(), }; servers.push(Server { label: config.command, client, subscribable, resources, }); } Ok(Self { servers, definitions, routes, duplicates, }) } /// Tool names declared by more than one server. pub fn duplicates(&self) -> &[DuplicateTool] { &self.duplicates } /// The server for a resource URI: exact listing, then template match, /// then the first server with resources (subscribable if required). fn resource_server(&self, uri: &str, subscribe: bool) -> Result<&Server, ToolError> { let listed = self.servers.iter().find(|server| { server.resources.iter().any(|resource| { if resource.template { template_matches(&resource.uri, uri) } else { resource.uri == uri } }) }); let fallback = || { self.servers.iter().find(|server| { if subscribe { server.subscribable } else { !server.resources.is_empty() || server.subscribable } }) }; let server = listed.or_else(fallback).ok_or_else(|| { ToolError::new(format!( "no MCP server {} resource {uri:?}", if subscribe { "can watch" } else { "serves" } )) })?; if subscribe && !server.subscribable { return Err(ToolError::new(format!( "MCP server {:?} serves {uri:?} but does not support resource subscriptions", server.label ))); } Ok(server) } } /// Connects one stdio server. `server/discover` (MCP 2026-07-28, required /// for watches) first; servers without discovery are reached with /// `initialize` and can serve tools but not watches. async fn connect_one( config: &StdioServerConfig, ) -> Result, ToolError> { let mut command = Command::new(&config.command); command.args(&config.args); let transport = TokioChildProcess::new(command).map_err(|error| { ToolError::new(format!( "failed to spawn MCP server {:?}: {error}", config.command )) })?; let lifecycle = ClientLifecycleMode::Auto { preferred_versions: vec![ProtocolVersion::LATEST], legacy_version: Some(ProtocolVersion::LATEST_WITH_INITIALIZE), }; ().serve_with_lifecycle(transport, lifecycle) .await .map_err(|error| { ToolError::new(format!( "MCP connect failed for {:?}: {error}", config.command )) }) } async fn list_server_resources( client: &RunningService, label: &str, subscribable: bool, ) -> Vec { let mut resources = Vec::new(); if let Ok(listed) = client.list_all_resources().await { resources.extend(listed.into_iter().map(|resource| ResourceInfo { uri: resource.uri.clone(), template: false, name: resource.name.clone(), example: example_of(resource.description.as_deref()), description: resource.description.clone(), mime_type: resource.mime_type.clone(), server: label.to_string(), subscribable, })); } if let Ok(templates) = client.list_all_resource_templates().await { resources.extend(templates.into_iter().map(|template| ResourceInfo { uri: template.uri_template.clone(), template: true, name: template.name.clone(), example: example_of(template.description.as_deref()), description: template.description.clone(), mime_type: template.mime_type.clone(), server: label.to_string(), subscribable, })); } resources } /// Aborts a forwarding task when the watch is dropped. struct AbortOnDrop(JoinHandle<()>); impl Drop for AbortOnDrop { fn drop(&mut self) { self.0.abort(); } } #[async_trait] impl ResourceSource for McpToolExecutor { async fn list_resources(&self) -> Result, ToolError> { Ok(self .servers .iter() .flat_map(|server| server.resources.iter().cloned()) .collect()) } async fn read_resource(&self, uri: &str) -> Result { let server = self.resource_server(uri, false)?; let result = server .client .read_resource(ReadResourceRequestParams::new(uri)) .await .map_err(|error| ToolError::new(format!("resources/read {uri:?} failed: {error}")))?; match result.contents.into_iter().next() { Some(ResourceContents::TextResourceContents { text, mime_type, .. }) => Ok(ResourceContent { text, mime_type }), Some(_) => Err(ToolError::new(format!( "resource {uri:?} is binary; only text resources are supported" ))), None => Ok(ResourceContent { text: String::new(), mime_type: None, }), } } async fn watch(&self, uri: &str) -> Result { let server = self.resource_server(uri, true)?; let (signals_tx, signals) = mpsc::channel(64); let filter = SubscriptionFilter::builder() .resource_subscription(uri) .build(); let mut subscription = server.client.peer().listen(filter).await.map_err(|error| { ToolError::new(format!( "cannot watch {uri:?}: subscriptions/listen failed on {:?} ({error}); watches need an MCP 2026-07-28 server", server.label )) })?; let accepted = subscription .acknowledged() .resource_subscriptions .as_ref() .is_some_and(|uris| uris.iter().any(|accepted| accepted == uri)); if !accepted { return Err(ToolError::new(format!( "MCP server {:?} did not accept a subscription to {uri:?}", server.label ))); } let watched = uri.to_string(); let task = tokio::spawn(async move { loop { let signal = match subscription.next().await { Ok(Some(ServerNotification::ResourceUpdatedNotification(notification))) if notification.params.uri == watched => { WatchSignal::Updated } Ok(Some(_)) => continue, Ok(None) => { WatchSignal::Ended(format!("subscription ended: {:?}", subscription.end())) } Err(error) => WatchSignal::Ended(error.to_string()), }; let ended = matches!(signal, WatchSignal::Ended(_)); if signals_tx.send(signal).await.is_err() || ended { break; } } }); Ok(ResourceWatch { signals, guard: Box::new(AbortOnDrop(task)), }) } } fn tool_definition(tool: &Tool) -> ToolDefinition { let annotations = tool.annotations.as_ref(); ToolDefinition { name: tool.name.to_string(), description: tool.description.as_ref().map(|value| value.to_string()), input_schema: Value::Object((*tool.input_schema).clone()), output_schema: tool .output_schema .as_ref() .map(|schema| Value::Object((**schema).clone())), read_only: annotations.and_then(|value| value.read_only_hint), destructive: annotations.and_then(|value| value.destructive_hint), } } #[async_trait] impl ToolExecutor for McpToolExecutor { async fn list_tools(&self) -> Result, ToolError> { Ok(self.definitions.clone()) } async fn call_tool(&self, name: &str, arguments: Value) -> Result { let arguments = match arguments { Value::Object(arguments) => arguments, Value::Null => Map::new(), other => { return Err(ToolError::new(format!( "tool arguments must be a JSON object: {other}" ))); } }; let client = self .routes .get(name) .and_then(|index| self.servers.get(*index)) .map(|server| &server.client) .ok_or_else(|| ToolError::new(format!("unknown tool {name:?}")))?; let params = CallToolRequestParams::new(name.to_string()).with_arguments(arguments); client .call_tool(params) .await .map(format_result) .map_err(|error| ToolError::new(format!("tool {name:?} failed: {error}"))) } } /// Joins text content. Structured content, when present, is the exact JSON /// value and is preferred. fn format_result(result: CallToolResult) -> ToolCallOutput { let is_error = result.is_error.unwrap_or(false); if let Some(structured) = result.structured_content { return ToolCallOutput { content: structured.to_string(), is_error, audit: None, }; } let parts: Vec = result .content .iter() .map(|block| match block { ContentBlock::Text(text) => text.text.clone(), other => { serde_json::to_string(other).unwrap_or_else(|_| "[unserializable content]".into()) } }) .collect(); ToolCallOutput { content: parts.join("\n"), is_error, audit: None, } } #[cfg(test)] mod tests { use rmcp::model::{CallToolResult, ContentBlock}; use serde_json::json; use super::format_result; #[test] fn large_tool_output_is_not_truncated() { let big = "x".repeat(1_000_000); let output = format_result(CallToolResult::success(vec![ContentBlock::text( big.clone(), )])); assert_eq!(output.content, big); assert!(!output.is_error); } #[test] fn structured_content_is_preferred() { let output = format_result(CallToolResult::structured(json!({"items": [1, 2]}))); assert_eq!(output.content, r#"{"items":[1,2]}"#); assert!(!output.is_error); } }