Skip to main content

chant/mcp/tools/
spec.rs

1//! MCP tools for spec query and management
2
3use anyhow::Result;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7use crate::diagnose;
8use crate::paths::LOGS_DIR;
9use crate::spec::{load_all_specs, resolve_spec, SpecStatus};
10use crate::spec_group;
11
12use super::super::handlers::mcp_ensure_initialized;
13
14pub fn tool_chant_spec_list(arguments: Option<&Value>) -> Result<Value> {
15    let specs_dir = match mcp_ensure_initialized() {
16        Ok(dir) => dir,
17        Err(err_response) => return Ok(err_response),
18    };
19
20    let mut specs = load_all_specs(&specs_dir)?;
21    specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
22
23    // Filter by status if provided
24    if let Some(args) = arguments {
25        if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
26            let filter_status = match status_str {
27                "pending" => Some(SpecStatus::Pending),
28                "in_progress" => Some(SpecStatus::InProgress),
29                "completed" => Some(SpecStatus::Completed),
30                "failed" => Some(SpecStatus::Failed),
31                "cancelled" => Some(SpecStatus::Cancelled),
32                _ => None,
33            };
34
35            if let Some(status) = filter_status {
36                specs.retain(|s| s.frontmatter.status == status);
37            }
38        } else {
39            // No status filter provided - filter out cancelled specs by default
40            specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
41        }
42    } else {
43        // No arguments provided - filter out cancelled specs by default
44        specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
45    }
46
47    // Get limit (default 50)
48    let limit = arguments
49        .and_then(|a| a.get("limit"))
50        .and_then(|v| v.as_u64())
51        .unwrap_or(50) as usize;
52
53    let total = specs.len();
54    let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
55
56    let specs_json: Vec<Value> = limited_specs
57        .iter()
58        .map(|s| {
59            json!({
60                "id": s.id,
61                "title": s.title,
62                "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
63                "type": s.frontmatter.r#type,
64                "depends_on": s.frontmatter.depends_on,
65                "labels": s.frontmatter.labels
66            })
67        })
68        .collect();
69
70    let response = json!({
71        "specs": specs_json,
72        "total": total,
73        "limit": limit,
74        "returned": limited_specs.len()
75    });
76
77    Ok(json!({
78        "content": [
79            {
80                "type": "text",
81                "text": serde_json::to_string_pretty(&response)?
82            }
83        ]
84    }))
85}
86
87pub fn tool_chant_spec_get(arguments: Option<&Value>) -> Result<Value> {
88    let specs_dir = match mcp_ensure_initialized() {
89        Ok(dir) => dir,
90        Err(err_response) => return Ok(err_response),
91    };
92
93    let id = arguments
94        .and_then(|a| a.get("id"))
95        .and_then(|v| v.as_str())
96        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
97
98    let spec = match resolve_spec(&specs_dir, id) {
99        Ok(s) => s,
100        Err(e) => {
101            return Ok(json!({
102                "content": [
103                    {
104                        "type": "text",
105                        "text": e.to_string()
106                    }
107                ],
108                "isError": true
109            }));
110        }
111    };
112
113    let spec_json = json!({
114        "id": spec.id,
115        "title": spec.title,
116        "status": format!("{:?}", spec.frontmatter.status).to_lowercase(),
117        "type": spec.frontmatter.r#type,
118        "depends_on": spec.frontmatter.depends_on,
119        "labels": spec.frontmatter.labels,
120        "target_files": spec.frontmatter.target_files,
121        "context": spec.frontmatter.context,
122        "prompt": spec.frontmatter.prompt,
123        "branch": spec.frontmatter.branch,
124        "commits": spec.frontmatter.commits,
125        "completed_at": spec.frontmatter.completed_at,
126        "model": spec.frontmatter.model,
127        "body": spec.body
128    });
129
130    Ok(json!({
131        "content": [
132            {
133                "type": "text",
134                "text": serde_json::to_string_pretty(&spec_json)?
135            }
136        ]
137    }))
138}
139
140pub fn tool_chant_ready(arguments: Option<&Value>) -> Result<Value> {
141    let specs_dir = match mcp_ensure_initialized() {
142        Ok(dir) => dir,
143        Err(err_response) => return Ok(err_response),
144    };
145
146    let mut specs = load_all_specs(&specs_dir)?;
147    specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
148
149    // Filter to ready specs only
150    let all_specs = specs.clone();
151    specs.retain(|s| s.is_ready(&all_specs));
152    // Filter out group specs - they are containers, not actionable work
153    specs.retain(|s| s.frontmatter.r#type != "group");
154
155    // Get limit (default 50)
156    let limit = arguments
157        .and_then(|a| a.get("limit"))
158        .and_then(|v| v.as_u64())
159        .unwrap_or(50) as usize;
160
161    let total = specs.len();
162    let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
163
164    let specs_json: Vec<Value> = limited_specs
165        .iter()
166        .map(|s| {
167            json!({
168                "id": s.id,
169                "title": s.title,
170                "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
171                "type": s.frontmatter.r#type,
172                "depends_on": s.frontmatter.depends_on,
173                "labels": s.frontmatter.labels
174            })
175        })
176        .collect();
177
178    let response = json!({
179        "specs": specs_json,
180        "total": total,
181        "limit": limit,
182        "returned": limited_specs.len()
183    });
184
185    Ok(json!({
186        "content": [
187            {
188                "type": "text",
189                "text": serde_json::to_string_pretty(&response)?
190            }
191        ]
192    }))
193}
194
195pub fn tool_chant_spec_update(arguments: Option<&Value>) -> Result<Value> {
196    let specs_dir = match mcp_ensure_initialized() {
197        Ok(dir) => dir,
198        Err(err_response) => return Ok(err_response),
199    };
200
201    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
202
203    let id = args
204        .get("id")
205        .and_then(|v| v.as_str())
206        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
207
208    let mut spec = match resolve_spec(&specs_dir, id) {
209        Ok(s) => s,
210        Err(e) => {
211            return Ok(json!({
212                "content": [
213                    {
214                        "type": "text",
215                        "text": e.to_string()
216                    }
217                ],
218                "isError": true
219            }));
220        }
221    };
222
223    let spec_id = spec.id.clone();
224    let spec_path = specs_dir.join(format!("{}.md", spec.id));
225
226    // Parse status if provided
227    let status = if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
228        let new_status = match status_str {
229            "pending" => SpecStatus::Pending,
230            "in_progress" => SpecStatus::InProgress,
231            "completed" => SpecStatus::Completed,
232            "failed" => SpecStatus::Failed,
233            _ => {
234                return Ok(json!({
235                    "content": [
236                        {
237                            "type": "text",
238                            "text": format!("Invalid status: {}. Must be one of: pending, in_progress, completed, failed", status_str)
239                        }
240                    ],
241                    "isError": true
242                }));
243            }
244        };
245        Some(new_status)
246    } else {
247        None
248    };
249
250    // Parse other fields
251    let depends_on = args
252        .get("depends_on")
253        .and_then(|v| v.as_array())
254        .map(|arr| {
255            arr.iter()
256                .filter_map(|v| v.as_str().map(String::from))
257                .collect()
258        });
259
260    let labels = args.get("labels").and_then(|v| v.as_array()).map(|arr| {
261        arr.iter()
262            .filter_map(|v| v.as_str().map(String::from))
263            .collect()
264    });
265
266    let target_files = args
267        .get("target_files")
268        .and_then(|v| v.as_array())
269        .map(|arr| {
270            arr.iter()
271                .filter_map(|v| v.as_str().map(String::from))
272                .collect()
273        });
274
275    let model = args.get("model").and_then(|v| v.as_str()).map(String::from);
276
277    let output = args
278        .get("output")
279        .and_then(|v| v.as_str())
280        .map(String::from);
281
282    let replace_body = args
283        .get("replace_body")
284        .and_then(|v| v.as_bool())
285        .unwrap_or(false);
286
287    // Parse force parameter if provided
288    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
289
290    // Use operations module for update
291    let options = crate::operations::update::UpdateOptions {
292        status,
293        depends_on,
294        labels,
295        target_files,
296        model,
297        output,
298        replace_body,
299        force,
300    };
301
302    match crate::operations::update::update_spec(&mut spec, &spec_path, options) {
303        Ok(_) => Ok(json!({
304            "content": [
305                {
306                    "type": "text",
307                    "text": format!("Updated spec: {}", spec_id)
308                }
309            ]
310        })),
311        Err(e) => Ok(json!({
312            "content": [
313                {
314                    "type": "text",
315                    "text": format!("Failed to update spec: {}", e)
316                }
317            ],
318            "isError": true
319        })),
320    }
321}
322
323pub fn tool_chant_status(arguments: Option<&Value>) -> Result<Value> {
324    let specs_dir = match mcp_ensure_initialized() {
325        Ok(dir) => dir,
326        Err(err_response) => return Ok(err_response),
327    };
328
329    let specs = load_all_specs(&specs_dir)?;
330
331    // Parse options
332    let brief = arguments
333        .and_then(|a| a.get("brief"))
334        .and_then(|v| v.as_bool())
335        .unwrap_or(false);
336    let include_activity = arguments
337        .and_then(|a| a.get("include_activity"))
338        .and_then(|v| v.as_bool())
339        .unwrap_or(false);
340
341    // Count by status
342    let mut pending = 0;
343    let mut in_progress = 0;
344    let mut completed = 0;
345    let mut failed = 0;
346    let mut blocked = 0;
347    let mut cancelled = 0;
348    let mut needs_attention = 0;
349
350    for spec in &specs {
351        match spec.frontmatter.status {
352            SpecStatus::Pending => pending += 1,
353            SpecStatus::InProgress => in_progress += 1,
354            SpecStatus::Paused => in_progress += 1, // Count paused as in_progress for summary
355            SpecStatus::Completed => completed += 1,
356            SpecStatus::Failed => failed += 1,
357            SpecStatus::Ready => pending += 1, // Ready is computed, treat as pending
358            SpecStatus::Blocked => blocked += 1,
359            SpecStatus::Cancelled => cancelled += 1,
360            SpecStatus::NeedsAttention => needs_attention += 1,
361        }
362    }
363
364    // Brief output mode
365    if brief {
366        let mut parts = vec![];
367        if pending > 0 {
368            parts.push(format!("{} pending", pending));
369        }
370        if in_progress > 0 {
371            parts.push(format!("{} in_progress", in_progress));
372        }
373        if completed > 0 {
374            parts.push(format!("{} completed", completed));
375        }
376        if failed > 0 {
377            parts.push(format!("{} failed", failed));
378        }
379        if blocked > 0 {
380            parts.push(format!("{} blocked", blocked));
381        }
382        if cancelled > 0 {
383            parts.push(format!("{} cancelled", cancelled));
384        }
385        if needs_attention > 0 {
386            parts.push(format!("{} needs_attention", needs_attention));
387        }
388        let brief_text = if parts.is_empty() {
389            "No specs".to_string()
390        } else {
391            parts.join(" | ")
392        };
393        return Ok(json!({
394            "content": [
395                {
396                    "type": "text",
397                    "text": brief_text
398                }
399            ]
400        }));
401    }
402
403    // Build status response
404    let mut status_json = json!({
405        "total": specs.len(),
406        "pending": pending,
407        "in_progress": in_progress,
408        "completed": completed,
409        "failed": failed,
410        "blocked": blocked,
411        "cancelled": cancelled,
412        "needs_attention": needs_attention
413    });
414
415    // Include activity info for in_progress specs
416    if include_activity {
417        let logs_dir = PathBuf::from(LOGS_DIR);
418        let mut activity: Vec<Value> = vec![];
419
420        for spec in &specs {
421            if spec.frontmatter.status != SpecStatus::InProgress {
422                continue;
423            }
424
425            let spec_path = specs_dir.join(format!("{}.md", spec.id));
426            let log_path = logs_dir.join(format!("{}.log", spec.id));
427
428            // Get spec file modification time
429            let spec_mtime = std::fs::metadata(&spec_path)
430                .and_then(|m| m.modified())
431                .ok()
432                .map(|t| {
433                    chrono::DateTime::<chrono::Local>::from(t)
434                        .format("%Y-%m-%d %H:%M:%S")
435                        .to_string()
436                });
437
438            // Get log file modification time (indicates last agent activity)
439            let log_mtime = std::fs::metadata(&log_path)
440                .and_then(|m| m.modified())
441                .ok()
442                .map(|t| {
443                    chrono::DateTime::<chrono::Local>::from(t)
444                        .format("%Y-%m-%d %H:%M:%S")
445                        .to_string()
446                });
447
448            // Check if log file exists
449            let has_log = log_path.exists();
450
451            activity.push(json!({
452                "id": spec.id,
453                "title": spec.title,
454                "spec_modified": spec_mtime,
455                "log_modified": log_mtime,
456                "has_log": has_log
457            }));
458        }
459
460        status_json["in_progress_activity"] = json!(activity);
461    }
462
463    Ok(json!({
464        "content": [
465            {
466                "type": "text",
467                "text": serde_json::to_string_pretty(&status_json)?
468            }
469        ]
470    }))
471}
472
473pub fn tool_chant_log(arguments: Option<&Value>) -> Result<Value> {
474    let specs_dir = match mcp_ensure_initialized() {
475        Ok(dir) => dir,
476        Err(err_response) => return Ok(err_response),
477    };
478
479    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
480
481    let id = args
482        .get("id")
483        .and_then(|v| v.as_str())
484        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
485
486    let lines = args
487        .get("lines")
488        .and_then(|v| v.as_u64())
489        .map(|v| v as usize);
490    let byte_offset = args.get("offset").and_then(|v| v.as_u64());
491    let since = args.get("since").and_then(|v| v.as_str());
492
493    // Resolve spec to get full ID
494    let spec = match resolve_spec(&specs_dir, id) {
495        Ok(s) => s,
496        Err(e) => {
497            return Ok(json!({
498                "content": [
499                    {
500                        "type": "text",
501                        "text": e.to_string()
502                    }
503                ],
504                "isError": true
505            }));
506        }
507    };
508
509    let logs_dir = PathBuf::from(LOGS_DIR);
510    let log_path = logs_dir.join(format!("{}.log", spec.id));
511
512    if !log_path.exists() {
513        return Ok(json!({
514            "content": [
515                {
516                    "type": "text",
517                    "text": format!("No log file found for spec '{}'. Logs are created when a spec is executed with `chant work`.", spec.id)
518                }
519            ],
520            "isError": true
521        }));
522    }
523
524    // Read log file
525    let content = std::fs::read_to_string(&log_path)?;
526    let file_byte_len = content.len() as u64;
527
528    // Filter by offset if provided
529    let content_after_offset = if let Some(offset) = byte_offset {
530        if offset >= file_byte_len {
531            // Offset is at or beyond end of file
532            String::new()
533        } else {
534            content[(offset as usize)..].to_string()
535        }
536    } else {
537        content.clone()
538    };
539
540    // Filter by timestamp if provided
541    let content_after_since = if let Some(since_ts) = since {
542        if let Ok(since_time) = chrono::DateTime::parse_from_rfc3339(since_ts) {
543            content_after_offset
544                .lines()
545                .filter(|line| {
546                    // Try to parse timestamp from line start
547                    // Assumes log format: YYYY-MM-DDTHH:MM:SS.sssZ ...
548                    if line.len() >= 24 {
549                        if let Ok(line_time) = chrono::DateTime::parse_from_rfc3339(&line[..24]) {
550                            return line_time > since_time;
551                        }
552                    }
553                    true // Include lines without parseable timestamps
554                })
555                .collect::<Vec<&str>>()
556                .join("\n")
557        } else {
558            content_after_offset
559        }
560    } else {
561        content_after_offset
562    };
563
564    // Apply lines limit
565    let all_lines: Vec<&str> = content_after_since.lines().collect();
566    let lines_limit = lines.unwrap_or(100);
567    let start = if all_lines.len() > lines_limit {
568        all_lines.len() - lines_limit
569    } else {
570        0
571    };
572    let log_output = all_lines[start..].join("\n");
573
574    // Calculate new byte offset
575    let new_byte_offset = if byte_offset.is_some() {
576        file_byte_len
577    } else {
578        content.len() as u64
579    };
580
581    let has_more = all_lines.len() > lines_limit;
582    let line_count = all_lines[start..].len();
583
584    let response = json!({
585        "content": log_output,
586        "byte_offset": new_byte_offset,
587        "line_count": line_count,
588        "has_more": has_more
589    });
590
591    Ok(json!({
592        "content": [
593            {
594                "type": "text",
595                "text": serde_json::to_string_pretty(&response)?
596            }
597        ]
598    }))
599}
600
601pub fn tool_chant_search(arguments: Option<&Value>) -> Result<Value> {
602    let specs_dir = match mcp_ensure_initialized() {
603        Ok(dir) => dir,
604        Err(err_response) => return Ok(err_response),
605    };
606
607    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
608
609    let query = args
610        .get("query")
611        .and_then(|v| v.as_str())
612        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?
613        .to_lowercase();
614
615    let status_filter = args.get("status").and_then(|v| v.as_str());
616
617    let mut specs = load_all_specs(&specs_dir)?;
618
619    // Filter by query (case-insensitive search in title and body)
620    specs.retain(|s| {
621        let title_match = s
622            .title
623            .as_ref()
624            .map(|t| t.to_lowercase().contains(&query))
625            .unwrap_or(false);
626        title_match || s.body.to_lowercase().contains(&query)
627    });
628
629    // Filter by status if provided
630    if let Some(status_str) = status_filter {
631        let filter_status = match status_str {
632            "pending" => Some(SpecStatus::Pending),
633            "in_progress" => Some(SpecStatus::InProgress),
634            "completed" => Some(SpecStatus::Completed),
635            "failed" => Some(SpecStatus::Failed),
636            "blocked" => Some(SpecStatus::Blocked),
637            "cancelled" => Some(SpecStatus::Cancelled),
638            _ => None,
639        };
640
641        if let Some(status) = filter_status {
642            specs.retain(|s| s.frontmatter.status == status);
643        }
644    }
645
646    specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
647
648    let specs_json: Vec<Value> = specs
649        .iter()
650        .map(|s| {
651            json!({
652                "id": s.id,
653                "title": s.title,
654                "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
655                "type": s.frontmatter.r#type
656            })
657        })
658        .collect();
659
660    Ok(json!({
661        "content": [
662            {
663                "type": "text",
664                "text": serde_json::to_string_pretty(&specs_json)?
665            }
666        ]
667    }))
668}
669
670pub fn tool_chant_diagnose(arguments: Option<&Value>) -> Result<Value> {
671    let specs_dir = match mcp_ensure_initialized() {
672        Ok(dir) => dir,
673        Err(err_response) => return Ok(err_response),
674    };
675
676    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
677
678    let id = args
679        .get("id")
680        .and_then(|v| v.as_str())
681        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
682
683    // Resolve spec to get full ID
684    let spec = match resolve_spec(&specs_dir, id) {
685        Ok(s) => s,
686        Err(e) => {
687            return Ok(json!({
688                "content": [
689                    {
690                        "type": "text",
691                        "text": e.to_string()
692                    }
693                ],
694                "isError": true
695            }));
696        }
697    };
698
699    // Run diagnostics
700    let report = match diagnose::diagnose_spec(&spec.id) {
701        Ok(r) => r,
702        Err(e) => {
703            return Ok(json!({
704                "content": [
705                    {
706                        "type": "text",
707                        "text": format!("Failed to diagnose spec: {}", e)
708                    }
709                ],
710                "isError": true
711            }));
712        }
713    };
714
715    // Format report as JSON
716    let checks_json: Vec<Value> = report
717        .checks
718        .iter()
719        .map(|c| {
720            json!({
721                "name": c.name,
722                "passed": c.passed,
723                "details": c.details
724            })
725        })
726        .collect();
727
728    let report_json = json!({
729        "spec_id": report.spec_id,
730        "status": format!("{:?}", report.status).to_lowercase(),
731        "location": report.location,
732        "checks": checks_json,
733        "diagnosis": report.diagnosis,
734        "suggestion": report.suggestion
735    });
736
737    Ok(json!({
738        "content": [
739            {
740                "type": "text",
741                "text": serde_json::to_string_pretty(&report_json)?
742            }
743        ]
744    }))
745}
746
747pub fn tool_chant_lint(arguments: Option<&Value>) -> Result<Value> {
748    let specs_dir = match mcp_ensure_initialized() {
749        Ok(dir) => dir,
750        Err(err_response) => return Ok(err_response),
751    };
752
753    let spec_id = arguments
754        .and_then(|args| args.get("id"))
755        .and_then(|v| v.as_str());
756
757    use crate::config::Config;
758    use crate::score::traffic_light;
759    use crate::scoring::{calculate_spec_score, TrafficLight};
760    use crate::spec::Spec;
761
762    // Load config for scoring
763    let config = match Config::load() {
764        Ok(c) => c,
765        Err(e) => {
766            return Ok(json!({
767                "content": [{ "type": "text", "text": format!("Failed to load config: {}", e) }],
768                "isError": true
769            }));
770        }
771    };
772
773    // Load all specs (needed for isolation scoring)
774    let all_specs = load_all_specs(&specs_dir)?;
775
776    // Collect specs to check
777    let specs_to_check: Vec<Spec> = if let Some(id) = spec_id {
778        match resolve_spec(&specs_dir, id) {
779            Ok(spec) => vec![spec],
780            Err(e) => {
781                return Ok(json!({
782                    "content": [{ "type": "text", "text": e.to_string() }],
783                    "isError": true
784                }));
785            }
786        }
787    } else {
788        all_specs.clone()
789    };
790
791    let mut results: Vec<Value> = Vec::new();
792    let mut red_count = 0;
793    let mut yellow_count = 0;
794    let mut green_count = 0;
795
796    // Run full quality assessment on each spec (same as chant work)
797    for spec in &specs_to_check {
798        let score = calculate_spec_score(spec, &all_specs, &config);
799        let suggestions = traffic_light::generate_suggestions(&score);
800
801        let traffic_light_str = match score.traffic_light {
802            TrafficLight::Ready => {
803                green_count += 1;
804                "green"
805            }
806            TrafficLight::Review => {
807                yellow_count += 1;
808                "yellow"
809            }
810            TrafficLight::Refine => {
811                red_count += 1;
812                "red"
813            }
814        };
815
816        results.push(json!({
817            "id": spec.id,
818            "title": spec.title,
819            "traffic_light": traffic_light_str,
820            "complexity": score.complexity.to_string(),
821            "confidence": score.confidence.to_string(),
822            "splittability": score.splittability.to_string(),
823            "ac_quality": score.ac_quality.to_string(),
824            "isolation": score.isolation.map(|i| i.to_string()),
825            "suggestions": suggestions
826        }));
827    }
828
829    let summary = json!({
830        "specs_checked": specs_to_check.len(),
831        "red": red_count,
832        "yellow": yellow_count,
833        "green": green_count,
834        "results": results
835    });
836
837    Ok(json!({
838        "content": [
839            {
840                "type": "text",
841                "text": serde_json::to_string_pretty(&summary)?
842            }
843        ]
844    }))
845}
846
847pub fn tool_chant_add(arguments: Option<&Value>) -> Result<Value> {
848    let specs_dir = match mcp_ensure_initialized() {
849        Ok(dir) => dir,
850        Err(err_response) => return Ok(err_response),
851    };
852
853    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
854
855    let description = args
856        .get("description")
857        .and_then(|v| v.as_str())
858        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: description"))?;
859
860    let prompt = args.get("prompt").and_then(|v| v.as_str());
861
862    // Load config for derivation
863    let config = match crate::config::Config::load() {
864        Ok(c) => c,
865        Err(e) => {
866            return Ok(json!({
867                "content": [{ "type": "text", "text": format!("Failed to load config: {}", e) }],
868                "isError": true
869            }));
870        }
871    };
872
873    // Use operations module for spec creation
874    let options = crate::operations::create::CreateOptions {
875        prompt: prompt.map(String::from),
876        needs_approval: false,
877        auto_commit: false, // MCP doesn't auto-commit
878    };
879
880    let (spec, _filepath) = match crate::operations::create::create_spec(
881        description,
882        &specs_dir,
883        &config,
884        options,
885    ) {
886        Ok(result) => result,
887        Err(e) => {
888            return Ok(json!({
889                "content": [{ "type": "text", "text": format!("Failed to create spec: {}", e) }],
890                "isError": true
891            }));
892        }
893    };
894
895    // Load all specs for scoring (needed for isolation)
896    let all_specs = match load_all_specs(&specs_dir) {
897        Ok(specs) => specs,
898        Err(e) => {
899            return Ok(json!({
900                "content": [{ "type": "text", "text": format!("Failed to load specs: {}", e) }],
901                "isError": true
902            }));
903        }
904    };
905
906    // Calculate lint score for the newly created spec
907    use crate::score::traffic_light;
908    use crate::scoring::calculate_spec_score;
909
910    let score = calculate_spec_score(&spec, &all_specs, &config);
911    let suggestions = traffic_light::generate_suggestions(&score);
912
913    // Convert traffic light to string
914    let traffic_light_str = match score.traffic_light {
915        crate::scoring::TrafficLight::Ready => "green",
916        crate::scoring::TrafficLight::Review => "yellow",
917        crate::scoring::TrafficLight::Refine => "red",
918    };
919
920    // Build response JSON with lint results
921    let response = json!({
922        "spec_id": spec.id,
923        "message": format!("Created spec: {}", spec.id),
924        "lint": {
925            "traffic_light": traffic_light_str,
926            "complexity": score.complexity.to_string(),
927            "confidence": score.confidence.to_string(),
928            "splittability": score.splittability.to_string(),
929            "ac_quality": score.ac_quality.to_string(),
930            "isolation": score.isolation.map(|i| i.to_string()),
931            "suggestions": suggestions
932        }
933    });
934
935    Ok(json!({
936        "content": [
937            {
938                "type": "text",
939                "text": serde_json::to_string_pretty(&response)?
940            }
941        ]
942    }))
943}
944
945pub fn tool_chant_verify(arguments: Option<&Value>) -> Result<Value> {
946    let specs_dir = match mcp_ensure_initialized() {
947        Ok(dir) => dir,
948        Err(err_response) => return Ok(err_response),
949    };
950
951    let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
952
953    let id = args
954        .get("id")
955        .and_then(|v| v.as_str())
956        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
957
958    let spec = match resolve_spec(&specs_dir, id) {
959        Ok(s) => s,
960        Err(e) => {
961            return Ok(json!({
962                "content": [
963                    {
964                        "type": "text",
965                        "text": e.to_string()
966                    }
967                ],
968                "isError": true
969            }));
970        }
971    };
972
973    let spec_id = spec.id.clone();
974
975    // Count checked and unchecked criteria using operations layer
976    let unchecked_count = spec.count_unchecked_checkboxes();
977
978    // Find total checkboxes in Acceptance Criteria section
979    let total_count: usize = {
980        let acceptance_criteria_marker = "## Acceptance Criteria";
981        let mut in_ac_section = false;
982        let mut in_code_fence = false;
983        let mut count = 0;
984
985        for line in spec.body.lines() {
986            let trimmed = line.trim_start();
987
988            if trimmed.starts_with("```") {
989                in_code_fence = !in_code_fence;
990                continue;
991            }
992
993            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
994                in_ac_section = true;
995                continue;
996            }
997
998            if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
999                break;
1000            }
1001
1002            if in_ac_section
1003                && !in_code_fence
1004                && (trimmed.starts_with("- [x]") || trimmed.starts_with("- [ ]"))
1005            {
1006                count += 1;
1007            }
1008        }
1009
1010        count
1011    };
1012
1013    let checked_count = total_count.saturating_sub(unchecked_count);
1014    let verified = unchecked_count == 0 && total_count > 0;
1015
1016    // Extract unchecked items
1017    let unchecked_items = if unchecked_count > 0 {
1018        let acceptance_criteria_marker = "## Acceptance Criteria";
1019        let mut in_ac_section = false;
1020        let mut in_code_fence = false;
1021        let mut items = Vec::new();
1022
1023        for line in spec.body.lines() {
1024            let trimmed = line.trim_start();
1025
1026            if trimmed.starts_with("```") {
1027                in_code_fence = !in_code_fence;
1028                continue;
1029            }
1030
1031            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
1032                in_ac_section = true;
1033                continue;
1034            }
1035
1036            if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
1037                break;
1038            }
1039
1040            if in_ac_section && !in_code_fence && trimmed.starts_with("- [ ]") {
1041                items.push(trimmed.to_string());
1042            }
1043        }
1044
1045        items
1046    } else {
1047        Vec::new()
1048    };
1049
1050    let verification_notes = if total_count == 0 {
1051        "No acceptance criteria found".to_string()
1052    } else if verified {
1053        "All acceptance criteria met".to_string()
1054    } else {
1055        format!("{} criteria not yet checked", unchecked_count)
1056    };
1057
1058    let result = json!({
1059        "spec_id": spec_id,
1060        "verified": verified,
1061        "criteria": {
1062            "total": total_count,
1063            "checked": checked_count,
1064            "unchecked": unchecked_count
1065        },
1066        "unchecked_items": unchecked_items,
1067        "verification_notes": verification_notes
1068    });
1069
1070    Ok(json!({
1071        "content": [
1072            {
1073                "type": "text",
1074                "text": serde_json::to_string_pretty(&result)?
1075            }
1076        ]
1077    }))
1078}