use std::path::{Path, PathBuf};
use std::time::Duration;
use byob_mcp::{CallResult, Content, HttpConfig, McpClient, McpTool, StdioConfig};
use byob_sandbox::{Policy, Sandbox};
use byob_tools::ToolContext;
use serde_json::Value;
use crate::connectors::{Connector, ConnectorContent, ConnectorResult, ConnectorTool};
use crate::manifest::McpServerConfig;
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
pub struct McpConnector {
client: McpClient,
call_timeout: Duration,
}
impl McpConnector {
pub fn connect(config: &McpServerConfig, cwd: Option<&Path>) -> Result<Self, String> {
Self::connect_in(config, cwd, None)
}
pub fn connect_in(config: &McpServerConfig, cwd: Option<&Path>, sandbox: Option<&McpSandbox>) -> Result<Self, String> {
let client = match config {
McpServerConfig::Stdio { command, args, env } => {
let mut c = StdioConfig::new(command.clone());
c.args.clone_from(args);
c.env.clone_from(env);
c.cwd = cwd.map(Path::to_path_buf);
if let Some(sb) = sandbox {
sb.wrap(&mut c);
}
McpClient::connect_stdio(&c)
}
McpServerConfig::Url { sse: true, url, .. } => {
return Err(format!("{url}: the legacy HTTP+SSE MCP transport isn't supported yet"));
}
McpServerConfig::Url { url, headers, .. } => {
let mut c = HttpConfig::new(url.clone());
c.headers = headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
McpClient::connect_http(&c)
}
}
.map_err(|e| e.to_string())?;
Ok(Self { client, call_timeout: CALL_TIMEOUT })
}
pub fn client(&self) -> &McpClient {
&self.client
}
}
#[derive(Debug, Clone)]
pub struct McpSandbox {
pub sandbox: Sandbox,
pub workspace: PathBuf,
}
impl McpSandbox {
pub fn policy(&self, cfg: &StdioConfig) -> Policy {
let mut p = Policy { read: vec![PathBuf::from("/")], ..Default::default() }
.network(true)
.allow_unsandboxed(true)
.remove_env("BYOB_API_KEY")
.allow_write(&self.workspace)
.allow_write(std::env::temp_dir());
p.write.extend(byob_sandbox::system_write_paths());
if let Some(dir) = &cfg.cwd {
p = p.allow_write(dir);
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
for d in [".npm", ".cache", ".local/share/uv"] {
p = p.allow_write(home.join(d));
}
}
let home = std::env::var_os("HOME").and_then(|h| PathBuf::from(h).canonicalize().ok());
for a in &cfg.args {
let Ok(path) = Path::new(a).canonicalize() else { continue };
if Path::new(a).is_absolute() && path.is_dir() && path.parent().is_some() && !home.as_ref().is_some_and(|h| h.starts_with(&path)) {
p = p.allow_write(path);
}
}
p
}
pub fn wrap(&self, cfg: &mut StdioConfig) {
let policy = self.policy(cfg);
let argv: Option<Vec<String>> = self.sandbox.argv(&policy, &cfg.command, &cfg.args).into_iter().map(|a| a.into_string().ok()).collect();
match (self.sandbox.helper().to_str(), argv) {
(Some(helper), Some(argv)) => {
cfg.command = helper.to_owned();
cfg.args = argv;
}
_ => log::warn!("MCP server {}: not sandboxed (the app's path isn't UTF-8)", cfg.command),
}
}
}
impl Connector for McpConnector {
fn list_tools(&self) -> Result<Vec<ConnectorTool>, String> {
Ok(self.client.list_tools().map_err(|e| e.to_string())?.into_iter().map(tool).collect())
}
fn call_tool(&self, name: &str, args: &Value, ctx: &ToolContext<'_>) -> Result<ConnectorResult, String> {
let result = self.client.call_tool_cancellable(name, args.clone(), self.call_timeout, ctx.cancel_flag()).map_err(|e| e.to_string())?;
Ok(result_of(result))
}
fn read_resource(&self, uri: &str) -> Result<Value, String> {
self.client.read_resource(uri, Duration::from_secs(30)).map_err(|e| e.to_string())
}
}
fn tool(t: McpTool) -> ConnectorTool {
ConnectorTool {
title: t.annotations.title.or(t.title),
description: Some(t.description).filter(|d| !d.is_empty()),
input_schema: t.input_schema,
read_only_hint: t.annotations.read_only_hint,
destructive_hint: t.annotations.destructive_hint,
resource_uri: t.ui.as_ref().and_then(|u| u.resource_uri.clone()),
app_only: t.ui.as_ref().is_some_and(|u| !u.model),
model_only: t.ui.as_ref().is_some_and(|u| !u.app),
raw: t.raw,
name: t.name,
}
}
fn result_of(r: CallResult) -> ConnectorResult {
let content = r
.content
.into_iter()
.map(|c| match c {
Content::Text(t) => ConnectorContent::Text(t),
Content::Image { mime_type, data } => ConnectorContent::Image { data, mime_type },
Content::Resource { uri, text, .. } => ConnectorContent::Resource { uri, text },
Content::Audio { .. } => ConnectorContent::Other("audio".into()),
Content::ResourceLink { uri, .. } => ConnectorContent::Other(format!("resource link {uri}")),
Content::Other { kind } => ConnectorContent::Other(kind),
})
.collect::<Vec<_>>();
let content = if content.is_empty() { vec![ConnectorContent::Text(r.text)] } else { content };
ConnectorResult { content, is_error: r.is_error, raw: r.raw }
}
#[cfg(test)]
mod tests {
use super::*;
use byob_mcp::ToolAnnotations;
#[test]
fn maps_tool_metadata() {
let t = McpTool {
name: "search".into(),
title: Some("Search".into()),
description: String::new(),
input_schema: serde_json::json!({"type":"object"}),
annotations: ToolAnnotations { read_only_hint: Some(true), ..Default::default() },
ui: None,
raw: serde_json::Value::Null,
};
let c = tool(t);
assert_eq!((c.name.as_str(), c.title.as_deref(), c.description, c.read_only_hint), ("search", Some("Search"), None, Some(true)));
}
#[test]
fn empty_content_falls_back_to_text() {
let r = CallResult { text: "(no result)".into(), is_error: true, content: Vec::new(), structured: None, raw: serde_json::json!({}) };
assert_eq!(result_of(r), ConnectorResult { content: vec![ConnectorContent::Text("(no result)".into())], is_error: true, raw: serde_json::json!({}) });
}
#[test]
fn sandbox_wraps_stdio_servers() {
let ws = std::env::temp_dir();
let sb = McpSandbox { sandbox: Sandbox::new("/opt/byob"), workspace: ws.clone() };
let mut cfg = StdioConfig::new("npx").args(["-y", "server", "/"]).cwd("/plugins/x");
let policy = sb.policy(&cfg);
assert!(policy.network && policy.remove_env.contains(&"BYOB_API_KEY".to_owned()));
assert!(policy.write.contains(&ws.canonicalize().unwrap()) && policy.write.contains(&PathBuf::from("/plugins/x")));
assert!(!policy.write.contains(&PathBuf::from("/")), "an argument of / must not open the whole disk");
sb.wrap(&mut cfg);
assert_eq!(cfg.command, "/opt/byob");
assert_eq!(cfg.args[0], byob_sandbox::HELPER_ARG);
assert_eq!(&cfg.args[2..], ["--", "npx", "-y", "server", "/"]);
}
#[test]
fn legacy_sse_is_refused_cleanly() {
let cfg = McpServerConfig::Url { url: "http://x/sse".into(), sse: true, headers: Default::default(), oauth: None };
assert!(McpConnector::connect(&cfg, None).is_err());
}
}