use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use rmcp::model::CallToolRequestParams;
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
use rmcp::ServiceExt;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::error::{Error, Result};
use crate::net::{self, NetGuard};
use crate::observe::{EventKind, RunEvent};
use crate::policy::{Act, Effect, Policy};
use crate::provider::ToolSpec;
use crate::run::{refused, Watch};
use crate::state::{McpEvent, PolicyEvent, Store};
pub const MCP_TOOL_PREFIX: &str = "mcp__";
const DEFAULT_TIMEOUT_SECS: u64 = 60;
fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "transport", rename_all = "snake_case")]
pub enum McpTransport {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
},
Http {
url: String,
#[serde(default)]
headers: BTreeMap<String, String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
#[serde(flatten)]
pub transport: McpTransport,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
impl McpServer {
pub fn stdio(id: impl Into<String>, command: impl Into<String>) -> Self {
Self {
id: id.into(),
transport: McpTransport::Stdio {
command: command.into(),
args: Vec::new(),
env: BTreeMap::new(),
},
timeout_secs: DEFAULT_TIMEOUT_SECS,
}
}
pub fn http(id: impl Into<String>, url: impl Into<String>) -> Self {
Self {
id: id.into(),
transport: McpTransport::Http {
url: url.into(),
headers: BTreeMap::new(),
},
timeout_secs: DEFAULT_TIMEOUT_SECS,
}
}
pub fn with_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
if let McpTransport::Stdio { args: a, .. } = &mut self.transport {
*a = args.into_iter().map(Into::into).collect();
}
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout_secs = timeout.as_secs().max(1);
self
}
fn timeout(&self) -> Duration {
Duration::from_secs(self.timeout_secs.max(1))
}
}
struct Connected {
id: String,
service: RunningService<RoleClient, ()>,
timeout: Duration,
tools: Vec<ToolSpec>,
}
pub(crate) struct McpSession {
servers: Vec<Connected>,
}
impl McpSession {
pub(crate) async fn connect(
servers: &[McpServer],
policy: &Policy,
store: &Store,
run_id: i64,
watch: &Watch<'_>,
) -> Result<Self> {
let mut connected = Vec::new();
for server in servers {
let started = Instant::now();
let service = match &server.transport {
McpTransport::Stdio { command, args, env } => {
authorize_spawn(command, policy, store, run_id, watch)?;
let mut cmd = tokio::process::Command::new(command);
cmd.args(args);
for (k, v) in env {
cmd.env(k, v);
}
let transport = TokioChildProcess::new(cmd).map_err(|e| Error::Mcp {
server: server.id.clone(),
reason: format!("could not spawn {command}: {e}"),
})?;
().serve(transport).await.map_err(|e| Error::Mcp {
server: server.id.clone(),
reason: format!("handshake failed: {e}"),
})?
}
McpTransport::Http { url, headers } => {
NetGuard::new(policy)
.tracing(store, run_id, 0)
.watching(watch, 0)
.check(url)?;
let mut config = StreamableHttpClientTransportConfig::with_uri(url.clone());
let custom: std::collections::HashMap<_, _> = headers
.iter()
.filter_map(|(k, v)| {
match (
k.parse::<reqwest::header::HeaderName>(),
v.parse::<reqwest::header::HeaderValue>(),
) {
(Ok(name), Ok(value)) => Some((name, value)),
_ => None,
}
})
.collect();
if !custom.is_empty() {
config = config.custom_headers(custom);
}
let transport =
StreamableHttpClientTransport::with_client(net::http_client(), config);
().serve(transport).await.map_err(|e| Error::Mcp {
server: server.id.clone(),
reason: format!("could not connect to {url}: {e}"),
})?
}
};
let listed = tokio::time::timeout(server.timeout(), service.list_all_tools())
.await
.map_err(|_| Error::Mcp {
server: server.id.clone(),
reason: "timed out listing tools".into(),
})?
.map_err(|e| Error::Mcp {
server: server.id.clone(),
reason: format!("could not list tools: {e}"),
})?;
let tools: Vec<ToolSpec> = listed
.iter()
.map(|t| ToolSpec {
name: tool_name(&server.id, &t.name),
description: t.description.as_deref().unwrap_or_default().to_string(),
parameters: serde_json::Value::Object((*t.input_schema).clone()),
})
.collect();
let ev = McpEvent::connected(&server.id, transport_name(&server.transport))
.with_millis(started.elapsed().as_millis() as u64);
store.record_mcp(run_id, &ev)?;
announce(watch, run_id, 0, &ev);
for t in &tools {
let ev = McpEvent::discovered(&server.id, &t.name);
store.record_mcp(run_id, &ev)?;
announce(watch, run_id, 0, &ev);
}
info!(server = %server.id, tools = tools.len(), "mcp server connected");
connected.push(Connected {
id: server.id.clone(),
service,
timeout: server.timeout(),
tools,
});
}
Ok(Self { servers: connected })
}
pub(crate) fn tool_specs(&self) -> Vec<ToolSpec> {
self.servers
.iter()
.flat_map(|s| s.tools.iter().cloned())
.collect()
}
pub(crate) fn owns(&self, name: &str) -> bool {
self.servers
.iter()
.any(|s| s.tools.iter().any(|t| t.name == name))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn call(
&self,
name: &str,
arguments: &serde_json::Value,
store: &Store,
run_id: i64,
step: u32,
cap: usize,
watch: &Watch<'_>,
depth: u32,
) -> Result<String> {
let Some(server) = self
.servers
.iter()
.find(|s| s.tools.iter().any(|t| t.name == name))
else {
return Ok(format!("[unknown tool {name}]"));
};
let Some(bare) = bare_name(&server.id, name) else {
return Ok(format!("[unknown tool {name}]"));
};
let mut params = CallToolRequestParams::default();
params.name = bare.to_string().into();
params.arguments = arguments.as_object().cloned();
let started = Instant::now();
let outcome = tokio::time::timeout(server.timeout, server.service.call_tool(params)).await;
let millis = started.elapsed().as_millis() as u64;
let (text, ok) = match outcome {
Err(_) => (
format!("[{name} timed out after {}s]", server.timeout.as_secs()),
false,
),
Ok(Err(e)) => (format!("[{name} failed] {e}"), false),
Ok(Ok(result)) => {
let body = render(&result);
let failed = result.is_error.unwrap_or(false);
if failed {
(format!("[{name} reported an error] {body}"), false)
} else {
(body, true)
}
}
};
let (text, truncated) = crate::tools::cap_result(text, cap);
let ev = McpEvent::called(&server.id, name, ok)
.at_step(step)
.with_millis(millis)
.with_detail(if truncated { "truncated" } else { "" });
store.record_mcp(run_id, &ev)?;
announce(watch, run_id, depth, &ev);
Ok(text)
}
pub(crate) async fn shutdown(self, store: &Store, run_id: i64, watch: &Watch<'_>) {
for s in self.servers {
let ev = McpEvent::disconnected(&s.id);
let _ = store.record_mcp(run_id, &ev);
announce(watch, run_id, 0, &ev);
let _ = s.service.cancel().await;
}
}
}
fn announce(watch: &Watch<'_>, run_id: i64, depth: u32, e: &McpEvent) {
watch.emit(RunEvent::at_depth(
run_id,
e.step,
depth,
EventKind::Mcp {
server: e.server.clone(),
tool: e.tool.clone(),
ok: e.ok,
millis: e.millis,
},
));
}
fn authorize_spawn(
command: &str,
policy: &Policy,
store: &Store,
run_id: i64,
watch: &Watch<'_>,
) -> Result<()> {
let verdict = policy.check(Act::Exec, command);
let mut ev = if verdict.effect == Effect::Allow {
PolicyEvent::decision(0, "exec", command, "allow", "policy")
} else {
PolicyEvent::refusal(0, "exec", command)
};
ev.rule = verdict.rule.clone();
ev.layer = verdict.layer.clone();
store.record_event(run_id, &ev)?;
if verdict.effect == Effect::Allow {
Ok(())
} else {
refused(watch, run_id, 0, &ev);
Err(Error::Refused {
act: "exec".into(),
target: command.to_string(),
rule: verdict.rule,
layer: verdict.layer,
})
}
}
fn tool_name(server: &str, tool: &str) -> String {
format!("{MCP_TOOL_PREFIX}{server}__{tool}")
}
fn bare_name<'a>(server: &str, namespaced: &'a str) -> Option<&'a str> {
namespaced.strip_prefix(&format!("{MCP_TOOL_PREFIX}{server}__"))
}
fn transport_name(t: &McpTransport) -> &'static str {
match t {
McpTransport::Stdio { .. } => "stdio",
McpTransport::Http { .. } => "http",
}
}
fn render(result: &rmcp::model::CallToolResult) -> String {
let mut parts: Vec<String> = result
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect();
if parts.is_empty() {
if let Some(structured) = &result.structured_content {
parts.push(structured.to_string());
}
}
if parts.is_empty() {
return "(no text content)".to_string();
}
parts.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_names_are_namespaced_and_reversible() {
let n = tool_name("files", "write_file");
assert_eq!(n, "mcp__files__write_file");
assert!(n.starts_with(MCP_TOOL_PREFIX));
assert_ne!(n, "write_file", "a server must not shadow a built-in");
assert_eq!(bare_name("files", &n), Some("write_file"));
assert_eq!(bare_name("other", &n), None);
}
#[test]
fn a_server_config_round_trips_through_serde() {
for server in [
McpServer::stdio("files", "mcp-files").with_args(["--root", "/tmp"]),
McpServer::http("remote", "https://mcp.example.com/mcp")
.with_timeout(Duration::from_secs(5)),
] {
let json = serde_json::to_string(&server).unwrap();
let back: McpServer = serde_json::from_str(&json).unwrap();
assert_eq!(server, back, "{json}");
}
}
#[test]
fn a_stdio_config_omitting_optional_fields_still_parses() {
let s: McpServer =
serde_json::from_str(r#"{"id":"files","transport":"stdio","command":"mcp-files"}"#)
.unwrap();
assert_eq!(s.timeout_secs, DEFAULT_TIMEOUT_SECS);
assert!(matches!(s.transport, McpTransport::Stdio { .. }));
}
#[test]
fn an_oversized_result_is_cut_on_a_char_boundary_and_says_so() {
let cap_chars =
crate::context::entry_cap_chars(crate::context::ContextBudget::default().max_tokens);
let (short, cut) = crate::tools::cap_result("hello".into(), cap_chars);
assert_eq!((short.as_str(), cut), ("hello", false));
let (long, cut) = crate::tools::cap_result("é".repeat(cap_chars), cap_chars);
assert!(cut);
assert!(long.contains("[truncated at"));
assert!(long.len() < 2 * cap_chars);
}
}