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 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; }
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 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
425fn 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#[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 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(); 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 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}