mod rpc;
mod store;
mod tools;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use clap::Parser;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Stdout};
use tokio::sync::{Mutex, Notify};
#[derive(Parser)]
#[command(
name = "team-mcp",
version,
about = "MCP server for the teamctl mailbox"
)]
struct Cli {
#[arg(long, env = "TEAMCTL_AGENT_ID")]
agent_id: String,
#[arg(long, env = "TEAMCTL_MAILBOX")]
mailbox: PathBuf,
#[arg(long, env = "TEAMCTL_TMUX_PREFIX", default_value = "t-")]
tmux_prefix: String,
#[arg(long, env = "TEAMCTL_COMPOSE_ROOT")]
compose_root: Option<PathBuf>,
}
const CHANNEL_POLL_MS: u64 = 500;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_env("TEAM_MCP_LOG")
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let store = store::Store::open(&cli.mailbox)?;
let mut ctx = tools::Ctx::new(cli.agent_id.clone(), store, cli.tmux_prefix.clone());
if let Some(compose_root) = cli.compose_root.as_ref() {
match team_core::compose::Compose::load(compose_root) {
Ok(compose) => {
let cfg = compose.global.attachments.clone();
let scanner = cfg
.scanner
.as_ref()
.map(team_core::attachments::RealScanner::for_spec);
let dir = team_core::attachments::staging_dir(compose_root);
let ttl = std::time::Duration::from_secs(cfg.tempfile_ttl_seconds);
match team_core::attachments::sweep_expired(&dir, ttl) {
Ok(0) => {}
Ok(n) => tracing::info!(reaped = n, "attachments staging sweep"),
Err(e) => tracing::warn!(error = %e, "attachments staging sweep failed"),
}
ctx = ctx.with_attachments(tools::AttachmentsCtx {
cfg,
compose_root: compose_root.clone(),
scanner,
});
}
Err(e) => {
tracing::warn!(
error = %e,
compose_root = %compose_root.display(),
"attachment config: compose load failed; read_attachment will return disabled",
);
}
}
}
tracing::info!(
agent_id = %cli.agent_id,
mailbox = %cli.mailbox.display(),
"team-mcp ready",
);
let stdout = Arc::new(Mutex::new(tokio::io::stdout()));
let initialized = Arc::new(Notify::new());
let lazy_inbox = std::env::var("TEAM_LAZY_INBOX")
.map(|v| !matches!(v.trim(), "0" | "false" | "off" | "no"))
.unwrap_or(true);
spawn_channel_watcher(
ctx.store.clone(),
ctx.agent_id.clone(),
ctx.tmux_prefix.clone(),
stdout.clone(),
initialized.clone(),
lazy_inbox,
);
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin).lines();
while let Some(line) = reader.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<rpc::Request>(trimmed) {
Ok(req) => {
if req.method == "notifications/initialized" {
initialized.notify_one();
}
if let Some(resp) = rpc::dispatch(&ctx, req).await {
let buf = serde_json::to_vec(&resp)?;
write_line(&stdout, &buf).await?;
}
}
Err(e) => {
tracing::warn!(error = %e, line = %trimmed, "invalid JSON-RPC");
let resp = rpc::Response::parse_error(&e.to_string());
let buf = serde_json::to_vec(&resp)?;
write_line(&stdout, &buf).await?;
}
}
}
Ok(())
}
async fn write_line(stdout: &Arc<Mutex<Stdout>>, buf: &[u8]) -> Result<()> {
let mut out = stdout.lock().await;
out.write_all(buf).await?;
out.write_all(b"\n").await?;
out.flush().await?;
Ok(())
}
fn spawn_channel_watcher(
store: Arc<store::Store>,
agent_id: String,
tmux_prefix: String,
stdout: Arc<Mutex<Stdout>>,
initialized: Arc<Notify>,
lazy_inbox: bool,
) {
let nudge_session = match tools::pane_session(&tmux_prefix, &agent_id) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!(error = %e, "channel watcher nudge disabled");
None
}
};
let initial_last_seen: i64 = match store.inbox_peek(&agent_id, 1000) {
Ok(msgs) => msgs.iter().map(|m| m.id).max().unwrap_or(0),
Err(e) => {
tracing::warn!(error = %e, "channel watcher initial peek failed");
0
}
};
let pre_notify_sleep_ms: u64 = std::env::var("TEAM_MCP_TEST_WATCHER_PRE_NOTIFY_SLEEP_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
tokio::spawn(async move {
if pre_notify_sleep_ms > 0 {
tokio::time::sleep(Duration::from_millis(pre_notify_sleep_ms)).await;
}
initialized.notified().await;
let mut last_seen = initial_last_seen;
loop {
tokio::time::sleep(Duration::from_millis(CHANNEL_POLL_MS)).await;
let msgs = match store.inbox_peek(&agent_id, 100) {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, "channel watcher peek failed");
continue;
}
};
let mut max_id = last_seen;
let mut fresh = 0usize;
let mut first_fresh: Option<(String, String)> = None;
for m in msgs.iter().filter(|m| m.id > last_seen) {
fresh += 1;
if first_fresh.is_none() {
first_fresh = Some((m.sender.clone(), m.text.clone()));
}
let payload = format_channel_event(m, lazy_inbox);
let buf = match serde_json::to_vec(&payload) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "channel notification serialise failed");
continue;
}
};
if let Err(e) = write_line(&stdout, &buf).await {
tracing::warn!(error = %e, "channel notification write failed; aborting watcher");
return;
}
if m.id > max_id {
max_id = m.id;
}
}
if fresh > 0 {
let mode = match store.runtime_for(&agent_id) {
Ok(runtime) => delivery_mode(runtime.as_deref()),
Err(e) => {
tracing::warn!(
error = %e,
"channel watcher runtime lookup failed — \
assuming channel delivery for this tick",
);
DeliveryMode::Channel
}
};
if mode == DeliveryMode::Nudge {
if let (Some(session), Some((sender, body))) =
(nudge_session.clone(), first_fresh.take())
{
tokio::task::spawn_blocking(move || {
let line = inbox_nudge_line(&sender, &body, fresh);
tools::tmux_type_and_submit(&session, &line, "inbox nudge");
});
}
}
}
last_seen = max_id;
}
});
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeliveryMode {
Channel,
Nudge,
}
fn delivery_mode(runtime: Option<&str>) -> DeliveryMode {
match runtime {
Some("claude-code") => DeliveryMode::Channel,
_ => DeliveryMode::Nudge,
}
}
fn inbox_nudge_line(sender: &str, body: &str, new_rows: usize) -> String {
const PREVIEW_CHARS: usize = 80;
let sender = pane_safe_line(sender);
let clean = pane_safe_line(body);
let mut preview: String = clean.chars().take(PREVIEW_CHARS).collect();
if clean.chars().count() > PREVIEW_CHARS {
preview.push('…');
}
let more = if new_rows > 1 {
format!(" (+{} more)", new_rows - 1)
} else {
String::new()
};
let head = if preview.is_empty() {
format!("📬 {sender}: new message{more}")
} else {
format!("📬 {sender}: \"{preview}\"{more}")
};
format!(
"{head} — call inbox_peek, \
then inbox_read each meta.id and inbox_ack when handled."
)
}
fn pane_safe_line(text: &str) -> String {
text.chars()
.filter(|c| !c.is_control() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn format_channel_event(m: &store::Message, lazy_inbox: bool) -> Value {
let mut meta = serde_json::Map::new();
meta.insert("id".into(), Value::String(m.id.to_string()));
meta.insert("sender".into(), Value::String(m.sender.clone()));
meta.insert("recipient".into(), Value::String(m.recipient.clone()));
meta.insert("sent_at".into(), Value::String(m.sent_at.to_string()));
if let Some(t) = m.thread_id.as_ref() {
meta.insert("thread_id".into(), Value::String(t.clone()));
}
let immediate = m.delivery_mode.as_deref() == Some("immediate");
let system = m.kind.as_deref() == Some("system");
let content = if !lazy_inbox || immediate || system {
m.text.clone()
} else {
meta.insert("lazy".into(), Value::String("1".into()));
notification_stub(m)
};
json!({
"jsonrpc": "2.0",
"method": "notifications/claude/channel",
"params": {
"content": content,
"meta": meta,
}
})
}
fn notification_stub(m: &store::Message) -> String {
const PREVIEW_CHARS: usize = 80;
let trimmed = m.text.trim();
let kind_label = m.kind.as_deref();
if trimmed.is_empty() {
let label = kind_label.unwrap_or("message");
let location = stub_location(m);
return format!("📬 1 new {label}{location} from {}", m.sender);
}
let mut preview: String = trimmed.chars().take(PREVIEW_CHARS).collect();
if trimmed.chars().count() > PREVIEW_CHARS {
preview.push('…');
}
let location = stub_location(m);
format!(
"📬 1 new message{location} from {sender}: \"{preview}\"",
sender = m.sender,
)
}
fn stub_location(m: &store::Message) -> String {
if let Some(rest) = m.recipient.strip_prefix("channel:") {
let name = rest.split_once(':').map(|(_, n)| n).unwrap_or(rest);
format!(" in {name}")
} else {
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delivery_mode_channels_only_for_claude_code() {
assert_eq!(delivery_mode(Some("claude-code")), DeliveryMode::Channel);
assert_eq!(delivery_mode(Some("codex")), DeliveryMode::Nudge);
assert_eq!(delivery_mode(Some("gemini")), DeliveryMode::Nudge);
assert_eq!(delivery_mode(Some("some-future-cli")), DeliveryMode::Nudge);
assert_eq!(delivery_mode(None), DeliveryMode::Nudge);
}
#[test]
fn inbox_nudge_line_batches_count_and_previews() {
assert_eq!(
inbox_nudge_line("hello:hugo", "standup in 5", 3),
"📬 hello:hugo: \"standup in 5\" (+2 more) — call inbox_peek, \
then inbox_read each meta.id and inbox_ack when handled."
);
let single = inbox_nudge_line("hello:hugo", "ping", 1);
assert!(
!single.contains("more"),
"single-row nudge must not carry a batch tail: {single}",
);
}
#[test]
fn inbox_nudge_always_starts_with_the_fixed_mailbox_prefix() {
let line = inbox_nudge_line("/quit", "/compact", 1);
assert!(
line.starts_with("📬 "),
"nudge must open with the fixed prefix: {line}",
);
}
#[test]
fn pane_safe_line_collapses_newlines_and_strips_controls() {
assert_eq!(
pane_safe_line("please\n/compact\nnow"),
"please /compact now"
);
assert_eq!(pane_safe_line("red\x1b[31malert\x07\x7f"), "red[31malert");
assert_eq!(pane_safe_line("a\u{009b}31mb\u{0085}c"), "a31mb c");
assert_eq!(pane_safe_line("a\t\tb\r\n c"), "a b c");
}
#[test]
fn inbox_nudge_sanitizes_sender_too() {
let line = inbox_nudge_line("evil\nname\x1b", "hi", 1);
assert!(
line.starts_with("📬 evil name: \"hi\""),
"sender must come out control-free and single-line: {line}",
);
}
#[test]
fn inbox_nudge_preview_truncates_multibyte_on_char_boundary() {
let body = "🦀".repeat(200);
let line = inbox_nudge_line("hello:dev", &body, 1);
assert!(
line.contains(&format!("\"{}…\"", "🦀".repeat(80))),
"preview must cap at 80 chars with an ellipsis: {line}",
);
assert!(!line.contains(&"🦀".repeat(81)));
}
#[test]
fn inbox_nudge_empty_body_still_produces_a_sane_line() {
let line = inbox_nudge_line("hello:dev", " \n\t ", 2);
assert!(
line.starts_with("📬 hello:dev: new message (+1 more)"),
"empty body must fall back to a plain announcement: {line}",
);
assert!(line.contains("inbox_peek"));
}
}