Skip to main content

agent_trace/adapters/mcp/
server.rs

1use crate::agent_trace_md;
2use crate::config::MergedConfig;
3use crate::data_plane::{self, WriteDocumentError};
4use crate::git_store::CommitInfo;
5use crate::manifest::Manifest;
6use crate::observability::format_permission_denied;
7use crate::permissions::{check_permission, Overrides, PermissionResult};
8use crate::running_summary;
9use crate::runtime::ActivityMonitor;
10use crate::session::{self, AgentState};
11use crate::store::Store;
12use crate::types::{Action, Actor, DocType};
13use anyhow::Result;
14use serde_json::{json, Value};
15use std::io::{BufRead, BufReader, Write};
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19pub fn run(root: &Path, actor_name: Option<String>) -> Result<()> {
20    let config = MergedConfig::load(root)?;
21    let manifest = Arc::new(Mutex::new(Manifest::load(root)?));
22    let agent_state = AgentState::new(actor_name.clone());
23    let _monitor = ActivityMonitor::try_start(
24        root,
25        config,
26        manifest,
27        AgentState::new(actor_name.clone()),
28        None,
29    )?;
30
31    let actor = agent_state.current_actor(root);
32    let mut session_id = session::session_id_for_actor(root, &actor);
33    if let Some(name) = actor.agent_name() {
34        if session_id.is_none() {
35            // MCP with explicit actor should create a durable session lineage.
36            if let Ok(s) = session::start_session(root, name, "mcp") {
37                session_id = Some(s.session_id);
38            }
39        } else {
40            let _ = session::touch_session(root, name);
41        }
42    }
43
44    if let Err(e) = running_summary::refresh_if_stale(root) {
45        tracing::warn!("running summary refresh on MCP start failed: {e}");
46    }
47
48    let stdin = std::io::stdin();
49    let stdout = std::io::stdout();
50    let mut reader = BufReader::new(stdin.lock());
51    let mut out = stdout.lock();
52
53    let mut line = String::new();
54    loop {
55        line.clear();
56        let n = reader.read_line(&mut line)?;
57        if n == 0 {
58            break; // EOF — client closed the connection
59        }
60        let trimmed = line.trim();
61        if trimmed.is_empty() {
62            continue;
63        }
64
65        let msg: Value = match serde_json::from_str(trimmed) {
66            Ok(v) => v,
67            Err(e) => {
68                let err = json!({
69                    "jsonrpc": "2.0",
70                    "id": null,
71                    "error": {"code": -32700, "message": format!("Parse error: {}", e)}
72                });
73                writeln!(out, "{err}")?;
74                out.flush()?;
75                continue;
76            }
77        };
78
79        // Notifications have no "id" field — process silently, no response sent
80        let id = match msg.get("id") {
81            Some(id) => id.clone(),
82            None => continue,
83        };
84
85        let method = msg.get("method").and_then(|v| v.as_str()).unwrap_or("");
86        if let Some(name) = actor.agent_name() {
87            let _ = session::touch_session(root, name);
88        }
89        let mut response = dispatch(&msg, method, root, &actor, session_id.as_deref());
90        response["id"] = id;
91
92        writeln!(out, "{response}")?;
93        out.flush()?;
94    }
95    Ok(())
96}
97
98fn dispatch(
99    msg: &Value,
100    method: &str,
101    root: &Path,
102    actor: &Actor,
103    session_id: Option<&str>,
104) -> Value {
105    match method {
106        "initialize" => handle_initialize(),
107        "tools/list" => handle_tools_list(),
108        "tools/call" => {
109            let params = msg.get("params").cloned().unwrap_or(json!({}));
110            let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
111            let args = params.get("arguments").cloned().unwrap_or(json!({}));
112            match name {
113                "read_file" => handle_read_file(root, &args),
114                "write_file" => handle_write_file(root, &args, actor, session_id),
115                "list_documents" => handle_list_documents(root, &args),
116                "get_permissions" => handle_get_permissions(root, actor),
117                "add_document" => handle_add_document(root, &args),
118                "get_resume_context" => handle_get_resume_context(root, actor, &args),
119                _ => error_response(-32601, &format!("Unknown tool: {name}")),
120            }
121        }
122        _ => error_response(-32601, &format!("Method not found: {method}")),
123    }
124}
125
126fn handle_initialize() -> Value {
127    json!({
128        "jsonrpc": "2.0",
129        "result": {
130            "protocolVersion": "2024-11-05",
131            "capabilities": {"tools": {}},
132            "serverInfo": {
133                "name": "agent-trace",
134                "version": env!("CARGO_PKG_VERSION")
135            },
136            "instructions": "Call get_resume_context before other tools to load session state."
137        }
138    })
139}
140
141fn handle_tools_list() -> Value {
142    json!({
143        "jsonrpc": "2.0",
144        "result": {
145            "tools": [
146                {
147                    "name": "read_file",
148                    "description": "Read a tracked document from the agent-trace store",
149                    "inputSchema": {
150                        "type": "object",
151                        "properties": {
152                            "path": {"type": "string", "description": "Relative path to the file"}
153                        },
154                        "required": ["path"]
155                    }
156                },
157                {
158                    "name": "write_file",
159                    "description": "Write content to a document. Enforces permissions synchronously — returns an error if the current actor cannot write this document type.",
160                    "inputSchema": {
161                        "type": "object",
162                        "properties": {
163                            "path": {"type": "string", "description": "Relative path to the file"},
164                            "content": {"type": "string", "description": "New file content"}
165                        },
166                        "required": ["path", "content"]
167                    }
168                },
169                {
170                    "name": "list_documents",
171                    "description": "List tracked documents, optionally filtered by type",
172                    "inputSchema": {
173                        "type": "object",
174                        "properties": {
175                            "type": {
176                                "type": "string",
177                                "description": "Filter by doc type: plan, context, log, reference, scratch"
178                            }
179                        }
180                    }
181                },
182                {
183                    "name": "get_permissions",
184                    "description": "Show what the current actor can read and write",
185                    "inputSchema": {
186                        "type": "object",
187                        "properties": {}
188                    }
189                },
190                {
191                    "name": "add_document",
192                    "description": "Register an existing file as a tracked document with a given type",
193                    "inputSchema": {
194                        "type": "object",
195                        "properties": {
196                            "path": {"type": "string", "description": "Relative path to the file"},
197                            "doc_type": {
198                                "type": "string",
199                                "description": "Document type: plan, context, log, reference, or scratch"
200                            }
201                        },
202                        "required": ["path", "doc_type"]
203                    }
204                },
205                {
206                    "name": "get_resume_context",
207                    "description": "Get the four-section resume briefing (objective, current state, recent events, earlier work). Call FIRST after initialize.",
208                    "inputSchema": {
209                        "type": "object",
210                        "properties": {
211                            "include_git_log": {"type": "boolean", "default": false},
212                            "git_log_limit": {"type": "integer", "default": 10},
213                            "include_prior_recap": {"type": "boolean", "default": true},
214                            "include_session_log": {"type": "boolean", "default": false}
215                        }
216                    }
217                }
218            ]
219        }
220    })
221}
222
223fn handle_get_resume_context(root: &Path, actor: &Actor, args: &Value) -> Value {
224    let include_git_log = args
225        .get("include_git_log")
226        .and_then(|v| v.as_bool())
227        .unwrap_or(false);
228    let git_log_limit = args
229        .get("git_log_limit")
230        .and_then(|v| v.as_u64())
231        .unwrap_or(10) as usize;
232    let include_prior_recap = args
233        .get("include_prior_recap")
234        .and_then(|v| v.as_bool())
235        .unwrap_or(true);
236    let include_session_log = args
237        .get("include_session_log")
238        .and_then(|v| v.as_bool())
239        .unwrap_or(false);
240
241    if let Err(e) = crate::session_recap::ensure_prior_session_recap(root) {
242        tracing::warn!("prior session recap failed: {e}");
243    }
244
245    if let Err(e) = running_summary::refresh_if_stale(root) {
246        tracing::warn!("running summary refresh before resume context failed: {e}");
247    }
248
249    let opts = crate::briefing::BriefingOptions {
250        include_git_log,
251        include_prior_recap,
252        include_session_log,
253        git_log_limit,
254        ..Default::default()
255    };
256
257    match crate::briefing::assemble_resume_briefing(root, actor, &opts) {
258        Ok(text) => tool_result(&text),
259        Err(e) => error_response(-32603, &format!("Cannot assemble resume context: {e}")),
260    }
261}
262
263fn handle_read_file(root: &Path, args: &Value) -> Value {
264    let path_str = match args.get("path").and_then(|v| v.as_str()) {
265        Some(p) => p,
266        None => return error_response(-32602, "Missing required argument: path"),
267    };
268    let rel = PathBuf::from(path_str);
269    let full = root.join(&rel);
270
271    let content = match std::fs::read_to_string(&full) {
272        Ok(c) => c,
273        Err(e) => return error_response(-32603, &format!("Cannot read {path_str}: {e}")),
274    };
275
276    let doc_type = Store::open(root)
277        .ok()
278        .and_then(|s| s.manifest.find_by_path(&rel).map(|e| e.doc_type.clone()))
279        .map(|dt| dt.to_string())
280        .unwrap_or_else(|| "untracked".to_string());
281
282    tool_result(&format!(
283        "path: {path_str}\ndoc_type: {doc_type}\n\n{content}"
284    ))
285}
286
287fn handle_write_file(root: &Path, args: &Value, actor: &Actor, session_id: Option<&str>) -> Value {
288    let path_str = match args.get("path").and_then(|v| v.as_str()) {
289        Some(p) => p,
290        None => return error_response(-32602, "Missing required argument: path"),
291    };
292    let content = match args.get("content").and_then(|v| v.as_str()) {
293        Some(c) => c,
294        None => return error_response(-32602, "Missing required argument: content"),
295    };
296
297    let rel = PathBuf::from(path_str);
298    match data_plane::write_document(root, &rel, content, actor, "mcp write", session_id) {
299        Ok(_) => tool_result(&format!("OK: {path_str} written")),
300        Err(WriteDocumentError::PermissionDenied { path, reason }) => {
301            tool_error(&format_permission_denied(&path, &reason))
302        }
303        Err(WriteDocumentError::Other(e)) => error_response(-32603, &format!("Write failed: {e}")),
304    }
305}
306
307fn handle_list_documents(root: &Path, args: &Value) -> Value {
308    let type_filter: Option<DocType> = args
309        .get("type")
310        .and_then(|v| v.as_str())
311        .and_then(|s| s.parse().ok());
312
313    let store = match Store::open(root) {
314        Ok(s) => s,
315        Err(e) => return error_response(-32603, &format!("Cannot open store: {e}")),
316    };
317
318    let docs: Vec<Value> = store
319        .manifest
320        .list(type_filter.as_ref())
321        .iter()
322        .map(|d| {
323            json!({
324                "path": d.path.display().to_string(),
325                "doc_type": d.doc_type.to_string(),
326                "id": d.id.to_string(),
327            })
328        })
329        .collect();
330
331    let text = serde_json::to_string_pretty(&docs).unwrap_or_default();
332    tool_result(&text)
333}
334
335fn handle_get_permissions(root: &Path, actor: &Actor) -> Value {
336    let overrides = Overrides::load(root).unwrap_or_default();
337
338    let doc_types = [
339        DocType::Plan,
340        DocType::Context,
341        DocType::Log,
342        DocType::Reference,
343        DocType::Scratch,
344    ];
345
346    let perms: Vec<Value> = doc_types
347        .iter()
348        .map(|dt| {
349            let status = match check_permission(dt, actor, &overrides, None) {
350                PermissionResult::Allowed => "allowed",
351                PermissionResult::Denied { .. } => "denied",
352                PermissionResult::RequiresConfirmation { .. } => "requires_confirmation",
353            };
354            json!({"doc_type": dt.to_string(), "write": status})
355        })
356        .collect();
357
358    let text = format!(
359        "Actor: {}\nPermissions:\n{}",
360        actor,
361        serde_json::to_string_pretty(&perms).unwrap_or_default()
362    );
363    tool_result(&text)
364}
365
366fn handle_add_document(root: &Path, args: &Value) -> Value {
367    let path_str = match args.get("path").and_then(|v| v.as_str()) {
368        Some(p) => p,
369        None => return error_response(-32602, "Missing required argument: path"),
370    };
371    let doc_type_str = match args.get("doc_type").and_then(|v| v.as_str()) {
372        Some(t) => t,
373        None => return error_response(-32602, "Missing required argument: doc_type"),
374    };
375    let doc_type: DocType = match doc_type_str.parse() {
376        Ok(dt) => dt,
377        Err(e) => return error_response(-32602, &format!("Invalid doc_type: {e}")),
378    };
379
380    let rel = PathBuf::from(path_str);
381    let mut store = match Store::open(root) {
382        Ok(s) => s,
383        Err(e) => return error_response(-32603, &format!("Cannot open store: {e}")),
384    };
385
386    if !root.join(&rel).exists() {
387        return tool_error(&format!("File does not exist: {path_str}"));
388    }
389    if store.manifest.is_tracked(&rel) {
390        return tool_error(&format!("Already tracked: {path_str}"));
391    }
392
393    if let Err(e) = store.manifest.register(&rel, doc_type.clone(), "") {
394        return error_response(-32603, &format!("Cannot register: {e}"));
395    }
396    if let Err(e) = store.manifest.save(root) {
397        return error_response(-32603, &format!("Cannot save manifest: {e}"));
398    }
399
400    let at_content = agent_trace_md::generate(root, &store.manifest);
401    let _ = std::fs::write(root.join("AGENT-TRACE.md"), &at_content);
402
403    let info = CommitInfo {
404        action: Action::Create,
405        files: vec![
406            (rel.clone(), Action::Create, doc_type.clone()),
407            (
408                PathBuf::from("AGENT-TRACE.md"),
409                Action::Modify,
410                DocType::Reference,
411            ),
412        ],
413        actor: Actor::System,
414        summary: format!("mcp add: {path_str} as {doc_type}"),
415        agent_name: None,
416        session_id: None,
417    };
418    if let Err(e) = store.commit(&info) {
419        return error_response(-32603, &format!("Cannot commit: {e}"));
420    }
421
422    tool_result(&format!("Added {path_str} as {doc_type}"))
423}
424
425// ── Response helpers ──────────────────────────────────────────────────────────
426
427fn tool_result(text: &str) -> Value {
428    json!({
429        "jsonrpc": "2.0",
430        "result": {
431            "content": [{"type": "text", "text": text}],
432            "isError": false
433        }
434    })
435}
436
437fn tool_error(text: &str) -> Value {
438    json!({
439        "jsonrpc": "2.0",
440        "result": {
441            "content": [{"type": "text", "text": text}],
442            "isError": true
443        }
444    })
445}
446
447fn error_response(code: i32, message: &str) -> Value {
448    json!({
449        "jsonrpc": "2.0",
450        "error": {"code": code, "message": message}
451    })
452}
453
454// ── Unit tests ────────────────────────────────────────────────────────────────
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::config::{GlobalConfig, MergedConfig, PollingConfig, StoreConfig, StoreInfo};
460    use crate::git_store::GitStore;
461    use crate::manifest::Manifest;
462    use tempfile::TempDir;
463
464    fn setup_store(tmp: &TempDir) -> PathBuf {
465        let root = tmp.path().to_path_buf();
466        std::fs::create_dir_all(root.join(".agent-trace/locks")).unwrap();
467        let git = GitStore::init(&root).unwrap();
468        let info = StoreInfo::new("test".into());
469        let manifest = Manifest::create_empty(info.clone(), &root).unwrap();
470        let global = GlobalConfig::default();
471        let store_cfg = StoreConfig {
472            store: info,
473            llm: None,
474            synthesis: None,
475            polling: PollingConfig::default(),
476        };
477        store_cfg.save(&root).unwrap();
478        let config = MergedConfig::merge(global, store_cfg);
479        // Silence unused warning — we init git which sets up the repo
480        drop((git, manifest, config));
481        root
482    }
483
484    fn agent(name: &str) -> Actor {
485        Actor::Agent { name: name.into() }
486    }
487
488    #[test]
489    fn test_initialize_response() {
490        let resp = handle_initialize();
491        assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
492        assert_eq!(resp["result"]["serverInfo"]["name"], "agent-trace");
493        assert!(resp["result"]["instructions"]
494            .as_str()
495            .unwrap()
496            .contains("get_resume_context"));
497        assert!(resp.get("error").is_none());
498    }
499
500    #[test]
501    fn test_tools_list_contains_all_tools() {
502        let resp = handle_tools_list();
503        let tools = resp["result"]["tools"].as_array().unwrap();
504        let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
505        assert!(names.contains(&"read_file"));
506        assert!(names.contains(&"write_file"));
507        assert!(names.contains(&"list_documents"));
508        assert!(names.contains(&"get_permissions"));
509        assert!(names.contains(&"add_document"));
510        assert!(names.contains(&"get_resume_context"));
511        assert_eq!(names.len(), 6);
512    }
513
514    #[test]
515    fn test_write_file_allowed_plan() {
516        let tmp = TempDir::new().unwrap();
517        let root = setup_store(&tmp);
518        std::fs::write(root.join("plan.md"), "# Plan").unwrap();
519        Store::open(&root).unwrap(); // ensure store opens
520                                     // Add plan.md via add command path so it's tracked
521        let mut store = Store::open(&root).unwrap();
522        store
523            .manifest
524            .register(&PathBuf::from("plan.md"), DocType::Plan, "")
525            .unwrap();
526        store.manifest.save(&root).unwrap();
527        let info = CommitInfo {
528            action: Action::Create,
529            files: vec![(PathBuf::from("plan.md"), Action::Create, DocType::Plan)],
530            actor: Actor::System,
531            summary: "setup".into(),
532            agent_name: None,
533            session_id: None,
534        };
535        store.commit(&info).unwrap();
536
537        let args = json!({"path": "plan.md", "content": "# Updated Plan"});
538        let resp = handle_write_file(&root, &args, &agent("test-agent"), None);
539        assert_eq!(resp["result"]["isError"], false);
540        assert_eq!(
541            std::fs::read_to_string(root.join("plan.md")).unwrap(),
542            "# Updated Plan"
543        );
544    }
545
546    #[test]
547    fn test_write_file_denied_context() {
548        let tmp = TempDir::new().unwrap();
549        let root = setup_store(&tmp);
550        std::fs::write(root.join("context.md"), "# Context").unwrap();
551        let mut store = Store::open(&root).unwrap();
552        store
553            .manifest
554            .register(&PathBuf::from("context.md"), DocType::Context, "")
555            .unwrap();
556        store.manifest.save(&root).unwrap();
557        let info = CommitInfo {
558            action: Action::Create,
559            files: vec![(
560                PathBuf::from("context.md"),
561                Action::Create,
562                DocType::Context,
563            )],
564            actor: Actor::System,
565            summary: "setup".into(),
566            agent_name: None,
567            session_id: None,
568        };
569        store.commit(&info).unwrap();
570
571        let original = std::fs::read_to_string(root.join("context.md")).unwrap();
572        let args = json!({"path": "context.md", "content": "# Hacked"});
573        let resp = handle_write_file(&root, &args, &agent("test-agent"), None);
574
575        assert_eq!(resp["result"]["isError"], true);
576        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
577        assert!(
578            text.contains("Permission denied"),
579            "expected denial, got: {text}"
580        );
581        // File must be unchanged
582        assert_eq!(
583            std::fs::read_to_string(root.join("context.md")).unwrap(),
584            original
585        );
586    }
587
588    #[test]
589    fn test_list_documents_returns_all() {
590        let tmp = TempDir::new().unwrap();
591        let root = setup_store(&tmp);
592        std::fs::write(root.join("plan.md"), "p").unwrap();
593        std::fs::write(root.join("ref.md"), "r").unwrap();
594        let mut store = Store::open(&root).unwrap();
595        store
596            .manifest
597            .register(&PathBuf::from("plan.md"), DocType::Plan, "")
598            .unwrap();
599        store
600            .manifest
601            .register(&PathBuf::from("ref.md"), DocType::Reference, "")
602            .unwrap();
603        store.manifest.save(&root).unwrap();
604        let info = CommitInfo {
605            action: Action::Create,
606            files: vec![
607                (PathBuf::from("plan.md"), Action::Create, DocType::Plan),
608                (PathBuf::from("ref.md"), Action::Create, DocType::Reference),
609            ],
610            actor: Actor::System,
611            summary: "setup".into(),
612            agent_name: None,
613            session_id: None,
614        };
615        store.commit(&info).unwrap();
616
617        let resp = handle_list_documents(&root, &json!({}));
618        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
619        assert!(text.contains("plan.md"));
620        assert!(text.contains("ref.md"));
621    }
622
623    #[test]
624    fn test_list_documents_type_filter() {
625        let tmp = TempDir::new().unwrap();
626        let root = setup_store(&tmp);
627        std::fs::write(root.join("plan.md"), "p").unwrap();
628        std::fs::write(root.join("ref.md"), "r").unwrap();
629        let mut store = Store::open(&root).unwrap();
630        store
631            .manifest
632            .register(&PathBuf::from("plan.md"), DocType::Plan, "")
633            .unwrap();
634        store
635            .manifest
636            .register(&PathBuf::from("ref.md"), DocType::Reference, "")
637            .unwrap();
638        store.manifest.save(&root).unwrap();
639        let info = CommitInfo {
640            action: Action::Create,
641            files: vec![
642                (PathBuf::from("plan.md"), Action::Create, DocType::Plan),
643                (PathBuf::from("ref.md"), Action::Create, DocType::Reference),
644            ],
645            actor: Actor::System,
646            summary: "setup".into(),
647            agent_name: None,
648            session_id: None,
649        };
650        store.commit(&info).unwrap();
651
652        let resp = handle_list_documents(&root, &json!({"type": "plan"}));
653        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
654        assert!(text.contains("plan.md"));
655        assert!(
656            !text.contains("ref.md"),
657            "type filter should exclude ref.md"
658        );
659    }
660
661    #[test]
662    fn test_get_permissions_agent_denied_context() {
663        let tmp = TempDir::new().unwrap();
664        let root = setup_store(&tmp);
665        let resp = handle_get_permissions(&root, &agent("test-agent"));
666        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
667        assert!(text.contains("context"), "should mention context type");
668        assert!(
669            text.contains("denied"),
670            "context should be denied for agent"
671        );
672        assert!(text.contains("allowed"), "plan should be allowed for agent");
673    }
674
675    #[test]
676    fn test_unknown_method_returns_error() {
677        let msg = json!({"jsonrpc":"2.0","id":1,"method":"bogus","params":{}});
678        let resp = dispatch(&msg, "bogus", Path::new("/tmp"), &Actor::User, None);
679        assert!(resp.get("error").is_some());
680        assert_eq!(resp["error"]["code"], -32601);
681    }
682
683    #[test]
684    fn test_unknown_tool_returns_error() {
685        let msg = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"fly","arguments":{}}});
686        let resp = dispatch(&msg, "tools/call", Path::new("/tmp"), &Actor::User, None);
687        assert!(resp.get("error").is_some());
688        assert_eq!(resp["error"]["code"], -32601);
689    }
690
691    #[test]
692    fn test_write_file_missing_path_arg() {
693        let args = json!({"content": "hello"});
694        let resp = handle_write_file(Path::new("/tmp"), &args, &Actor::User, None);
695        assert_eq!(resp["error"]["code"], -32602);
696    }
697
698    #[test]
699    fn test_write_file_missing_content_arg() {
700        let args = json!({"path": "plan.md"});
701        let resp = handle_write_file(Path::new("/tmp"), &args, &Actor::User, None);
702        assert_eq!(resp["error"]["code"], -32602);
703    }
704}