crucible-mcp 1.0.1__py3-none-any.whl → 1.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/knowledge/loader.py +6 -3
- crucible/models.py +0 -13
- crucible/server.py +79 -529
- {crucible_mcp-1.0.1.dist-info → crucible_mcp-1.2.0.dist-info}/METADATA +20 -3
- {crucible_mcp-1.0.1.dist-info → crucible_mcp-1.2.0.dist-info}/RECORD +8 -8
- {crucible_mcp-1.0.1.dist-info → crucible_mcp-1.2.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-1.0.1.dist-info → crucible_mcp-1.2.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-1.0.1.dist-info → crucible_mcp-1.2.0.dist-info}/top_level.txt +0 -0
crucible/knowledge/loader.py
CHANGED
|
@@ -247,14 +247,17 @@ def get_custom_knowledge_files() -> set[str]:
|
|
|
247
247
|
|
|
248
248
|
|
|
249
249
|
def load_all_knowledge(
|
|
250
|
-
include_bundled: bool =
|
|
250
|
+
include_bundled: bool = True,
|
|
251
251
|
filenames: set[str] | None = None,
|
|
252
252
|
) -> tuple[list[str], str]:
|
|
253
253
|
"""Load multiple knowledge files.
|
|
254
254
|
|
|
255
|
+
Knowledge follows cascade priority: project > user > bundled.
|
|
256
|
+
Project/user files override bundled files with the same name.
|
|
257
|
+
|
|
255
258
|
Args:
|
|
256
|
-
include_bundled: If True, include bundled knowledge files
|
|
257
|
-
filenames: Specific files to load (if None, loads
|
|
259
|
+
include_bundled: If True, include bundled knowledge files (default: True)
|
|
260
|
+
filenames: Specific files to load (if None, loads all from cascade)
|
|
258
261
|
|
|
259
262
|
Returns:
|
|
260
263
|
Tuple of (list of loaded filenames, combined content)
|
crucible/models.py
CHANGED
|
@@ -61,16 +61,3 @@ DOMAIN_HEURISTICS: dict[Domain, dict[str, list[str]]] = {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
@dataclass(frozen=True)
|
|
65
|
-
class FullReviewResult:
|
|
66
|
-
"""Result from full_review tool."""
|
|
67
|
-
|
|
68
|
-
domains_detected: tuple[str, ...]
|
|
69
|
-
severity_summary: dict[str, int]
|
|
70
|
-
findings: tuple[ToolFinding, ...]
|
|
71
|
-
applicable_skills: tuple[str, ...]
|
|
72
|
-
skill_triggers_matched: dict[str, tuple[str, ...]]
|
|
73
|
-
principles_loaded: tuple[str, ...]
|
|
74
|
-
principles_content: str
|
|
75
|
-
sage_knowledge: str | None = None
|
|
76
|
-
sage_query_used: str | None = None
|
crucible/server.py
CHANGED
|
@@ -7,12 +7,12 @@ 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.enforcement.assertions import load_assertions
|
|
10
11
|
from crucible.knowledge.loader import (
|
|
11
|
-
get_custom_knowledge_files,
|
|
12
12
|
load_all_knowledge,
|
|
13
13
|
load_principles,
|
|
14
14
|
)
|
|
15
|
-
from crucible.models import Domain,
|
|
15
|
+
from crucible.models import Domain, Severity, ToolFinding
|
|
16
16
|
from crucible.review.core import (
|
|
17
17
|
compute_severity_counts,
|
|
18
18
|
deduplicate_findings,
|
|
@@ -22,14 +22,12 @@ from crucible.review.core import (
|
|
|
22
22
|
run_enforcement,
|
|
23
23
|
run_static_analysis,
|
|
24
24
|
)
|
|
25
|
-
from crucible.skills import get_knowledge_for_skills, load_skill, match_skills_for_domain
|
|
26
25
|
from crucible.tools.delegation import (
|
|
27
26
|
check_all_tools,
|
|
28
27
|
delegate_bandit,
|
|
29
28
|
delegate_ruff,
|
|
30
29
|
delegate_semgrep,
|
|
31
30
|
delegate_slither,
|
|
32
|
-
get_semgrep_config,
|
|
33
31
|
)
|
|
34
32
|
from crucible.tools.git import (
|
|
35
33
|
GitContext,
|
|
@@ -134,76 +132,6 @@ async def list_tools() -> list[Tool]:
|
|
|
134
132
|
},
|
|
135
133
|
},
|
|
136
134
|
),
|
|
137
|
-
Tool(
|
|
138
|
-
name="quick_review",
|
|
139
|
-
description="[DEPRECATED: use review(path, include_skills=false)] Run static analysis only.",
|
|
140
|
-
inputSchema={
|
|
141
|
-
"type": "object",
|
|
142
|
-
"properties": {
|
|
143
|
-
"path": {
|
|
144
|
-
"type": "string",
|
|
145
|
-
"description": "File or directory path to scan",
|
|
146
|
-
},
|
|
147
|
-
"tools": {
|
|
148
|
-
"type": "array",
|
|
149
|
-
"items": {"type": "string"},
|
|
150
|
-
"description": "Tools to run (semgrep, ruff, slither, bandit). Default: auto-detect based on file type",
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
"required": ["path"],
|
|
154
|
-
},
|
|
155
|
-
),
|
|
156
|
-
Tool(
|
|
157
|
-
name="full_review",
|
|
158
|
-
description="[DEPRECATED: use review(path)] Comprehensive code review with skills and knowledge.",
|
|
159
|
-
inputSchema={
|
|
160
|
-
"type": "object",
|
|
161
|
-
"properties": {
|
|
162
|
-
"path": {
|
|
163
|
-
"type": "string",
|
|
164
|
-
"description": "File or directory path to review",
|
|
165
|
-
},
|
|
166
|
-
"skills": {
|
|
167
|
-
"type": "array",
|
|
168
|
-
"items": {"type": "string"},
|
|
169
|
-
"description": "Override skill selection (default: auto-detect based on domain)",
|
|
170
|
-
},
|
|
171
|
-
"include_sage": {
|
|
172
|
-
"type": "boolean",
|
|
173
|
-
"description": "Include Sage knowledge recall (not yet implemented)",
|
|
174
|
-
"default": True,
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
"required": ["path"],
|
|
178
|
-
},
|
|
179
|
-
),
|
|
180
|
-
Tool(
|
|
181
|
-
name="review_changes",
|
|
182
|
-
description="[DEPRECATED: use review(mode='staged')] Review git changes.",
|
|
183
|
-
inputSchema={
|
|
184
|
-
"type": "object",
|
|
185
|
-
"properties": {
|
|
186
|
-
"mode": {
|
|
187
|
-
"type": "string",
|
|
188
|
-
"enum": ["staged", "unstaged", "branch", "commits"],
|
|
189
|
-
"description": "What changes to review",
|
|
190
|
-
},
|
|
191
|
-
"base": {
|
|
192
|
-
"type": "string",
|
|
193
|
-
"description": "Base branch for 'branch' mode or commit count for 'commits' mode",
|
|
194
|
-
},
|
|
195
|
-
"path": {
|
|
196
|
-
"type": "string",
|
|
197
|
-
"description": "Repository path (default: current directory)",
|
|
198
|
-
},
|
|
199
|
-
"include_context": {
|
|
200
|
-
"type": "boolean",
|
|
201
|
-
"description": "Include findings near changes (default: false)",
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
"required": ["mode"],
|
|
205
|
-
},
|
|
206
|
-
),
|
|
207
135
|
Tool(
|
|
208
136
|
name="get_principles",
|
|
209
137
|
description="Load engineering principles by topic",
|
|
@@ -293,19 +221,19 @@ async def list_tools() -> list[Tool]:
|
|
|
293
221
|
),
|
|
294
222
|
Tool(
|
|
295
223
|
name="load_knowledge",
|
|
296
|
-
description="Load knowledge/principles files without running static analysis. Useful for getting guidance on patterns, best practices, or domain-specific knowledge.
|
|
224
|
+
description="Load knowledge/principles files without running static analysis. Useful for getting guidance on patterns, best practices, or domain-specific knowledge. Loads all 14 bundled knowledge files by default, with project/user files overriding bundled ones.",
|
|
297
225
|
inputSchema={
|
|
298
226
|
"type": "object",
|
|
299
227
|
"properties": {
|
|
300
228
|
"files": {
|
|
301
229
|
"type": "array",
|
|
302
230
|
"items": {"type": "string"},
|
|
303
|
-
"description": "Specific knowledge files to load (e.g., ['SECURITY.md', 'SMART_CONTRACT.md']). If not specified, loads all
|
|
231
|
+
"description": "Specific knowledge files to load (e.g., ['SECURITY.md', 'SMART_CONTRACT.md']). If not specified, loads all available knowledge files.",
|
|
304
232
|
},
|
|
305
233
|
"include_bundled": {
|
|
306
234
|
"type": "boolean",
|
|
307
|
-
"description": "Include bundled knowledge files
|
|
308
|
-
"default":
|
|
235
|
+
"description": "Include bundled knowledge files (default: true). Project/user files override bundled ones with same name.",
|
|
236
|
+
"default": True,
|
|
309
237
|
},
|
|
310
238
|
"topic": {
|
|
311
239
|
"type": "string",
|
|
@@ -314,6 +242,20 @@ async def list_tools() -> list[Tool]:
|
|
|
314
242
|
},
|
|
315
243
|
},
|
|
316
244
|
),
|
|
245
|
+
Tool(
|
|
246
|
+
name="get_assertions",
|
|
247
|
+
description="Load active enforcement assertions for this project. Call at session start to understand what code patterns are enforced. Returns all pattern and LLM assertions that will be checked during reviews.",
|
|
248
|
+
inputSchema={
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": {
|
|
251
|
+
"include_compliance": {
|
|
252
|
+
"type": "boolean",
|
|
253
|
+
"description": "Include LLM compliance assertion details (default: true)",
|
|
254
|
+
"default": True,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
),
|
|
317
259
|
]
|
|
318
260
|
|
|
319
261
|
|
|
@@ -683,7 +625,7 @@ def _handle_get_principles(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
683
625
|
def _handle_load_knowledge(arguments: dict[str, Any]) -> list[TextContent]:
|
|
684
626
|
"""Handle load_knowledge tool."""
|
|
685
627
|
files = arguments.get("files")
|
|
686
|
-
include_bundled = arguments.get("include_bundled",
|
|
628
|
+
include_bundled = arguments.get("include_bundled", True)
|
|
687
629
|
topic = arguments.get("topic")
|
|
688
630
|
|
|
689
631
|
# If topic specified, use load_principles
|
|
@@ -715,6 +657,61 @@ def _handle_load_knowledge(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
715
657
|
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
716
658
|
|
|
717
659
|
|
|
660
|
+
def _handle_get_assertions(arguments: dict[str, Any]) -> list[TextContent]:
|
|
661
|
+
"""Handle get_assertions tool - load active enforcement rules."""
|
|
662
|
+
include_compliance = arguments.get("include_compliance", True)
|
|
663
|
+
|
|
664
|
+
assertions, load_errors = load_assertions()
|
|
665
|
+
|
|
666
|
+
if not assertions and not load_errors:
|
|
667
|
+
return [TextContent(type="text", text="No assertions found. Add assertion files to .crucible/assertions/ or use bundled assertions.")]
|
|
668
|
+
|
|
669
|
+
parts: list[str] = ["# Active Enforcement Assertions\n"]
|
|
670
|
+
parts.append("These patterns are enforced during code review. Avoid these in your code.\n")
|
|
671
|
+
|
|
672
|
+
if load_errors:
|
|
673
|
+
parts.append("## Load Errors\n")
|
|
674
|
+
for error in load_errors:
|
|
675
|
+
parts.append(f"- {error}")
|
|
676
|
+
parts.append("")
|
|
677
|
+
|
|
678
|
+
# Group by source file / category
|
|
679
|
+
pattern_assertions = [a for a in assertions if a.type.value == "pattern"]
|
|
680
|
+
llm_assertions = [a for a in assertions if a.type.value == "llm"]
|
|
681
|
+
|
|
682
|
+
if pattern_assertions:
|
|
683
|
+
parts.append("## Pattern Assertions (fast, always run)\n")
|
|
684
|
+
parts.append("| ID | Message | Severity | Languages |")
|
|
685
|
+
parts.append("|---|---|---|---|")
|
|
686
|
+
for a in pattern_assertions:
|
|
687
|
+
langs = ", ".join(a.languages) if a.languages else "all"
|
|
688
|
+
parts.append(f"| `{a.id}` | {a.message} | {a.severity} | {langs} |")
|
|
689
|
+
parts.append("")
|
|
690
|
+
|
|
691
|
+
if llm_assertions and include_compliance:
|
|
692
|
+
parts.append("## LLM Compliance Assertions (semantic, budget-controlled)\n")
|
|
693
|
+
parts.append("| ID | Message | Severity | Model |")
|
|
694
|
+
parts.append("|---|---|---|---|")
|
|
695
|
+
for a in llm_assertions:
|
|
696
|
+
model = a.model or "sonnet"
|
|
697
|
+
parts.append(f"| `{a.id}` | {a.message} | {a.severity} | {model} |")
|
|
698
|
+
parts.append("")
|
|
699
|
+
|
|
700
|
+
# Show compliance requirements for LLM assertions
|
|
701
|
+
parts.append("### Compliance Requirements\n")
|
|
702
|
+
for a in llm_assertions:
|
|
703
|
+
parts.append(f"**{a.id}:**")
|
|
704
|
+
if a.compliance:
|
|
705
|
+
parts.append(f"```\n{a.compliance.strip()}\n```")
|
|
706
|
+
parts.append("")
|
|
707
|
+
|
|
708
|
+
# Summary
|
|
709
|
+
parts.append("---\n")
|
|
710
|
+
parts.append(f"**Total:** {len(pattern_assertions)} pattern + {len(llm_assertions)} LLM assertions")
|
|
711
|
+
|
|
712
|
+
return [TextContent(type="text", text="\n".join(parts))]
|
|
713
|
+
|
|
714
|
+
|
|
718
715
|
def _handle_delegate_semgrep(arguments: dict[str, Any]) -> list[TextContent]:
|
|
719
716
|
"""Handle delegate_semgrep tool."""
|
|
720
717
|
path = arguments.get("path", "")
|
|
@@ -786,464 +783,17 @@ def _handle_check_tools(arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
786
783
|
return [TextContent(type="text", text="\n".join(parts))]
|
|
787
784
|
|
|
788
785
|
|
|
789
|
-
def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
790
|
-
"""Handle quick_review tool - returns findings with domain metadata."""
|
|
791
|
-
path = arguments.get("path", "")
|
|
792
|
-
tools = arguments.get("tools")
|
|
793
|
-
|
|
794
|
-
# Internal domain detection
|
|
795
|
-
domain, domain_tags = detect_domain(path)
|
|
796
|
-
|
|
797
|
-
# Select tools based on domain
|
|
798
|
-
if domain == Domain.SMART_CONTRACT:
|
|
799
|
-
default_tools = ["slither", "semgrep"]
|
|
800
|
-
elif domain == Domain.BACKEND and "python" in domain_tags:
|
|
801
|
-
default_tools = ["ruff", "bandit", "semgrep"]
|
|
802
|
-
elif domain == Domain.FRONTEND:
|
|
803
|
-
default_tools = ["semgrep"]
|
|
804
|
-
else:
|
|
805
|
-
default_tools = ["semgrep"]
|
|
806
|
-
|
|
807
|
-
if not tools:
|
|
808
|
-
tools = default_tools
|
|
809
|
-
|
|
810
|
-
# Collect all findings
|
|
811
|
-
all_findings: list[ToolFinding] = []
|
|
812
|
-
tool_results: list[str] = []
|
|
813
|
-
|
|
814
|
-
if "semgrep" in tools:
|
|
815
|
-
config = get_semgrep_config(domain)
|
|
816
|
-
result = delegate_semgrep(path, config)
|
|
817
|
-
if result.is_ok:
|
|
818
|
-
all_findings.extend(result.value)
|
|
819
|
-
tool_results.append(f"## Semgrep\n{_format_findings(result.value)}")
|
|
820
|
-
else:
|
|
821
|
-
tool_results.append(f"## Semgrep\nError: {result.error}")
|
|
822
|
-
|
|
823
|
-
if "ruff" in tools:
|
|
824
|
-
result = delegate_ruff(path)
|
|
825
|
-
if result.is_ok:
|
|
826
|
-
all_findings.extend(result.value)
|
|
827
|
-
tool_results.append(f"## Ruff\n{_format_findings(result.value)}")
|
|
828
|
-
else:
|
|
829
|
-
tool_results.append(f"## Ruff\nError: {result.error}")
|
|
830
|
-
|
|
831
|
-
if "slither" in tools:
|
|
832
|
-
result = delegate_slither(path)
|
|
833
|
-
if result.is_ok:
|
|
834
|
-
all_findings.extend(result.value)
|
|
835
|
-
tool_results.append(f"## Slither\n{_format_findings(result.value)}")
|
|
836
|
-
else:
|
|
837
|
-
tool_results.append(f"## Slither\nError: {result.error}")
|
|
838
|
-
|
|
839
|
-
if "bandit" in tools:
|
|
840
|
-
result = delegate_bandit(path)
|
|
841
|
-
if result.is_ok:
|
|
842
|
-
all_findings.extend(result.value)
|
|
843
|
-
tool_results.append(f"## Bandit\n{_format_findings(result.value)}")
|
|
844
|
-
else:
|
|
845
|
-
tool_results.append(f"## Bandit\nError: {result.error}")
|
|
846
|
-
|
|
847
|
-
# Deduplicate findings
|
|
848
|
-
all_findings = deduplicate_findings(all_findings)
|
|
849
|
-
|
|
850
|
-
# Compute severity summary
|
|
851
|
-
severity_counts: dict[str, int] = {}
|
|
852
|
-
for f in all_findings:
|
|
853
|
-
sev = f.severity.value
|
|
854
|
-
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
855
|
-
|
|
856
|
-
# Build structured output
|
|
857
|
-
output_parts = [
|
|
858
|
-
"# Review Results\n",
|
|
859
|
-
f"**Domains detected:** {', '.join(domain_tags)}",
|
|
860
|
-
f"**Severity summary:** {severity_counts or 'No findings'}\n",
|
|
861
|
-
"\n".join(tool_results),
|
|
862
|
-
]
|
|
863
|
-
|
|
864
|
-
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
def _format_change_review(
|
|
868
|
-
context: GitContext,
|
|
869
|
-
findings: list[ToolFinding],
|
|
870
|
-
severity_counts: dict[str, int],
|
|
871
|
-
tool_errors: list[str] | None = None,
|
|
872
|
-
matched_skills: list[tuple[str, list[str]]] | None = None,
|
|
873
|
-
skill_content: dict[str, str] | None = None,
|
|
874
|
-
knowledge_files: set[str] | None = None,
|
|
875
|
-
knowledge_content: dict[str, str] | None = None,
|
|
876
|
-
) -> str:
|
|
877
|
-
"""Format change review output."""
|
|
878
|
-
parts: list[str] = ["# Change Review\n"]
|
|
879
|
-
parts.append(f"**Mode:** {context.mode}")
|
|
880
|
-
if context.base_ref:
|
|
881
|
-
parts.append(f"**Base:** {context.base_ref}")
|
|
882
|
-
parts.append("")
|
|
883
|
-
|
|
884
|
-
# Files changed
|
|
885
|
-
added = [c for c in context.changes if c.status == "A"]
|
|
886
|
-
modified = [c for c in context.changes if c.status == "M"]
|
|
887
|
-
deleted = [c for c in context.changes if c.status == "D"]
|
|
888
|
-
renamed = [c for c in context.changes if c.status == "R"]
|
|
889
|
-
|
|
890
|
-
total = len(context.changes)
|
|
891
|
-
parts.append(f"## Files Changed ({total})")
|
|
892
|
-
for c in added:
|
|
893
|
-
parts.append(f"- `+` {c.path}")
|
|
894
|
-
for c in modified:
|
|
895
|
-
parts.append(f"- `~` {c.path}")
|
|
896
|
-
for c in renamed:
|
|
897
|
-
parts.append(f"- `R` {c.old_path} -> {c.path}")
|
|
898
|
-
for c in deleted:
|
|
899
|
-
parts.append(f"- `-` {c.path}")
|
|
900
|
-
parts.append("")
|
|
901
|
-
|
|
902
|
-
# Commit messages (if available)
|
|
903
|
-
if context.commit_messages:
|
|
904
|
-
parts.append("## Commits")
|
|
905
|
-
for msg in context.commit_messages:
|
|
906
|
-
parts.append(f"- {msg}")
|
|
907
|
-
parts.append("")
|
|
908
|
-
|
|
909
|
-
# Applicable skills
|
|
910
|
-
if matched_skills:
|
|
911
|
-
parts.append("## Applicable Skills\n")
|
|
912
|
-
for skill_name, triggers in matched_skills:
|
|
913
|
-
parts.append(f"- **{skill_name}**: matched on {', '.join(triggers)}")
|
|
914
|
-
parts.append("")
|
|
915
|
-
|
|
916
|
-
# Knowledge loaded
|
|
917
|
-
if knowledge_files:
|
|
918
|
-
parts.append("## Knowledge Loaded\n")
|
|
919
|
-
parts.append(f"Files: {', '.join(sorted(knowledge_files))}")
|
|
920
|
-
parts.append("")
|
|
921
|
-
|
|
922
|
-
# Tool errors (if any)
|
|
923
|
-
if tool_errors:
|
|
924
|
-
parts.append("## Tool Errors\n")
|
|
925
|
-
for error in tool_errors:
|
|
926
|
-
parts.append(f"- {error}")
|
|
927
|
-
parts.append("")
|
|
928
|
-
|
|
929
|
-
# Findings
|
|
930
|
-
if findings:
|
|
931
|
-
parts.append("## Findings in Changed Code\n")
|
|
932
|
-
parts.append(f"**Summary:** {severity_counts}\n")
|
|
933
|
-
parts.append(_format_findings(findings))
|
|
934
|
-
else:
|
|
935
|
-
parts.append("## Findings in Changed Code\n")
|
|
936
|
-
parts.append("No issues found in changed code.")
|
|
937
|
-
parts.append("")
|
|
938
|
-
|
|
939
|
-
# Review checklists from skills
|
|
940
|
-
if skill_content:
|
|
941
|
-
parts.append("---\n")
|
|
942
|
-
parts.append("## Review Checklists\n")
|
|
943
|
-
for skill_name, content in skill_content.items():
|
|
944
|
-
parts.append(f"### {skill_name}\n")
|
|
945
|
-
parts.append(content)
|
|
946
|
-
parts.append("")
|
|
947
|
-
|
|
948
|
-
# Knowledge reference
|
|
949
|
-
if knowledge_content:
|
|
950
|
-
parts.append("---\n")
|
|
951
|
-
parts.append("## Principles Reference\n")
|
|
952
|
-
for filename, content in sorted(knowledge_content.items()):
|
|
953
|
-
parts.append(f"### {filename}\n")
|
|
954
|
-
parts.append(content)
|
|
955
|
-
parts.append("")
|
|
956
|
-
|
|
957
|
-
return "\n".join(parts)
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
|
|
961
|
-
"""Handle review_changes tool - review git changes."""
|
|
962
|
-
import os
|
|
963
|
-
|
|
964
|
-
mode = arguments.get("mode", "staged")
|
|
965
|
-
base = arguments.get("base")
|
|
966
|
-
path = arguments.get("path", os.getcwd())
|
|
967
|
-
include_context = arguments.get("include_context", False)
|
|
968
|
-
|
|
969
|
-
# Get repo root
|
|
970
|
-
root_result = get_repo_root(path)
|
|
971
|
-
if root_result.is_err:
|
|
972
|
-
return [TextContent(type="text", text=f"Error: {root_result.error}")]
|
|
973
|
-
|
|
974
|
-
repo_path = root_result.value
|
|
975
|
-
|
|
976
|
-
# Get git context based on mode
|
|
977
|
-
if mode == "staged":
|
|
978
|
-
context_result = get_staged_changes(repo_path)
|
|
979
|
-
elif mode == "unstaged":
|
|
980
|
-
context_result = get_unstaged_changes(repo_path)
|
|
981
|
-
elif mode == "branch":
|
|
982
|
-
base_branch = base if base else "main"
|
|
983
|
-
context_result = get_branch_diff(repo_path, base_branch)
|
|
984
|
-
elif mode == "commits":
|
|
985
|
-
try:
|
|
986
|
-
count = int(base) if base else 1
|
|
987
|
-
except ValueError:
|
|
988
|
-
return [TextContent(type="text", text=f"Error: Invalid commit count '{base}'")]
|
|
989
|
-
context_result = get_recent_commits(repo_path, count)
|
|
990
|
-
else:
|
|
991
|
-
return [TextContent(type="text", text=f"Error: Unknown mode '{mode}'")]
|
|
992
|
-
|
|
993
|
-
if context_result.is_err:
|
|
994
|
-
return [TextContent(type="text", text=f"Error: {context_result.error}")]
|
|
995
|
-
|
|
996
|
-
context = context_result.value
|
|
997
|
-
|
|
998
|
-
# Check if there are any changes
|
|
999
|
-
if not context.changes:
|
|
1000
|
-
if mode == "staged":
|
|
1001
|
-
return [TextContent(type="text", text="No changes to review. Stage files with `git add` first.")]
|
|
1002
|
-
elif mode == "unstaged":
|
|
1003
|
-
return [TextContent(type="text", text="No unstaged changes to review.")]
|
|
1004
|
-
else:
|
|
1005
|
-
return [TextContent(type="text", text="No changes found.")]
|
|
1006
|
-
|
|
1007
|
-
# Get changed files (excluding deleted)
|
|
1008
|
-
changed_files = get_changed_files(context)
|
|
1009
|
-
if not changed_files:
|
|
1010
|
-
return [TextContent(type="text", text="No files to analyze (only deletions).")]
|
|
1011
|
-
|
|
1012
|
-
# Run analysis on changed files
|
|
1013
|
-
all_findings: list[ToolFinding] = []
|
|
1014
|
-
tool_errors: list[str] = []
|
|
1015
|
-
domains_detected: set[Domain] = set()
|
|
1016
|
-
all_domain_tags: set[str] = set()
|
|
1017
|
-
|
|
1018
|
-
for file_path in changed_files:
|
|
1019
|
-
full_path = f"{repo_path}/{file_path}"
|
|
1020
|
-
|
|
1021
|
-
# Detect domain for this file
|
|
1022
|
-
domain, domain_tags = detect_domain(file_path)
|
|
1023
|
-
domains_detected.add(domain)
|
|
1024
|
-
all_domain_tags.update(domain_tags)
|
|
1025
|
-
|
|
1026
|
-
# Select tools based on domain
|
|
1027
|
-
if domain == Domain.SMART_CONTRACT:
|
|
1028
|
-
tools = ["slither", "semgrep"]
|
|
1029
|
-
elif domain == Domain.BACKEND and "python" in domain_tags:
|
|
1030
|
-
tools = ["ruff", "bandit", "semgrep"]
|
|
1031
|
-
elif domain == Domain.FRONTEND:
|
|
1032
|
-
tools = ["semgrep"]
|
|
1033
|
-
else:
|
|
1034
|
-
tools = ["semgrep"]
|
|
1035
|
-
|
|
1036
|
-
# Run tools
|
|
1037
|
-
if "semgrep" in tools:
|
|
1038
|
-
config = get_semgrep_config(domain)
|
|
1039
|
-
result = delegate_semgrep(full_path, config)
|
|
1040
|
-
if result.is_ok:
|
|
1041
|
-
all_findings.extend(result.value)
|
|
1042
|
-
elif result.is_err:
|
|
1043
|
-
tool_errors.append(f"semgrep ({file_path}): {result.error}")
|
|
1044
|
-
|
|
1045
|
-
if "ruff" in tools:
|
|
1046
|
-
result = delegate_ruff(full_path)
|
|
1047
|
-
if result.is_ok:
|
|
1048
|
-
all_findings.extend(result.value)
|
|
1049
|
-
elif result.is_err:
|
|
1050
|
-
tool_errors.append(f"ruff ({file_path}): {result.error}")
|
|
1051
|
-
|
|
1052
|
-
if "slither" in tools:
|
|
1053
|
-
result = delegate_slither(full_path)
|
|
1054
|
-
if result.is_ok:
|
|
1055
|
-
all_findings.extend(result.value)
|
|
1056
|
-
elif result.is_err:
|
|
1057
|
-
tool_errors.append(f"slither ({file_path}): {result.error}")
|
|
1058
|
-
|
|
1059
|
-
if "bandit" in tools:
|
|
1060
|
-
result = delegate_bandit(full_path)
|
|
1061
|
-
if result.is_ok:
|
|
1062
|
-
all_findings.extend(result.value)
|
|
1063
|
-
elif result.is_err:
|
|
1064
|
-
tool_errors.append(f"bandit ({file_path}): {result.error}")
|
|
1065
|
-
|
|
1066
|
-
# Filter findings to changed lines
|
|
1067
|
-
filtered_findings = filter_findings_to_changes(all_findings, context, include_context)
|
|
1068
|
-
|
|
1069
|
-
# Deduplicate findings
|
|
1070
|
-
filtered_findings = deduplicate_findings(filtered_findings)
|
|
1071
|
-
|
|
1072
|
-
# Compute severity summary
|
|
1073
|
-
severity_counts: dict[str, int] = {}
|
|
1074
|
-
for f in filtered_findings:
|
|
1075
|
-
sev = f.severity.value
|
|
1076
|
-
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
1077
|
-
|
|
1078
|
-
# Match skills and load knowledge based on detected domains
|
|
1079
|
-
from crucible.knowledge.loader import load_knowledge_file
|
|
1080
|
-
from crucible.skills.loader import (
|
|
1081
|
-
get_knowledge_for_skills,
|
|
1082
|
-
load_skill,
|
|
1083
|
-
match_skills_for_domain,
|
|
1084
|
-
)
|
|
1085
|
-
|
|
1086
|
-
primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
|
|
1087
|
-
matched_skills = match_skills_for_domain(
|
|
1088
|
-
primary_domain, list(all_domain_tags), override=None
|
|
1089
|
-
)
|
|
1090
|
-
|
|
1091
|
-
skill_names = [name for name, _ in matched_skills]
|
|
1092
|
-
skill_content: dict[str, str] = {}
|
|
1093
|
-
for skill_name, _triggers in matched_skills:
|
|
1094
|
-
result = load_skill(skill_name)
|
|
1095
|
-
if result.is_ok:
|
|
1096
|
-
_, content = result.value
|
|
1097
|
-
skill_content[skill_name] = content
|
|
1098
|
-
|
|
1099
|
-
knowledge_files = get_knowledge_for_skills(skill_names)
|
|
1100
|
-
knowledge_content: dict[str, str] = {}
|
|
1101
|
-
for filename in knowledge_files:
|
|
1102
|
-
result = load_knowledge_file(filename)
|
|
1103
|
-
if result.is_ok:
|
|
1104
|
-
knowledge_content[filename] = result.value
|
|
1105
|
-
|
|
1106
|
-
# Format output
|
|
1107
|
-
output = _format_change_review(
|
|
1108
|
-
context,
|
|
1109
|
-
filtered_findings,
|
|
1110
|
-
severity_counts,
|
|
1111
|
-
tool_errors,
|
|
1112
|
-
matched_skills,
|
|
1113
|
-
skill_content,
|
|
1114
|
-
knowledge_files,
|
|
1115
|
-
knowledge_content,
|
|
1116
|
-
)
|
|
1117
|
-
return [TextContent(type="text", text=output)]
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
1121
|
-
"""Handle full_review tool - comprehensive code review.
|
|
1122
|
-
|
|
1123
|
-
DEPRECATED: Use _handle_review with path parameter instead.
|
|
1124
|
-
"""
|
|
1125
|
-
from crucible.review.core import run_static_analysis
|
|
1126
|
-
|
|
1127
|
-
path = arguments.get("path", "")
|
|
1128
|
-
skills_override = arguments.get("skills")
|
|
1129
|
-
|
|
1130
|
-
# 1. Detect domain
|
|
1131
|
-
domain, domain_tags = detect_domain(path)
|
|
1132
|
-
|
|
1133
|
-
# 2. Run static analysis using shared core function
|
|
1134
|
-
all_findings, tool_errors = run_static_analysis(path, domain, domain_tags)
|
|
1135
|
-
|
|
1136
|
-
# 3. Match applicable skills
|
|
1137
|
-
matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
|
|
1138
|
-
skill_names = [name for name, _ in matched_skills]
|
|
1139
|
-
skill_triggers: dict[str, tuple[str, ...]] = {
|
|
1140
|
-
name: tuple(triggers) for name, triggers in matched_skills
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
# 4. Load skill content (checklists/prompts)
|
|
1144
|
-
skill_contents: dict[str, str] = {}
|
|
1145
|
-
for skill_name in skill_names:
|
|
1146
|
-
result = load_skill(skill_name)
|
|
1147
|
-
if result.is_ok:
|
|
1148
|
-
_, content = result.value
|
|
1149
|
-
# Extract content after frontmatter
|
|
1150
|
-
if "\n---\n" in content:
|
|
1151
|
-
skill_contents[skill_name] = content.split("\n---\n", 1)[1].strip()
|
|
1152
|
-
else:
|
|
1153
|
-
skill_contents[skill_name] = content
|
|
1154
|
-
|
|
1155
|
-
# 5. Collect knowledge files from matched skills + custom project/user knowledge
|
|
1156
|
-
skill_knowledge = get_knowledge_for_skills(skill_names)
|
|
1157
|
-
custom_knowledge = get_custom_knowledge_files()
|
|
1158
|
-
# Merge: custom knowledge always included, plus skill-referenced files
|
|
1159
|
-
knowledge_files = skill_knowledge | custom_knowledge
|
|
1160
|
-
|
|
1161
|
-
# 6. Load knowledge content
|
|
1162
|
-
loaded_files, principles_content = load_all_knowledge(
|
|
1163
|
-
include_bundled=False,
|
|
1164
|
-
filenames=knowledge_files,
|
|
1165
|
-
)
|
|
1166
|
-
|
|
1167
|
-
# 7. Deduplicate findings
|
|
1168
|
-
all_findings = deduplicate_findings(all_findings)
|
|
1169
|
-
|
|
1170
|
-
# 8. Compute severity summary
|
|
1171
|
-
severity_counts = compute_severity_counts(all_findings)
|
|
1172
|
-
|
|
1173
|
-
# 8. Build result
|
|
1174
|
-
review_result = FullReviewResult(
|
|
1175
|
-
domains_detected=tuple(domain_tags),
|
|
1176
|
-
severity_summary=severity_counts,
|
|
1177
|
-
findings=tuple(all_findings),
|
|
1178
|
-
applicable_skills=tuple(skill_names),
|
|
1179
|
-
skill_triggers_matched=skill_triggers,
|
|
1180
|
-
principles_loaded=tuple(loaded_files),
|
|
1181
|
-
principles_content=principles_content,
|
|
1182
|
-
sage_knowledge=None, # Not implemented yet
|
|
1183
|
-
sage_query_used=None, # Not implemented yet
|
|
1184
|
-
)
|
|
1185
|
-
|
|
1186
|
-
# 8. Format output
|
|
1187
|
-
output_parts = [
|
|
1188
|
-
"# Full Review Results\n",
|
|
1189
|
-
f"**Path:** `{path}`",
|
|
1190
|
-
f"**Domains detected:** {', '.join(review_result.domains_detected)}",
|
|
1191
|
-
f"**Severity summary:** {review_result.severity_summary or 'No findings'}\n",
|
|
1192
|
-
]
|
|
1193
|
-
|
|
1194
|
-
if tool_errors:
|
|
1195
|
-
output_parts.append("## Tool Errors\n")
|
|
1196
|
-
for error in tool_errors:
|
|
1197
|
-
output_parts.append(f"- {error}")
|
|
1198
|
-
output_parts.append("")
|
|
1199
|
-
|
|
1200
|
-
output_parts.append("## Applicable Skills\n")
|
|
1201
|
-
if review_result.applicable_skills:
|
|
1202
|
-
for skill in review_result.applicable_skills:
|
|
1203
|
-
triggers = review_result.skill_triggers_matched.get(skill, ())
|
|
1204
|
-
output_parts.append(f"- **{skill}**: matched on {', '.join(triggers)}")
|
|
1205
|
-
else:
|
|
1206
|
-
output_parts.append("- No skills matched")
|
|
1207
|
-
output_parts.append("")
|
|
1208
|
-
|
|
1209
|
-
# Include skill checklists
|
|
1210
|
-
if skill_contents:
|
|
1211
|
-
output_parts.append("## Review Checklists\n")
|
|
1212
|
-
for skill_name, content in skill_contents.items():
|
|
1213
|
-
output_parts.append(f"### {skill_name}\n")
|
|
1214
|
-
output_parts.append(content)
|
|
1215
|
-
output_parts.append("")
|
|
1216
|
-
|
|
1217
|
-
output_parts.append("## Knowledge Loaded\n")
|
|
1218
|
-
if review_result.principles_loaded:
|
|
1219
|
-
output_parts.append(f"Files: {', '.join(review_result.principles_loaded)}\n")
|
|
1220
|
-
else:
|
|
1221
|
-
output_parts.append("No knowledge files loaded.\n")
|
|
1222
|
-
|
|
1223
|
-
output_parts.append("## Static Analysis Findings\n")
|
|
1224
|
-
output_parts.append(_format_findings(list(review_result.findings)))
|
|
1225
|
-
|
|
1226
|
-
if review_result.principles_content:
|
|
1227
|
-
output_parts.append("\n---\n")
|
|
1228
|
-
output_parts.append("## Principles Reference\n")
|
|
1229
|
-
output_parts.append(review_result.principles_content)
|
|
1230
|
-
|
|
1231
|
-
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
786
|
@server.call_tool() # type: ignore[misc]
|
|
1235
787
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
1236
788
|
"""Handle tool calls."""
|
|
1237
789
|
handlers = {
|
|
1238
790
|
# Unified review tool
|
|
1239
791
|
"review": _handle_review,
|
|
1240
|
-
#
|
|
1241
|
-
"
|
|
1242
|
-
"full_review": _handle_full_review,
|
|
1243
|
-
"review_changes": _handle_review_changes,
|
|
1244
|
-
# Other tools
|
|
792
|
+
# Context injection tools (call at session start)
|
|
793
|
+
"get_assertions": _handle_get_assertions,
|
|
1245
794
|
"get_principles": _handle_get_principles,
|
|
1246
795
|
"load_knowledge": _handle_load_knowledge,
|
|
796
|
+
# Direct tool access
|
|
1247
797
|
"delegate_semgrep": _handle_delegate_semgrep,
|
|
1248
798
|
"delegate_ruff": _handle_delegate_ruff,
|
|
1249
799
|
"delegate_slither": _handle_delegate_slither,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crucible-mcp
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
|
|
5
5
|
Author: be.nvy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,6 +58,20 @@ That's it. Crucible will now:
|
|
|
58
58
|
2. Review files Claude edits (Claude Code hook)
|
|
59
59
|
3. Block code that violates bundled assertions (security, error handling, smart contracts)
|
|
60
60
|
|
|
61
|
+
## CLAUDE.md Setup
|
|
62
|
+
|
|
63
|
+
Add to your `CLAUDE.md` to inject rules at session start:
|
|
64
|
+
|
|
65
|
+
```markdown
|
|
66
|
+
# Project
|
|
67
|
+
|
|
68
|
+
At session start, call get_assertions() to load enforcement rules.
|
|
69
|
+
|
|
70
|
+
For code review: crucible review
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This tells Claude to load the active assertions *before* writing code, not just catch violations after.
|
|
74
|
+
|
|
61
75
|
## How Enforcement Works
|
|
62
76
|
|
|
63
77
|
```
|
|
@@ -109,13 +123,16 @@ Add to Claude Code (`.mcp.json`):
|
|
|
109
123
|
|
|
110
124
|
| Tool | Purpose |
|
|
111
125
|
|------|---------|
|
|
126
|
+
| `get_assertions()` | **Session start:** Load enforced patterns into context |
|
|
127
|
+
| `get_principles(topic)` | **Session start:** Load engineering knowledge by topic |
|
|
128
|
+
| `load_knowledge(files)` | **Session start:** Load specific knowledge files |
|
|
112
129
|
| `review(path)` | Full review: analysis + skills + knowledge + assertions |
|
|
113
130
|
| `review(mode='staged')` | Review git changes with enforcement |
|
|
114
|
-
| `load_knowledge(files)` | Load specific knowledge files |
|
|
115
|
-
| `get_principles(topic)` | Load engineering knowledge by topic |
|
|
116
131
|
| `delegate_*` | Direct tool access (semgrep, ruff, slither, bandit) |
|
|
117
132
|
| `check_tools()` | Show installed analysis tools |
|
|
118
133
|
|
|
134
|
+
**Tip:** Call `get_assertions()` at the start of a session so Claude knows what patterns to avoid *before* writing code.
|
|
135
|
+
|
|
119
136
|
## CLI
|
|
120
137
|
|
|
121
138
|
```bash
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
crucible/__init__.py,sha256=M4v_CsJVOdiAAPgmd54mxkkbnes8e5ifMznDuOJhzzY,77
|
|
2
2
|
crucible/cli.py,sha256=DTYt-V5W-DJSkQV3qXumsxImd2Ib3rn2ZgeL-m-dEvA,79233
|
|
3
3
|
crucible/errors.py,sha256=HrX_yvJEhXJoKodXGo_iY9wqx2J3ONYy0a_LbrVC5As,819
|
|
4
|
-
crucible/models.py,sha256=
|
|
5
|
-
crucible/server.py,sha256=
|
|
4
|
+
crucible/models.py,sha256=VBRnoL5e9-zmbIMkcegxVQCXTgsdBFZ13XQVUxKuA1M,1626
|
|
5
|
+
crucible/server.py,sha256=_Fh3KX1hpif0cycBdQfajDRQCMmaN60dC6BynwXsKqs,30981
|
|
6
6
|
crucible/domain/__init__.py,sha256=2fsoB5wH2Pl3vtGRt4voYOSZ04-zLoW8pNq6nvzVMgU,118
|
|
7
7
|
crucible/domain/detection.py,sha256=TNeLB_VQgS1AsT5BKDf_tIpGa47THrFoRXwU4u54VB0,1797
|
|
8
8
|
crucible/enforcement/__init__.py,sha256=FOaGSrE1SWFPxBJ1L5VoDhQDmlJgRXXs_iiI20wHf2Q,867
|
|
@@ -18,7 +18,7 @@ crucible/hooks/__init__.py,sha256=k5oEWhTJKEQi-QWBfTbp1p6HaKg55_wVCBVD5pZzdqw,27
|
|
|
18
18
|
crucible/hooks/claudecode.py,sha256=9wAHbxYJkmvPPAt-GqGyMASItorF7uruMpYRDL-W-M0,11396
|
|
19
19
|
crucible/hooks/precommit.py,sha256=5W8ty_ji2F9NknvpHIUr8BU75KlXaplq5Itdcfazw68,25190
|
|
20
20
|
crucible/knowledge/__init__.py,sha256=unb7kyO1MtB3Zt-TGx_O8LE79KyrGrNHoFFHgUWUvGU,40
|
|
21
|
-
crucible/knowledge/loader.py,sha256=
|
|
21
|
+
crucible/knowledge/loader.py,sha256=GZn-pnxcCHdzu_IMkscW0b-g5SZ50PCfAmp7QwmKIvU,12582
|
|
22
22
|
crucible/knowledge/principles/API_DESIGN.md,sha256=XBYfi2Q-i47p88I72ZlmXtkj7Ph4UMHT43t3HOR6lhQ,3133
|
|
23
23
|
crucible/knowledge/principles/COMMITS.md,sha256=JlP-z_izywF2qObTQabE4hlgQTcHsZn9AJM32VTk-Ng,2044
|
|
24
24
|
crucible/knowledge/principles/DATABASE.md,sha256=FxxK_UWrfdwl-6f9l7esOmC2WFscwrmcziTYPmEWPXc,2816
|
|
@@ -59,8 +59,8 @@ crucible/synthesis/__init__.py,sha256=CYrkZG4bdAjp8XdOh1smfKscd3YU5lZlaDLGwLE9c-
|
|
|
59
59
|
crucible/tools/__init__.py,sha256=gFRThTk1E-fHzpe8bB5rtBG6Z6G-ysPzjVEHfKGbEYU,400
|
|
60
60
|
crucible/tools/delegation.py,sha256=_x1y76No3qkmGjjROVvMx1pSKKwU59aRu5R-r07lVFU,12871
|
|
61
61
|
crucible/tools/git.py,sha256=7-aJCesoQe3ZEBFcRxHBhY8RpZrBlNtHSns__RqiG04,10406
|
|
62
|
-
crucible_mcp-1.0.
|
|
63
|
-
crucible_mcp-1.0.
|
|
64
|
-
crucible_mcp-1.0.
|
|
65
|
-
crucible_mcp-1.0.
|
|
66
|
-
crucible_mcp-1.0.
|
|
62
|
+
crucible_mcp-1.2.0.dist-info/METADATA,sha256=cyRc5H0nkAiS8J4do0eD0Mc6d1dmquHH8n9JISfyQNc,6784
|
|
63
|
+
crucible_mcp-1.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
64
|
+
crucible_mcp-1.2.0.dist-info/entry_points.txt,sha256=18BZaH1OlFSFYtKuHq0Z8yYX8Wmx7Ikfqay-P00ZX3Q,83
|
|
65
|
+
crucible_mcp-1.2.0.dist-info/top_level.txt,sha256=4hzuFgqbFPOO-WiU_DYxTm8VYIxTXh7Wlp0gRcWR0Cs,9
|
|
66
|
+
crucible_mcp-1.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|