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(),
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,
stdout: Arc<Mutex<Stdout>>,
initialized: Arc<Notify>,
lazy_inbox: bool,
) {
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;
for m in msgs.iter().filter(|m| m.id > last_seen) {
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;
}
}
last_seen = max_id;
}
});
}
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()
}
}