Skip to main content

supercode_harness/
mail_mcp.rs

1//! Messaging as tools: the three an agent needs to reach any session, served
2//! over MCP by `supercode message mcp` (which `supercode teams connect
3//! --install` registers with each harness at user scope).
4//!
5//! They are Claude Code's native pair (ListAgents, SendMessage) plus the one
6//! a harness without an injection door needs:
7//!
8//! * `list_agents` — every session this machine can reach ([`LiveSessions`]).
9//! * `send_message` — [`crate::mail_send::send`], the one send every sender uses.
10//! * `read_messages` — the caller's unread mail, each with how to answer it.
11//!
12//! The sender is never an argument: the MCP server is started by the session
13//! that uses it, so [`resolve_caller`] finds that session in the server's own
14//! process ancestry, exactly as for `supercode message send`.
15
16use async_trait::async_trait;
17use serde_json::{json, Value};
18
19use crate::error::{Error, Result};
20use crate::mail_route::{process_ancestry, resolve_caller, LiveSessions};
21use crate::mail_send::SendOptions;
22use crate::mailbox::{mail_root, Mailbox};
23use crate::tools::{Tool, ToolContext, ToolRegistry};
24use crate::HarnessHomes;
25
26/// The executable this server started as, and which file that was. A harness keeps its MCP servers
27/// for days; an upgrade replaces the installed file under them. A server whose file has been
28/// replaced sends through the installed build, never through the code it was started with.
29static STARTED_AS: std::sync::OnceLock<Option<(std::path::PathBuf, (u64, u64))>> =
30    std::sync::OnceLock::new();
31
32#[cfg(unix)]
33fn file_identity(path: &std::path::Path) -> Option<(u64, u64)> {
34    use std::os::unix::fs::MetadataExt;
35    std::fs::metadata(path)
36        .ok()
37        .map(|metadata| (metadata.dev(), metadata.ino()))
38}
39
40#[cfg(not(unix))]
41fn file_identity(_path: &std::path::Path) -> Option<(u64, u64)> {
42    None
43}
44
45fn started_as() -> &'static Option<(std::path::PathBuf, (u64, u64))> {
46    STARTED_AS.get_or_init(|| {
47        let path = std::env::current_exe().ok()?;
48        let identity = file_identity(&path)?;
49        Some((path, identity))
50    })
51}
52
53/// The installed build, when an upgrade has replaced the file this server started as.
54fn replaced_by() -> Option<std::path::PathBuf> {
55    let (path, identity) = started_as().as_ref()?;
56    let now = file_identity(path)?;
57    (now != *identity).then(|| path.clone())
58}
59
60/// The registry `supercode message mcp` serves.
61pub fn registry() -> ToolRegistry {
62    started_as();
63    let mut registry = ToolRegistry::new();
64    registry.register(ListAgents);
65    registry.register(SendMessage);
66    registry.register(ReadMessages);
67    registry
68}
69
70fn caller(tool: &str, homes: &HarnessHomes) -> Result<crate::mail_route::Caller> {
71    resolve_caller(homes, &process_ancestry()).map_err(|message| Error::tool(tool, message))
72}
73
74struct ListAgents;
75
76#[async_trait]
77impl Tool for ListAgents {
78    fn name(&self) -> &str {
79        "list_agents"
80    }
81
82    fn description(&self) -> &str {
83        "List the coding-agent sessions you can message, of every harness (Claude Code, Codex, \
84         sessions supercode hosts): each one's name, harness, status (busy, idle, hosted), how it \
85         is reached, and address. `query` keeps the ones whose name or address contains it."
86    }
87
88    fn parameters(&self) -> Value {
89        json!({
90            "type": "object",
91            "properties": {
92                "query": {"type": "string", "description": "Keep sessions whose name or address contains this."}
93            },
94            "additionalProperties": false
95        })
96    }
97
98    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
99        let query = args
100            .get("query")
101            .and_then(Value::as_str)
102            .map(str::to_lowercase);
103        let sessions = LiveSessions::read(&HarnessHomes::default());
104        let rows: Vec<String> = sessions
105            .all()
106            .iter()
107            .filter(|session| {
108                query.as_deref().is_none_or(|query| {
109                    session.name.to_lowercase().contains(query)
110                        || session.address.to_string().to_lowercase().contains(query)
111                })
112            })
113            .map(|session| {
114                format!(
115                    "{} ({}, {}, {}) {}",
116                    session.name,
117                    session.address.harness,
118                    session.status,
119                    session.door.name(),
120                    session.address
121                )
122            })
123            .collect();
124        Ok(if rows.is_empty() {
125            "No session to message matches.".into()
126        } else {
127            rows.join("\n")
128        })
129    }
130}
131
132struct SendMessage;
133
134#[async_trait]
135impl Tool for SendMessage {
136    fn name(&self) -> &str {
137        "send_message"
138    }
139
140    fn description(&self) -> &str {
141        "Send a message to another coding-agent session, of any harness, on this machine or \
142         another of your team's (`name@machine`). `to` is a name from list_agents, `name@machine`, \
143         or an address. To answer a message, send to its `from` with `reply_to` set to its id. \
144         The result says whether it was delivered, queued, held for approval, refused or stored."
145    }
146
147    fn parameters(&self) -> Value {
148        json!({
149            "type": "object",
150            "properties": {
151                "to": {"type": "string", "description": "A session name, name@machine, or address."},
152                "message": {"type": "string", "description": "The message."},
153                "subject": {"type": "string", "description": "A short subject shown on the unopened envelope."},
154                "reply_to": {"type": "string", "description": "Id of the message this answers."},
155                "notify_when_idle": {"type": "boolean", "description": "Also get one notice when the receiver's next turn ends."}
156            },
157            "required": ["to", "message"],
158            "additionalProperties": false
159        })
160    }
161
162    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
163        let text = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_string);
164        let (Some(to), Some(message)) = (text("to"), text("message")) else {
165            return Err(Error::tool(self.name(), "`to` and `message` are required"));
166        };
167        if message.trim().is_empty() {
168            return Err(Error::tool(
169                self.name(),
170                "Nothing was sent: the message is empty.",
171            ));
172        }
173        if let Some(installed) = replaced_by() {
174            return send_through(&installed, self.name(), &to, &message, &args).await;
175        }
176        let homes = HarnessHomes::default();
177        let caller = caller(self.name(), &homes)?;
178        let options = SendOptions {
179            subject: text("subject"),
180            in_reply_to: text("reply_to"),
181            notify_when_idle: args
182                .get("notify_when_idle")
183                .and_then(Value::as_bool)
184                .unwrap_or(false),
185            ..Default::default()
186        };
187        let outcome = crate::mail_send::send(&homes, &caller, &to, &message, options)
188            .await
189            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
190        if outcome.code == 0 {
191            Ok(outcome.text)
192        } else {
193            Err(Error::tool(self.name(), outcome.text))
194        }
195    }
196}
197
198/// `supercode message send` of the installed build, from this session (it finds the same caller in
199/// its process ancestry): what a server whose own code was replaced sends through.
200async fn send_through(
201    installed: &std::path::Path,
202    tool: &str,
203    to: &str,
204    message: &str,
205    args: &Value,
206) -> Result<String> {
207    use tokio::io::AsyncWriteExt;
208    let mut command = tokio::process::Command::new(installed);
209    command.args(["message", "send", to]);
210    if let Some(subject) = args.get("subject").and_then(Value::as_str) {
211        command.args(["--subject", subject]);
212    }
213    if let Some(answered) = args.get("reply_to").and_then(Value::as_str) {
214        command.args(["--re", answered]);
215    }
216    if args.get("notify_when_idle").and_then(Value::as_bool) == Some(true) {
217        command.arg("--notify-when-idle");
218    }
219    command
220        .stdin(std::process::Stdio::piped())
221        .stdout(std::process::Stdio::piped())
222        .stderr(std::process::Stdio::piped());
223    let mut child = command.spawn().map_err(|error| {
224        Error::tool(
225            tool,
226            format!("could not start {}: {error}", installed.display()),
227        )
228    })?;
229    if let Some(mut stdin) = child.stdin.take() {
230        stdin
231            .write_all(message.as_bytes())
232            .await
233            .map_err(|error| Error::tool(tool, error.to_string()))?;
234    }
235    let output = child
236        .wait_with_output()
237        .await
238        .map_err(|error| Error::tool(tool, error.to_string()))?;
239    let said = |bytes: &[u8]| String::from_utf8_lossy(bytes).trim().to_string();
240    if output.status.success() {
241        Ok(said(&output.stdout))
242    } else {
243        let stderr = said(&output.stderr);
244        Err(Error::tool(
245            tool,
246            if stderr.is_empty() {
247                said(&output.stdout)
248            } else {
249                stderr
250            },
251        ))
252    }
253}
254
255struct ReadMessages;
256
257#[async_trait]
258impl Tool for ReadMessages {
259    fn name(&self) -> &str {
260        "read_messages"
261    }
262
263    fn description(&self) -> &str {
264        "Read your unread messages from other sessions, each with how to answer it. Messages \
265         usually arrive in your conversation by themselves; this reads any that are waiting."
266    }
267
268    fn parameters(&self) -> Value {
269        json!({"type": "object", "properties": {}, "additionalProperties": false})
270    }
271
272    async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
273        let homes = HarnessHomes::default();
274        let caller = caller(self.name(), &homes)?;
275        let mailbox = Mailbox::open(&mail_root(), &caller.address)
276            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
277        let claimed = mailbox
278            .claim_unread()
279            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
280        let text = if claimed.is_empty() {
281            "No unread messages.".to_string()
282        } else {
283            claimed
284                .iter()
285                .map(|stored| stored.envelope.render())
286                .collect::<Vec<_>>()
287                .join("\n\n")
288        };
289        for stored in &claimed {
290            mailbox
291                .acknowledge(stored)
292                .map_err(|error| Error::tool(self.name(), error.to_string()))?;
293        }
294        Ok(text)
295    }
296}