use std::path::{Path, PathBuf};
use std::sync::Arc;
use rmcp::ErrorData;
use rmcp::ServiceExt;
use rmcp::handler::server::ServerHandler;
use rmcp::handler::server::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, ServerCapabilities, ServerInfo,
};
use rmcp::transport::stdio;
use rmcp::{schemars, tool, tool_handler, tool_router};
use serde::Deserialize;
use serde_json::json;
use tokio::sync::Mutex;
const DEFAULT_MAX_TURNS: u32 = 30;
#[derive(Clone)]
pub struct DirgeMcp {
state: Arc<Mutex<State>>,
#[allow(dead_code)]
tool_router: ToolRouter<DirgeMcp>,
}
struct State {
project_dir: PathBuf,
exe: PathBuf,
session_id: String,
label: Option<String>,
model: Option<String>,
sandbox: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct DelegateArgs {
task: String,
#[serde(default)]
new_session: bool,
#[serde(default)]
session_label: Option<String>,
#[serde(default)]
max_turns: Option<u32>,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct NewSessionArgs {
#[serde(default)]
label: Option<String>,
}
#[tool_handler]
impl ServerHandler for DirgeMcp {
fn get_info(&self) -> ServerInfo {
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("dirge", env!("CARGO_PKG_VERSION")))
.with_instructions(
"dirge is a coding agent you delegate implementation work to. Call `delegate` \
with a task — dirge edits files and runs commands in this project on a \
persistent session, then returns a summary plus the files it changed for you \
to review. Call `delegate` again in the same session to ask for a fix (it keeps \
the context). Set new_session=true (or call `new_session`) when moving to a new \
task/thread so it isn't anchored to the old one. Use `session_info` / \
`list_sessions` for orientation.",
)
}
}
#[tool_router]
impl DirgeMcp {
fn new(state: State) -> Self {
Self {
state: Arc::new(Mutex::new(state)),
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Delegate an implementation task to dirge. dirge works in the project \
(editing files, running commands) on its persistent session and returns a summary \
plus the list of files it changed, so you can review the result and either accept \
it, ask for a fix (call delegate again — same session keeps the context), or move \
on. Set new_session=true when starting a new task/thread."
)]
async fn delegate(
&self,
Parameters(args): Parameters<DelegateArgs>,
) -> Result<CallToolResult, ErrorData> {
let max_turns = args.max_turns.unwrap_or(DEFAULT_MAX_TURNS);
let (exe, project_dir, session_id, model, sandbox) = {
let mut st = self.state.lock().await;
if args.new_session {
st.session_id = new_session_id();
st.label = args.session_label.clone();
if let Err(e) = persist_pointer(&st.project_dir, &st.session_id, &st.label) {
return Ok(tool_err(format!("failed to persist new session: {e}")));
}
}
(
st.exe.clone(),
st.project_dir.clone(),
st.session_id.clone(),
st.model.clone(),
st.sandbox.clone(),
)
};
let before = git_status_set(&project_dir);
let run = run_delegation(
&exe,
&project_dir,
&session_id,
&args.task,
max_turns,
model.as_deref(),
sandbox.as_deref(),
)
.await;
let after = git_status_set(&project_dir);
let files_changed = changed_paths(&before, &after);
match run {
Ok(env) => {
let result = json!({
"session_id": session_id,
"status": env.status,
"summary": env.summary,
"files_changed": files_changed,
"turns": env.turns,
"duration_ms": env.duration_ms,
});
Ok(tool_json(&result))
}
Err(e) => Ok(tool_err(format!("delegation failed to run: {e}"))),
}
}
#[tool(
description = "Start a fresh dirge session (new task/thread) without immediately \
delegating. Returns the new session id; subsequent delegate calls use it."
)]
async fn new_session(
&self,
Parameters(args): Parameters<NewSessionArgs>,
) -> Result<CallToolResult, ErrorData> {
let mut st = self.state.lock().await;
st.session_id = new_session_id();
st.label = args.label.clone();
if let Err(e) = persist_pointer(&st.project_dir, &st.session_id, &st.label) {
return Ok(tool_err(format!("failed to persist new session: {e}")));
}
Ok(tool_json(&json!({
"session_id": st.session_id,
"label": st.label,
})))
}
#[tool(
description = "Info about the current dirge session: its id, label, the project dir, \
message count, last activity, and the model dirge uses for delegated work."
)]
async fn session_info(&self) -> Result<CallToolResult, ErrorData> {
let st = self.state.lock().await;
let (message_count, last_active) =
match crate::session::storage::load_session(&st.session_id) {
Ok(s) => (s.messages.len(), Some(s.updated_at.to_string())),
Err(_) => (0, None),
};
Ok(tool_json(&json!({
"session_id": st.session_id,
"label": st.label,
"project_dir": st.project_dir.to_string_lossy(),
"message_count": message_count,
"last_active": last_active,
"model": st.model,
"sandbox": st.sandbox,
})))
}
#[tool(
description = "List recent dirge sessions in this project (id, last activity, and a \
one-line preview) so you can resume a past task thread by passing its id."
)]
async fn list_sessions(&self) -> Result<CallToolResult, ErrorData> {
let sessions = crate::session::storage::find_recent_sessions(20).unwrap_or_default();
let list: Vec<_> = sessions
.iter()
.map(|s| {
json!({
"id": s.id,
"last_active": s.updated_at,
"messages": s.messages.len(),
"preview": crate::ui::events::session_preview(s, 80),
})
})
.collect();
Ok(tool_json(&json!({ "sessions": list })))
}
}
struct Envelope {
status: String,
summary: String,
turns: u64,
duration_ms: u64,
}
async fn run_delegation(
exe: &Path,
project_dir: &Path,
session_id: &str,
task: &str,
max_turns: u32,
model: Option<&str>,
sandbox: Option<&str>,
) -> anyhow::Result<Envelope> {
let mut cmd = tokio::process::Command::new(exe);
cmd.current_dir(project_dir)
.arg("--print")
.arg("--accept-all")
.arg("--session")
.arg(session_id)
.arg("--output-format")
.arg("json")
.arg("--max-agent-turns")
.arg(max_turns.to_string());
if let Some(m) = model {
cmd.arg("--model").arg(m);
}
if let Some(s) = sandbox {
cmd.arg("--sandbox").arg(s);
}
cmd.arg("--").arg(task);
cmd.stdin(std::process::Stdio::null());
let out = cmd
.output()
.await
.map_err(|e| anyhow::anyhow!("spawn dirge: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let env_val = stdout
.lines()
.rev()
.find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
.ok_or_else(|| {
anyhow::anyhow!(
"dirge produced no JSON result (exit {}). stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)
})?;
Ok(Envelope {
status: env_val
.get("subtype")
.and_then(|v| v.as_str())
.unwrap_or("error")
.to_string(),
summary: env_val
.get("result")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
turns: env_val
.get("num_turns")
.and_then(|v| v.as_u64())
.unwrap_or(0),
duration_ms: env_val
.get("duration_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
})
}
fn git_status_set(dir: &Path) -> std::collections::HashMap<String, (u64, i128)> {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(["status", "--porcelain"])
.output();
let mut map = std::collections::HashMap::new();
if let Ok(o) = out
&& o.status.success()
{
for line in String::from_utf8_lossy(&o.stdout).lines() {
let path = porcelain_path(line);
if path.is_empty() {
continue;
}
let sig = std::fs::metadata(dir.join(&path))
.ok()
.map(|m| (m.len(), mtime_nanos(&m)))
.unwrap_or((0, 0));
map.insert(path, sig);
}
}
map
}
fn porcelain_path(line: &str) -> String {
let p = line.get(3..).unwrap_or("").trim();
match p.find(" -> ") {
Some(i) => p[i + 4..].to_string(),
None => p.to_string(),
}
}
fn mtime_nanos(m: &std::fs::Metadata) -> i128 {
m.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i128)
.unwrap_or(0)
}
fn changed_paths(
before: &std::collections::HashMap<String, (u64, i128)>,
after: &std::collections::HashMap<String, (u64, i128)>,
) -> Vec<String> {
let mut v: Vec<String> = after
.iter()
.filter(|(path, sig)| before.get(*path) != Some(*sig))
.map(|(path, _)| path.clone())
.collect();
v.sort();
v.dedup();
v
}
fn new_session_id() -> String {
format!("mcp-{}", crate::agent::runner::uuid_v4_simple())
}
fn pointer_path(project_dir: &Path) -> PathBuf {
project_dir.join(".dirge").join("mcp_current_session.json")
}
fn persist_pointer(project_dir: &Path, id: &str, label: &Option<String>) -> anyhow::Result<()> {
let path = pointer_path(project_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, json!({ "id": id, "label": label }).to_string())?;
Ok(())
}
fn load_or_create_pointer(project_dir: &Path) -> anyhow::Result<(String, Option<String>)> {
let path = pointer_path(project_dir);
if let Ok(bytes) = std::fs::read(&path)
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
&& let Some(id) = v.get("id").and_then(|x| x.as_str())
{
let label = v
.get("label")
.and_then(|x| x.as_str())
.map(|s| s.to_string());
return Ok((id.to_string(), label));
}
let id = new_session_id();
persist_pointer(project_dir, &id, &None)?;
Ok((id, None))
}
fn tool_json(value: &serde_json::Value) -> CallToolResult {
let body = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
CallToolResult::success(vec![Content::text(body)])
}
fn tool_err(msg: String) -> CallToolResult {
CallToolResult::error(vec![Content::text(msg)])
}
pub async fn serve(
_cli: &crate::cli::Cli,
_cfg: &crate::config::Config,
model: Option<String>,
sandbox: Option<String>,
) -> anyhow::Result<()> {
let project_dir = std::env::current_dir()?;
let exe = std::env::current_exe()?;
let (session_id, label) = load_or_create_pointer(&project_dir)?;
let server = DirgeMcp::new(State {
project_dir,
exe,
session_id,
label,
model,
sandbox,
});
let service = server
.serve(stdio())
.await
.map_err(|e| anyhow::anyhow!("MCP server failed to start: {e}"))?;
service
.waiting()
.await
.map_err(|e| anyhow::anyhow!("MCP server error: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changed_paths_detects_new_and_modified_not_untouched() {
use std::collections::HashMap;
let before: HashMap<String, (u64, i128)> =
[("src/a.rs".into(), (10, 1)), ("c.rs".into(), (5, 1))].into();
let after: HashMap<String, (u64, i128)> = [
("src/a.rs".into(), (10, 1)), ("src/b.rs".into(), (3, 2)), ("c.rs".into(), (8, 9)), ]
.into();
let changed = changed_paths(&before, &after);
assert_eq!(changed, vec!["c.rs".to_string(), "src/b.rs".to_string()]);
}
#[test]
fn porcelain_path_strips_status_and_handles_rename() {
assert_eq!(porcelain_path("?? src/b.rs"), "src/b.rs");
assert_eq!(porcelain_path(" M src/a.rs"), "src/a.rs");
assert_eq!(porcelain_path("R old.rs -> new.rs"), "new.rs");
}
#[test]
fn new_session_id_is_prefixed() {
let id = new_session_id();
assert!(id.starts_with("mcp-"), "got {id}");
assert!(id.len() > 4);
}
#[test]
fn pointer_round_trips_and_persists() {
let dir = std::env::temp_dir().join(format!(
"dirge-mcp-ptr-{}",
crate::agent::runner::uuid_v4_simple()
));
std::fs::create_dir_all(&dir).unwrap();
let (id1, label1) = load_or_create_pointer(&dir).unwrap();
assert!(id1.starts_with("mcp-"));
assert_eq!(label1, None);
let (id2, _) = load_or_create_pointer(&dir).unwrap();
assert_eq!(id1, id2);
persist_pointer(&dir, "mcp-fixed", &Some("auth".to_string())).unwrap();
let (id3, label3) = load_or_create_pointer(&dir).unwrap();
assert_eq!(id3, "mcp-fixed");
assert_eq!(label3, Some("auth".to_string()));
let _ = std::fs::remove_dir_all(&dir);
}
}