Skip to main content

byob_plugins/
connectors.rs

1//! Connectors: MCP servers a plugin declares (or the user pastes), adapted into
2//! [`byob_tools::Tool`]s.
3//!
4//! The MCP client itself lives in `byob-mcp`; this module only needs the small
5//! [`Connector`] trait, so the adapter is testable with a fake and the client can be
6//! swapped. [`ConnectorTool::from_mcp`] and [`ConnectorResult::from_mcp`] turn MCP's
7//! `tools/list` entries and `tools/call` results (raw JSON) into these types.
8
9use 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
22/// Most tools taken from one server (a huge server would drown the model's context).
23pub const MAX_TOOLS_PER_SERVER: usize = 128;
24/// Most text returned to the model from one call.
25pub const MAX_RESULT_CHARS: usize = 100_000;
26/// Biggest image saved from one result.
27const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
28const MAX_DESCRIPTION_CHARS: usize = 1000;
29
30/// A running MCP server, as far as tools are concerned. Implemented by `byob-mcp`'s client.
31pub trait Connector: Send + Sync {
32    /// The server's tools (`tools/list`, all pages).
33    fn list_tools(&self) -> Result<Vec<ConnectorTool>, String>;
34    /// Call one tool (`tools/call`). Long calls should poll `ctx.cancelled()`.
35    /// `Err` is a transport/protocol failure; a tool-level failure is
36    /// `Ok(ConnectorResult { is_error: true, .. })`.
37    fn call_tool(&self, name: &str, args: &Value, ctx: &ToolContext<'_>) -> Result<ConnectorResult, String>;
38    /// Read one resource (`resources/read`): the result as sent (`{contents: [...]}`).
39    fn read_resource(&self, _uri: &str) -> Result<Value, String> {
40        Err("this server can't read resources".into())
41    }
42}
43
44/// One tool of an MCP server.
45#[derive(Debug, Clone, Default, PartialEq)]
46pub struct ConnectorTool {
47    pub name: String,
48    /// `annotations.title`, else `title`.
49    pub title: Option<String>,
50    pub description: Option<String>,
51    /// JSON Schema for the arguments (`inputSchema`).
52    pub input_schema: Value,
53    pub read_only_hint: Option<bool>,
54    pub destructive_hint: Option<bool>,
55    /// MCP Apps: the `ui://` resource that shows its results (`_meta.ui.resourceUri`).
56    pub resource_uri: Option<String>,
57    /// MCP Apps visibility: hidden from the model (`visibility` without `"model"`).
58    pub app_only: bool,
59    /// Not callable from the server's apps (`visibility` without `"app"`).
60    pub model_only: bool,
61    /// The entry as the server listed it (null if unknown).
62    pub raw: Value,
63}
64
65impl ConnectorTool {
66    /// From one entry of MCP `tools/list` → `tools`. `None` without a usable name.
67    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    /// What people see: the title, else the name.
94    pub fn label(&self) -> &str {
95        self.title.as_deref().unwrap_or(&self.name)
96    }
97
98    /// Only reads: says so (`readOnlyHint`), or gives no hints and its name only reads
99    /// (`get_issue`, `searchDocs`). MCP's default for a tool without hints is "may change
100    /// things".
101    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    /// Should the user approve each call? Hob's rule: everything that isn't read-only,
106    /// unless it says it isn't destructive and nothing untrusted entered the task.
107    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
119/// `get_issue`, `listFiles`, `search` read; `get_or_create`, `query` don't count.
120/// ("query" isn't a read verb: database servers run any SQL through it.)
121pub 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    // a change word anywhere not glued to a digit (hob: `(?:^|[_\-.]|[a-z])word`, /i)
130    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/// One piece of a tool result.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum ConnectorContent {
139    Text(String),
140    /// Base64 image data.
141    Image {
142        data: String,
143        mime_type: String,
144    },
145    Resource {
146        uri: String,
147        text: Option<String>,
148    },
149    /// Something we don't show (audio, resource links…): its type.
150    Other(String),
151}
152
153/// A `tools/call` result.
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct ConnectorResult {
156    pub content: Vec<ConnectorContent>,
157    pub is_error: bool,
158    /// The `CallToolResult` as sent (null if unknown), for MCP Apps.
159    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    /// From an MCP `CallToolResult` JSON object. Unknown shapes are tolerated.
168    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    /// The `CallToolResult` for an MCP App (the raw result, else one made of the content),
194    /// at most `max` bytes of JSON: a larger one keeps only its text parts (cut to share half
195    /// of `max`) and `structuredContent` if that fits in the rest.
196    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        // one budget for every text part (as JSON, with its wrapper)
216        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
241/// Bytes `c` takes in a JSON string (at most).
242fn 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
250/// Largest `CallToolResult` kept on an MCP App's step view.
251const MAX_APP_RESULT: usize = 160 * 1024;
252
253/// One MCP server tool as a byob tool.
254pub struct ConnectorToolAdapter {
255    name: String,
256    plugin: PluginRef,
257    /// The server's key in the plugin's `mcpServers`.
258    server: String,
259    /// The server is a local program (stdio), not a URL.
260    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    /// The server's key in the plugin's `mcpServers` (named on MCP App views).
271    #[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    /// A local program (stdio) rather than a URL.
282    pub fn local(&self) -> bool {
283        self.local
284    }
285
286    pub fn connector(&self) -> &Arc<dyn Connector> {
287        &self.connector
288    }
289
290    /// The MCP App view for a call's result, when the tool has a UI.
291    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    /// The MCP tool behind this one (hints for the approval gate: [`ConnectorTool::needs_approval`]).
308    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    /// A local server is unsandboxed code on this computer (Execute); an online one only
332    /// reaches the network. Read-only hints are on [`Self::connector_tool`] for a finer gate.
333    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
354/// MCP result → text for the model; images become files in `dir`.
355fn 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
408/// Standard or URL-safe base64, padding and whitespace optional.
409pub 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
434/// List a server's tools and adapt them, named `<plugin>__<tool>` (made unique through
435/// `names`). `local`: the server is a program on this computer
436/// ([`McpServerConfig::is_local`]).
437pub 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
447/// [`connector_tools`] from a tool list fetched earlier (e.g. on a background thread right
448/// after connecting), so building the adapters does no I/O.
449///
450/// Tools only the server's apps may call (MCP Apps `visibility: ["app"]`) are left out: the
451/// model never sees them (the engine finds them in the server's tool list for app calls).
452pub 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
462/// [`connector_tools_from`] for server `server` of the plugin (its `mcpServers` key).
463pub 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/// A connector the user added by pasting a link, a command line, or a Claude / Cursor /
487/// VS Code style `{"mcpServers": …}` config. Turn it into a plugin with [`Self::manifest_json`].
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct ConnectorDraft {
490    /// Plugin id (a slug of the name).
491    pub id: String,
492    pub name: String,
493    pub servers: BTreeMap<String, McpServerConfig>,
494    /// Keys and values typed alongside (headers, env); store them as secret settings,
495    /// never in the plugin folder.
496    pub secrets: BTreeMap<String, String>,
497    /// Labels for `secrets` keys that came from headers.
498    pub labels: BTreeMap<String, String>,
499}
500
501/// `"https://mcp.linear.app/mcp"` → `"Linear"`.
502pub 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
519/// `npx -y '@a/b' My\ Folder` → `["npx", "-y", "@a/b", "My Folder"]`.
520pub 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
561/// Parse what the user pasted into "Add connector".
562/// `env`: `KEY=value` lines for commands, `Header: value` lines for links.
563pub 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
633/// An `mcpServers` entry in hob's JSON form.
634pub 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    /// Online-only: every server is a URL.
664    pub fn online(&self) -> bool {
665        self.servers.values().all(|c| !c.is_local())
666    }
667
668    /// The `byob-plugin.json` to write for this connector (already validated).
669    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    /// Check the generated manifest parses (it always should).
696    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        // a small one comes through whole
852        let small = ConnectorResult::from_mcp(&json!({"content": [{"type": "text", "text": "hi"}]}));
853        assert_eq!(small.for_app(max)["content"][0]["text"], "hi");
854    }
855}