1use 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 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?; 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
218fn 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}