Skip to main content

active_rpc/
tool.rs

1//! Tool handles: MCP sessions, HTTP/OpenAPI endpoints, local processes.
2//!
3//! Tools are capabilities like everything else, which means a tool handle
4//! inherits the same guarantees: no ambient authority, attenuation-only
5//! narrowing, budget accounting and drop notification.
6//!
7//! | Adapter | What it talks to |
8//! |---------|------------------|
9//! | [`McpSession`] | an MCP server, over an injected line transport (stdio or in-memory) |
10//! | [`HttpTool`] | an HTTP endpoint restricted to an allowlist of `METHOD path` pairs |
11//! | [`LocalProcess`] | a sandboxed child process: allowlisted program, no shell, scrubbed environment |
12//!
13//! Every adapter returns a [`Response::Value`] built from JSON, never a raw
14//! string dump, and every adapter refuses a call outside its policy with
15//! `rpc/tool-policy`.
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::io::{BufRead, BufReader, Read, Write};
19use std::net::TcpStream;
20use std::process::{Child, Command, Stdio};
21
22use serde_json::{json, Value as Json};
23
24use crate::cap::ToolPolicy;
25use crate::error::{Error, Result};
26use crate::kernel::Provider;
27use crate::value::Response;
28use crate::value::{Arg, Call, Value};
29
30/// Convert JSON into an RPC value.
31pub fn json_to_value(value: &Json) -> Value {
32    match value {
33        Json::Null => Value::Null,
34        Json::Bool(flag) => Value::Bool(*flag),
35        Json::Number(number) => {
36            if let Some(int) = number.as_i64() {
37                Value::Int(int)
38            } else {
39                Value::Float(number.as_f64().unwrap_or(0.0))
40            }
41        }
42        Json::String(text) => Value::Text(text.clone()),
43        Json::Array(items) => Value::List(items.iter().map(json_to_value).collect()),
44        Json::Object(entries) => Value::Map(
45            entries
46                .iter()
47                .map(|(key, value)| (key.clone(), json_to_value(value)))
48                .collect(),
49        ),
50    }
51}
52
53/// Convert an RPC value into JSON.
54pub fn value_to_json(value: &Value) -> Json {
55    match value {
56        Value::Null => Json::Null,
57        Value::Bool(flag) => Json::Bool(*flag),
58        Value::Int(number) => json!(number),
59        Value::Float(number) => json!(number),
60        Value::Text(text) => Json::String(text.clone()),
61        Value::List(items) => Json::Array(items.iter().map(value_to_json).collect()),
62        Value::Map(entries) => Json::Object(
63            entries
64                .iter()
65                .map(|(key, value)| (key.clone(), value_to_json(value)))
66                .collect(),
67        ),
68        Value::Secret => Json::String("***SECRET***".to_string()),
69        Value::Module(bytes) => json!({ "$module_bytes": bytes.len() }),
70        Value::Path(path) => Json::String(path.clone()),
71    }
72}
73
74// ---------------------------------------------------------------------------
75// MCP
76// ---------------------------------------------------------------------------
77
78/// A line-oriented JSON-RPC transport used by [`McpSession`].
79pub trait McpTransport {
80    /// Send one JSON line.
81    fn send_line(&mut self, line: &str) -> Result<()>;
82    /// Read one JSON line (blocking).
83    fn recv_line(&mut self) -> Result<String>;
84}
85
86/// An in-memory transport: requests are answered by a closure.
87pub struct MemoryTransport {
88    responses: BTreeMap<String, String>,
89    queue: Vec<String>,
90    sent: Vec<String>,
91}
92
93impl MemoryTransport {
94    /// Build from `method -> response` pairs.
95    pub fn new(responses: BTreeMap<String, String>) -> Self {
96        Self {
97            responses,
98            queue: Vec::new(),
99            sent: Vec::new(),
100        }
101    }
102
103    /// Lines sent by the client (tests).
104    pub fn sent(&self) -> &[String] {
105        &self.sent
106    }
107}
108
109impl McpTransport for MemoryTransport {
110    fn send_line(&mut self, line: &str) -> Result<()> {
111        self.sent.push(line.to_string());
112        let request: Json = serde_json::from_str(line).map_err(|_| Error::Provider {
113            code: "mcp/bad-json".to_string(),
114        })?;
115        let method = request.get("method").and_then(Json::as_str).unwrap_or("");
116        let id = request.get("id").cloned().unwrap_or(Json::Null);
117        let response = self.responses.get(method).cloned().unwrap_or_else(|| {
118            json!({
119                "jsonrpc": "2.0",
120                "id": id,
121                "error": { "code": -32601, "message": "method not found" }
122            })
123            .to_string()
124        });
125        self.queue.push(response);
126        Ok(())
127    }
128
129    fn recv_line(&mut self) -> Result<String> {
130        if self.queue.is_empty() {
131            return Err(Error::Provider {
132                code: "mcp/no-response".to_string(),
133            });
134        }
135        Ok(self.queue.remove(0))
136    }
137}
138
139/// A child-process MCP transport speaking JSON-RPC over stdin/stdout.
140pub struct StdioTransport {
141    child: Child,
142    stdin: std::process::ChildStdin,
143    stdout: BufReader<std::process::ChildStdout>,
144}
145
146impl StdioTransport {
147    /// Spawn an MCP server process (no shell, no inherited environment beyond
148    /// what the caller passes).
149    pub fn spawn(program: &str, args: &[&str], env: &BTreeMap<String, String>) -> Result<Self> {
150        let mut command = Command::new(program);
151        command
152            .args(args)
153            .stdin(Stdio::piped())
154            .stdout(Stdio::piped())
155            .stderr(Stdio::null())
156            .env_clear();
157        for (key, value) in env {
158            command.env(key, value);
159        }
160        let mut child = command.spawn().map_err(|error| Error::Provider {
161            code: format!("mcp/spawn:{error}"),
162        })?;
163        let stdin = child.stdin.take().ok_or(Error::Provider {
164            code: "mcp/stdin".to_string(),
165        })?;
166        let stdout = child.stdout.take().ok_or(Error::Provider {
167            code: "mcp/stdout".to_string(),
168        })?;
169        Ok(Self {
170            child,
171            stdin,
172            stdout: BufReader::new(stdout),
173        })
174    }
175}
176
177impl McpTransport for StdioTransport {
178    fn send_line(&mut self, line: &str) -> Result<()> {
179        self.stdin
180            .write_all(line.as_bytes())
181            .and_then(|_| self.stdin.write_all(b"\n"))
182            .and_then(|_| self.stdin.flush())
183            .map_err(|error| Error::Provider {
184                code: format!("mcp/write:{error}"),
185            })
186    }
187
188    fn recv_line(&mut self) -> Result<String> {
189        let mut line = String::new();
190        let read = self
191            .stdout
192            .read_line(&mut line)
193            .map_err(|error| Error::Provider {
194                code: format!("mcp/read:{error}"),
195            })?;
196        if read == 0 {
197            return Err(Error::Provider {
198                code: "mcp/eof".to_string(),
199            });
200        }
201        Ok(line.trim_end().to_string())
202    }
203}
204
205impl Drop for StdioTransport {
206    fn drop(&mut self) {
207        let _ = self.child.kill();
208        let _ = self.child.wait();
209    }
210}
211
212/// One tool exposed by an MCP server.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct McpToolDef {
215    /// Tool name.
216    pub name: String,
217    /// Free-form description.
218    pub description: String,
219}
220
221/// A provider backed by an MCP session.
222///
223/// `object` selects the tool index; `method` is the tool name. The session
224/// performs the real JSON-RPC handshake (`initialize`, `tools/list`) and then
225/// `tools/call` per invocation.
226pub struct McpSession<T: McpTransport> {
227    transport: T,
228    tools: Vec<McpToolDef>,
229    next_id: u64,
230    /// Tool invocations issued through this session (the handshake is not
231    /// counted).
232    pub calls: u64,
233}
234
235impl<T: McpTransport> McpSession<T> {
236    /// Perform the MCP handshake and discover tools.
237    pub fn handshake(transport: T) -> Result<Self> {
238        let mut session = McpSession {
239            transport,
240            tools: Vec::new(),
241            next_id: 0,
242            calls: 0,
243        };
244        let initialize = session.request(
245            "initialize",
246            json!({
247                "protocolVersion": "2025-06-18",
248                "capabilities": {},
249                "clientInfo": { "name": "active-rpc", "version": env!("CARGO_PKG_VERSION") }
250            }),
251        )?;
252        if initialize.get("error").is_some() {
253            return Err(Error::Provider {
254                code: "mcp/initialize".to_string(),
255            });
256        }
257        let list = session.request("tools/list", json!({}))?;
258        if let Some(items) = list
259            .get("result")
260            .and_then(|result| result.get("tools"))
261            .and_then(Json::as_array)
262        {
263            for item in items {
264                let name = item.get("name").and_then(Json::as_str).unwrap_or("");
265                if name.is_empty() {
266                    continue;
267                }
268                session.tools.push(McpToolDef {
269                    name: name.to_string(),
270                    description: item
271                        .get("description")
272                        .and_then(Json::as_str)
273                        .unwrap_or("")
274                        .to_string(),
275                });
276            }
277        }
278        Ok(session)
279    }
280
281    fn request(&mut self, method: &str, params: Json) -> Result<Json> {
282        self.next_id += 1;
283        let payload = json!({
284            "jsonrpc": "2.0",
285            "id": self.next_id,
286            "method": method,
287            "params": params,
288        });
289        self.transport.send_line(&payload.to_string())?;
290        let line = self.transport.recv_line()?;
291        serde_json::from_str(&line).map_err(|_| Error::Provider {
292            code: "mcp/bad-response".to_string(),
293        })
294    }
295
296    /// Discovered tools.
297    pub fn tools(&self) -> &[McpToolDef] {
298        &self.tools
299    }
300
301    /// A policy allowing exactly the discovered tools.
302    pub fn policy(&self) -> ToolPolicy {
303        let mut policy = ToolPolicy::default();
304        for tool in &self.tools {
305            policy.allowed.insert(tool.name.clone());
306        }
307        policy.max_arg_bytes = 1 << 20;
308        policy
309    }
310}
311
312impl<T: McpTransport> Provider for McpSession<T> {
313    fn name(&self) -> &'static str {
314        "mcp"
315    }
316
317    fn methods(&self) -> Vec<String> {
318        self.tools.iter().map(|tool| tool.name.clone()).collect()
319    }
320
321    fn mutating(&self, _method: &str) -> bool {
322        true
323    }
324
325    fn invoke(&mut self, object: u64, call: &Call) -> Result<Response> {
326        self.calls += 1;
327        let tool = self
328            .tools
329            .get(object as usize)
330            .ok_or(Error::InvalidArgument("unknown MCP tool"))?;
331        if tool.name != call.method {
332            return Err(Error::MethodNotGranted {
333                method: call.method.clone(),
334            });
335        }
336        let mut arguments = serde_json::Map::new();
337        for (index, arg) in call.args.iter().enumerate() {
338            if let Arg::Value(value) = arg {
339                arguments.insert(format!("arg{index}"), value_to_json(value));
340            }
341        }
342        let response = self.request(
343            "tools/call",
344            json!({ "name": tool.name, "arguments": Json::Object(arguments) }),
345        )?;
346        if let Some(error) = response.get("error") {
347            return Err(Error::Provider {
348                code: format!(
349                    "mcp/{}",
350                    error.get("code").and_then(Json::as_i64).unwrap_or(0)
351                ),
352            });
353        }
354        let result = response.get("result").cloned().unwrap_or(Json::Null);
355        Ok(Response::Value(json_to_value(&result)))
356    }
357}
358
359// ---------------------------------------------------------------------------
360// HTTP / OpenAPI
361// ---------------------------------------------------------------------------
362
363/// An HTTP endpoint restricted to an allowlist.
364///
365/// The allowlist is `(METHOD, path)` pairs. There is no redirect following, no
366/// chunked encoding, and the body is bounded by `max_body`. A URL outside the
367/// allowlist is `rpc/tool-policy`.
368#[derive(Debug)]
369pub struct HttpTool {
370    /// Base URL, e.g. `http://127.0.0.1:8080`.
371    pub base: String,
372    /// Allowed `(method, path)` pairs.
373    pub allow: BTreeSet<(String, String)>,
374    /// Maximum response bytes.
375    pub max_body: usize,
376    /// Maximum request bytes.
377    pub max_request: usize,
378    /// Calls served.
379    pub calls: u64,
380}
381
382impl HttpTool {
383    /// Build a tool for `base` with an allowlist.
384    pub fn new(base: impl Into<String>, allow: &[(&str, &str)]) -> Self {
385        Self {
386            base: base.into(),
387            allow: allow
388                .iter()
389                .map(|(method, path)| (method.to_string(), path.to_string()))
390                .collect(),
391            max_body: 1 << 20,
392            max_request: 1 << 20,
393            calls: 0,
394        }
395    }
396
397    /// Build a tool policy for a capability.
398    pub fn policy(&self) -> ToolPolicy {
399        ToolPolicy {
400            allowed: ["get", "post", "request"]
401                .iter()
402                .map(|method| method.to_string())
403                .collect(),
404            max_arg_bytes: self.max_request as u64,
405            allow_process: false,
406        }
407    }
408
409    fn perform(&self, method: &str, path: &str, body: &[u8]) -> Result<(u16, Vec<u8>)> {
410        let base = self
411            .base
412            .strip_prefix("http://")
413            .ok_or(Error::ToolPolicy("only http:// endpoints are supported"))?;
414        let mut stream = TcpStream::connect(base).map_err(|error| Error::Provider {
415            code: format!("http/connect:{error}"),
416        })?;
417        stream
418            .set_read_timeout(Some(std::time::Duration::from_secs(10)))
419            .ok();
420        let head = format!(
421            "{method} {path} HTTP/1.1\r\nhost: {base}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
422            body.len()
423        );
424        stream
425            .write_all(head.as_bytes())
426            .and_then(|_| stream.write_all(body))
427            .and_then(|_| stream.flush())
428            .map_err(|error| Error::Provider {
429                code: format!("http/write:{error}"),
430            })?;
431        let mut reader = BufReader::new(stream);
432        let mut status_line = String::new();
433        reader
434            .read_line(&mut status_line)
435            .map_err(|error| Error::Provider {
436                code: format!("http/read:{error}"),
437            })?;
438        let status: u16 = status_line
439            .split_whitespace()
440            .nth(1)
441            .and_then(|code| code.parse().ok())
442            .ok_or(Error::Provider {
443                code: "http/status".to_string(),
444            })?;
445        let mut content_length: Option<usize> = None;
446        loop {
447            let mut line = String::new();
448            let read = reader
449                .read_line(&mut line)
450                .map_err(|error| Error::Provider {
451                    code: format!("http/read:{error}"),
452                })?;
453            if read == 0 || line.trim().is_empty() {
454                break;
455            }
456            if let Some((name, value)) = line.split_once(':') {
457                if name.eq_ignore_ascii_case("content-length") {
458                    content_length = value.trim().parse().ok();
459                }
460            }
461        }
462        let mut body = Vec::new();
463        match content_length {
464            Some(length) => {
465                if length > self.max_body {
466                    return Err(Error::ToolPolicy("response too large"));
467                }
468                body.resize(length, 0);
469                reader
470                    .read_exact(&mut body)
471                    .map_err(|error| Error::Provider {
472                        code: format!("http/body:{error}"),
473                    })?;
474            }
475            None => {
476                let mut rest = Vec::new();
477                reader
478                    .take(self.max_body as u64 + 1)
479                    .read_to_end(&mut rest)
480                    .map_err(|error| Error::Provider {
481                        code: format!("http/body:{error}"),
482                    })?;
483                if rest.len() > self.max_body {
484                    return Err(Error::ToolPolicy("response too large"));
485                }
486                body = rest;
487            }
488        }
489        Ok((status, body))
490    }
491}
492
493impl Provider for HttpTool {
494    fn name(&self) -> &'static str {
495        "http"
496    }
497
498    fn methods(&self) -> Vec<String> {
499        ["get", "post", "request"]
500            .iter()
501            .map(|method| method.to_string())
502            .collect()
503    }
504
505    fn mutating(&self, method: &str) -> bool {
506        method != "get"
507    }
508
509    fn invoke(&mut self, _object: u64, call: &Call) -> Result<Response> {
510        self.calls += 1;
511        let method: String = match call.method.as_str() {
512            "get" => "GET".to_string(),
513            "post" => "POST".to_string(),
514            "request" => call
515                .args
516                .iter()
517                .find_map(|arg| match arg {
518                    Arg::Value(Value::Text(text)) => Some(text.to_uppercase()),
519                    _ => None,
520                })
521                .unwrap_or_else(|| "GET".to_string()),
522            other => {
523                return Err(Error::MethodNotGranted {
524                    method: other.to_string(),
525                })
526            }
527        };
528        let path = call
529            .args
530            .iter()
531            .find_map(|arg| match arg {
532                Arg::Value(Value::Text(text)) if text.starts_with('/') => Some(text.clone()),
533                Arg::Value(Value::Path(path)) => Some(path.clone()),
534                _ => None,
535            })
536            .ok_or(Error::InvalidArgument("an HTTP path argument is required"))?;
537        if !self.allow.contains(&(method.to_string(), path.clone())) {
538            return Err(Error::ToolPolicy("path is not on the allowlist"));
539        }
540        let body = serde_json::to_vec(
541            &call
542                .args
543                .iter()
544                .filter_map(|arg| match arg {
545                    Arg::Value(Value::Map(entries)) => {
546                        Some(value_to_json(&Value::Map(entries.clone())))
547                    }
548                    _ => None,
549                })
550                .next()
551                .unwrap_or(Json::Null),
552        )
553        .unwrap_or_default();
554        if body.len() > self.max_request {
555            return Err(Error::ToolPolicy("request too large"));
556        }
557        let (status, response) = self.perform(&method, &path, &body)?;
558        if status >= 400 {
559            return Err(Error::Provider {
560                code: format!("http/{status}"),
561            });
562        }
563        let text = String::from_utf8_lossy(&response).to_string();
564        let value = serde_json::from_str::<Json>(&text)
565            .map(|json| json_to_value(&json))
566            .unwrap_or(Value::Text(text));
567        Ok(Response::Value(value))
568    }
569}
570
571// ---------------------------------------------------------------------------
572// Local process
573// ---------------------------------------------------------------------------
574
575/// A sandboxed local process tool.
576///
577/// The allowlist is the program plus its permitted argument prefixes. The child
578/// gets a scrubbed environment (only what the caller allows), no shell, and a
579/// byte-bounded stdout. Anything else is `rpc/tool-policy`.
580#[derive(Debug)]
581pub struct LocalProcess {
582    /// Absolute program path or allowlisted name.
583    pub program: String,
584    /// Arguments the caller may pass (each must be an exact match or a prefix
585    /// when it ends with `*`).
586    pub allow_args: Vec<String>,
587    /// Environment variables the child inherits.
588    pub env: BTreeMap<String, String>,
589    /// Maximum output bytes.
590    pub max_output: usize,
591    /// Calls served.
592    pub calls: u64,
593}
594
595impl LocalProcess {
596    /// Build a tool for `program` with an argument allowlist.
597    pub fn new(program: impl Into<String>, allow_args: &[&str]) -> Self {
598        Self {
599            program: program.into(),
600            allow_args: allow_args.iter().map(|arg| arg.to_string()).collect(),
601            env: BTreeMap::new(),
602            max_output: 1 << 20,
603            calls: 0,
604        }
605    }
606
607    /// Policy for a capability: process execution must be granted explicitly.
608    pub fn policy(&self) -> ToolPolicy {
609        ToolPolicy {
610            allowed: ["run"].iter().map(|method| method.to_string()).collect(),
611            max_arg_bytes: 64 << 10,
612            allow_process: true,
613        }
614    }
615
616    fn permitted(&self, arg: &str) -> bool {
617        self.allow_args.iter().any(|allowed| {
618            if let Some(prefix) = allowed.strip_suffix('*') {
619                arg.starts_with(prefix)
620            } else {
621                allowed == arg
622            }
623        })
624    }
625}
626
627impl Provider for LocalProcess {
628    fn name(&self) -> &'static str {
629        "local-process"
630    }
631
632    fn methods(&self) -> Vec<String> {
633        vec!["run".to_string()]
634    }
635
636    fn mutating(&self, _method: &str) -> bool {
637        true
638    }
639
640    fn invoke(&mut self, _object: u64, call: &Call) -> Result<Response> {
641        self.calls += 1;
642        if call.method != "run" {
643            return Err(Error::MethodNotGranted {
644                method: call.method.clone(),
645            });
646        }
647        let mut args = Vec::new();
648        for arg in &call.args {
649            if let Arg::Value(Value::Text(text)) = arg {
650                if !self.permitted(text) {
651                    return Err(Error::ToolPolicy("argument is not allowed"));
652                }
653                args.push(text.clone());
654            }
655        }
656        // The policy in the capability must have granted process execution.
657        if !call.tool_allows_process {
658            return Err(Error::ToolPolicy("process execution not granted"));
659        }
660        let mut command = Command::new(&self.program);
661        command
662            .args(&args)
663            .env_clear()
664            .stdin(Stdio::null())
665            .stderr(Stdio::piped())
666            .stdout(Stdio::piped());
667        for (key, value) in &self.env {
668            command.env(key, value);
669        }
670        let output = command.output().map_err(|error| Error::Provider {
671            code: format!("process/spawn:{error}"),
672        })?;
673        if output.stdout.len() > self.max_output {
674            return Err(Error::ToolPolicy("output too large"));
675        }
676        let mut map = BTreeMap::new();
677        map.insert(
678            "status".to_string(),
679            Value::Int(output.status.code().unwrap_or(-1) as i64),
680        );
681        map.insert(
682            "stdout".to_string(),
683            Value::Text(String::from_utf8_lossy(&output.stdout).to_string()),
684        );
685        map.insert(
686            "stderr".to_string(),
687            Value::Text(String::from_utf8_lossy(&output.stderr).to_string()),
688        );
689        Ok(Response::Value(Value::Map(map)))
690    }
691}