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 registry `supercode message mcp` serves.
27pub fn registry() -> ToolRegistry {
28    let mut registry = ToolRegistry::new();
29    registry.register(ListAgents);
30    registry.register(SendMessage);
31    registry.register(ReadMessages);
32    registry
33}
34
35fn caller(tool: &str, homes: &HarnessHomes) -> Result<crate::mail_route::Caller> {
36    resolve_caller(homes, &process_ancestry()).map_err(|message| Error::tool(tool, message))
37}
38
39struct ListAgents;
40
41#[async_trait]
42impl Tool for ListAgents {
43    fn name(&self) -> &str {
44        "list_agents"
45    }
46
47    fn description(&self) -> &str {
48        "List the coding-agent sessions you can message, of every harness (Claude Code, Codex, \
49         sessions supercode hosts): each one's name, harness, status (busy, idle, hosted), how it \
50         is reached, and address. `query` keeps the ones whose name or address contains it."
51    }
52
53    fn parameters(&self) -> Value {
54        json!({
55            "type": "object",
56            "properties": {
57                "query": {"type": "string", "description": "Keep sessions whose name or address contains this."}
58            },
59            "additionalProperties": false
60        })
61    }
62
63    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
64        let query = args
65            .get("query")
66            .and_then(Value::as_str)
67            .map(str::to_lowercase);
68        let sessions = LiveSessions::read(&HarnessHomes::default());
69        let rows: Vec<String> = sessions
70            .all()
71            .iter()
72            .filter(|session| {
73                query.as_deref().is_none_or(|query| {
74                    session.name.to_lowercase().contains(query)
75                        || session.address.to_string().to_lowercase().contains(query)
76                })
77            })
78            .map(|session| {
79                format!(
80                    "{} ({}, {}, {}) {}",
81                    session.name,
82                    session.address.harness,
83                    session.status,
84                    session.door.name(),
85                    session.address
86                )
87            })
88            .collect();
89        Ok(if rows.is_empty() {
90            "No session to message matches.".into()
91        } else {
92            rows.join("\n")
93        })
94    }
95}
96
97struct SendMessage;
98
99#[async_trait]
100impl Tool for SendMessage {
101    fn name(&self) -> &str {
102        "send_message"
103    }
104
105    fn description(&self) -> &str {
106        "Send a message to another coding-agent session, of any harness, on this machine or \
107         another of your team's (`name@machine`). `to` is a name from list_agents, `name@machine`, \
108         or an address. To answer a message, send to its `from` with `reply_to` set to its id. \
109         The result says whether it was delivered, queued, held for approval, refused or stored."
110    }
111
112    fn parameters(&self) -> Value {
113        json!({
114            "type": "object",
115            "properties": {
116                "to": {"type": "string", "description": "A session name, name@machine, or address."},
117                "message": {"type": "string", "description": "The message."},
118                "reply_to": {"type": "string", "description": "Id of the message this answers."},
119                "notify_when_idle": {"type": "boolean", "description": "Also get one notice when the receiver's next turn ends."}
120            },
121            "required": ["to", "message"],
122            "additionalProperties": false
123        })
124    }
125
126    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
127        let text = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_string);
128        let (Some(to), Some(message)) = (text("to"), text("message")) else {
129            return Err(Error::tool(self.name(), "`to` and `message` are required"));
130        };
131        if message.trim().is_empty() {
132            return Err(Error::tool(
133                self.name(),
134                "Nothing was sent: the message is empty.",
135            ));
136        }
137        let homes = HarnessHomes::default();
138        let caller = caller(self.name(), &homes)?;
139        let options = SendOptions {
140            in_reply_to: text("reply_to"),
141            notify_when_idle: args
142                .get("notify_when_idle")
143                .and_then(Value::as_bool)
144                .unwrap_or(false),
145            ..Default::default()
146        };
147        let outcome = crate::mail_send::send(&homes, &caller, &to, &message, options)
148            .await
149            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
150        if outcome.code == 0 {
151            Ok(outcome.text)
152        } else {
153            Err(Error::tool(self.name(), outcome.text))
154        }
155    }
156}
157
158struct ReadMessages;
159
160#[async_trait]
161impl Tool for ReadMessages {
162    fn name(&self) -> &str {
163        "read_messages"
164    }
165
166    fn description(&self) -> &str {
167        "Read your unread messages from other sessions, each with how to answer it. Messages \
168         usually arrive in your conversation by themselves; this reads any that are waiting."
169    }
170
171    fn parameters(&self) -> Value {
172        json!({"type": "object", "properties": {}, "additionalProperties": false})
173    }
174
175    async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
176        let homes = HarnessHomes::default();
177        let caller = caller(self.name(), &homes)?;
178        let mailbox = Mailbox::open(&mail_root(), &caller.address)
179            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
180        let claimed = mailbox
181            .claim_unread()
182            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
183        let text = if claimed.is_empty() {
184            "No unread messages.".to_string()
185        } else {
186            claimed
187                .iter()
188                .map(|stored| stored.envelope.render())
189                .collect::<Vec<_>>()
190                .join("\n\n")
191        };
192        for stored in &claimed {
193            mailbox
194                .acknowledge(stored)
195                .map_err(|error| Error::tool(self.name(), error.to_string()))?;
196        }
197        Ok(text)
198    }
199}