//! App-owned subagents, the way T3 Code does them: the agent calls //! `delegate_task`, and the app starts a child thread for the task, shows it //! working and hands its result back. Any agent can run any task. //! //! The tools reach each agent its own way. Claude and Pi call an MCP server //! the app runs on loopback: Claude through `--mcp-config`, Pi through an //! extension the app writes and loads with `--extension`. That extension //! also switches off Pi's other subagent tools before each run. jcode runs //! them as session tools registered over its API, with its swarm switched //! off. Nothing needs installing or setting up. //! //! Each thread's agent gets a token of its own, which tells the server who's //! calling. Calls go to the workspace on the UI thread, which answers them. use std::{ collections::HashMap, io::{BufRead, BufReader, Read, Write}, net::{TcpListener, TcpStream}, path::PathBuf, sync::{Mutex, OnceLock}, thread, time::Duration, }; use serde_json::{Value, json}; /// The MCP server's name, as Claude's tool names carry it. pub const SERVER: &str = "vectrum-code"; pub const DELEGATE: &str = "delegate_task"; pub const TASK_STATUS: &str = "task_status"; pub const TASK_CANCEL: &str = "task_cancel"; pub const CAPABILITIES: &str = "orchestrator_capabilities"; pub const DEVICE_LIST: &str = "device_list"; pub const DEVICE_OPEN: &str = "device_open"; pub const DEVICE_SCREENSHOT: &str = "device_screenshot"; pub const DEVICE_CLOSE: &str = "device_close"; /// Which of the app's tools a thread's agent gets. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Caps { /// The subagent tools; subagents' own threads don't get them. pub delegate: bool, /// The `device_*` tools, when agent device access is on. pub devices: bool, } impl Caps { pub fn any(self) -> bool { self.delegate || self.devices } } /// How the message that hands a subagent's result to its parent starts. pub const RESULT_PREFIX: &str = "[Vectrum Code] Delegated task"; /// The environment that tells the Pi extension where the server is. const URL_ENV: &str = "VECTRUM_CODE_MCP_URL"; const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN"; /// What the tools are for, for agents whose instructions the app adds to: /// the MCP server's instructions (Claude), the system prompt (Pi) and a /// reminder with the first message (jcode). pub const INSTRUCTIONS: &str = "\ ## Vectrum Code subagents Vectrum Code runs this conversation and offers app-owned subagents through its `delegate_task`, `task_status`, `task_cancel` and `orchestrator_capabilities` tools. Tool names may carry a prefix, such as `mcp__vectrum-code__delegate_task`; they work the same. - `delegate_task` starts a child agent of this thread, working in the same folder. It sees only the task you give it, none of this conversation, so make the task self-contained: the goal, the files and facts that matter, and what to report back. The user watches it work in Vectrum Code's subagents panel. - The child runs this thread's agent and model unless `target` names others. `orchestrator_capabilities` lists every agent (provider instance) and model the app has, so a task can go to another agent entirely. - Use `delegate_task` for subagent work; other subagent tools may be switched off. For independent pieces of work, start several tasks so they run in parallel. - `mode` defaults to `async`: the call returns a `taskId` at once, and the child's result arrives in this thread as a message once it finishes. Carry on with other work, or end your turn instead of polling. Use `task_status` only when you need a result mid-turn. `mode: \"wait\"` blocks until the child is done or `timeoutMs` runs out; the child keeps working after a timeout. - `task_cancel` stops a task. For a further round of work, call `delegate_task` again with the full context: the brief, what came back and what's still open."; /// What the app's tools are for, for a thread with these tools. pub fn instructions(caps: Caps) -> String { let mut parts = Vec::new(); if caps.delegate { parts.push(INSTRUCTIONS); } if caps.devices { parts.push(crate::device::agent::INSTRUCTIONS); } parts.join("\n\n") } /// The tools a thread with these capabilities gets, as MCP describes tools. pub fn tool_definitions(caps: Caps) -> Value { let mut tools = Vec::new(); if caps.delegate { tools.extend(delegation_tools().as_array().cloned().unwrap_or_default()); } if caps.devices { tools.extend(device_tools().as_array().cloned().unwrap_or_default()); } Value::Array(tools) } /// The device tools, deliberately few: lifecycle, the user's view and one /// image. Driving the device happens through the agent-device CLI. fn device_tools() -> Value { json!([ { "name": DEVICE_LIST, "description": "List iOS Simulators and Android Emulators on this machine, which platforms it can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", "inputSchema": { "type": "object", "properties": {"hostId": {"type": "string", "description": "Limit to one device host. Defaults to all hosts."}} }, "annotations": {"title": "List devices", "readOnlyHint": true, "idempotentHint": true} }, { "name": DEVICE_OPEN, "description": "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", "inputSchema": { "type": "object", "properties": { "deviceId": {"type": "string", "description": "Simulator udid or emulator serial from device_list. Omit to use the booted device for the platform, or the most recently used one."}, "platform": {"type": "string", "enum": ["ios", "android"], "description": "Required when deviceId is omitted and both platforms are available."}, "hostId": {"type": "string", "description": "Device host from device_list. Defaults to local."} } }, "annotations": {"title": "Open device", "readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true} }, { "name": DEVICE_SCREENSHOT, "description": "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", "inputSchema": { "type": "object", "properties": { "deviceId": {"type": "string", "description": "Device from device_list. Omit to use the device most recently opened in this thread."}, "hostId": {"type": "string"} } }, "annotations": {"title": "Screenshot device", "readOnlyHint": true, "idempotentHint": true, "openWorldHint": true} }, { "name": DEVICE_CLOSE, "description": "Remove a device from this thread's Device panel. Pass shutdown=true to also power the simulator or emulator off.", "inputSchema": { "type": "object", "properties": { "deviceId": {"type": "string", "description": "Device to close. Omit to close every device in this thread."}, "hostId": {"type": "string"}, "shutdown": {"type": "boolean", "description": "Also power the simulator or emulator off. Defaults to false."} } }, "annotations": {"title": "Close device", "readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true} } ]) } /// The subagent tools, as MCP describes tools. fn delegation_tools() -> Value { json!([ { "name": DELEGATE, "description": "Delegate one task to a subagent: a child agent of this thread that Vectrum Code runs in a thread of its own, in this thread's working folder. It gets only the task text, not this conversation, so make it self-contained. The provider instance and model default to this thread's; orchestrator_capabilities lists the others. mode 'async' (the default) returns a taskId at once, and the result arrives in this thread as a message when the child finishes; end your turn rather than polling. mode 'wait' blocks until the child finishes or timeoutMs passes (the child carries on after a timeout; its result then arrives as a message).", "inputSchema": { "type": "object", "properties": { "task": {"type": "string", "description": "The complete, self-contained task for the child agent."}, "title": {"type": "string", "description": "A short title for the subagent, shown to the user."}, "target": { "type": "object", "description": "Which agent and model run the task. Omitted fields inherit this thread's.", "properties": { "providerInstanceId": {"type": "string", "description": "A provider instance id from orchestrator_capabilities."}, "model": {"type": "string", "description": "A model id that instance offers."} } }, "mode": {"type": "string", "enum": ["async", "wait"], "description": "async (default) returns at once; wait blocks for the result."}, "timeoutMs": {"type": "number", "description": "How long mode 'wait' waits. The child isn't stopped when it runs out."} }, "required": ["task"] }, "annotations": {"title": "Delegate a task", "destructiveHint": true, "openWorldHint": true} }, { "name": TASK_STATUS, "description": "Where a delegated task stands: running, completed, failed or cancelled, and once it's done, its result.", "inputSchema": { "type": "object", "properties": {"taskId": {"type": "string"}}, "required": ["taskId"] }, "annotations": {"title": "Get a task's status", "readOnlyHint": true} }, { "name": TASK_CANCEL, "description": "Stop a delegated task that's still running. Its result so far stays readable with task_status.", "inputSchema": { "type": "object", "properties": {"taskId": {"type": "string"}, "reason": {"type": "string"}}, "required": ["taskId"] }, "annotations": {"title": "Cancel a task", "destructiveHint": true} }, { "name": CAPABILITIES, "description": "The agents (provider instances) and models Vectrum Code can run a delegated task with, and which ones this thread uses.", "inputSchema": {"type": "object", "properties": {}}, "annotations": {"title": "List agents and models", "readOnlyHint": true} } ]) } fn bare(name: &str) -> &str { name.strip_prefix("mcp__vectrum-code__").or_else(|| name.strip_prefix("mcp__vectrum_code__")).unwrap_or(name) } /// Whether a tool call, by name, is one of the app's subagent tools: plain /// for Pi and jcode, prefixed for Claude (`mcp__vectrum-code__delegate_task`). pub fn own_tool(name: &str) -> Option<&'static str> { let bare = bare(name); [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES].into_iter().find(|tool| *tool == bare) } /// Whether a tool call, by name, is one of the app's device tools. pub fn device_tool(name: &str) -> Option<&'static str> { let bare = bare(name); [DEVICE_LIST, DEVICE_OPEN, DEVICE_SCREENSHOT, DEVICE_CLOSE].into_iter().find(|tool| *tool == bare) } /// A call from an agent, for the workspace to answer. pub struct ToolRequest { /// The calling thread's id. pub thread: String, pub tool: String, pub args: Value, /// Takes the answer: the result, or what went wrong. pub reply: smol::channel::Sender>, } struct Server { url: String, /// Thread id by token, and token by thread id. tokens: Mutex<(HashMap, HashMap)>, /// Each thread's tools, as its agent was started with. caps: Mutex>, } static LISTENER: OnceLock> = OnceLock::new(); static REQUESTS: OnceLock<(smol::channel::Sender, smol::channel::Receiver)> = OnceLock::new(); fn channel() -> &'static (smol::channel::Sender, smol::channel::Receiver) { REQUESTS.get_or_init(smol::channel::unbounded) } /// The calls agents make, for the workspace to answer. pub fn requests() -> smol::channel::Receiver { channel().1.clone() } /// Starts the server the first time it's needed. `None` if it can't listen. fn server() -> Option<&'static Server> { LISTENER .get_or_init(|| { let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?; let port = listener.local_addr().ok()?.port(); thread::Builder::new() .name("mcp-listener".into()) .spawn(move || { for stream in listener.incoming() { let Ok(stream) = stream else { continue }; let _ = thread::Builder::new().name("mcp-connection".into()).spawn(move || serve(stream)); } }) .ok()?; Some(Server { url: format!("http://127.0.0.1:{port}/mcp"), tokens: Mutex::default(), caps: Mutex::default() }) }) .as_ref() } /// The server's address and the token a thread's agent calls it with. pub fn credentials(thread: &str) -> Option<(String, String)> { let server = server()?; let mut tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let token = match tokens.1.get(thread) { Some(token) => token.clone(), None => { let token = crate::remote::new_key(); tokens.0.insert(token.clone(), thread.to_string()); tokens.1.insert(thread.to_string(), token.clone()); token } }; Some((server.url.clone(), token)) } /// Records which tools a thread's agent gets, as it starts. pub fn set_caps(thread: &str, caps: Caps) { if let Some(server) = server() { server.caps.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(thread.to_string(), caps); } } fn caps_for(thread: &str) -> Caps { let Some(server) = server() else { return Caps::default() }; // A thread nobody registered (an older agent) has the subagent tools, as before. server.caps.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(thread).copied().unwrap_or(Caps { delegate: true, devices: false }) } fn thread_for(token: &str) -> Option { let server = server()?; let tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); tokens.0.get(token).cloned() } /// Claude's `--mcp-config` for a thread. pub fn claude_config(thread: &str) -> Option { let (url, token) = credentials(thread)?; let config = json!({ "mcpServers": { SERVER: { "type": "http", "url": url, "headers": {"Authorization": format!("Bearer {token}")}, // Long enough for a `mode: "wait"` call. "timeout": 3_900_000, } } }); Some(config.to_string()) } /// Answers one connection's requests, one at a time, for as long as it stays /// open. Plain HTTP/1.1 with JSON replies: all of MCP's streamable HTTP /// transport a tools-only server needs. fn serve(stream: TcpStream) { let _ = stream.set_nodelay(true); let Ok(mut writer) = stream.try_clone() else { return }; let mut reader = BufReader::new(stream); loop { let mut request_line = String::new(); match reader.read_line(&mut request_line) { Ok(0) | Err(_) => return, Ok(_) => {} } let method = request_line.split_whitespace().next().unwrap_or_default().to_string(); let mut length = 0usize; let mut token = None; loop { let mut line = String::new(); if reader.read_line(&mut line).unwrap_or(0) == 0 { return; } let line = line.trim_end(); if line.is_empty() { break; } let Some((name, value)) = line.split_once(':') else { continue }; let value = value.trim(); match name.trim().to_ascii_lowercase().as_str() { "content-length" => length = value.parse().unwrap_or(0), "authorization" => token = value.strip_prefix("Bearer ").map(str::to_string), _ => {} } } let mut body = vec![0; length.min(16 << 20)]; if reader.read_exact(&mut body).is_err() { return; } let (status, reply) = if method != "POST" { // No server-sent event stream: everything's answered in the reply. ("405 Method Not Allowed", None) } else { match token.as_deref().and_then(thread_for) { None => ("401 Unauthorized", Some(json!({"error": "unknown token"}))), Some(thread) => match serde_json::from_slice::(&body) { Ok(message) => match handle(&thread, &message) { Some(reply) => ("200 OK", Some(reply)), // A notification. None => ("202 Accepted", None), }, Err(err) => ("400 Bad Request", Some(json!({"error": err.to_string()}))), }, } }; let body = reply.map(|reply| reply.to_string()).unwrap_or_default(); let head = format!( "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len() ); if writer.write_all(head.as_bytes()).and_then(|_| writer.write_all(body.as_bytes())).is_err() { return; } let _ = writer.flush(); } } /// Answers one JSON-RPC message; `None` for a notification. fn handle(thread: &str, message: &Value) -> Option { let id = message.get("id").cloned()?; let params = message.get("params").cloned().unwrap_or(Value::Null); let caps = caps_for(thread); let result = match message.get("method").and_then(Value::as_str).unwrap_or_default() { "initialize" => Ok(json!({ "protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"), "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": SERVER, "version": env!("CARGO_PKG_VERSION")}, "instructions": instructions(caps), })), "ping" => Ok(json!({})), "tools/list" => Ok(json!({"tools": tool_definitions(caps)})), "tools/call" => { let tool = params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); let args = params.get("arguments").cloned().unwrap_or_else(|| json!({})); Ok(tool_result(call(thread, tool, args))) } method => Err(json!({"code": -32601, "message": format!("unknown method {method}")})), }; Some(match result { Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}), }) } /// Hands a call to the workspace and waits for its answer. fn call(thread: &str, tool: String, args: Value) -> Result { let (reply, answer) = smol::channel::bounded(1); let request = ToolRequest { thread: thread.to_string(), tool, args, reply }; channel().0.send_blocking(request).map_err(|_| "Vectrum Code is shutting down.".to_string())?; answer.recv_blocking().unwrap_or_else(|_| Err("Vectrum Code dropped the call.".into())) } /// An answer as an MCP tool result: the data as text for the model, and /// structured for scripts. A device screenshot goes as an image. pub fn tool_result(outcome: Result) -> Value { if let Ok(mut value) = outcome.clone() && let Some(data) = value.pointer_mut("/screenshot/data").map(Value::take).and_then(|data| data.as_str().map(str::to_string)) { if let Some(screenshot) = value.get_mut("screenshot").and_then(Value::as_object_mut) { screenshot.remove("data"); } let mime = value.pointer("/screenshot/mimeType").and_then(Value::as_str).unwrap_or("image/png").to_string(); return json!({ "content": [ {"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default()}, {"type": "image", "data": data, "mimeType": mime}, ], "structuredContent": value, }); } match outcome { Ok(value) => json!({ "content": [{"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default()}], "structuredContent": value, }), Err(error) => json!({"content": [{"type": "text", "text": error}], "isError": true}), } } /// The Pi extension: it bridges the app's tools into Pi (when the thread /// may delegate), adds the instructions to the system prompt, and switches /// off other subagent tools before every run. const PI_EXTENSION: &str = r#"// Written by Vectrum Code, which loads it into the Pi agents it runs. // Changes are overwritten. const URL = process.env.__URL_ENV__; const TOKEN = process.env.__TOKEN_ENV__; // The thread's tools and instructions come from the app when it starts. let TOOLS = []; let INSTRUCTIONS = ""; const OWN = new Set(__TOOL_NAMES__); // Tools that start agents of their own: Pi's subagent extension and the like. const OTHER_SUBAGENTS = /subagent|^(task|tasks|agent|agents|swarm|spawn_agent|spawn_agents|delegate)$/i; async function schema(inputSchema) { for (const name of ["typebox", "@sinclair/typebox"]) { try { const { Type } = await import(name); if (typeof Type?.Unsafe === "function") return Type.Unsafe(inputSchema); } catch {} } return inputSchema; } let nextId = 1; async function rpc(method, params, signal) { const response = await fetch(URL, { method: "POST", headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${TOKEN}` }, body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }), signal, }); const body = await response.text(); if (!response.ok) throw new Error(`Vectrum Code answered ${response.status}: ${body.slice(0, 400)}`); const message = JSON.parse(body); if (message.error) throw new Error(message.error.message ?? "Vectrum Code returned an error"); return message.result; } const callTool = (name, args, signal) => rpc("tools/call", { name, arguments: args ?? {} }, signal); export default async function vectrumCode(pi) { // Subagents go through the app, so other subagent tools stay off. Checked // before every run, since extensions can turn tools back on. const switchOffOthers = () => { if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return; const active = pi.getActiveTools(); const kept = active.filter((name) => OWN.has(name) || !OTHER_SUBAGENTS.test(name)); if (kept.length !== active.length) pi.setActiveTools(kept); }; pi.on("session_start", switchOffOthers); pi.on("before_agent_start", (event) => { switchOffOthers(); if (URL && TOKEN && INSTRUCTIONS) return { systemPrompt: event.systemPrompt + "\n\n" + INSTRUCTIONS }; }); if (!URL || !TOKEN) return; try { INSTRUCTIONS = (await rpc("initialize", { protocolVersion: "2025-06-18" }))?.instructions ?? ""; TOOLS = (await rpc("tools/list", {}))?.tools ?? []; } catch {} for (const tool of TOOLS) { pi.registerTool({ name: tool.name, label: tool.annotations?.title ?? tool.name, description: tool.description, parameters: await schema(tool.inputSchema), async execute(_toolCallId, params, signal) { const result = await callTool(tool.name, params, signal); const parts = result?.content ?? []; const text = parts.filter((part) => part?.type === "text").map((part) => part.text).join("\n"); if (result?.isError) throw new Error(text || `${tool.name} failed`); const images = parts.filter((part) => part?.type === "image").map((part) => ({ type: "image", data: part.data, mimeType: part.mimeType })); return { content: [{ type: "text", text }, ...images], details: result?.structuredContent }; }, }); } } "#; /// Writes the Pi extension where Pi can load it, and returns its path. fn pi_extension() -> Option { static PATH: OnceLock> = OnceLock::new(); PATH.get_or_init(|| { let names: Vec = tool_definitions(Caps { delegate: true, devices: true }) .as_array() .into_iter() .flatten() .map(|tool| tool["name"].clone()) .collect(); let source = PI_EXTENSION .replace("__URL_ENV__", URL_ENV) .replace("__TOKEN_ENV__", TOKEN_ENV) .replace("__TOOL_NAMES__", &Value::Array(names).to_string()); let path = crate::settings::data_dir()?.join("pi").join("vectrum-code.js"); std::fs::create_dir_all(path.parent()?).ok()?; if std::fs::read_to_string(&path).ok().as_deref() != Some(source.as_str()) { std::fs::write(&path, source).ok()?; } Some(path) }) .clone() } /// Loads the extension into a Pi command. A thread with tools gets them; /// without any (a subagent's own thread) Pi only has its other subagent /// tools turned off. pub fn prepare_pi(command: &mut std::process::Command, thread: &str, caps: Caps) { let Some(extension) = pi_extension() else { return }; command.arg("--extension").arg(extension); if let Some((url, token)) = caps.any().then(|| credentials(thread)).flatten() { command.env(URL_ENV, url).env(TOKEN_ENV, token); } else { command.env_remove(URL_ENV).env_remove(TOKEN_ENV); } } /// jcode's session tool configuration: the app's tools the thread gets, /// and jcode's own agent-spawning tools off. pub fn jcode_tools(caps: Caps) -> Value { let custom: Vec = tool_definitions(caps) .as_array() .into_iter() .flatten() .map(|tool| json!({"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]})) .collect(); // `schedule` can spawn a session too. json!({"disabled": ["swarm", "schedule"], "custom": custom}) } /// How long a call's `mode: "wait"` may block before the agent's own client /// gives up on it: jcode allows a session tool two minutes, and Node's /// fetch (Pi) waits five for a reply's headers. pub fn wait_limit(provider: crate::models::Provider) -> Duration { match provider { crate::models::Provider::Claude => Duration::from_secs(30 * 60), crate::models::Provider::Pi => Duration::from_secs(270), crate::models::Provider::Jcode => Duration::from_secs(100), } } #[cfg(test)] mod tests { use serde_json::{Value, json}; use super::{DELEGATE, credentials, own_tool, requests}; fn post(url: &str, token: &str, body: Value) -> (u16, String) { let response = ureq::post(url).set("Authorization", &format!("Bearer {token}")).set("Content-Type", "application/json").send_string(&body.to_string()); match response { Ok(response) => (response.status(), response.into_string().unwrap_or_default()), Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()), Err(err) => panic!("{err}"), } } #[::core::prelude::v1::test] fn serves_mcp_to_the_threads_it_knows() { let (url, token) = credentials("thread-a").expect("listens"); assert_eq!(credentials("thread-a").unwrap().1, token, "a thread keeps its token"); assert_ne!(credentials("thread-b").unwrap().1, token); let (status, _) = post(&url, "nope", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})); assert_eq!(status, 401); let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}})); assert_eq!(status, 200); let reply: Value = serde_json::from_str(&body).unwrap(); assert_eq!(reply["result"]["protocolVersion"], "2025-03-26"); assert!(reply["result"]["instructions"].as_str().unwrap().contains("delegate_task")); let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); assert_eq!((status, body.as_str()), (202, "")); super::set_caps("thread-a", super::Caps { delegate: true, devices: false }); let (_, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})); let reply: Value = serde_json::from_str(&body).unwrap(); assert_eq!(reply["result"]["tools"][0]["name"], DELEGATE); // The workspace's side, answering with who called and what. let incoming = requests(); std::thread::spawn(move || { while let Ok(request) = incoming.recv_blocking() { let outcome = match request.tool.as_str() { "task_status" => Ok(json!({"thread": request.thread, "args": request.args})), _ => Err("no such task".to_string()), }; let _ = request.reply.send_blocking(outcome); } }); let call = |name: &str| { let body = json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": name, "arguments": {"taskId": "t1"}}}); let (_, body) = post(&url, &token, body); serde_json::from_str::(&body).unwrap()["result"].clone() }; let result = call("task_status"); assert_eq!(result["structuredContent"], json!({"thread": "thread-a", "args": {"taskId": "t1"}})); assert!(result.get("isError").is_none()); let result = call("task_cancel"); assert_eq!(result["isError"], true); assert_eq!(result["content"][0]["text"], "no such task"); } #[::core::prelude::v1::test] fn recognizes_its_tools_under_any_prefix() { assert_eq!(own_tool("delegate_task"), Some(DELEGATE)); assert_eq!(own_tool("mcp__vectrum-code__delegate_task"), Some(DELEGATE)); assert_eq!(own_tool("mcp__vectrum_code__task_status"), Some("task_status")); assert_eq!(own_tool("mcp__other__delegate_task"), None); assert_eq!(own_tool("Task"), None); } }