thoughts_tool/mcp/
mod.rs

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// Type definitions
13
14#[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// Tool implementation
49
50#[derive(Clone, Default)]
51pub struct ThoughtsMcpTools;
52
53#[universal_tool_router(mcp(name = "thoughts_tool", version = "0.3.0"))]
54impl ThoughtsMcpTools {
55    /// Write markdown to active work directory (research/plans/artifacts)
56    #[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 filename
67        validate_simple_filename(&filename).map_err(|e| ToolError::invalid_input(e.to_string()))?;
68
69        // Ensure active work exists
70        let aw =
71            ensure_active_work().map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
72
73        // Determine target path
74        let dir = doc_type.subdir(&aw);
75        let target = dir.join(&filename);
76
77        // Atomic write
78        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        // Return repo-relative path
86        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    /// List files in current active work directory
96    #[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        // Determine which subdirs to scan
108        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    /// List reference repository directory paths
153    #[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                    // Best-effort fallback for unparseable URLs
182                    out.push(format!("{}/{}", ds.mount_dirs.references, url));
183                }
184            }
185        }
186        Ok(out)
187    }
188}
189
190// MCP server wrapper
191pub struct ThoughtsMcpServer {
192    tools: std::sync::Arc<ThoughtsMcpTools>,
193}
194universal_tool_core::implement_mcp_server!(ThoughtsMcpServer, tools);
195
196/// Serve MCP over stdio (called from main)
197pub 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}