1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use universal_tool_core::mcp::ServiceExt;
5use universal_tool_core::prelude::*;
6
7use crate::config::RepoConfigManager;
8use crate::config::extract_org_repo_from_url;
9use crate::utils::validation::validate_simple_filename;
10use crate::workspace::{ActiveWork, ensure_active_work};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "snake_case")]
16pub enum DocumentType {
17 Research,
18 Plan,
19 Artifact,
20}
21
22impl DocumentType {
23 fn subdir<'a>(&self, aw: &'a ActiveWork) -> &'a std::path::PathBuf {
24 match self {
25 DocumentType::Research => &aw.research,
26 DocumentType::Plan => &aw.plans,
27 DocumentType::Artifact => &aw.artifacts,
28 }
29 }
30
31 fn subdir_name(&self) -> &'static str {
32 match self {
33 DocumentType::Research => "research",
34 DocumentType::Plan => "plans",
35 DocumentType::Artifact => "artifacts",
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41pub struct DocumentInfo {
42 pub path: String,
43 pub doc_type: String,
44 pub size: u64,
45 pub modified: String,
46}
47
48#[derive(Clone, Default)]
51pub struct ThoughtsMcpTools;
52
53#[universal_tool_router(mcp(name = "thoughts_tool", version = "0.3.0"))]
54impl ThoughtsMcpTools {
55 #[universal_tool(
57 description = "Write markdown to the active work directory",
58 mcp(destructive = false)
59 )]
60 pub async fn write_document(
61 &self,
62 doc_type: DocumentType,
63 filename: String,
64 content: String,
65 ) -> Result<String, ToolError> {
66 validate_simple_filename(&filename).map_err(|e| ToolError::invalid_input(e.to_string()))?;
68
69 let aw =
71 ensure_active_work().map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
72
73 let dir = doc_type.subdir(&aw);
75 let target = dir.join(&filename);
76
77 let af =
79 atomicwrites::AtomicFile::new(&target, atomicwrites::OverwriteBehavior::AllowOverwrite);
80 af.write(|f| std::io::Write::write_all(f, content.as_bytes()))
81 .map_err(|e| {
82 ToolError::new(ErrorCode::IoError, format!("Failed to write file: {}", e))
83 })?;
84
85 let repo_rel = format!(
87 "thoughts/active/{}/{}/{}",
88 aw.dir_name,
89 doc_type.subdir_name(),
90 filename
91 );
92 Ok(repo_rel)
93 }
94
95 #[universal_tool(
97 description = "List files in the current active work directory",
98 mcp(read_only = true, idempotent = true)
99 )]
100 pub async fn list_active_documents(
101 &self,
102 subdir: Option<DocumentType>,
103 ) -> Result<Vec<DocumentInfo>, ToolError> {
104 let aw =
105 ensure_active_work().map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
106
107 let sets: Vec<(String, std::path::PathBuf)> = match subdir {
109 Some(d) => vec![(d.subdir_name().to_string(), d.subdir(&aw).clone())],
110 None => vec![
111 ("research".to_string(), aw.research.clone()),
112 ("plans".to_string(), aw.plans.clone()),
113 ("artifacts".to_string(), aw.artifacts.clone()),
114 ],
115 };
116
117 let mut out = Vec::new();
118 for (name, dir) in sets {
119 if !dir.exists() {
120 continue;
121 }
122 for entry in fs::read_dir(&dir).map_err(|e| {
123 ToolError::new(
124 ErrorCode::IoError,
125 format!("Failed to read dir {}: {}", dir.display(), e),
126 )
127 })? {
128 let entry = entry.map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
129 let meta = entry
130 .metadata()
131 .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
132 if meta.is_file() {
133 let modified = meta
134 .modified()
135 .ok()
136 .and_then(|t| chrono::DateTime::<chrono::Utc>::from(t).into())
137 .unwrap_or_else(chrono::Utc::now);
138 let file_name = entry.file_name().to_string_lossy().to_string();
139 out.push(DocumentInfo {
140 path: format!("thoughts/active/{}/{}/{}", aw.dir_name, name, file_name),
141 doc_type: name.clone(),
142 size: meta.len(),
143 modified: modified.to_rfc3339(),
144 });
145 }
146 }
147 }
148
149 Ok(out)
150 }
151
152 #[universal_tool(
154 description = "List reference repository directory paths (references/org/repo)",
155 mcp(read_only = true, idempotent = true)
156 )]
157 pub async fn list_references(&self) -> Result<Vec<String>, ToolError> {
158 let control_root = crate::git::utils::get_control_repo_root(
159 &std::env::current_dir()
160 .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?,
161 )
162 .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
163 let mgr = RepoConfigManager::new(control_root);
164 let ds = mgr
165 .load_desired_state()
166 .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?
167 .ok_or_else(|| {
168 ToolError::new(
169 universal_tool_core::error::ErrorCode::NotFound,
170 "No repository configuration found",
171 )
172 })?;
173
174 let mut out = Vec::new();
175 for url in &ds.references {
176 match extract_org_repo_from_url(url) {
177 Ok((org, repo)) => {
178 out.push(format!("{}/{}/{}", ds.mount_dirs.references, org, repo));
179 }
180 Err(_) => {
181 out.push(format!("{}/{}", ds.mount_dirs.references, url));
183 }
184 }
185 }
186 Ok(out)
187 }
188}
189
190pub struct ThoughtsMcpServer {
192 tools: std::sync::Arc<ThoughtsMcpTools>,
193}
194universal_tool_core::implement_mcp_server!(ThoughtsMcpServer, tools);
195
196pub async fn serve_stdio() -> Result<(), Box<dyn std::error::Error>> {
198 let server = ThoughtsMcpServer {
199 tools: std::sync::Arc::new(ThoughtsMcpTools),
200 };
201 let transport = universal_tool_core::mcp::stdio();
202 let svc = server.serve(transport).await?;
203 svc.waiting().await?;
204 Ok(())
205}