Skip to main content

mail4agent_messenger_shell/cli/
mcp.rs

1//! `m4a-agent mcp`: stdio MCP server (newline-delimited JSON-RPC 2.0) giving a provider session
2//! its own mailbox tools: `whoami`, `send`, `inbox`.
3//!
4//! Identity, first match wins:
5//! 1. the environment (`M4A_NICK`, `M4A_SESSION_ID`, `M4A_SESSION_CAP`), also filled by
6//!    `m4a-agent --session-file <path> mcp`;
7//! 2. on-demand attach: the local node's ops listener (`M4A_NODE_OPS`, `host:port`) is asked
8//!    `POST /m4a/attach` with the owner-only control token (its file path comes from the node's
9//!    `/health`; only the same OS user can read it). The node verifies this process descends
10//!    from a live provider process, applies its adoption policy and issues a per-session account.
11//!
12//! The capability stays in this process's memory; it reaches only the short-lived `m4a-agent`
13//! child that performs a send, through that child's environment. It is never written to stdout,
14//! a file, or a log.
15
16use serde_json::{json, Value};
17use std::io::{BufRead, Read, Write};
18use std::net::TcpStream;
19use std::process::{Command, Stdio};
20use std::time::Duration;
21
22const PROTOCOL_VERSION: &str = "2025-06-18";
23pub const NODE_OPS_ENV: &str = "M4A_NODE_OPS";
24pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
25pub const PROVIDER_SESSION_ENV: &str = "M4A_PROVIDER_SESSION";
26
27#[derive(Clone, Default)]
28pub struct Identity {
29    pub nick: String,
30    pub session_id: String,
31    cap: String,
32    /// The account's own store root and the messenger URL, when the node told them (attach) or
33    /// the environment has them. Directories and URLs only, no secret.
34    pub store_root: String,
35    pub product_url: String,
36}
37
38impl std::fmt::Debug for Identity {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("Identity").field("nick", &self.nick).finish_non_exhaustive()
41    }
42}
43
44impl Identity {
45    pub fn from_env() -> Option<Self> {
46        let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
47        Some(Self {
48            nick: get("M4A_NICK")?,
49            session_id: get("M4A_SESSION_ID").unwrap_or_default(),
50            cap: get(crate::session_account::SESSION_CAP_ENV).unwrap_or_default(),
51            store_root: get("M4A_STORE_ROOT").unwrap_or_default(),
52            product_url: get("M4A_PRODUCT_URL").unwrap_or_default(),
53        })
54    }
55
56    fn child_env(&self, cmd: &mut Command) {
57        cmd.env("M4A_NICK", &self.nick);
58        if !self.store_root.is_empty() {
59            cmd.env("M4A_STORE_ROOT", &self.store_root);
60        }
61        if !self.product_url.is_empty() {
62            cmd.env("M4A_PRODUCT_URL", &self.product_url);
63            if std::env::var_os("M4A_HOMESERVER_URL").is_none_or(|v| v.is_empty()) {
64                cmd.env("M4A_HOMESERVER_URL", &self.product_url);
65            }
66        }
67        if !self.session_id.is_empty() {
68            cmd.env("M4A_SESSION_ID", &self.session_id);
69        }
70        if self.cap.is_empty() {
71            cmd.env_remove(crate::session_account::SESSION_CAP_ENV);
72        } else {
73            cmd.env(crate::session_account::SESSION_CAP_ENV, &self.cap);
74        }
75    }
76}
77
78struct Server {
79    identity: Option<Identity>,
80    provider: Option<String>,
81    provider_session: Option<String>,
82    node_ops: String,
83}
84
85pub fn run(args: Vec<String>) {
86    let mut provider = std::env::var(PROVIDER_ENV).ok().filter(|v| !v.is_empty());
87    let mut provider_session = std::env::var(PROVIDER_SESSION_ENV).ok().filter(|v| !v.is_empty());
88    let mut node_ops = std::env::var(NODE_OPS_ENV).ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "127.0.0.1:18330".into());
89    let mut it = args.into_iter();
90    while let Some(a) = it.next() {
91        match a.as_str() {
92            "--provider" => provider = it.next(),
93            "--provider-session" => provider_session = it.next(),
94            "--node-ops" => node_ops = it.next().unwrap_or(node_ops),
95            "-h" | "--help" => {
96                println!("usage: m4a-agent [--session-file <path>] mcp [--provider <id>] [--provider-session <id>] [--node-ops <host:port>]");
97                return;
98            }
99            _ => {}
100        }
101    }
102    let mut server = Server { identity: Identity::from_env(), provider, provider_session, node_ops };
103    let stdin = std::io::stdin();
104    let mut out = std::io::stdout();
105    for line in stdin.lock().lines() {
106        let Ok(line) = line else { break };
107        if line.trim().is_empty() {
108            continue;
109        }
110        if let Some(reply) = server.handle_line(&line) {
111            let _ = writeln!(out, "{reply}");
112            let _ = out.flush();
113        }
114    }
115}
116
117impl Server {
118    fn handle_line(&mut self, line: &str) -> Option<Value> {
119        let Ok(msg) = serde_json::from_str::<Value>(line) else {
120            return Some(json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse error"}}));
121        };
122        let id = msg.get("id").cloned();
123        let method = msg.get("method").and_then(Value::as_str).unwrap_or_default();
124        let id = id?; // notifications get no reply
125        let result = match method {
126            "initialize" => Ok(json!({
127                "protocolVersion": msg.pointer("/params/protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION),
128                "capabilities": {"tools": {}},
129                "serverInfo": {"name": "m4a", "version": env!("CARGO_PKG_VERSION")},
130            })),
131            "ping" => Ok(json!({})),
132            "tools/list" => Ok(json!({"tools": tools()})),
133            "tools/call" => {
134                let name = msg.pointer("/params/name").and_then(Value::as_str).unwrap_or_default().to_owned();
135                let args = msg.pointer("/params/arguments").cloned().unwrap_or(json!({}));
136                Ok(match self.call(&name, &args) {
137                    Ok(text) => json!({"content":[{"type":"text","text":text}]}),
138                    Err(text) => json!({"content":[{"type":"text","text":text}],"isError":true}),
139                })
140            }
141            _ => Err(json!({"code":-32601,"message":"method not found"})),
142        };
143        Some(match result {
144            Ok(r) => json!({"jsonrpc":"2.0","id":id,"result":r}),
145            Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}),
146        })
147    }
148
149    fn identity(&mut self) -> Result<Identity, String> {
150        if let Some(i) = &self.identity {
151            return Ok(i.clone());
152        }
153        let provider = self.provider.clone().ok_or("not attached: no identity in the environment and no provider (--provider / M4A_PROVIDER) to attach with")?;
154        let i = attach(&self.node_ops, &provider, self.provider_session.as_deref(), std::process::id())?;
155        self.identity = Some(i.clone());
156        Ok(i)
157    }
158
159    fn call(&mut self, name: &str, args: &Value) -> Result<String, String> {
160        match name {
161            "whoami" => {
162                let i = self.identity()?;
163                Ok(json!({"nick": i.nick, "session_id": i.session_id, "has_capability": !i.cap.is_empty()}).to_string())
164            }
165            "send" => {
166                let to = args.get("to").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("`to` is required")?;
167                let text = args.get("text").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("`text` is required")?;
168                let i = self.identity()?;
169                let mut a = vec!["send", "--as", i.nick.as_str(), "--to", to];
170                if !i.session_id.is_empty() {
171                    a.extend(["--session", i.session_id.as_str()]);
172                }
173                a.push("-");
174                run_self(&i, &a, Some(text))
175            }
176            "inbox" => {
177                let i = self.identity()?;
178                let mut a = vec!["inbox", "drain", "--format", "text"];
179                let provider = self.provider.clone();
180                if let Some(p) = provider.as_deref() {
181                    a.extend(["--provider", p]);
182                }
183                if !i.session_id.is_empty() {
184                    a.extend(["--session", i.session_id.as_str()]);
185                }
186                run_self(&i, &a, None)
187            }
188            other => Err(format!("unknown tool {other:?}")),
189        }
190    }
191}
192
193pub fn tools() -> Value {
194    json!([
195        {"name":"whoami","description":"This session's own mailbox nick and session id (never the key).",
196         "inputSchema":{"type":"object","properties":{}}},
197        {"name":"send","description":"Send an encrypted direct message from this session's own mailbox to a nick (or a room alias the client accepts as `to`).",
198         "inputSchema":{"type":"object","properties":{"to":{"type":"string"},"text":{"type":"string"}},"required":["to","text"]}},
199        {"name":"inbox","description":"Read (drain) the letters waiting for this session.",
200         "inputSchema":{"type":"object","properties":{}}}
201    ])
202}
203
204fn run_self(identity: &Identity, args: &[&str], stdin: Option<&str>) -> Result<String, String> {
205    let exe = std::env::current_exe().map_err(|e| format!("self path: {}", e.kind()))?;
206    let mut cmd = Command::new(exe);
207    cmd.args(args).stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }).stdout(Stdio::piped()).stderr(Stdio::piped());
208    identity.child_env(&mut cmd);
209    let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e.kind()))?;
210    if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
211        let _ = pipe.write_all(text.as_bytes());
212    }
213    let out = child.wait_with_output().map_err(|e| format!("wait: {}", e.kind()))?;
214    let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).trim().to_owned();
215    if out.status.success() { Ok(if text.is_empty() { "ok".into() } else { text }) } else { Err(text) }
216}
217
218/// Minimal HTTP/1.1 to the loopback ops listener: `(status, body)`.
219fn http(addr: &str, method: &str, path: &str, bearer: Option<&str>) -> Result<(u16, String), String> {
220    let mut s = TcpStream::connect(addr).map_err(|e| format!("node unreachable: {}", e.kind()))?;
221    let _ = s.set_read_timeout(Some(Duration::from_secs(20)));
222    let auth = bearer.map(|t| format!("Authorization: Bearer {t}\r\n")).unwrap_or_default();
223    let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n{auth}Content-Length: 0\r\nConnection: close\r\n\r\n");
224    s.write_all(req.as_bytes()).map_err(|e| e.kind().to_string())?;
225    let mut buf = String::new();
226    s.read_to_string(&mut buf).map_err(|e| e.kind().to_string())?;
227    let status = buf.split_whitespace().nth(1).and_then(|c| c.parse().ok()).ok_or("bad http reply")?;
228    let body = buf.split_once("\r\n\r\n").map(|(_, b)| b.to_owned()).unwrap_or_default();
229    Ok((status, body))
230}
231
232fn query_escape(v: &str) -> String {
233    v.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
234}
235
236pub fn attach(node_ops: &str, provider: &str, provider_session: Option<&str>, pid: u32) -> Result<Identity, String> {
237    let (_, health) = http(node_ops, "GET", "/health", None)?;
238    let health: Value = serde_json::from_str(&health).map_err(|_| "node health is not json")?;
239    let file = health.get("control_token_file").and_then(Value::as_str).ok_or("node has no owner control token (attach unavailable)")?;
240    let token = std::fs::read_to_string(file).map_err(|e| format!("owner token not readable by this user: {}", e.kind()))?;
241    let mut path = format!("/m4a/attach?provider={}&pid={pid}", query_escape(provider));
242    if let Some(s) = provider_session {
243        path.push_str(&format!("&session={}", query_escape(s)));
244    }
245    let (status, body) = http(node_ops, "POST", &path, Some(token.trim()))?;
246    let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
247    if status != 200 {
248        return Err(format!("attach refused ({status}): {}", v.get("error").and_then(Value::as_str).unwrap_or("no reason")));
249    }
250    let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_owned();
251    let identity = Identity { nick: s("nick"), session_id: s("session_id"), cap: s("cap"), store_root: s("store_root"), product_url: s("product_url") };
252    if identity.nick.is_empty() {
253        return Err("attach reply has no nick".into());
254    }
255    Ok(identity)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn server(identity: Option<Identity>) -> Server {
263        Server { identity, provider: None, provider_session: None, node_ops: "127.0.0.1:1".into() }
264    }
265
266    #[test]
267    fn initialize_and_list_tools() {
268        let mut s = server(None);
269        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}"#).unwrap();
270        assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
271        assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
272        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#).unwrap();
273        let names: Vec<_> = r["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_owned()).collect();
274        assert_eq!(names, ["whoami", "send", "inbox"]);
275    }
276
277    #[test]
278    fn whoami_never_reveals_the_capability() {
279        let mut s = server(Some(Identity { nick: "a-1".into(), session_id: "s".into(), cap: "secret-cap-value".into(), ..Default::default() }));
280        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"whoami","arguments":{}}}"#).unwrap();
281        let text = r.to_string();
282        assert!(text.contains("a-1") && text.contains("has_capability"));
283        assert!(!text.contains("secret-cap-value"));
284        assert!(!format!("{:?}", s.identity).contains("secret-cap-value"));
285    }
286
287    #[test]
288    fn unattached_without_provider_is_a_tool_error_not_a_crash() {
289        let mut s = server(None);
290        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"send","arguments":{"to":"b","text":"hi"}}}"#).unwrap();
291        assert_eq!(r["result"]["isError"], true);
292    }
293
294    #[test]
295    fn attach_reads_token_from_health_and_returns_identity() {
296        use std::net::TcpListener;
297        let l = TcpListener::bind("127.0.0.1:0").unwrap();
298        let addr = l.local_addr().unwrap().to_string();
299        let dir = std::env::temp_dir().join(format!("m4a-mcp-attach-{}", std::process::id()));
300        std::fs::create_dir_all(&dir).unwrap();
301        let tok = dir.join("token");
302        std::fs::write(&tok, "tkn\n").unwrap();
303        let tok_s = tok.to_string_lossy().replace('\\', "\\\\");
304        let h = std::thread::spawn(move || {
305            for (i, conn) in l.incoming().take(2).enumerate() {
306                let mut c = conn.unwrap();
307                let mut buf = [0u8; 2048];
308                let n = c.read(&mut buf).unwrap();
309                let req = String::from_utf8_lossy(&buf[..n]).to_string();
310                let body = if i == 0 {
311                    format!(r#"{{"control_token_file":"{tok_s}"}}"#)
312                } else {
313                    assert!(req.starts_with("POST /m4a/attach?provider=grok&pid=7&session=x%2Fy"));
314                    assert!(req.contains("Authorization: Bearer tkn\r\n"));
315                    r#"{"nick":"grok-9","session_id":"k","cap":"c"}"#.to_owned()
316                };
317                write!(c, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
318            }
319        });
320        let i = attach(&addr, "grok", Some("x/y"), 7).unwrap();
321        h.join().unwrap();
322        assert_eq!(i.nick, "grok-9");
323        let _ = std::fs::remove_dir_all(dir);
324    }
325}