1use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use byob_tools::{Access, FileChange, Tool, ToolContext, ToolOutput, ToolSpec};
15use serde_json::{Map, Value, json};
16
17use crate::PluginRef;
18use crate::manifest::{McpServerConfig, ParseOptions, parse_manifest, parse_server, plugin_id_ok, setting_key_ok};
19use crate::names::{ToolNames, namespaced};
20use crate::skills::slug;
21
22pub const MAX_TOOLS_PER_SERVER: usize = 128;
24pub const MAX_RESULT_CHARS: usize = 100_000;
26const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
28const MAX_DESCRIPTION_CHARS: usize = 1000;
29
30pub trait Connector: Send + Sync {
32 fn list_tools(&self) -> Result<Vec<ConnectorTool>, String>;
34 fn call_tool(&self, name: &str, args: &Value, ctx: &ToolContext<'_>) -> Result<ConnectorResult, String>;
38 fn read_resource(&self, _uri: &str) -> Result<Value, String> {
40 Err("this server can't read resources".into())
41 }
42}
43
44#[derive(Debug, Clone, Default, PartialEq)]
46pub struct ConnectorTool {
47 pub name: String,
48 pub title: Option<String>,
50 pub description: Option<String>,
51 pub input_schema: Value,
53 pub read_only_hint: Option<bool>,
54 pub destructive_hint: Option<bool>,
55 pub resource_uri: Option<String>,
57 pub app_only: bool,
59 pub model_only: bool,
61 pub raw: Value,
63}
64
65impl ConnectorTool {
66 pub fn from_mcp(v: &Value) -> Option<Self> {
68 let name = v.get("name")?.as_str()?.trim();
69 if name.is_empty() {
70 return None;
71 }
72 let ann = v.get("annotations");
73 let text = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_string).filter(|s| !s.trim().is_empty());
74 Some(Self {
75 name: name.to_string(),
76 title: text(ann.and_then(|a| a.get("title"))).or_else(|| text(v.get("title"))),
77 description: text(v.get("description")),
78 input_schema: v.get("inputSchema").cloned().unwrap_or(Value::Null),
79 read_only_hint: ann.and_then(|a| a.get("readOnlyHint")).and_then(Value::as_bool),
80 destructive_hint: ann.and_then(|a| a.get("destructiveHint")).and_then(Value::as_bool),
81 resource_uri: v
82 .pointer("/_meta/ui/resourceUri")
83 .or_else(|| v.get("_meta").and_then(|m| m.get("ui/resourceUri")))
84 .and_then(Value::as_str)
85 .filter(|u| u.starts_with("ui://"))
86 .map(str::to_owned),
87 app_only: v.pointer("/_meta/ui/visibility").and_then(Value::as_array).is_some_and(|a| !a.iter().any(|x| x == "model")),
88 model_only: v.pointer("/_meta/ui/visibility").and_then(Value::as_array).is_some_and(|a| !a.iter().any(|x| x == "app")),
89 raw: v.clone(),
90 })
91 }
92
93 pub fn label(&self) -> &str {
95 self.title.as_deref().unwrap_or(&self.name)
96 }
97
98 pub fn read_only(&self) -> bool {
102 self.read_only_hint == Some(true) || (self.read_only_hint.is_none() && self.destructive_hint.is_none() && name_reads_only(&self.name))
103 }
104
105 pub fn needs_approval(&self, untrusted: bool) -> bool {
108 !self.read_only() && (self.destructive_hint != Some(false) || untrusted)
109 }
110}
111
112const READ_VERBS: [&str; 13] = ["get", "list", "read", "search", "find", "fetch", "lookup", "describe", "ask", "show", "view", "count", "browse"];
113const CHANGE_WORDS: &[&str] = &[
114 "create", "add", "update", "set", "delete", "remove", "send", "post", "write", "edit", "move", "reset", "clear", "run", "exec", "archive", "cancel",
115 "close", "merge", "publish", "upload", "replace", "drop", "kill", "purge", "apply", "insert", "modify", "rename", "revoke", "grant", "pay", "buy", "order",
116 "book", "transfer",
117];
118
119pub fn name_reads_only(name: &str) -> bool {
122 let Some(rest) = READ_VERBS.iter().find_map(|v| {
123 let rest = name.strip_prefix(v)?;
124 let next = rest.chars().next();
125 (next.is_none() || next.is_some_and(|c| matches!(c, '_' | '-' | '.') || c.is_ascii_uppercase())).then_some(rest)
126 }) else {
127 return false;
128 };
129 let lower = rest.to_ascii_lowercase();
131 !CHANGE_WORDS.iter().any(|w| {
132 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()))
133 })
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum ConnectorContent {
139 Text(String),
140 Image {
142 data: String,
143 mime_type: String,
144 },
145 Resource {
146 uri: String,
147 text: Option<String>,
148 },
149 Other(String),
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct ConnectorResult {
156 pub content: Vec<ConnectorContent>,
157 pub is_error: bool,
158 pub raw: Value,
160}
161
162impl ConnectorResult {
163 pub fn text(text: impl Into<String>) -> Self {
164 Self { content: vec![ConnectorContent::Text(text.into())], is_error: false, raw: Value::Null }
165 }
166
167 pub fn from_mcp(v: &Value) -> Self {
169 let s = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_string);
170 let content = v
171 .get("content")
172 .and_then(Value::as_array)
173 .map(|items| {
174 items
175 .iter()
176 .map(|c| match c.get("type").and_then(Value::as_str) {
177 Some("text") => ConnectorContent::Text(s(c.get("text")).unwrap_or_default()),
178 Some("image") => {
179 ConnectorContent::Image { data: s(c.get("data")).unwrap_or_default(), mime_type: s(c.get("mimeType")).unwrap_or_default() }
180 }
181 Some("resource") => {
182 let r = c.get("resource");
183 ConnectorContent::Resource { uri: s(r.and_then(|r| r.get("uri"))).unwrap_or_default(), text: s(r.and_then(|r| r.get("text"))) }
184 }
185 other => ConnectorContent::Other(other.unwrap_or("unknown").to_string()),
186 })
187 .collect()
188 })
189 .unwrap_or_default();
190 Self { content, is_error: v.get("isError").and_then(Value::as_bool).unwrap_or(false), raw: v.clone() }
191 }
192
193 pub fn for_app(&self, max: usize) -> Value {
197 let full = if self.raw.is_object() {
198 self.raw.clone()
199 } else {
200 let content: Vec<Value> = self
201 .content
202 .iter()
203 .filter_map(|c| match c {
204 ConnectorContent::Text(t) => Some(json!({"type": "text", "text": t})),
205 ConnectorContent::Image { data, mime_type } => Some(json!({"type": "image", "data": data, "mimeType": mime_type})),
206 ConnectorContent::Resource { uri, text } => Some(json!({"type": "resource", "resource": {"uri": uri, "text": text}})),
207 ConnectorContent::Other(_) => None,
208 })
209 .collect();
210 json!({"content": content, "isError": self.is_error})
211 };
212 if full.to_string().len() <= max {
213 return full;
214 }
215 const PART: usize = r#"{"type":"text","text":""},"#.len();
217 let mut left = max / 2;
218 let mut texts = Vec::new();
219 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)) {
220 let Some(room) = left.checked_sub(PART).filter(|r| *r > 0) else { break };
221 let mut used = 0;
222 let cut: String = t
223 .chars()
224 .take_while(|c| {
225 used += json_len(*c);
226 used <= room
227 })
228 .collect();
229 left = left.saturating_sub(PART + cut.chars().map(json_len).sum::<usize>());
230 texts.push(json!({"type": "text", "text": cut}));
231 }
232 let mut out = json!({"content": texts, "isError": self.is_error});
233 let room = max.saturating_sub(out.to_string().len()).saturating_sub(r#","structuredContent":"#.len());
234 if let Some(s) = full.get("structuredContent").filter(|s| s.to_string().len() <= room) {
235 out["structuredContent"] = s.clone();
236 }
237 out
238 }
239}
240
241fn json_len(c: char) -> usize {
243 match c {
244 '"' | '\\' => 2,
245 c if u32::from(c) < 0x20 => 6,
246 c => c.len_utf8(),
247 }
248}
249
250const MAX_APP_RESULT: usize = 160 * 1024;
252
253pub struct ConnectorToolAdapter {
255 name: String,
256 plugin: PluginRef,
257 server: String,
259 local: bool,
261 tool: ConnectorTool,
262 connector: Arc<dyn Connector>,
263}
264
265impl ConnectorToolAdapter {
266 pub fn new(name: String, plugin: PluginRef, local: bool, tool: ConnectorTool, connector: Arc<dyn Connector>) -> Self {
267 Self { name, plugin, server: String::new(), local, tool, connector }
268 }
269
270 #[must_use]
272 pub fn on_server(mut self, server: &str) -> Self {
273 server.clone_into(&mut self.server);
274 self
275 }
276
277 pub fn server(&self) -> &str {
278 &self.server
279 }
280
281 pub fn local(&self) -> bool {
283 self.local
284 }
285
286 pub fn connector(&self) -> &Arc<dyn Connector> {
287 &self.connector
288 }
289
290 fn app_view(&self, args: &Value, r: &ConnectorResult) -> Option<byob_protocol::View> {
292 let uri = self.tool.resource_uri.clone()?;
293 let raw = if self.tool.raw.to_string().len() <= 32 * 1024 { self.tool.raw.clone() } else { Value::Null };
294 let app = byob_protocol::web::McpApp {
295 plugin: self.plugin.id.clone(),
296 server: self.server.clone(),
297 tool: self.tool.name.clone(),
298 uri,
299 title: self.tool.label().to_owned(),
300 input: if args.to_string().len() <= 32 * 1024 { args.clone() } else { json!({}) },
301 result: r.for_app(MAX_APP_RESULT),
302 tool_def: raw,
303 };
304 Some(byob_protocol::View::of(&app))
305 }
306
307 pub fn connector_tool(&self) -> &ConnectorTool {
309 &self.tool
310 }
311
312 pub fn plugin(&self) -> &PluginRef {
313 &self.plugin
314 }
315}
316
317impl Tool for ConnectorToolAdapter {
318 fn spec(&self) -> ToolSpec {
319 let schema = match &self.tool.input_schema {
320 Value::Object(o) if o.get("type").and_then(Value::as_str) == Some("object") => Value::Object(o.clone()),
321 _ => json!({"type": "object", "properties": {}}),
322 };
323 let description = self.tool.description.as_deref().unwrap_or(self.tool.label()).chars().take(MAX_DESCRIPTION_CHARS).collect();
324 ToolSpec { name: self.name.clone(), description, parameters: schema }
325 }
326
327 fn title(&self, _args: &Value) -> String {
328 format!("Used {}: {}", self.plugin.name, self.tool.label())
329 }
330
331 fn access(&self, _args: &Value) -> Access {
334 if self.local { Access::Execute } else { Access::Network }
335 }
336
337 fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
338 if ctx.cancelled() {
339 return ToolOutput::err("Stopped by the user.");
340 }
341 let args = if args.is_object() { args.clone() } else { Value::Object(Map::new()) };
342 match self.connector.call_tool(&self.tool.name, &args, ctx) {
343 Ok(r) => {
344 if let Some(view) = self.app_view(&args, &r) {
345 ctx.attach_view(view);
346 }
347 output(&r, ctx.workspace, &slug(&self.plugin.id))
348 }
349 Err(e) => ToolOutput::err(format!("{} couldn't do that: {e}", self.plugin.name)),
350 }
351 }
352}
353
354fn output(r: &ConnectorResult, dir: &Path, label: &str) -> ToolOutput {
356 let mut texts = Vec::new();
357 let mut changes = Vec::new();
358 for c in &r.content {
359 match c {
360 ConnectorContent::Text(t) => texts.push(t.clone()),
361 ConnectorContent::Image { data, mime_type } => match save_image(dir, label, changes.len() + 1, data, mime_type) {
362 Ok(path) => {
363 let shown = byob_tools::sandbox::slash(path.strip_prefix(dir).unwrap_or(&path));
364 texts.push(format!("[image saved as {shown}]"));
365 changes.push(FileChange::Created(path));
366 }
367 Err(e) => texts.push(format!("[image not saved: {e}]")),
368 },
369 ConnectorContent::Resource { uri, text } => texts.push(text.clone().unwrap_or_else(|| format!("[resource {uri}]"))),
370 ConnectorContent::Other(_) => {}
371 }
372 }
373 let mut text = texts.join("\n");
374 if text.is_empty() {
375 text = "(no result)".into();
376 }
377 if text.chars().count() > MAX_RESULT_CHARS {
378 text = text.chars().take(MAX_RESULT_CHARS).collect::<String>() + "\n…(cut: the result was too long)";
379 }
380 ToolOutput { text, failed: r.is_error, changes, images: Vec::new() }
381}
382
383fn save_image(dir: &Path, label: &str, n: usize, data: &str, mime: &str) -> Result<PathBuf, String> {
384 let ext = match mime {
385 "image/jpeg" => "jpg",
386 "image/gif" => "gif",
387 "image/webp" => "webp",
388 _ => "png",
389 };
390 if data.len() / 4 * 3 > MAX_IMAGE_BYTES {
391 return Err("too big".into());
392 }
393 let bytes = base64_decode(data).ok_or("not valid base64")?;
394 let label = if label.is_empty() { "image" } else { label };
395 let mut i = 0u32;
396 let path = loop {
397 let suffix = if i == 0 { String::new() } else { format!("-{i}") };
398 let p = dir.join(format!("{label}-{n}{suffix}.{ext}"));
399 if !p.exists() {
400 break p;
401 }
402 i = i.checked_add(1).ok_or("no free file name")?;
403 };
404 fs::write(&path, bytes).map_err(|e| e.to_string())?;
405 Ok(path)
406}
407
408pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
410 let mut out = Vec::with_capacity(s.len() / 4 * 3);
411 let mut buf = 0u32;
412 let mut bits = 0u32;
413 for c in s.bytes() {
414 let v = match c {
415 b'A'..=b'Z' => c - b'A',
416 b'a'..=b'z' => c - b'a' + 26,
417 b'0'..=b'9' => c - b'0' + 52,
418 b'+' | b'-' => 62,
419 b'/' | b'_' => 63,
420 b'=' => break,
421 b' ' | b'\n' | b'\r' | b'\t' => continue,
422 _ => return None,
423 };
424 buf = (buf << 6) | u32::from(v);
425 bits += 6;
426 if bits >= 8 {
427 bits -= 8;
428 out.push(((buf >> bits) & 0xff) as u8);
429 }
430 }
431 Some(out)
432}
433
434pub fn connector_tools(
438 plugin: &PluginRef,
439 local: bool,
440 connector: Arc<dyn Connector>,
441 names: &mut ToolNames,
442) -> Result<Vec<Arc<ConnectorToolAdapter>>, String> {
443 let listed = connector.list_tools()?;
444 Ok(connector_tools_from(plugin, local, connector, listed, names))
445}
446
447pub fn connector_tools_from(
453 plugin: &PluginRef,
454 local: bool,
455 connector: Arc<dyn Connector>,
456 listed: Vec<ConnectorTool>,
457 names: &mut ToolNames,
458) -> Vec<Arc<ConnectorToolAdapter>> {
459 connector_tools_on("", plugin, local, connector, listed, names)
460}
461
462pub fn connector_tools_on(
464 server: &str,
465 plugin: &PluginRef,
466 local: bool,
467 connector: Arc<dyn Connector>,
468 listed: Vec<ConnectorTool>,
469 names: &mut ToolNames,
470) -> Vec<Arc<ConnectorToolAdapter>> {
471 if listed.len() > MAX_TOOLS_PER_SERVER {
472 log::warn!("plugin {}: server offers {} tools; using the first {MAX_TOOLS_PER_SERVER}", plugin.id, listed.len());
473 }
474 listed
475 .into_iter()
476 .take(MAX_TOOLS_PER_SERVER)
477 .filter(|t| !t.app_only)
478 .map(|t| {
479 let preferred = namespaced(&plugin.id, &t.name);
480 let name = names.claim(&preferred, &preferred);
481 Arc::new(ConnectorToolAdapter::new(name, plugin.clone(), local, t, Arc::clone(&connector)).on_server(server))
482 })
483 .collect()
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct ConnectorDraft {
490 pub id: String,
492 pub name: String,
493 pub servers: BTreeMap<String, McpServerConfig>,
494 pub secrets: BTreeMap<String, String>,
497 pub labels: BTreeMap<String, String>,
499}
500
501pub fn name_from_url(url: &str) -> String {
503 let rest = url.split_once("://").map_or(url, |(_, r)| r);
504 let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
505 let host_port = host_port.rsplit_once('@').map_or(host_port, |(_, h)| h);
506 let host = if host_port.starts_with('[') { host_port } else { host_port.split(':').next().unwrap_or("") };
507 if host.is_empty() {
508 return "App".into();
509 }
510 if host == "localhost" || host.contains(':') || host.starts_with('[') || host.chars().all(|c| c.is_ascii_digit() || c == '.') {
511 return "Local app".into();
512 }
513 let parts: Vec<&str> = host.split('.').filter(|p| !matches!(*p, "www" | "mcp" | "api" | "app" | "server" | "remote")).collect();
514 let n = if parts.len() > 1 { parts[parts.len() - 2] } else { parts.first().copied().unwrap_or("App") };
515 let mut c = n.chars();
516 c.next().map_or_else(|| "App".into(), |f| f.to_uppercase().chain(c).collect())
517}
518
519pub fn parse_command_line(s: &str) -> Vec<String> {
521 let mut out = Vec::new();
522 let mut cur = String::new();
523 let mut quote: Option<char> = None;
524 let mut has = false;
525 let mut chars = s.chars().peekable();
526 while let Some(c) = chars.next() {
527 if let Some(q) = quote {
528 if c == q {
529 quote = None;
530 } else if c == '\\' && q == '"' && chars.peek().is_some() {
531 cur.extend(chars.next());
532 } else {
533 cur.push(c);
534 }
535 continue;
536 }
537 match c {
538 '"' | '\'' => {
539 quote = Some(c);
540 has = true;
541 }
542 '\\' if chars.peek().is_some() => {
543 cur.extend(chars.next());
544 has = true;
545 }
546 c if c.is_whitespace() => {
547 if has || !cur.is_empty() {
548 out.push(std::mem::take(&mut cur));
549 }
550 has = false;
551 }
552 c => cur.push(c),
553 }
554 }
555 if has || !cur.is_empty() {
556 out.push(cur);
557 }
558 out
559}
560
561pub fn parse_connector(name: &str, input: &str, env: &str) -> Result<ConnectorDraft, String> {
564 let text = input.trim();
565 let mut name = name.trim().to_string();
566 let lines: Vec<&str> = env.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
567 let mut secrets = BTreeMap::new();
568 let mut labels = BTreeMap::new();
569 let lower = text.to_ascii_lowercase();
570 let servers = if lower.starts_with("http://") || lower.starts_with("https://") {
571 if name.is_empty() {
572 name = name_from_url(text);
573 }
574 let mut headers = BTreeMap::new();
575 for l in &lines {
576 let Some(i) = l.find([':', '=']).filter(|&i| i > 0) else { continue };
577 let header = l[..i].trim();
578 let key: String =
579 format!("HEADER_{}", header.to_ascii_uppercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect::<String>())
580 .chars()
581 .take(64)
582 .collect();
583 headers.insert(header.to_string(), format!("${{{key}}}"));
584 secrets.insert(key.clone(), l[i + 1..].trim().to_string());
585 labels.insert(key, header.to_string());
586 }
587 let raw = json!({"url": text, "headers": headers});
588 BTreeMap::from([(server_key(&name), parse_server(&raw, "link").map_err(|_| "That link doesn't look right.".to_string())?)])
589 } else if text.starts_with('{') {
590 let j: Value = serde_json::from_str(text).map_err(|_| "That config isn't valid JSON.".to_string())?;
591 let map = j.get("mcpServers").or_else(|| j.get("servers")).unwrap_or(&j);
592 let map = map.as_object().ok_or("No servers found in that config.")?;
593 let servers = map.iter().map(|(k, v)| Ok((k.clone(), parse_server(v, &format!("mcpServers.{k}"))?))).collect::<Result<BTreeMap<_, _>, String>>()?;
594 if name.is_empty() {
595 name = map.keys().next().cloned().ok_or("No servers found in that config.")?;
596 }
597 servers
598 } else {
599 let mut words = parse_command_line(text);
600 if words.is_empty() {
601 return Err("Enter the command that starts the connector.".into());
602 }
603 let command = words.remove(0);
604 for l in &lines {
605 if let Some((k, v)) = l.split_once('=').filter(|(k, _)| !k.trim().is_empty()) {
606 secrets.insert(k.trim().to_string(), v.trim().to_string());
607 }
608 }
609 if name.is_empty() {
610 let last = words.last().unwrap_or(&command);
611 name = Path::new(last).file_name().map_or_else(|| last.clone(), |n| n.to_string_lossy().into_owned());
612 }
613 BTreeMap::from([(server_key(&name), McpServerConfig::Stdio { command, args: words, env: BTreeMap::new() })])
614 };
615 if servers.is_empty() {
616 return Err("No servers found in that config.".into());
617 }
618 if let Some(k) = secrets.keys().find(|k| !setting_key_ok(k)) {
619 return Err(format!("“{k}” must be UPPER_CASE, like API_KEY."));
620 }
621 let id = slug(&name);
622 if !plugin_id_ok(&id) {
623 return Err("Give the connection a name with letters or numbers.".into());
624 }
625 Ok(ConnectorDraft { id, name, servers, secrets, labels })
626}
627
628fn server_key(name: &str) -> String {
629 let s = slug(name);
630 if s.is_empty() { "app".into() } else { s }
631}
632
633pub fn server_json(c: &McpServerConfig) -> Value {
635 match c {
636 McpServerConfig::Stdio { command, args, env } => {
637 let mut o = json!({"command": command, "args": args});
638 if !env.is_empty() {
639 o["env"] = json!(env);
640 }
641 o
642 }
643 McpServerConfig::Url { url, sse, headers, oauth } => {
644 let mut o = json!({"url": url});
645 if *sse {
646 o["type"] = json!("sse");
647 }
648 if !headers.is_empty() {
649 o["headers"] = json!(headers);
650 }
651 if let Some(a) = oauth {
652 o["oauth"] = json!({"clientId": a.client_id});
653 if let Some(s) = &a.client_secret {
654 o["oauth"]["clientSecret"] = json!(s);
655 }
656 }
657 o
658 }
659 }
660}
661
662impl ConnectorDraft {
663 pub fn online(&self) -> bool {
665 self.servers.values().all(|c| !c.is_local())
666 }
667
668 pub fn manifest_json(&self) -> Value {
670 let description = if self.online() {
671 let hosts: Vec<String> = self
672 .servers
673 .values()
674 .filter_map(|c| match c {
675 McpServerConfig::Url { url, .. } => url.split_once("://").and_then(|(_, r)| r.split('/').next()).map(str::to_string),
676 McpServerConfig::Stdio { .. } => None,
677 })
678 .collect();
679 format!("Online connector: {}", hosts.join(", "))
680 } else {
681 "Connector (MCP).".into()
682 };
683 let servers: Map<String, Value> = self.servers.iter().map(|(k, c)| (k.clone(), server_json(c))).collect();
684 let mut m = json!({
685 "id": self.id, "name": self.name, "version": "1.0.0", "description": description,
686 "icon": if self.online() { "globe" } else { "plug" }, "mcpServers": servers,
687 });
688 if !self.secrets.is_empty() {
689 let settings: Vec<Value> = self.secrets.keys().map(|k| json!({"key": k, "label": self.labels.get(k).unwrap_or(k), "secret": true})).collect();
690 m["settings"] = json!(settings);
691 }
692 m
693 }
694
695 pub fn check(&self) -> Result<(), String> {
697 parse_manifest(&self.manifest_json(), ParseOptions::default()).map(|_| ()).map_err(|e| e.to_string())
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use std::sync::Mutex;
705 use std::sync::atomic::AtomicBool;
706
707 struct Fake {
708 calls: Mutex<Vec<(String, Value)>>,
709 }
710
711 impl Connector for Fake {
712 fn list_tools(&self) -> Result<Vec<ConnectorTool>, String> {
713 let listed = json!([
714 {"name": "get-issue", "description": "Read an issue", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}}},
715 {"name": "create.issue", "annotations": {"title": "Create issue", "destructiveHint": true}},
716 {"name": "snapshot", "inputSchema": "nonsense"},
717 {"name": ""},
718 {"nope": 1},
719 ]);
720 Ok(listed.as_array().map(|a| a.iter().filter_map(ConnectorTool::from_mcp).collect()).unwrap_or_default())
721 }
722 fn call_tool(&self, name: &str, args: &Value, _ctx: &ToolContext<'_>) -> Result<ConnectorResult, String> {
723 self.calls.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((name.to_string(), args.clone()));
724 match name {
725 "get-issue" => Ok(ConnectorResult::from_mcp(
726 &json!({"content": [{"type": "text", "text": "Issue 1"}, {"type": "resource", "resource": {"uri": "x://1"}}, {"type": "audio"}]}),
727 )),
728 "snapshot" => Ok(ConnectorResult::from_mcp(&json!({"content": [{"type": "image", "data": "aGk=", "mimeType": "image/png"}]}))),
729 _ => Err("connection closed".into()),
730 }
731 }
732 }
733
734 fn plugin() -> PluginRef {
735 PluginRef { id: "linear".into(), name: "Linear".into(), dir: PathBuf::from("/p"), network: false, env: BTreeMap::new() }
736 }
737
738 #[test]
739 fn adapts_server_tools() {
740 let fake = Arc::new(Fake { calls: Mutex::new(Vec::new()) });
741 let mut names = ToolNames::new(["linear__get-issue"]);
742 let tools = connector_tools(&plugin(), false, fake.clone(), &mut names).unwrap();
743 let specs: Vec<String> = tools.iter().map(|t| t.spec().name).collect();
744 assert_eq!(specs, ["linear__get-issue_2", "linear__create_issue", "linear__snapshot"]);
745 assert_eq!(tools[2].spec().parameters, json!({"type": "object", "properties": {}}));
746 assert_eq!(tools[1].spec().description, "Create issue");
747 assert_eq!(tools[1].title(&json!({})), "Used Linear: Create issue");
748 assert_eq!(tools[0].access(&json!({})), Access::Network);
749 assert!(tools[0].connector_tool().read_only() && !tools[0].connector_tool().needs_approval(false));
750 assert!(tools[1].connector_tool().needs_approval(false));
751
752 let ws = std::env::temp_dir().join(format!("byob-plugins-conn-{}", std::process::id()));
753 let _ = fs::remove_dir_all(&ws);
754 fs::create_dir_all(&ws).unwrap();
755 let cancel = AtomicBool::new(false);
756 let ctx = ToolContext::new(&ws, &cancel);
757 let out = tools[0].run(&json!("not an object"), &ctx);
758 assert_eq!(out.text, "Issue 1\n[resource x://1]");
759 assert!(!out.failed);
760 assert_eq!(fake.calls.lock().unwrap()[0], ("get-issue".to_string(), json!({})));
761 let out = tools[1].run(&json!({"title": "x"}), &ctx);
762 assert!(out.failed && out.text == "Linear couldn't do that: connection closed");
763 let out = tools[2].run(&json!({}), &ctx);
764 assert_eq!(out.text, "[image saved as linear-1.png]");
765 assert_eq!(fs::read(ws.join("linear-1.png")).unwrap(), b"hi");
766 assert_eq!(out.changes, [FileChange::Created(ws.join("linear-1.png"))]);
767 let again = tools[2].run(&json!({}), &ctx);
768 assert_eq!(again.text, "[image saved as linear-1-1.png]");
769 let _ = fs::remove_dir_all(&ws);
770 }
771
772 #[test]
773 fn local_servers_execute() {
774 let fake = Arc::new(Fake { calls: Mutex::new(Vec::new()) });
775 let tools = connector_tools(&plugin(), true, fake, &mut ToolNames::default()).unwrap();
776 assert_eq!(tools[0].access(&json!({})), Access::Execute);
777 }
778
779 #[test]
780 fn read_only_names() {
781 for n in ["get_issue", "listFiles", "search", "fetch-page", "get_order_status"] {
782 assert_eq!(name_reads_only(n), n != "get_order_status", "{n}");
783 }
784 for n in ["query", "getaway", "get_or_create", "list_and_delete", "create_issue"] {
785 assert!(!name_reads_only(n), "{n}");
786 }
787 }
788
789 #[test]
790 fn base64() {
791 assert_eq!(base64_decode("aGVsbG8gd29ybGQ=").unwrap(), b"hello world");
792 assert_eq!(base64_decode("aGVsbG8\ngd29ybGQ").unwrap(), b"hello world");
793 assert!(base64_decode("!!").is_none());
794 }
795
796 #[test]
797 fn pasted_configs() {
798 let d = parse_connector("", r#"{"mcpServers": {"memory": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-memory"]}, "docs": {"url": "https://example.com/mcp"}}}"#, "").unwrap();
799 assert_eq!(d.id, "docs");
800 assert_eq!(d.servers.len(), 2);
801 assert!(!d.online());
802 d.check().unwrap();
803
804 let d = parse_connector("", r#"{"servers": {"gh": {"type": "http", "url": "https://api.githubcopilot.com/mcp/"}}}"#, "").unwrap();
805 assert!(d.online());
806
807 let d = parse_connector("", "https://mcp.linear.app/mcp", "Authorization: Bearer abc\n").unwrap();
808 assert_eq!((d.id.as_str(), d.name.as_str()), ("linear", "Linear"));
809 assert_eq!(d.secrets["HEADER_AUTHORIZATION"], "Bearer abc");
810 let m = d.manifest_json();
811 assert_eq!(m["mcpServers"]["linear"]["headers"]["Authorization"], "${HEADER_AUTHORIZATION}");
812 assert_eq!(m["settings"][0]["label"], "Authorization");
813 assert_eq!(m["description"], "Online connector: mcp.linear.app");
814 d.check().unwrap();
815
816 let d = parse_connector("", "npx -y '@scope/my server' ~/My\\ Notes", "API_KEY=abc").unwrap();
817 assert_eq!(d.name, "My Notes");
818 match &d.servers["my-notes"] {
819 McpServerConfig::Stdio { command, args, .. } => {
820 assert_eq!((command.as_str(), args.as_slice()), ("npx", &["-y".to_string(), "@scope/my server".into(), "~/My Notes".into()][..]))
821 }
822 other => panic!("{other:?}"),
823 }
824 d.check().unwrap();
825
826 assert_eq!(parse_connector("", "{nope", "").unwrap_err(), "That config isn't valid JSON.");
827 assert_eq!(parse_connector("", "{}", "").unwrap_err(), "No servers found in that config.");
828 assert_eq!(parse_connector("", " ", "").unwrap_err(), "Enter the command that starts the connector.");
829 assert!(parse_connector("", r#"{"mcpServers": {"x": {"url": "ftp://x"}}}"#, "").unwrap_err().contains("must start with https://"));
830 assert!(parse_connector("", "server", "lower=1").unwrap_err().contains("UPPER_CASE"));
831 }
832
833 #[test]
834 fn url_names() {
835 assert_eq!(name_from_url("https://mcp.linear.app/mcp"), "Linear");
836 assert_eq!(name_from_url("http://localhost:3000/mcp"), "Local app");
837 assert_eq!(name_from_url("http://127.0.0.1/mcp"), "Local app");
838 assert_eq!(name_from_url("https://notion.com"), "Notion");
839 }
840
841 #[test]
842 fn an_app_result_stays_under_its_cap_however_many_parts() {
843 let max = 16 * 1024;
844 let parts: Vec<Value> = (0..20).map(|i| json!({"type": "text", "text": format!("{i}\"\n{}", "é".repeat(5000))})).collect();
845 let r = ConnectorResult::from_mcp(&json!({"content": parts, "structuredContent": {"n": 1}}));
846 let out = r.for_app(max);
847 assert!(out.to_string().len() <= max, "{} bytes", out.to_string().len());
848 let first = out["content"][0]["text"].as_str().unwrap();
849 assert!(first.starts_with("0\"\n") && first.len() > 1000, "{first:.20}");
850 assert_eq!(out["structuredContent"], json!({"n": 1}));
851 let small = ConnectorResult::from_mcp(&json!({"content": [{"type": "text", "text": "hi"}]}));
853 assert_eq!(small.for_app(max)["content"][0]["text"], "hi");
854 }
855}