use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use byob_tools::{Access, FileChange, Tool, ToolContext, ToolOutput, ToolSpec};
use serde_json::{Map, Value, json};
use crate::PluginRef;
use crate::manifest::{McpServerConfig, ParseOptions, parse_manifest, parse_server, plugin_id_ok, setting_key_ok};
use crate::names::{ToolNames, namespaced};
use crate::skills::slug;
pub const MAX_TOOLS_PER_SERVER: usize = 128;
pub const MAX_RESULT_CHARS: usize = 100_000;
const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
const MAX_DESCRIPTION_CHARS: usize = 1000;
pub trait Connector: Send + Sync {
fn list_tools(&self) -> Result<Vec<ConnectorTool>, String>;
fn call_tool(&self, name: &str, args: &Value, ctx: &ToolContext<'_>) -> Result<ConnectorResult, String>;
fn read_resource(&self, _uri: &str) -> Result<Value, String> {
Err("this server can't read resources".into())
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConnectorTool {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub input_schema: Value,
pub read_only_hint: Option<bool>,
pub destructive_hint: Option<bool>,
pub resource_uri: Option<String>,
pub app_only: bool,
pub model_only: bool,
pub raw: Value,
}
impl ConnectorTool {
pub fn from_mcp(v: &Value) -> Option<Self> {
let name = v.get("name")?.as_str()?.trim();
if name.is_empty() {
return None;
}
let ann = v.get("annotations");
let text = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_string).filter(|s| !s.trim().is_empty());
Some(Self {
name: name.to_string(),
title: text(ann.and_then(|a| a.get("title"))).or_else(|| text(v.get("title"))),
description: text(v.get("description")),
input_schema: v.get("inputSchema").cloned().unwrap_or(Value::Null),
read_only_hint: ann.and_then(|a| a.get("readOnlyHint")).and_then(Value::as_bool),
destructive_hint: ann.and_then(|a| a.get("destructiveHint")).and_then(Value::as_bool),
resource_uri: v
.pointer("/_meta/ui/resourceUri")
.or_else(|| v.get("_meta").and_then(|m| m.get("ui/resourceUri")))
.and_then(Value::as_str)
.filter(|u| u.starts_with("ui://"))
.map(str::to_owned),
app_only: v.pointer("/_meta/ui/visibility").and_then(Value::as_array).is_some_and(|a| !a.iter().any(|x| x == "model")),
model_only: v.pointer("/_meta/ui/visibility").and_then(Value::as_array).is_some_and(|a| !a.iter().any(|x| x == "app")),
raw: v.clone(),
})
}
pub fn label(&self) -> &str {
self.title.as_deref().unwrap_or(&self.name)
}
pub fn read_only(&self) -> bool {
self.read_only_hint == Some(true) || (self.read_only_hint.is_none() && self.destructive_hint.is_none() && name_reads_only(&self.name))
}
pub fn needs_approval(&self, untrusted: bool) -> bool {
!self.read_only() && (self.destructive_hint != Some(false) || untrusted)
}
}
const READ_VERBS: [&str; 13] = ["get", "list", "read", "search", "find", "fetch", "lookup", "describe", "ask", "show", "view", "count", "browse"];
const CHANGE_WORDS: &[&str] = &[
"create", "add", "update", "set", "delete", "remove", "send", "post", "write", "edit", "move", "reset", "clear", "run", "exec", "archive", "cancel",
"close", "merge", "publish", "upload", "replace", "drop", "kill", "purge", "apply", "insert", "modify", "rename", "revoke", "grant", "pay", "buy", "order",
"book", "transfer",
];
pub fn name_reads_only(name: &str) -> bool {
let Some(rest) = READ_VERBS.iter().find_map(|v| {
let rest = name.strip_prefix(v)?;
let next = rest.chars().next();
(next.is_none() || next.is_some_and(|c| matches!(c, '_' | '-' | '.') || c.is_ascii_uppercase())).then_some(rest)
}) else {
return false;
};
let lower = rest.to_ascii_lowercase();
!CHANGE_WORDS.iter().any(|w| {
lower.match_indices(w).any(|(i, _)| i == 0 || lower.as_bytes().get(i - 1).is_some_and(|&c| matches!(c, b'_' | b'-' | b'.') || c.is_ascii_alphabetic()))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectorContent {
Text(String),
Image {
data: String,
mime_type: String,
},
Resource {
uri: String,
text: Option<String>,
},
Other(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectorResult {
pub content: Vec<ConnectorContent>,
pub is_error: bool,
pub raw: Value,
}
impl ConnectorResult {
pub fn text(text: impl Into<String>) -> Self {
Self { content: vec![ConnectorContent::Text(text.into())], is_error: false, raw: Value::Null }
}
pub fn from_mcp(v: &Value) -> Self {
let s = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_string);
let content = v
.get("content")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|c| match c.get("type").and_then(Value::as_str) {
Some("text") => ConnectorContent::Text(s(c.get("text")).unwrap_or_default()),
Some("image") => {
ConnectorContent::Image { data: s(c.get("data")).unwrap_or_default(), mime_type: s(c.get("mimeType")).unwrap_or_default() }
}
Some("resource") => {
let r = c.get("resource");
ConnectorContent::Resource { uri: s(r.and_then(|r| r.get("uri"))).unwrap_or_default(), text: s(r.and_then(|r| r.get("text"))) }
}
other => ConnectorContent::Other(other.unwrap_or("unknown").to_string()),
})
.collect()
})
.unwrap_or_default();
Self { content, is_error: v.get("isError").and_then(Value::as_bool).unwrap_or(false), raw: v.clone() }
}
pub fn for_app(&self, max: usize) -> Value {
let full = if self.raw.is_object() {
self.raw.clone()
} else {
let content: Vec<Value> = self
.content
.iter()
.filter_map(|c| match c {
ConnectorContent::Text(t) => Some(json!({"type": "text", "text": t})),
ConnectorContent::Image { data, mime_type } => Some(json!({"type": "image", "data": data, "mimeType": mime_type})),
ConnectorContent::Resource { uri, text } => Some(json!({"type": "resource", "resource": {"uri": uri, "text": text}})),
ConnectorContent::Other(_) => None,
})
.collect();
json!({"content": content, "isError": self.is_error})
};
if full.to_string().len() <= max {
return full;
}
const PART: usize = r#"{"type":"text","text":""},"#.len();
let mut left = max / 2;
let mut texts = Vec::new();
for t in full.get("content").and_then(Value::as_array).into_iter().flatten().filter_map(|p| p.get("text").and_then(Value::as_str)) {
let Some(room) = left.checked_sub(PART).filter(|r| *r > 0) else { break };
let mut used = 0;
let cut: String = t
.chars()
.take_while(|c| {
used += json_len(*c);
used <= room
})
.collect();
left = left.saturating_sub(PART + cut.chars().map(json_len).sum::<usize>());
texts.push(json!({"type": "text", "text": cut}));
}
let mut out = json!({"content": texts, "isError": self.is_error});
let room = max.saturating_sub(out.to_string().len()).saturating_sub(r#","structuredContent":"#.len());
if let Some(s) = full.get("structuredContent").filter(|s| s.to_string().len() <= room) {
out["structuredContent"] = s.clone();
}
out
}
}
fn json_len(c: char) -> usize {
match c {
'"' | '\\' => 2,
c if u32::from(c) < 0x20 => 6,
c => c.len_utf8(),
}
}
const MAX_APP_RESULT: usize = 160 * 1024;
pub struct ConnectorToolAdapter {
name: String,
plugin: PluginRef,
server: String,
local: bool,
tool: ConnectorTool,
connector: Arc<dyn Connector>,
}
impl ConnectorToolAdapter {
pub fn new(name: String, plugin: PluginRef, local: bool, tool: ConnectorTool, connector: Arc<dyn Connector>) -> Self {
Self { name, plugin, server: String::new(), local, tool, connector }
}
#[must_use]
pub fn on_server(mut self, server: &str) -> Self {
server.clone_into(&mut self.server);
self
}
pub fn server(&self) -> &str {
&self.server
}
pub fn local(&self) -> bool {
self.local
}
pub fn connector(&self) -> &Arc<dyn Connector> {
&self.connector
}
fn app_view(&self, args: &Value, r: &ConnectorResult) -> Option<byob_protocol::View> {
let uri = self.tool.resource_uri.clone()?;
let raw = if self.tool.raw.to_string().len() <= 32 * 1024 { self.tool.raw.clone() } else { Value::Null };
let app = byob_protocol::web::McpApp {
plugin: self.plugin.id.clone(),
server: self.server.clone(),
tool: self.tool.name.clone(),
uri,
title: self.tool.label().to_owned(),
input: if args.to_string().len() <= 32 * 1024 { args.clone() } else { json!({}) },
result: r.for_app(MAX_APP_RESULT),
tool_def: raw,
};
Some(byob_protocol::View::of(&app))
}
pub fn connector_tool(&self) -> &ConnectorTool {
&self.tool
}
pub fn plugin(&self) -> &PluginRef {
&self.plugin
}
}
impl Tool for ConnectorToolAdapter {
fn spec(&self) -> ToolSpec {
let schema = match &self.tool.input_schema {
Value::Object(o) if o.get("type").and_then(Value::as_str) == Some("object") => Value::Object(o.clone()),
_ => json!({"type": "object", "properties": {}}),
};
let description = self.tool.description.as_deref().unwrap_or(self.tool.label()).chars().take(MAX_DESCRIPTION_CHARS).collect();
ToolSpec { name: self.name.clone(), description, parameters: schema }
}
fn title(&self, _args: &Value) -> String {
format!("Used {}: {}", self.plugin.name, self.tool.label())
}
fn access(&self, _args: &Value) -> Access {
if self.local { Access::Execute } else { Access::Network }
}
fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
if ctx.cancelled() {
return ToolOutput::err("Stopped by the user.");
}
let args = if args.is_object() { args.clone() } else { Value::Object(Map::new()) };
match self.connector.call_tool(&self.tool.name, &args, ctx) {
Ok(r) => {
if let Some(view) = self.app_view(&args, &r) {
ctx.attach_view(view);
}
output(&r, ctx.workspace, &slug(&self.plugin.id))
}
Err(e) => ToolOutput::err(format!("{} couldn't do that: {e}", self.plugin.name)),
}
}
}
fn output(r: &ConnectorResult, dir: &Path, label: &str) -> ToolOutput {
let mut texts = Vec::new();
let mut changes = Vec::new();
for c in &r.content {
match c {
ConnectorContent::Text(t) => texts.push(t.clone()),
ConnectorContent::Image { data, mime_type } => match save_image(dir, label, changes.len() + 1, data, mime_type) {
Ok(path) => {
let shown = byob_tools::sandbox::slash(path.strip_prefix(dir).unwrap_or(&path));
texts.push(format!("[image saved as {shown}]"));
changes.push(FileChange::Created(path));
}
Err(e) => texts.push(format!("[image not saved: {e}]")),
},
ConnectorContent::Resource { uri, text } => texts.push(text.clone().unwrap_or_else(|| format!("[resource {uri}]"))),
ConnectorContent::Other(_) => {}
}
}
let mut text = texts.join("\n");
if text.is_empty() {
text = "(no result)".into();
}
if text.chars().count() > MAX_RESULT_CHARS {
text = text.chars().take(MAX_RESULT_CHARS).collect::<String>() + "\n…(cut: the result was too long)";
}
ToolOutput { text, failed: r.is_error, changes, images: Vec::new() }
}
fn save_image(dir: &Path, label: &str, n: usize, data: &str, mime: &str) -> Result<PathBuf, String> {
let ext = match mime {
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
};
if data.len() / 4 * 3 > MAX_IMAGE_BYTES {
return Err("too big".into());
}
let bytes = base64_decode(data).ok_or("not valid base64")?;
let label = if label.is_empty() { "image" } else { label };
let mut i = 0u32;
let path = loop {
let suffix = if i == 0 { String::new() } else { format!("-{i}") };
let p = dir.join(format!("{label}-{n}{suffix}.{ext}"));
if !p.exists() {
break p;
}
i = i.checked_add(1).ok_or("no free file name")?;
};
fs::write(&path, bytes).map_err(|e| e.to_string())?;
Ok(path)
}
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let mut buf = 0u32;
let mut bits = 0u32;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' => break,
b' ' | b'\n' | b'\r' | b'\t' => continue,
_ => return None,
};
buf = (buf << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((buf >> bits) & 0xff) as u8);
}
}
Some(out)
}
pub fn connector_tools(
plugin: &PluginRef,
local: bool,
connector: Arc<dyn Connector>,
names: &mut ToolNames,
) -> Result<Vec<Arc<ConnectorToolAdapter>>, String> {
let listed = connector.list_tools()?;
Ok(connector_tools_from(plugin, local, connector, listed, names))
}
pub fn connector_tools_from(
plugin: &PluginRef,
local: bool,
connector: Arc<dyn Connector>,
listed: Vec<ConnectorTool>,
names: &mut ToolNames,
) -> Vec<Arc<ConnectorToolAdapter>> {
connector_tools_on("", plugin, local, connector, listed, names)
}
pub fn connector_tools_on(
server: &str,
plugin: &PluginRef,
local: bool,
connector: Arc<dyn Connector>,
listed: Vec<ConnectorTool>,
names: &mut ToolNames,
) -> Vec<Arc<ConnectorToolAdapter>> {
if listed.len() > MAX_TOOLS_PER_SERVER {
log::warn!("plugin {}: server offers {} tools; using the first {MAX_TOOLS_PER_SERVER}", plugin.id, listed.len());
}
listed
.into_iter()
.take(MAX_TOOLS_PER_SERVER)
.filter(|t| !t.app_only)
.map(|t| {
let preferred = namespaced(&plugin.id, &t.name);
let name = names.claim(&preferred, &preferred);
Arc::new(ConnectorToolAdapter::new(name, plugin.clone(), local, t, Arc::clone(&connector)).on_server(server))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectorDraft {
pub id: String,
pub name: String,
pub servers: BTreeMap<String, McpServerConfig>,
pub secrets: BTreeMap<String, String>,
pub labels: BTreeMap<String, String>,
}
pub fn name_from_url(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
let host_port = host_port.rsplit_once('@').map_or(host_port, |(_, h)| h);
let host = if host_port.starts_with('[') { host_port } else { host_port.split(':').next().unwrap_or("") };
if host.is_empty() {
return "App".into();
}
if host == "localhost" || host.contains(':') || host.starts_with('[') || host.chars().all(|c| c.is_ascii_digit() || c == '.') {
return "Local app".into();
}
let parts: Vec<&str> = host.split('.').filter(|p| !matches!(*p, "www" | "mcp" | "api" | "app" | "server" | "remote")).collect();
let n = if parts.len() > 1 { parts[parts.len() - 2] } else { parts.first().copied().unwrap_or("App") };
let mut c = n.chars();
c.next().map_or_else(|| "App".into(), |f| f.to_uppercase().chain(c).collect())
}
pub fn parse_command_line(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut has = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if let Some(q) = quote {
if c == q {
quote = None;
} else if c == '\\' && q == '"' && chars.peek().is_some() {
cur.extend(chars.next());
} else {
cur.push(c);
}
continue;
}
match c {
'"' | '\'' => {
quote = Some(c);
has = true;
}
'\\' if chars.peek().is_some() => {
cur.extend(chars.next());
has = true;
}
c if c.is_whitespace() => {
if has || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
has = false;
}
c => cur.push(c),
}
}
if has || !cur.is_empty() {
out.push(cur);
}
out
}
pub fn parse_connector(name: &str, input: &str, env: &str) -> Result<ConnectorDraft, String> {
let text = input.trim();
let mut name = name.trim().to_string();
let lines: Vec<&str> = env.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
let mut secrets = BTreeMap::new();
let mut labels = BTreeMap::new();
let lower = text.to_ascii_lowercase();
let servers = if lower.starts_with("http://") || lower.starts_with("https://") {
if name.is_empty() {
name = name_from_url(text);
}
let mut headers = BTreeMap::new();
for l in &lines {
let Some(i) = l.find([':', '=']).filter(|&i| i > 0) else { continue };
let header = l[..i].trim();
let key: String =
format!("HEADER_{}", header.to_ascii_uppercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect::<String>())
.chars()
.take(64)
.collect();
headers.insert(header.to_string(), format!("${{{key}}}"));
secrets.insert(key.clone(), l[i + 1..].trim().to_string());
labels.insert(key, header.to_string());
}
let raw = json!({"url": text, "headers": headers});
BTreeMap::from([(server_key(&name), parse_server(&raw, "link").map_err(|_| "That link doesn't look right.".to_string())?)])
} else if text.starts_with('{') {
let j: Value = serde_json::from_str(text).map_err(|_| "That config isn't valid JSON.".to_string())?;
let map = j.get("mcpServers").or_else(|| j.get("servers")).unwrap_or(&j);
let map = map.as_object().ok_or("No servers found in that config.")?;
let servers = map.iter().map(|(k, v)| Ok((k.clone(), parse_server(v, &format!("mcpServers.{k}"))?))).collect::<Result<BTreeMap<_, _>, String>>()?;
if name.is_empty() {
name = map.keys().next().cloned().ok_or("No servers found in that config.")?;
}
servers
} else {
let mut words = parse_command_line(text);
if words.is_empty() {
return Err("Enter the command that starts the connector.".into());
}
let command = words.remove(0);
for l in &lines {
if let Some((k, v)) = l.split_once('=').filter(|(k, _)| !k.trim().is_empty()) {
secrets.insert(k.trim().to_string(), v.trim().to_string());
}
}
if name.is_empty() {
let last = words.last().unwrap_or(&command);
name = Path::new(last).file_name().map_or_else(|| last.clone(), |n| n.to_string_lossy().into_owned());
}
BTreeMap::from([(server_key(&name), McpServerConfig::Stdio { command, args: words, env: BTreeMap::new() })])
};
if servers.is_empty() {
return Err("No servers found in that config.".into());
}
if let Some(k) = secrets.keys().find(|k| !setting_key_ok(k)) {
return Err(format!("“{k}” must be UPPER_CASE, like API_KEY."));
}
let id = slug(&name);
if !plugin_id_ok(&id) {
return Err("Give the connection a name with letters or numbers.".into());
}
Ok(ConnectorDraft { id, name, servers, secrets, labels })
}
fn server_key(name: &str) -> String {
let s = slug(name);
if s.is_empty() { "app".into() } else { s }
}
pub fn server_json(c: &McpServerConfig) -> Value {
match c {
McpServerConfig::Stdio { command, args, env } => {
let mut o = json!({"command": command, "args": args});
if !env.is_empty() {
o["env"] = json!(env);
}
o
}
McpServerConfig::Url { url, sse, headers, oauth } => {
let mut o = json!({"url": url});
if *sse {
o["type"] = json!("sse");
}
if !headers.is_empty() {
o["headers"] = json!(headers);
}
if let Some(a) = oauth {
o["oauth"] = json!({"clientId": a.client_id});
if let Some(s) = &a.client_secret {
o["oauth"]["clientSecret"] = json!(s);
}
}
o
}
}
}
impl ConnectorDraft {
pub fn online(&self) -> bool {
self.servers.values().all(|c| !c.is_local())
}
pub fn manifest_json(&self) -> Value {
let description = if self.online() {
let hosts: Vec<String> = self
.servers
.values()
.filter_map(|c| match c {
McpServerConfig::Url { url, .. } => url.split_once("://").and_then(|(_, r)| r.split('/').next()).map(str::to_string),
McpServerConfig::Stdio { .. } => None,
})
.collect();
format!("Online connector: {}", hosts.join(", "))
} else {
"Connector (MCP).".into()
};
let servers: Map<String, Value> = self.servers.iter().map(|(k, c)| (k.clone(), server_json(c))).collect();
let mut m = json!({
"id": self.id, "name": self.name, "version": "1.0.0", "description": description,
"icon": if self.online() { "globe" } else { "plug" }, "mcpServers": servers,
});
if !self.secrets.is_empty() {
let settings: Vec<Value> = self.secrets.keys().map(|k| json!({"key": k, "label": self.labels.get(k).unwrap_or(k), "secret": true})).collect();
m["settings"] = json!(settings);
}
m
}
pub fn check(&self) -> Result<(), String> {
parse_manifest(&self.manifest_json(), ParseOptions::default()).map(|_| ()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
struct Fake {
calls: Mutex<Vec<(String, Value)>>,
}
impl Connector for Fake {
fn list_tools(&self) -> Result<Vec<ConnectorTool>, String> {
let listed = json!([
{"name": "get-issue", "description": "Read an issue", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}}},
{"name": "create.issue", "annotations": {"title": "Create issue", "destructiveHint": true}},
{"name": "snapshot", "inputSchema": "nonsense"},
{"name": ""},
{"nope": 1},
]);
Ok(listed.as_array().map(|a| a.iter().filter_map(ConnectorTool::from_mcp).collect()).unwrap_or_default())
}
fn call_tool(&self, name: &str, args: &Value, _ctx: &ToolContext<'_>) -> Result<ConnectorResult, String> {
self.calls.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((name.to_string(), args.clone()));
match name {
"get-issue" => Ok(ConnectorResult::from_mcp(
&json!({"content": [{"type": "text", "text": "Issue 1"}, {"type": "resource", "resource": {"uri": "x://1"}}, {"type": "audio"}]}),
)),
"snapshot" => Ok(ConnectorResult::from_mcp(&json!({"content": [{"type": "image", "data": "aGk=", "mimeType": "image/png"}]}))),
_ => Err("connection closed".into()),
}
}
}
fn plugin() -> PluginRef {
PluginRef { id: "linear".into(), name: "Linear".into(), dir: PathBuf::from("/p"), network: false, env: BTreeMap::new() }
}
#[test]
fn adapts_server_tools() {
let fake = Arc::new(Fake { calls: Mutex::new(Vec::new()) });
let mut names = ToolNames::new(["linear__get-issue"]);
let tools = connector_tools(&plugin(), false, fake.clone(), &mut names).unwrap();
let specs: Vec<String> = tools.iter().map(|t| t.spec().name).collect();
assert_eq!(specs, ["linear__get-issue_2", "linear__create_issue", "linear__snapshot"]);
assert_eq!(tools[2].spec().parameters, json!({"type": "object", "properties": {}}));
assert_eq!(tools[1].spec().description, "Create issue");
assert_eq!(tools[1].title(&json!({})), "Used Linear: Create issue");
assert_eq!(tools[0].access(&json!({})), Access::Network);
assert!(tools[0].connector_tool().read_only() && !tools[0].connector_tool().needs_approval(false));
assert!(tools[1].connector_tool().needs_approval(false));
let ws = std::env::temp_dir().join(format!("byob-plugins-conn-{}", std::process::id()));
let _ = fs::remove_dir_all(&ws);
fs::create_dir_all(&ws).unwrap();
let cancel = AtomicBool::new(false);
let ctx = ToolContext::new(&ws, &cancel);
let out = tools[0].run(&json!("not an object"), &ctx);
assert_eq!(out.text, "Issue 1\n[resource x://1]");
assert!(!out.failed);
assert_eq!(fake.calls.lock().unwrap()[0], ("get-issue".to_string(), json!({})));
let out = tools[1].run(&json!({"title": "x"}), &ctx);
assert!(out.failed && out.text == "Linear couldn't do that: connection closed");
let out = tools[2].run(&json!({}), &ctx);
assert_eq!(out.text, "[image saved as linear-1.png]");
assert_eq!(fs::read(ws.join("linear-1.png")).unwrap(), b"hi");
assert_eq!(out.changes, [FileChange::Created(ws.join("linear-1.png"))]);
let again = tools[2].run(&json!({}), &ctx);
assert_eq!(again.text, "[image saved as linear-1-1.png]");
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn local_servers_execute() {
let fake = Arc::new(Fake { calls: Mutex::new(Vec::new()) });
let tools = connector_tools(&plugin(), true, fake, &mut ToolNames::default()).unwrap();
assert_eq!(tools[0].access(&json!({})), Access::Execute);
}
#[test]
fn read_only_names() {
for n in ["get_issue", "listFiles", "search", "fetch-page", "get_order_status"] {
assert_eq!(name_reads_only(n), n != "get_order_status", "{n}");
}
for n in ["query", "getaway", "get_or_create", "list_and_delete", "create_issue"] {
assert!(!name_reads_only(n), "{n}");
}
}
#[test]
fn base64() {
assert_eq!(base64_decode("aGVsbG8gd29ybGQ=").unwrap(), b"hello world");
assert_eq!(base64_decode("aGVsbG8\ngd29ybGQ").unwrap(), b"hello world");
assert!(base64_decode("!!").is_none());
}
#[test]
fn pasted_configs() {
let d = parse_connector("", r#"{"mcpServers": {"memory": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-memory"]}, "docs": {"url": "https://example.com/mcp"}}}"#, "").unwrap();
assert_eq!(d.id, "docs");
assert_eq!(d.servers.len(), 2);
assert!(!d.online());
d.check().unwrap();
let d = parse_connector("", r#"{"servers": {"gh": {"type": "http", "url": "https://api.githubcopilot.com/mcp/"}}}"#, "").unwrap();
assert!(d.online());
let d = parse_connector("", "https://mcp.linear.app/mcp", "Authorization: Bearer abc\n").unwrap();
assert_eq!((d.id.as_str(), d.name.as_str()), ("linear", "Linear"));
assert_eq!(d.secrets["HEADER_AUTHORIZATION"], "Bearer abc");
let m = d.manifest_json();
assert_eq!(m["mcpServers"]["linear"]["headers"]["Authorization"], "${HEADER_AUTHORIZATION}");
assert_eq!(m["settings"][0]["label"], "Authorization");
assert_eq!(m["description"], "Online connector: mcp.linear.app");
d.check().unwrap();
let d = parse_connector("", "npx -y '@scope/my server' ~/My\\ Notes", "API_KEY=abc").unwrap();
assert_eq!(d.name, "My Notes");
match &d.servers["my-notes"] {
McpServerConfig::Stdio { command, args, .. } => {
assert_eq!((command.as_str(), args.as_slice()), ("npx", &["-y".to_string(), "@scope/my server".into(), "~/My Notes".into()][..]))
}
other => panic!("{other:?}"),
}
d.check().unwrap();
assert_eq!(parse_connector("", "{nope", "").unwrap_err(), "That config isn't valid JSON.");
assert_eq!(parse_connector("", "{}", "").unwrap_err(), "No servers found in that config.");
assert_eq!(parse_connector("", " ", "").unwrap_err(), "Enter the command that starts the connector.");
assert!(parse_connector("", r#"{"mcpServers": {"x": {"url": "ftp://x"}}}"#, "").unwrap_err().contains("must start with https://"));
assert!(parse_connector("", "server", "lower=1").unwrap_err().contains("UPPER_CASE"));
}
#[test]
fn url_names() {
assert_eq!(name_from_url("https://mcp.linear.app/mcp"), "Linear");
assert_eq!(name_from_url("http://localhost:3000/mcp"), "Local app");
assert_eq!(name_from_url("http://127.0.0.1/mcp"), "Local app");
assert_eq!(name_from_url("https://notion.com"), "Notion");
}
#[test]
fn an_app_result_stays_under_its_cap_however_many_parts() {
let max = 16 * 1024;
let parts: Vec<Value> = (0..20).map(|i| json!({"type": "text", "text": format!("{i}\"\n{}", "é".repeat(5000))})).collect();
let r = ConnectorResult::from_mcp(&json!({"content": parts, "structuredContent": {"n": 1}}));
let out = r.for_app(max);
assert!(out.to_string().len() <= max, "{} bytes", out.to_string().len());
let first = out["content"][0]["text"].as_str().unwrap();
assert!(first.starts_with("0\"\n") && first.len() > 1000, "{first:.20}");
assert_eq!(out["structuredContent"], json!({"n": 1}));
let small = ConnectorResult::from_mcp(&json!({"content": [{"type": "text", "text": "hi"}]}));
assert_eq!(small.for_app(max)["content"][0]["text"], "hi");
}
}