Skip to main content

thoughts_tool/mcp/
mod.rs

1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5mod templates;
6
7use crate::config::validation::{canonical_reference_key, validate_reference_url_https_only};
8use crate::config::{
9    ReferenceEntry, ReferenceMount, RepoConfigManager, RepoMappingManager,
10    extract_org_repo_from_url,
11};
12use crate::git::utils::get_control_repo_root;
13use crate::mount::auto_mount::update_active_mounts;
14use crate::mount::get_mount_manager;
15use crate::platform::detect_platform;
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "snake_case")]
19pub enum TemplateType {
20    Research,
21    Plan,
22    Requirements,
23    PrDescription,
24}
25
26impl TemplateType {
27    pub fn label(&self) -> &'static str {
28        match self {
29            TemplateType::Research => "research",
30            TemplateType::Plan => "plan",
31            TemplateType::Requirements => "requirements",
32            TemplateType::PrDescription => "pr_description",
33        }
34    }
35    pub fn content(&self) -> &'static str {
36        match self {
37            TemplateType::Research => templates::RESEARCH_TEMPLATE_MD,
38            TemplateType::Plan => templates::PLAN_TEMPLATE_MD,
39            TemplateType::Requirements => templates::REQUIREMENTS_TEMPLATE_MD,
40            TemplateType::PrDescription => templates::PR_DESCRIPTION_TEMPLATE_MD,
41        }
42    }
43    pub fn guidance(&self) -> &'static str {
44        match self {
45            TemplateType::Research => templates::RESEARCH_GUIDANCE,
46            TemplateType::Plan => templates::PLAN_GUIDANCE,
47            TemplateType::Requirements => templates::REQUIREMENTS_GUIDANCE,
48            TemplateType::PrDescription => templates::PR_DESCRIPTION_GUIDANCE,
49        }
50    }
51}
52
53// Data types for MCP tools (formatting implementations in src/fmt.rs)
54
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct ReferenceItem {
57    pub path: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub description: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct ReferencesList {
64    pub base: String,
65    pub entries: Vec<ReferenceItem>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct AddReferenceOk {
70    pub url: String,
71    pub org: String,
72    pub repo: String,
73    pub mount_path: String,
74    pub mount_target: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub mapping_path: Option<String>,
77    pub already_existed: bool,
78    pub config_updated: bool,
79    pub cloned: bool,
80    pub mounted: bool,
81    #[serde(default)]
82    pub warnings: Vec<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
86pub struct TemplateResponse {
87    pub template_type: TemplateType,
88}
89
90// Note: Tool implementations are in thoughts-mcp-tools crate using agentic-tools framework.
91
92/// Public adapter for add_reference implementation.
93///
94/// This function is callable by agentic-tools wrappers. It contains the actual
95/// logic for adding a GitHub repository as a reference.
96///
97/// # Arguments
98/// * `url` - HTTPS GitHub URL (https://github.com/org/repo or .git) or generic https://*.git clone URL
99/// * `description` - Optional description for why this reference was added
100///
101/// # Returns
102/// `AddReferenceOk` on success, `anyhow::Error` on failure.
103pub async fn add_reference_impl_adapter(
104    url: String,
105    description: Option<String>,
106) -> Result<AddReferenceOk> {
107    let input_url = url.trim().to_string();
108
109    // Validate URL per MCP HTTPS-only rules
110    validate_reference_url_https_only(&input_url)
111        .context("invalid input: URL failed HTTPS validation")?;
112
113    // Parse org/repo; safe after validation
114    let (org, repo) = extract_org_repo_from_url(&input_url)
115        .context("invalid input: failed to extract org/repo from URL")?;
116
117    // Resolve repo root and config manager
118    let repo_root =
119        get_control_repo_root(&std::env::current_dir().context("failed to get current directory")?)
120            .context("failed to get control repo root")?;
121
122    let mgr = RepoConfigManager::new(repo_root.clone());
123    let mut cfg = mgr
124        .ensure_v2_default()
125        .context("failed to ensure v2 config")?;
126
127    // Build existing canonical keys set for duplicate detection
128    let mut existing_keys = std::collections::HashSet::new();
129    for e in &cfg.references {
130        let existing_url = match e {
131            ReferenceEntry::Simple(s) => s.as_str(),
132            ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
133        };
134        if let Ok(k) = canonical_reference_key(existing_url) {
135            existing_keys.insert(k);
136        }
137    }
138    let this_key =
139        canonical_reference_key(&input_url).context("invalid input: failed to canonicalize URL")?;
140    let already_existed = existing_keys.contains(&this_key);
141
142    // Compute paths for response
143    let ds = mgr
144        .load_desired_state()
145        .context("failed to load desired state")?
146        .ok_or_else(|| anyhow::anyhow!("not found: no repository configuration found"))?;
147    let mount_path = format!("{}/{}/{}", ds.mount_dirs.references, org, repo);
148    let mount_target = repo_root
149        .join(".thoughts-data")
150        .join(&mount_path)
151        .to_string_lossy()
152        .to_string();
153
154    // Capture pre-sync mapping status
155    let repo_mapping =
156        RepoMappingManager::new().context("failed to create repo mapping manager")?;
157    let pre_mapping = repo_mapping
158        .resolve_url(&input_url)
159        .ok()
160        .flatten()
161        .map(|p| p.to_string_lossy().to_string());
162
163    // Update config if new
164    let mut config_updated = false;
165    let mut warnings: Vec<String> = Vec::new();
166    if !already_existed {
167        if let Some(desc) = description.clone() {
168            cfg.references
169                .push(ReferenceEntry::WithMetadata(ReferenceMount {
170                    remote: input_url.clone(),
171                    description: if desc.trim().is_empty() {
172                        None
173                    } else {
174                        Some(desc)
175                    },
176                }));
177        } else {
178            cfg.references
179                .push(ReferenceEntry::Simple(input_url.clone()));
180        }
181
182        let ws = mgr
183            .save_v2_validated(&cfg)
184            .context("failed to save config")?;
185        warnings.extend(ws);
186        config_updated = true;
187    } else if description.is_some() {
188        warnings.push(
189            "Reference already exists; description was not updated (use CLI to modify metadata)"
190                .to_string(),
191        );
192    }
193
194    // Always attempt to sync clone+mount (best-effort, no rollback)
195    if let Err(e) = update_active_mounts().await {
196        warnings.push(format!("Mount synchronization encountered an error: {}", e));
197    }
198
199    // Post-sync mapping status to infer cloning
200    let repo_mapping_post =
201        RepoMappingManager::new().context("failed to create repo mapping manager")?;
202    let post_mapping = repo_mapping_post
203        .resolve_url(&input_url)
204        .ok()
205        .flatten()
206        .map(|p| p.to_string_lossy().to_string());
207    let cloned = pre_mapping.is_none() && post_mapping.is_some();
208
209    // Determine mounted by listing active mounts
210    let platform = detect_platform().context("failed to detect platform")?;
211    let mount_manager = get_mount_manager(&platform).context("failed to get mount manager")?;
212    let active = mount_manager
213        .list_mounts()
214        .await
215        .context("failed to list mounts")?;
216    let target_path = std::path::PathBuf::from(&mount_target);
217    let target_canon = std::fs::canonicalize(&target_path).unwrap_or(target_path.clone());
218    let mut mounted = false;
219    for mi in active {
220        let canon = std::fs::canonicalize(&mi.target).unwrap_or(mi.target.clone());
221        if canon == target_canon {
222            mounted = true;
223            break;
224        }
225    }
226
227    // Additional warnings for visibility
228    if post_mapping.is_none() {
229        warnings.push(
230            "Repository was not cloned or mapped. It may be private or network unavailable. \
231             You can retry or run 'thoughts references sync' via CLI."
232                .to_string(),
233        );
234    }
235    if !mounted {
236        warnings.push(
237            "Mount is not active. You can retry or run 'thoughts mount update' via CLI."
238                .to_string(),
239        );
240    }
241
242    Ok(AddReferenceOk {
243        url: input_url,
244        org,
245        repo,
246        mount_path,
247        mount_target,
248        mapping_path: post_mapping,
249        already_existed,
250        config_updated,
251        cloned,
252        mounted,
253        warnings,
254    })
255}
256
257// Note: MCP server implementation moved to thoughts-mcp-tools crate using agentic-tools framework.
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::documents::{ActiveDocuments, DocumentInfo, WriteDocumentOk};
263    use crate::utils::human_size;
264    use agentic_tools_core::fmt::{TextFormat, TextOptions};
265
266    #[test]
267    fn test_human_size_formatting() {
268        assert_eq!(human_size(0), "0 B");
269        assert_eq!(human_size(1), "1 B");
270        assert_eq!(human_size(1023), "1023 B");
271        assert_eq!(human_size(1024), "1.0 KB");
272        assert_eq!(human_size(2048), "2.0 KB");
273        assert_eq!(human_size(1024 * 1024), "1.0 MB");
274        assert_eq!(human_size(2 * 1024 * 1024), "2.0 MB");
275    }
276
277    #[test]
278    fn test_write_document_ok_format() {
279        let ok = WriteDocumentOk {
280            path: "./thoughts/feat/research/a.md".into(),
281            bytes_written: 2048,
282        };
283        let text = ok.fmt_text(&TextOptions::default());
284        assert!(text.contains("2.0 KB"));
285        assert!(text.contains("\u{2713} Created")); // ✓
286        assert!(text.contains("./thoughts/feat/research/a.md"));
287    }
288
289    #[test]
290    fn test_active_documents_empty() {
291        let docs = ActiveDocuments {
292            base: "./thoughts/x".into(),
293            files: vec![],
294        };
295        let s = docs.fmt_text(&TextOptions::default());
296        assert!(s.contains("<none>"));
297        assert!(s.contains("./thoughts/x"));
298    }
299
300    #[test]
301    fn test_active_documents_with_files() {
302        let docs = ActiveDocuments {
303            base: "./thoughts/feature".into(),
304            files: vec![DocumentInfo {
305                path: "./thoughts/feature/research/test.md".into(),
306                doc_type: "research".into(),
307                size: 1024,
308                modified: "2025-10-15T12:00:00Z".into(),
309            }],
310        };
311        let text = docs.fmt_text(&TextOptions::default());
312        assert!(text.contains("research/test.md"));
313        assert!(text.contains("2025-10-15 12:00 UTC"));
314    }
315
316    // Note: DocumentType serde tests are in crate::documents::tests
317
318    #[test]
319    fn test_references_list_empty() {
320        let refs = ReferencesList {
321            base: "references".into(),
322            entries: vec![],
323        };
324        let s = refs.fmt_text(&TextOptions::default());
325        assert!(s.contains("<none>"));
326        assert!(s.contains("references"));
327    }
328
329    #[test]
330    fn test_references_list_without_descriptions() {
331        let refs = ReferencesList {
332            base: "references".into(),
333            entries: vec![
334                ReferenceItem {
335                    path: "references/org/repo1".into(),
336                    description: None,
337                },
338                ReferenceItem {
339                    path: "references/org/repo2".into(),
340                    description: None,
341                },
342            ],
343        };
344        let text = refs.fmt_text(&TextOptions::default());
345        assert!(text.contains("org/repo1"));
346        assert!(text.contains("org/repo2"));
347        assert!(!text.contains("\u{2014}")); // No em-dash separator
348    }
349
350    #[test]
351    fn test_references_list_with_descriptions() {
352        let refs = ReferencesList {
353            base: "references".into(),
354            entries: vec![
355                ReferenceItem {
356                    path: "references/org/repo1".into(),
357                    description: Some("First repo".into()),
358                },
359                ReferenceItem {
360                    path: "references/org/repo2".into(),
361                    description: Some("Second repo".into()),
362                },
363            ],
364        };
365        let text = refs.fmt_text(&TextOptions::default());
366        assert!(text.contains("org/repo1 \u{2014} First repo")); // em-dash
367        assert!(text.contains("org/repo2 \u{2014} Second repo"));
368    }
369
370    #[test]
371    fn test_add_reference_ok_format() {
372        let ok = AddReferenceOk {
373            url: "https://github.com/org/repo".into(),
374            org: "org".into(),
375            repo: "repo".into(),
376            mount_path: "references/org/repo".into(),
377            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
378            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
379            already_existed: false,
380            config_updated: true,
381            cloned: true,
382            mounted: true,
383            warnings: vec!["note".into()],
384        };
385        let s = ok.fmt_text(&TextOptions::default());
386        assert!(s.contains("\u{2713} Added reference")); // ✓
387        assert!(s.contains("Org/Repo: org/repo"));
388        assert!(s.contains("Cloned: true"));
389        assert!(s.contains("Mounted: true"));
390        assert!(s.contains("Warnings:\n- note"));
391    }
392
393    #[test]
394    fn test_add_reference_ok_format_already_existed() {
395        let ok = AddReferenceOk {
396            url: "https://github.com/org/repo".into(),
397            org: "org".into(),
398            repo: "repo".into(),
399            mount_path: "references/org/repo".into(),
400            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
401            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
402            already_existed: true,
403            config_updated: false,
404            cloned: false,
405            mounted: true,
406            warnings: vec![],
407        };
408        let s = ok.fmt_text(&TextOptions::default());
409        assert!(s.contains("\u{2713} Reference already exists (idempotent)"));
410        assert!(s.contains("Config updated: false"));
411        assert!(!s.contains("Warnings:"));
412    }
413
414    #[test]
415    fn test_add_reference_ok_format_no_mapping() {
416        let ok = AddReferenceOk {
417            url: "https://github.com/org/repo".into(),
418            org: "org".into(),
419            repo: "repo".into(),
420            mount_path: "references/org/repo".into(),
421            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
422            mapping_path: None,
423            already_existed: false,
424            config_updated: true,
425            cloned: false,
426            mounted: false,
427            warnings: vec!["Clone failed".into()],
428        };
429        let s = ok.fmt_text(&TextOptions::default());
430        assert!(s.contains("Mapping: <none>"));
431        assert!(s.contains("Mounted: false"));
432        assert!(s.contains("- Clone failed"));
433    }
434
435    #[test]
436    fn test_template_response_format_research() {
437        let resp = TemplateResponse {
438            template_type: TemplateType::Research,
439        };
440        let s = resp.fmt_text(&TextOptions::default());
441        assert!(s.starts_with("Here is the research template:"));
442        assert!(s.contains("```markdown"));
443        // spot-check content from the research template
444        assert!(s.contains("# Research: [Topic]"));
445        // research guidance presence
446        assert!(s.contains("Stop. Before writing this document"));
447    }
448
449    #[test]
450    fn test_template_variants_non_empty() {
451        let all = [
452            TemplateType::Research,
453            TemplateType::Plan,
454            TemplateType::Requirements,
455            TemplateType::PrDescription,
456        ];
457        for t in all {
458            assert!(
459                !t.content().trim().is_empty(),
460                "Embedded content unexpectedly empty for {:?}",
461                t
462            );
463            assert!(
464                !t.label().trim().is_empty(),
465                "Label unexpectedly empty for {:?}",
466                t
467            );
468        }
469    }
470}