crucible-mcp 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crucible/server.py CHANGED
@@ -7,8 +7,13 @@ from mcp.server import Server
7
7
  from mcp.server.stdio import stdio_server
8
8
  from mcp.types import TextContent, Tool
9
9
 
10
- from crucible.knowledge.loader import load_principles
11
- from crucible.models import Domain, Severity, ToolFinding
10
+ from crucible.knowledge.loader import (
11
+ get_custom_knowledge_files,
12
+ load_all_knowledge,
13
+ load_principles,
14
+ )
15
+ from crucible.models import Domain, FullReviewResult, Severity, ToolFinding
16
+ from crucible.skills import get_knowledge_for_skills, load_skill, match_skills_for_domain
12
17
  from crucible.tools.delegation import (
13
18
  check_all_tools,
14
19
  delegate_bandit,
@@ -17,6 +22,15 @@ from crucible.tools.delegation import (
17
22
  delegate_slither,
18
23
  get_semgrep_config,
19
24
  )
25
+ from crucible.tools.git import (
26
+ GitContext,
27
+ get_branch_diff,
28
+ get_changed_files,
29
+ get_recent_commits,
30
+ get_repo_root,
31
+ get_staged_changes,
32
+ get_unstaged_changes,
33
+ )
20
34
 
21
35
  server = Server("crucible")
22
36
 
@@ -47,6 +61,38 @@ def _format_findings(findings: list[ToolFinding]) -> str:
47
61
  return "\n".join(parts) if parts else "No findings."
48
62
 
49
63
 
64
+ def _deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
65
+ """Deduplicate findings by location and message.
66
+
67
+ When multiple tools report the same issue at the same location,
68
+ keep only the highest severity finding.
69
+ """
70
+ # Group by (location, normalized_message)
71
+ seen: dict[tuple[str, str], ToolFinding] = {}
72
+
73
+ for f in findings:
74
+ # Normalize the message for comparison (lowercase, strip whitespace)
75
+ norm_msg = f.message.lower().strip()
76
+ key = (f.location, norm_msg)
77
+
78
+ if key not in seen:
79
+ seen[key] = f
80
+ else:
81
+ # Keep the higher severity finding
82
+ existing = seen[key]
83
+ severity_order = [
84
+ Severity.CRITICAL,
85
+ Severity.HIGH,
86
+ Severity.MEDIUM,
87
+ Severity.LOW,
88
+ Severity.INFO,
89
+ ]
90
+ if severity_order.index(f.severity) < severity_order.index(existing.severity):
91
+ seen[key] = f
92
+
93
+ return list(seen.values())
94
+
95
+
50
96
  @server.list_tools() # type: ignore[misc]
51
97
  async def list_tools() -> list[Tool]:
52
98
  """List available tools."""
@@ -157,6 +203,80 @@ async def list_tools() -> list[Tool]:
157
203
  "properties": {},
158
204
  },
159
205
  ),
206
+ Tool(
207
+ name="review_changes",
208
+ description="Review git changes (staged, unstaged, branch diff, commits). Runs analysis on changed files and filters findings to changed lines only.",
209
+ inputSchema={
210
+ "type": "object",
211
+ "properties": {
212
+ "mode": {
213
+ "type": "string",
214
+ "enum": ["staged", "unstaged", "branch", "commits"],
215
+ "description": "What changes to review: staged (about to commit), unstaged (working dir), branch (PR diff vs base), commits (recent N commits)",
216
+ },
217
+ "base": {
218
+ "type": "string",
219
+ "description": "Base branch for 'branch' mode (default: main) or commit count for 'commits' mode (default: 1)",
220
+ },
221
+ "path": {
222
+ "type": "string",
223
+ "description": "Repository path (default: current directory)",
224
+ },
225
+ "include_context": {
226
+ "type": "boolean",
227
+ "description": "Include findings near (within 5 lines of) changes, not just in changed lines (default: false)",
228
+ },
229
+ },
230
+ "required": ["mode"],
231
+ },
232
+ ),
233
+ Tool(
234
+ name="full_review",
235
+ description="Comprehensive code review: runs static analysis, matches applicable skills based on domain, loads linked knowledge. Returns unified report for synthesis.",
236
+ inputSchema={
237
+ "type": "object",
238
+ "properties": {
239
+ "path": {
240
+ "type": "string",
241
+ "description": "File or directory path to review",
242
+ },
243
+ "skills": {
244
+ "type": "array",
245
+ "items": {"type": "string"},
246
+ "description": "Override skill selection (default: auto-detect based on domain)",
247
+ },
248
+ "include_sage": {
249
+ "type": "boolean",
250
+ "description": "Include Sage knowledge recall (not yet implemented)",
251
+ "default": True,
252
+ },
253
+ },
254
+ "required": ["path"],
255
+ },
256
+ ),
257
+ Tool(
258
+ name="load_knowledge",
259
+ description="Load knowledge/principles files without running static analysis. Useful for getting guidance on patterns, best practices, or domain-specific knowledge. Automatically includes project and user knowledge files.",
260
+ inputSchema={
261
+ "type": "object",
262
+ "properties": {
263
+ "files": {
264
+ "type": "array",
265
+ "items": {"type": "string"},
266
+ "description": "Specific knowledge files to load (e.g., ['SECURITY.md', 'SMART_CONTRACT.md']). If not specified, loads all project/user knowledge files.",
267
+ },
268
+ "include_bundled": {
269
+ "type": "boolean",
270
+ "description": "Include bundled knowledge files in addition to project/user files (default: false)",
271
+ "default": False,
272
+ },
273
+ "topic": {
274
+ "type": "string",
275
+ "description": "Load by topic instead of files: 'security', 'engineering', 'smart_contract', 'checklist', 'repo_hygiene'",
276
+ },
277
+ },
278
+ },
279
+ ),
160
280
  ]
161
281
 
162
282
 
@@ -170,6 +290,41 @@ def _handle_get_principles(arguments: dict[str, Any]) -> list[TextContent]:
170
290
  return [TextContent(type="text", text=f"Error: {result.error}")]
171
291
 
172
292
 
293
+ def _handle_load_knowledge(arguments: dict[str, Any]) -> list[TextContent]:
294
+ """Handle load_knowledge tool."""
295
+ files = arguments.get("files")
296
+ include_bundled = arguments.get("include_bundled", False)
297
+ topic = arguments.get("topic")
298
+
299
+ # If topic specified, use load_principles
300
+ if topic:
301
+ result = load_principles(topic)
302
+ if result.is_ok:
303
+ return [TextContent(type="text", text=result.value)]
304
+ return [TextContent(type="text", text=f"Error: {result.error}")]
305
+
306
+ # Otherwise load by files
307
+ filenames = set(files) if files else None
308
+ loaded, content = load_all_knowledge(
309
+ include_bundled=include_bundled,
310
+ filenames=filenames,
311
+ )
312
+
313
+ if not loaded:
314
+ if filenames:
315
+ return [TextContent(type="text", text=f"No knowledge files found matching: {', '.join(sorted(filenames))}")]
316
+ return [TextContent(type="text", text="No knowledge files found. Add files to .crucible/knowledge/ or ~/.claude/crucible/knowledge/")]
317
+
318
+ output_parts = [
319
+ "# Knowledge Loaded\n",
320
+ f"**Files:** {', '.join(loaded)}\n",
321
+ "---\n",
322
+ content,
323
+ ]
324
+
325
+ return [TextContent(type="text", text="\n".join(output_parts))]
326
+
327
+
173
328
  def _handle_delegate_semgrep(arguments: dict[str, Any]) -> list[TextContent]:
174
329
  """Handle delegate_semgrep tool."""
175
330
  path = arguments.get("path", "")
@@ -241,8 +396,8 @@ def _handle_check_tools(arguments: dict[str, Any]) -> list[TextContent]:
241
396
  return [TextContent(type="text", text="\n".join(parts))]
242
397
 
243
398
 
244
- def _detect_domain(path: str) -> tuple[Domain, list[str]]:
245
- """Internal domain detection from file path.
399
+ def _detect_domain_for_file(path: str) -> tuple[Domain, list[str]]:
400
+ """Detect domain from a single file path.
246
401
 
247
402
  Returns (domain, list of domain tags for skill matching).
248
403
  """
@@ -263,8 +418,59 @@ def _detect_domain(path: str) -> tuple[Domain, list[str]]:
263
418
  elif path.endswith((".tf", ".yaml", ".yml")):
264
419
  return Domain.INFRASTRUCTURE, ["infrastructure", "devops"]
265
420
  else:
421
+ return Domain.UNKNOWN, []
422
+
423
+
424
+ def _detect_domain(path: str) -> tuple[Domain, list[str]]:
425
+ """Detect domain from file or directory path.
426
+
427
+ For directories, scans contained files and aggregates domains.
428
+ Returns (primary_domain, list of all domain tags).
429
+ """
430
+ from collections import Counter
431
+ from pathlib import Path
432
+
433
+ p = Path(path)
434
+
435
+ # Single file - use direct detection
436
+ if p.is_file():
437
+ return _detect_domain_for_file(path)
438
+
439
+ # Directory - scan and aggregate
440
+ if not p.is_dir():
266
441
  return Domain.UNKNOWN, ["unknown"]
267
442
 
443
+ domain_counts: Counter[Domain] = Counter()
444
+ all_tags: set[str] = set()
445
+
446
+ # Scan files in directory (up to 1000 to avoid huge repos)
447
+ file_count = 0
448
+ max_files = 1000
449
+
450
+ for file_path in p.rglob("*"):
451
+ if file_count >= max_files:
452
+ break
453
+ if not file_path.is_file():
454
+ continue
455
+ # Skip hidden files and common non-code directories
456
+ if any(part.startswith(".") for part in file_path.parts):
457
+ continue
458
+ if any(part in ("node_modules", "__pycache__", "venv", ".venv", "dist", "build") for part in file_path.parts):
459
+ continue
460
+
461
+ domain, tags = _detect_domain_for_file(str(file_path))
462
+ if domain != Domain.UNKNOWN:
463
+ domain_counts[domain] += 1
464
+ all_tags.update(tags)
465
+ file_count += 1
466
+
467
+ # Return most common domain, or UNKNOWN if none found
468
+ if not domain_counts:
469
+ return Domain.UNKNOWN, ["unknown"]
470
+
471
+ primary_domain = domain_counts.most_common(1)[0][0]
472
+ return primary_domain, sorted(all_tags) if all_tags else ["unknown"]
473
+
268
474
 
269
475
  def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
270
476
  """Handle quick_review tool - returns findings with domain metadata."""
@@ -324,6 +530,9 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
324
530
  else:
325
531
  tool_results.append(f"## Bandit\nError: {result.error}")
326
532
 
533
+ # Deduplicate findings
534
+ all_findings = _deduplicate_findings(all_findings)
535
+
327
536
  # Compute severity summary
328
537
  severity_counts: dict[str, int] = {}
329
538
  for f in all_findings:
@@ -341,17 +550,403 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
341
550
  return [TextContent(type="text", text="\n".join(output_parts))]
342
551
 
343
552
 
553
+ def _filter_findings_to_changes(
554
+ findings: list[ToolFinding],
555
+ context: GitContext,
556
+ include_context: bool = False,
557
+ ) -> list[ToolFinding]:
558
+ """Filter findings to only those in changed lines."""
559
+ # Build a lookup of file -> changed line ranges
560
+ changed_ranges: dict[str, list[tuple[int, int]]] = {}
561
+ for change in context.changes:
562
+ if change.status == "D":
563
+ continue # Skip deleted files
564
+ ranges = [(r.start, r.end) for r in change.added_lines]
565
+ changed_ranges[change.path] = ranges
566
+
567
+ context_lines = 5 if include_context else 0
568
+ filtered: list[ToolFinding] = []
569
+
570
+ for finding in findings:
571
+ # Parse location: "path:line" or "path:line:col"
572
+ parts = finding.location.split(":")
573
+ if len(parts) < 2:
574
+ continue
575
+
576
+ file_path = parts[0]
577
+ try:
578
+ line_num = int(parts[1])
579
+ except ValueError:
580
+ continue
581
+
582
+ # Check if file is in changes
583
+ # Handle both absolute and relative paths
584
+ matching_file = None
585
+ for changed_file in changed_ranges:
586
+ if file_path.endswith(changed_file) or changed_file.endswith(file_path):
587
+ matching_file = changed_file
588
+ break
589
+
590
+ if not matching_file:
591
+ continue
592
+
593
+ # Check if line is in changed ranges
594
+ ranges = changed_ranges[matching_file]
595
+ in_range = False
596
+ for start, end in ranges:
597
+ if start - context_lines <= line_num <= end + context_lines:
598
+ in_range = True
599
+ break
600
+
601
+ if in_range:
602
+ filtered.append(finding)
603
+
604
+ return filtered
605
+
606
+
607
+ def _format_change_review(
608
+ context: GitContext,
609
+ findings: list[ToolFinding],
610
+ severity_counts: dict[str, int],
611
+ tool_errors: list[str] | None = None,
612
+ ) -> str:
613
+ """Format change review output."""
614
+ parts: list[str] = ["# Change Review\n"]
615
+ parts.append(f"**Mode:** {context.mode}")
616
+ if context.base_ref:
617
+ parts.append(f"**Base:** {context.base_ref}")
618
+ parts.append("")
619
+
620
+ # Files changed
621
+ added = [c for c in context.changes if c.status == "A"]
622
+ modified = [c for c in context.changes if c.status == "M"]
623
+ deleted = [c for c in context.changes if c.status == "D"]
624
+ renamed = [c for c in context.changes if c.status == "R"]
625
+
626
+ total = len(context.changes)
627
+ parts.append(f"## Files Changed ({total})")
628
+ for c in added:
629
+ parts.append(f"- `+` {c.path}")
630
+ for c in modified:
631
+ parts.append(f"- `~` {c.path}")
632
+ for c in renamed:
633
+ parts.append(f"- `R` {c.old_path} -> {c.path}")
634
+ for c in deleted:
635
+ parts.append(f"- `-` {c.path}")
636
+ parts.append("")
637
+
638
+ # Commit messages (if available)
639
+ if context.commit_messages:
640
+ parts.append("## Commits")
641
+ for msg in context.commit_messages:
642
+ parts.append(f"- {msg}")
643
+ parts.append("")
644
+
645
+ # Tool errors (if any)
646
+ if tool_errors:
647
+ parts.append("## Tool Errors\n")
648
+ for error in tool_errors:
649
+ parts.append(f"- {error}")
650
+ parts.append("")
651
+
652
+ # Findings
653
+ if findings:
654
+ parts.append("## Findings in Changed Code\n")
655
+ parts.append(f"**Summary:** {severity_counts}\n")
656
+ parts.append(_format_findings(findings))
657
+ else:
658
+ parts.append("## Findings in Changed Code\n")
659
+ parts.append("No issues found in changed code.")
660
+
661
+ return "\n".join(parts)
662
+
663
+
664
+ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
665
+ """Handle review_changes tool - review git changes."""
666
+ import os
667
+
668
+ mode = arguments.get("mode", "staged")
669
+ base = arguments.get("base")
670
+ path = arguments.get("path", os.getcwd())
671
+ include_context = arguments.get("include_context", False)
672
+
673
+ # Get repo root
674
+ root_result = get_repo_root(path)
675
+ if root_result.is_err:
676
+ return [TextContent(type="text", text=f"Error: {root_result.error}")]
677
+
678
+ repo_path = root_result.value
679
+
680
+ # Get git context based on mode
681
+ if mode == "staged":
682
+ context_result = get_staged_changes(repo_path)
683
+ elif mode == "unstaged":
684
+ context_result = get_unstaged_changes(repo_path)
685
+ elif mode == "branch":
686
+ base_branch = base if base else "main"
687
+ context_result = get_branch_diff(repo_path, base_branch)
688
+ elif mode == "commits":
689
+ try:
690
+ count = int(base) if base else 1
691
+ except ValueError:
692
+ return [TextContent(type="text", text=f"Error: Invalid commit count '{base}'")]
693
+ context_result = get_recent_commits(repo_path, count)
694
+ else:
695
+ return [TextContent(type="text", text=f"Error: Unknown mode '{mode}'")]
696
+
697
+ if context_result.is_err:
698
+ return [TextContent(type="text", text=f"Error: {context_result.error}")]
699
+
700
+ context = context_result.value
701
+
702
+ # Check if there are any changes
703
+ if not context.changes:
704
+ if mode == "staged":
705
+ return [TextContent(type="text", text="No changes to review. Stage files with `git add` first.")]
706
+ elif mode == "unstaged":
707
+ return [TextContent(type="text", text="No unstaged changes to review.")]
708
+ else:
709
+ return [TextContent(type="text", text="No changes found.")]
710
+
711
+ # Get changed files (excluding deleted)
712
+ changed_files = get_changed_files(context)
713
+ if not changed_files:
714
+ return [TextContent(type="text", text="No files to analyze (only deletions).")]
715
+
716
+ # Run analysis on changed files
717
+ all_findings: list[ToolFinding] = []
718
+ tool_errors: list[str] = []
719
+
720
+ for file_path in changed_files:
721
+ full_path = f"{repo_path}/{file_path}"
722
+
723
+ # Detect domain for this file
724
+ domain, domain_tags = _detect_domain(file_path)
725
+
726
+ # Select tools based on domain
727
+ if domain == Domain.SMART_CONTRACT:
728
+ tools = ["slither", "semgrep"]
729
+ elif domain == Domain.BACKEND and "python" in domain_tags:
730
+ tools = ["ruff", "bandit", "semgrep"]
731
+ elif domain == Domain.FRONTEND:
732
+ tools = ["semgrep"]
733
+ else:
734
+ tools = ["semgrep"]
735
+
736
+ # Run tools
737
+ if "semgrep" in tools:
738
+ config = get_semgrep_config(domain)
739
+ result = delegate_semgrep(full_path, config)
740
+ if result.is_ok:
741
+ all_findings.extend(result.value)
742
+ elif result.is_err:
743
+ tool_errors.append(f"semgrep ({file_path}): {result.error}")
744
+
745
+ if "ruff" in tools:
746
+ result = delegate_ruff(full_path)
747
+ if result.is_ok:
748
+ all_findings.extend(result.value)
749
+ elif result.is_err:
750
+ tool_errors.append(f"ruff ({file_path}): {result.error}")
751
+
752
+ if "slither" in tools:
753
+ result = delegate_slither(full_path)
754
+ if result.is_ok:
755
+ all_findings.extend(result.value)
756
+ elif result.is_err:
757
+ tool_errors.append(f"slither ({file_path}): {result.error}")
758
+
759
+ if "bandit" in tools:
760
+ result = delegate_bandit(full_path)
761
+ if result.is_ok:
762
+ all_findings.extend(result.value)
763
+ elif result.is_err:
764
+ tool_errors.append(f"bandit ({file_path}): {result.error}")
765
+
766
+ # Filter findings to changed lines
767
+ filtered_findings = _filter_findings_to_changes(all_findings, context, include_context)
768
+
769
+ # Deduplicate findings
770
+ filtered_findings = _deduplicate_findings(filtered_findings)
771
+
772
+ # Compute severity summary
773
+ severity_counts: dict[str, int] = {}
774
+ for f in filtered_findings:
775
+ sev = f.severity.value
776
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
777
+
778
+ # Format output
779
+ output = _format_change_review(context, filtered_findings, severity_counts, tool_errors)
780
+ return [TextContent(type="text", text=output)]
781
+
782
+
783
+ def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
784
+ """Handle full_review tool - comprehensive code review."""
785
+ path = arguments.get("path", "")
786
+ skills_override = arguments.get("skills")
787
+ # include_sage is accepted but not yet implemented
788
+ # _ = arguments.get("include_sage", True)
789
+
790
+ # 1. Detect domain
791
+ domain, domain_tags = _detect_domain(path)
792
+
793
+ # 2. Run static analysis (reuse quick_review logic)
794
+ if domain == Domain.SMART_CONTRACT:
795
+ default_tools = ["slither", "semgrep"]
796
+ elif domain == Domain.BACKEND and "python" in domain_tags:
797
+ default_tools = ["ruff", "bandit", "semgrep"]
798
+ elif domain == Domain.FRONTEND:
799
+ default_tools = ["semgrep"]
800
+ else:
801
+ default_tools = ["semgrep"]
802
+
803
+ all_findings: list[ToolFinding] = []
804
+ tool_errors: list[str] = []
805
+
806
+ if "semgrep" in default_tools:
807
+ config = get_semgrep_config(domain)
808
+ result = delegate_semgrep(path, config)
809
+ if result.is_ok:
810
+ all_findings.extend(result.value)
811
+ elif result.is_err:
812
+ tool_errors.append(f"semgrep: {result.error}")
813
+
814
+ if "ruff" in default_tools:
815
+ result = delegate_ruff(path)
816
+ if result.is_ok:
817
+ all_findings.extend(result.value)
818
+ elif result.is_err:
819
+ tool_errors.append(f"ruff: {result.error}")
820
+
821
+ if "slither" in default_tools:
822
+ result = delegate_slither(path)
823
+ if result.is_ok:
824
+ all_findings.extend(result.value)
825
+ elif result.is_err:
826
+ tool_errors.append(f"slither: {result.error}")
827
+
828
+ if "bandit" in default_tools:
829
+ result = delegate_bandit(path)
830
+ if result.is_ok:
831
+ all_findings.extend(result.value)
832
+ elif result.is_err:
833
+ tool_errors.append(f"bandit: {result.error}")
834
+
835
+ # 3. Match applicable skills
836
+ matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
837
+ skill_names = [name for name, _ in matched_skills]
838
+ skill_triggers: dict[str, tuple[str, ...]] = {
839
+ name: tuple(triggers) for name, triggers in matched_skills
840
+ }
841
+
842
+ # 4. Load skill content (checklists/prompts)
843
+ skill_contents: dict[str, str] = {}
844
+ for skill_name in skill_names:
845
+ result = load_skill(skill_name)
846
+ if result.is_ok:
847
+ _, content = result.value
848
+ # Extract content after frontmatter
849
+ if "\n---\n" in content:
850
+ skill_contents[skill_name] = content.split("\n---\n", 1)[1].strip()
851
+ else:
852
+ skill_contents[skill_name] = content
853
+
854
+ # 5. Collect knowledge files from matched skills + custom project/user knowledge
855
+ skill_knowledge = get_knowledge_for_skills(skill_names)
856
+ custom_knowledge = get_custom_knowledge_files()
857
+ # Merge: custom knowledge always included, plus skill-referenced files
858
+ knowledge_files = skill_knowledge | custom_knowledge
859
+
860
+ # 6. Load knowledge content
861
+ loaded_files, principles_content = load_all_knowledge(
862
+ include_bundled=False,
863
+ filenames=knowledge_files,
864
+ )
865
+
866
+ # 7. Deduplicate findings
867
+ all_findings = _deduplicate_findings(all_findings)
868
+
869
+ # 8. Compute severity summary
870
+ severity_counts: dict[str, int] = {}
871
+ for f in all_findings:
872
+ sev = f.severity.value
873
+ severity_counts[sev] = severity_counts.get(sev, 0) + 1
874
+
875
+ # 8. Build result
876
+ review_result = FullReviewResult(
877
+ domains_detected=tuple(domain_tags),
878
+ severity_summary=severity_counts,
879
+ findings=tuple(all_findings),
880
+ applicable_skills=tuple(skill_names),
881
+ skill_triggers_matched=skill_triggers,
882
+ principles_loaded=tuple(loaded_files),
883
+ principles_content=principles_content,
884
+ sage_knowledge=None, # Not implemented yet
885
+ sage_query_used=None, # Not implemented yet
886
+ )
887
+
888
+ # 8. Format output
889
+ output_parts = [
890
+ "# Full Review Results\n",
891
+ f"**Path:** `{path}`",
892
+ f"**Domains detected:** {', '.join(review_result.domains_detected)}",
893
+ f"**Severity summary:** {review_result.severity_summary or 'No findings'}\n",
894
+ ]
895
+
896
+ if tool_errors:
897
+ output_parts.append("## Tool Errors\n")
898
+ for error in tool_errors:
899
+ output_parts.append(f"- {error}")
900
+ output_parts.append("")
901
+
902
+ output_parts.append("## Applicable Skills\n")
903
+ if review_result.applicable_skills:
904
+ for skill in review_result.applicable_skills:
905
+ triggers = review_result.skill_triggers_matched.get(skill, ())
906
+ output_parts.append(f"- **{skill}**: matched on {', '.join(triggers)}")
907
+ else:
908
+ output_parts.append("- No skills matched")
909
+ output_parts.append("")
910
+
911
+ # Include skill checklists
912
+ if skill_contents:
913
+ output_parts.append("## Review Checklists\n")
914
+ for skill_name, content in skill_contents.items():
915
+ output_parts.append(f"### {skill_name}\n")
916
+ output_parts.append(content)
917
+ output_parts.append("")
918
+
919
+ output_parts.append("## Knowledge Loaded\n")
920
+ if review_result.principles_loaded:
921
+ output_parts.append(f"Files: {', '.join(review_result.principles_loaded)}\n")
922
+ else:
923
+ output_parts.append("No knowledge files loaded.\n")
924
+
925
+ output_parts.append("## Static Analysis Findings\n")
926
+ output_parts.append(_format_findings(list(review_result.findings)))
927
+
928
+ if review_result.principles_content:
929
+ output_parts.append("\n---\n")
930
+ output_parts.append("## Principles Reference\n")
931
+ output_parts.append(review_result.principles_content)
932
+
933
+ return [TextContent(type="text", text="\n".join(output_parts))]
934
+
935
+
344
936
  @server.call_tool() # type: ignore[misc]
345
937
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
346
938
  """Handle tool calls."""
347
939
  handlers = {
348
940
  "get_principles": _handle_get_principles,
941
+ "load_knowledge": _handle_load_knowledge,
349
942
  "delegate_semgrep": _handle_delegate_semgrep,
350
943
  "delegate_ruff": _handle_delegate_ruff,
351
944
  "delegate_slither": _handle_delegate_slither,
352
945
  "delegate_bandit": _handle_delegate_bandit,
353
946
  "quick_review": _handle_quick_review,
354
947
  "check_tools": _handle_check_tools,
948
+ "review_changes": _handle_review_changes,
949
+ "full_review": _handle_full_review,
355
950
  }
356
951
 
357
952
  handler = handlers.get(name)
@@ -0,0 +1,23 @@
1
+ """Skill loading and matching."""
2
+
3
+ from crucible.skills.loader import (
4
+ SkillMetadata,
5
+ clear_skill_cache,
6
+ get_all_skill_names,
7
+ get_knowledge_for_skills,
8
+ load_skill,
9
+ match_skills_for_domain,
10
+ parse_skill_frontmatter,
11
+ resolve_skill_path,
12
+ )
13
+
14
+ __all__ = [
15
+ "SkillMetadata",
16
+ "clear_skill_cache",
17
+ "get_all_skill_names",
18
+ "get_knowledge_for_skills",
19
+ "load_skill",
20
+ "match_skills_for_domain",
21
+ "parse_skill_frontmatter",
22
+ "resolve_skill_path",
23
+ ]