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" if args.get("to").and_then(Value::as_str).is_some_and(|t| t.starts_with('!') || t.starts_with('#')) => {
166                // A room id (`!id:server`) or alias (`#name`): a room message, not a letter.
167                let room = args.get("to").and_then(Value::as_str).unwrap_or_default().to_owned();
168                self.call("room_send", &json!({"room": room, "text": args.get("text").cloned().unwrap_or(Value::Null)}))
169            }
170            "rooms" => {
171                let i = self.identity()?;
172                run_self(&i, &["mail", "--as", i.nick.as_str(), "rooms"], None)
173            }
174            "room_read" | "room_join" | "room_leave" | "room_send" => {
175                let room = args.get("room").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty() && !s.starts_with('-')).ok_or("`room` is required")?.to_owned();
176                let i = self.identity()?;
177                match name {
178                    "room_read" => {
179                        let n = args.get("limit").and_then(Value::as_u64).unwrap_or(20).clamp(1, 200).to_string();
180                        run_self(&i, &["mail", "--as", i.nick.as_str(), "read", room.as_str(), "-n", n.as_str()], None)
181                    }
182                    "room_join" => run_self(&i, &["mail", "--as", i.nick.as_str(), "join", room.as_str()], None),
183                    "room_leave" => run_self(&i, &["mail", "--as", i.nick.as_str(), "leave", room.as_str()], None),
184                    _ => {
185                        let text = args.get("text").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("`text` is required")?;
186                        let reply = args.get("reply_to").and_then(Value::as_str).filter(|e| e.starts_with('$') && !e.contains(char::is_whitespace));
187                        let mut a = vec!["mail", "--as", i.nick.as_str(), "send", room.as_str()];
188                        if let Some(e) = reply {
189                            a.extend(["--reply-to", e]);
190                        }
191                        a.push("-");
192                        run_self(&i, &a, Some(text))
193                    }
194                }
195            }
196            "send" => {
197                let to = args.get("to").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("`to` is required")?;
198                let text = args.get("text").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("`text` is required")?;
199                let i = self.identity()?;
200                let mut a = vec!["send", "--as", i.nick.as_str(), "--to", to];
201                if !i.session_id.is_empty() {
202                    a.extend(["--session", i.session_id.as_str()]);
203                }
204                a.push("-");
205                run_self(&i, &a, Some(text))
206            }
207            "inbox" => {
208                let i = self.identity()?;
209                let mut a = vec!["inbox", "drain", "--format", "text"];
210                let provider = self.provider.clone();
211                if let Some(p) = provider.as_deref() {
212                    a.extend(["--provider", p]);
213                }
214                if !i.session_id.is_empty() {
215                    a.extend(["--session", i.session_id.as_str()]);
216                }
217                run_self(&i, &a, None)
218            }
219            other => Err(format!("unknown tool {other:?}")),
220        }
221    }
222}
223
224pub fn tools() -> Value {
225    json!([
226        {"name":"whoami","description":"This session's own mailbox nick and session id (never the key).",
227         "inputSchema":{"type":"object","properties":{}}},
228        {"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`).",
229         "inputSchema":{"type":"object","properties":{"to":{"type":"string"},"text":{"type":"string"}},"required":["to","text"]}},
230        {"name":"inbox","description":"Read (drain) the letters waiting for this session.",
231         "inputSchema":{"type":"object","properties":{}}},
232        {"name":"rooms","description":"List the rooms this session's account is in or invited to (room id, title, membership, last message).",
233         "inputSchema":{"type":"object","properties":{}}},
234        {"name":"room_read","description":"Read the latest messages of a room (room id `!id:server`, alias `#name` or title).",
235         "inputSchema":{"type":"object","properties":{"room":{"type":"string"},"limit":{"type":"integer"}},"required":["room"]}},
236        {"name":"room_send","description":"Post a message into a room (group chat) as this session; mention someone with @nick. Use this to answer a room letter (its `room:` line).",
237         "inputSchema":{"type":"object","properties":{"room":{"type":"string"},"text":{"type":"string"},"reply_to":{"type":"string","description":"event id ($...) to reply to"}},"required":["room","text"]}},
238        {"name":"room_join","description":"Join a room (or accept its invite).",
239         "inputSchema":{"type":"object","properties":{"room":{"type":"string"}},"required":["room"]}},
240        {"name":"room_leave","description":"Leave a room.",
241         "inputSchema":{"type":"object","properties":{"room":{"type":"string"}},"required":["room"]}}
242    ])
243}
244
245fn run_self(identity: &Identity, args: &[&str], stdin: Option<&str>) -> Result<String, String> {
246    let exe = std::env::current_exe().map_err(|e| format!("self path: {}", e.kind()))?;
247    let mut cmd = Command::new(exe);
248    cmd.args(args).stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }).stdout(Stdio::piped()).stderr(Stdio::piped());
249    identity.child_env(&mut cmd);
250    let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e.kind()))?;
251    if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
252        let _ = pipe.write_all(text.as_bytes());
253    }
254    let out = child.wait_with_output().map_err(|e| format!("wait: {}", e.kind()))?;
255    let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).trim().to_owned();
256    if out.status.success() { Ok(if text.is_empty() { "ok".into() } else { text }) } else { Err(text) }
257}
258
259/// Minimal HTTP/1.1 to the loopback ops listener: `(status, body)`.
260fn http(addr: &str, method: &str, path: &str, bearer: Option<&str>) -> Result<(u16, String), String> {
261    let mut s = TcpStream::connect(addr).map_err(|e| format!("node unreachable: {}", e.kind()))?;
262    let _ = s.set_read_timeout(Some(Duration::from_secs(20)));
263    let auth = bearer.map(|t| format!("Authorization: Bearer {t}\r\n")).unwrap_or_default();
264    let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n{auth}Content-Length: 0\r\nConnection: close\r\n\r\n");
265    s.write_all(req.as_bytes()).map_err(|e| e.kind().to_string())?;
266    let mut buf = String::new();
267    s.read_to_string(&mut buf).map_err(|e| e.kind().to_string())?;
268    let status = buf.split_whitespace().nth(1).and_then(|c| c.parse().ok()).ok_or("bad http reply")?;
269    let body = buf.split_once("\r\n\r\n").map(|(_, b)| b.to_owned()).unwrap_or_default();
270    Ok((status, body))
271}
272
273fn query_escape(v: &str) -> String {
274    v.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
275}
276
277pub fn attach(node_ops: &str, provider: &str, provider_session: Option<&str>, pid: u32) -> Result<Identity, String> {
278    let (_, health) = http(node_ops, "GET", "/health", None)?;
279    let health: Value = serde_json::from_str(&health).map_err(|_| "node health is not json")?;
280    let file = health.get("control_token_file").and_then(Value::as_str).ok_or("node has no owner control token (attach unavailable)")?;
281    let token = std::fs::read_to_string(file).map_err(|e| format!("owner token not readable by this user: {}", e.kind()))?;
282    let mut path = format!("/m4a/attach?provider={}&pid={pid}", query_escape(provider));
283    if let Some(s) = provider_session {
284        path.push_str(&format!("&session={}", query_escape(s)));
285    }
286    let (status, body) = http(node_ops, "POST", &path, Some(token.trim()))?;
287    let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
288    if status != 200 {
289        return Err(format!("attach refused ({status}): {}", v.get("error").and_then(Value::as_str).unwrap_or("no reason")));
290    }
291    let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_owned();
292    let identity = Identity { nick: s("nick"), session_id: s("session_id"), cap: s("cap"), store_root: s("store_root"), product_url: s("product_url") };
293    if identity.nick.is_empty() {
294        return Err("attach reply has no nick".into());
295    }
296    Ok(identity)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn server(identity: Option<Identity>) -> Server {
304        Server { identity, provider: None, provider_session: None, node_ops: "127.0.0.1:1".into() }
305    }
306
307    #[test]
308    fn initialize_and_list_tools() {
309        let mut s = server(None);
310        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}"#).unwrap();
311        assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
312        assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
313        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#).unwrap();
314        let names: Vec<_> = r["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_owned()).collect();
315        assert_eq!(names, ["whoami", "send", "inbox", "rooms", "room_read", "room_send", "room_join", "room_leave"]);
316    }
317
318    #[test]
319    fn whoami_never_reveals_the_capability() {
320        let mut s = server(Some(Identity { nick: "a-1".into(), session_id: "s".into(), cap: "secret-cap-value".into(), ..Default::default() }));
321        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"whoami","arguments":{}}}"#).unwrap();
322        let text = r.to_string();
323        assert!(text.contains("a-1") && text.contains("has_capability"));
324        assert!(!text.contains("secret-cap-value"));
325        assert!(!format!("{:?}", s.identity).contains("secret-cap-value"));
326    }
327
328    #[test]
329    fn unattached_without_provider_is_a_tool_error_not_a_crash() {
330        let mut s = server(None);
331        let r = s.handle_line(r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"send","arguments":{"to":"b","text":"hi"}}}"#).unwrap();
332        assert_eq!(r["result"]["isError"], true);
333    }
334
335    #[test]
336    fn attach_reads_token_from_health_and_returns_identity() {
337        use std::net::TcpListener;
338        let l = TcpListener::bind("127.0.0.1:0").unwrap();
339        let addr = l.local_addr().unwrap().to_string();
340        let dir = std::env::temp_dir().join(format!("m4a-mcp-attach-{}", std::process::id()));
341        std::fs::create_dir_all(&dir).unwrap();
342        let tok = dir.join("token");
343        std::fs::write(&tok, "tkn\n").unwrap();
344        let tok_s = tok.to_string_lossy().replace('\\', "\\\\");
345        let h = std::thread::spawn(move || {
346            for (i, conn) in l.incoming().take(2).enumerate() {
347                let mut c = conn.unwrap();
348                let mut buf = [0u8; 2048];
349                let n = c.read(&mut buf).unwrap();
350                let req = String::from_utf8_lossy(&buf[..n]).to_string();
351                let body = if i == 0 {
352                    format!(r#"{{"control_token_file":"{tok_s}"}}"#)
353                } else {
354                    assert!(req.starts_with("POST /m4a/attach?provider=grok&pid=7&session=x%2Fy"));
355                    assert!(req.contains("Authorization: Bearer tkn\r\n"));
356                    r#"{"nick":"grok-9","session_id":"k","cap":"c"}"#.to_owned()
357                };
358                write!(c, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
359            }
360        });
361        let i = attach(&addr, "grok", Some("x/y"), 7).unwrap();
362        h.join().unwrap();
363        assert_eq!(i.nick, "grok-9");
364        let _ = std::fs::remove_dir_all(dir);
365    }
366}