crucible-mcp 0.1.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/__init__.py +3 -0
- crucible/cli.py +523 -0
- crucible/domain/__init__.py +5 -0
- crucible/domain/detection.py +67 -0
- crucible/errors.py +50 -0
- crucible/knowledge/__init__.py +1 -0
- crucible/knowledge/loader.py +141 -0
- crucible/models.py +61 -0
- crucible/server.py +376 -0
- crucible/synthesis/__init__.py +1 -0
- crucible/tools/__init__.py +21 -0
- crucible/tools/delegation.py +326 -0
- crucible_mcp-0.1.0.dist-info/METADATA +158 -0
- crucible_mcp-0.1.0.dist-info/RECORD +17 -0
- crucible_mcp-0.1.0.dist-info/WHEEL +5 -0
- crucible_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- crucible_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Load engineering principles from markdown files.
|
|
2
|
+
|
|
3
|
+
Knowledge follows the same cascade as skills:
|
|
4
|
+
1. Project: .crucible/knowledge/
|
|
5
|
+
2. User: ~/.claude/crucible/knowledge/
|
|
6
|
+
3. Bundled: package knowledge/
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from crucible.errors import Result, err, ok
|
|
12
|
+
|
|
13
|
+
# Knowledge directories (same pattern as skills)
|
|
14
|
+
KNOWLEDGE_BUNDLED = Path(__file__).parent / "principles"
|
|
15
|
+
KNOWLEDGE_USER = Path.home() / ".claude" / "crucible" / "knowledge"
|
|
16
|
+
KNOWLEDGE_PROJECT = Path(".crucible") / "knowledge"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_knowledge_file(filename: str) -> tuple[Path | None, str]:
|
|
20
|
+
"""Find knowledge file with cascade priority.
|
|
21
|
+
|
|
22
|
+
Returns (path, source) where source is 'project', 'user', or 'bundled'.
|
|
23
|
+
"""
|
|
24
|
+
# 1. Project-level (highest priority)
|
|
25
|
+
project_path = KNOWLEDGE_PROJECT / filename
|
|
26
|
+
if project_path.exists():
|
|
27
|
+
return project_path, "project"
|
|
28
|
+
|
|
29
|
+
# 2. User-level
|
|
30
|
+
user_path = KNOWLEDGE_USER / filename
|
|
31
|
+
if user_path.exists():
|
|
32
|
+
return user_path, "user"
|
|
33
|
+
|
|
34
|
+
# 3. Bundled (lowest priority)
|
|
35
|
+
bundled_path = KNOWLEDGE_BUNDLED / filename
|
|
36
|
+
if bundled_path.exists():
|
|
37
|
+
return bundled_path, "bundled"
|
|
38
|
+
|
|
39
|
+
return None, ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_all_knowledge_files() -> set[str]:
|
|
43
|
+
"""Get all available knowledge file names from all sources."""
|
|
44
|
+
files: set[str] = set()
|
|
45
|
+
|
|
46
|
+
for source_dir in [KNOWLEDGE_BUNDLED, KNOWLEDGE_USER, KNOWLEDGE_PROJECT]:
|
|
47
|
+
if source_dir.exists():
|
|
48
|
+
for file_path in source_dir.iterdir():
|
|
49
|
+
if file_path.is_file() and file_path.suffix == ".md":
|
|
50
|
+
files.add(file_path.name)
|
|
51
|
+
|
|
52
|
+
return files
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_principles(topic: str | None = None) -> Result[str, str]:
|
|
56
|
+
"""
|
|
57
|
+
Load engineering principles from markdown files.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
topic: Optional topic filter (e.g., "security", "smart_contract", "engineering")
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Result containing principles content or error message
|
|
64
|
+
"""
|
|
65
|
+
# Map topics to domain-specific files
|
|
66
|
+
topic_files = {
|
|
67
|
+
None: ["SECURITY.md", "TESTING.md"], # Default: security + testing basics
|
|
68
|
+
"engineering": ["TESTING.md", "ERROR_HANDLING.md", "TYPE_SAFETY.md"],
|
|
69
|
+
"security": ["SECURITY.md"],
|
|
70
|
+
"smart_contract": ["SMART_CONTRACT.md"],
|
|
71
|
+
"checklist": ["SECURITY.md", "TESTING.md", "ERROR_HANDLING.md"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
files_to_load = topic_files.get(topic, topic_files[None])
|
|
75
|
+
content_parts: list[str] = []
|
|
76
|
+
|
|
77
|
+
for filename in files_to_load:
|
|
78
|
+
path, _source = resolve_knowledge_file(filename)
|
|
79
|
+
if path and path.exists():
|
|
80
|
+
content_parts.append(path.read_text())
|
|
81
|
+
|
|
82
|
+
if not content_parts:
|
|
83
|
+
available = get_all_knowledge_files()
|
|
84
|
+
if available:
|
|
85
|
+
return err(f"No principles found for topic: {topic}. Available files: {', '.join(sorted(available))}")
|
|
86
|
+
return err("No knowledge files found. Run 'crucible knowledge list' to see available topics.")
|
|
87
|
+
|
|
88
|
+
return ok("\n\n---\n\n".join(content_parts))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_persona_section(persona: str, content: str) -> str | None:
|
|
92
|
+
"""
|
|
93
|
+
Extract a specific persona section from the checklist content.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
persona: Persona name (e.g., "security", "web3")
|
|
97
|
+
content: Full checklist markdown content
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The persona section content or None if not found
|
|
101
|
+
"""
|
|
102
|
+
# Normalize persona name for matching
|
|
103
|
+
persona_headers = {
|
|
104
|
+
"security": "## Security Engineer",
|
|
105
|
+
"web3": "## Web3/Blockchain Engineer",
|
|
106
|
+
"backend": "## Backend/Systems Engineer",
|
|
107
|
+
"devops": "## DevOps/SRE",
|
|
108
|
+
"product": "## Product Engineer",
|
|
109
|
+
"performance": "## Performance Engineer",
|
|
110
|
+
"data": "## Data Engineer",
|
|
111
|
+
"accessibility": "## Accessibility Engineer",
|
|
112
|
+
"mobile": "## Mobile/Client Engineer",
|
|
113
|
+
"uiux": "## UI/UX Designer",
|
|
114
|
+
"fde": "## Forward Deployed",
|
|
115
|
+
"customer_success": "## Customer Success",
|
|
116
|
+
"tech_lead": "## Tech Lead",
|
|
117
|
+
"pragmatist": "## Pragmatist",
|
|
118
|
+
"purist": "## Purist",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
header = persona_headers.get(persona.lower())
|
|
122
|
+
if not header:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# Find the section
|
|
126
|
+
lines = content.split("\n")
|
|
127
|
+
start_idx = None
|
|
128
|
+
end_idx = None
|
|
129
|
+
|
|
130
|
+
for i, line in enumerate(lines):
|
|
131
|
+
if header in line:
|
|
132
|
+
start_idx = i
|
|
133
|
+
elif start_idx is not None and line.startswith("## ") and i > start_idx:
|
|
134
|
+
end_idx = i
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if start_idx is None:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
end_idx = end_idx or len(lines)
|
|
141
|
+
return "\n".join(lines[start_idx:end_idx])
|
crucible/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Data models for crucible."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Domain(Enum):
|
|
8
|
+
"""Code domain classification."""
|
|
9
|
+
|
|
10
|
+
SMART_CONTRACT = "smart_contract"
|
|
11
|
+
FRONTEND = "frontend"
|
|
12
|
+
BACKEND = "backend"
|
|
13
|
+
INFRASTRUCTURE = "infrastructure"
|
|
14
|
+
UNKNOWN = "unknown"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Severity(Enum):
|
|
18
|
+
"""Finding severity levels."""
|
|
19
|
+
|
|
20
|
+
CRITICAL = "critical"
|
|
21
|
+
HIGH = "high"
|
|
22
|
+
MEDIUM = "medium"
|
|
23
|
+
LOW = "low"
|
|
24
|
+
INFO = "info"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ToolFinding:
|
|
29
|
+
"""A finding from a static analysis tool."""
|
|
30
|
+
|
|
31
|
+
tool: str
|
|
32
|
+
rule: str
|
|
33
|
+
severity: Severity
|
|
34
|
+
message: str
|
|
35
|
+
location: str
|
|
36
|
+
suggestion: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Domain detection heuristics
|
|
40
|
+
DOMAIN_HEURISTICS: dict[Domain, dict[str, list[str]]] = {
|
|
41
|
+
Domain.SMART_CONTRACT: {
|
|
42
|
+
"extensions": [".sol"],
|
|
43
|
+
"imports": ["@openzeppelin", "hardhat", "foundry", "forge-std"],
|
|
44
|
+
"markers": ["pragma solidity", "contract ", "function ", "modifier "],
|
|
45
|
+
},
|
|
46
|
+
Domain.FRONTEND: {
|
|
47
|
+
"extensions": [".tsx", ".jsx", ".vue", ".svelte"],
|
|
48
|
+
"imports": ["react", "next", "vue", "svelte", "@tanstack"],
|
|
49
|
+
"markers": ["use client", "use server", "useState", "useEffect"],
|
|
50
|
+
},
|
|
51
|
+
Domain.BACKEND: {
|
|
52
|
+
"extensions": [".py", ".go", ".rs"],
|
|
53
|
+
"imports": ["fastapi", "flask", "django", "gin", "axum", "actix"],
|
|
54
|
+
"markers": ["@app.route", "@router", "def ", "func ", "fn "],
|
|
55
|
+
},
|
|
56
|
+
Domain.INFRASTRUCTURE: {
|
|
57
|
+
"extensions": [".tf", ".yaml", ".yml", ".toml"],
|
|
58
|
+
"imports": [],
|
|
59
|
+
"markers": ["resource ", "provider ", "apiVersion:", "kind:"],
|
|
60
|
+
},
|
|
61
|
+
}
|
crucible/server.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""crucible MCP server - code review orchestration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server import Server
|
|
7
|
+
from mcp.server.stdio import stdio_server
|
|
8
|
+
from mcp.types import TextContent, Tool
|
|
9
|
+
|
|
10
|
+
from crucible.knowledge.loader import load_principles
|
|
11
|
+
from crucible.models import Domain, Severity, ToolFinding
|
|
12
|
+
from crucible.tools.delegation import (
|
|
13
|
+
check_all_tools,
|
|
14
|
+
delegate_bandit,
|
|
15
|
+
delegate_ruff,
|
|
16
|
+
delegate_semgrep,
|
|
17
|
+
delegate_slither,
|
|
18
|
+
get_semgrep_config,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
server = Server("crucible")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _format_findings(findings: list[ToolFinding]) -> str:
|
|
25
|
+
"""Format tool findings as markdown."""
|
|
26
|
+
if not findings:
|
|
27
|
+
return "No findings."
|
|
28
|
+
|
|
29
|
+
# Group by severity
|
|
30
|
+
by_severity: dict[Severity, list[ToolFinding]] = {}
|
|
31
|
+
for f in findings:
|
|
32
|
+
by_severity.setdefault(f.severity, []).append(f)
|
|
33
|
+
|
|
34
|
+
parts: list[str] = []
|
|
35
|
+
for severity in [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO]:
|
|
36
|
+
items = by_severity.get(severity, [])
|
|
37
|
+
if not items:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
parts.append(f"\n### {severity.value.upper()} ({len(items)})\n")
|
|
41
|
+
for f in items:
|
|
42
|
+
parts.append(f"- **[{f.tool}:{f.rule}]** {f.message}")
|
|
43
|
+
parts.append(f" - Location: `{f.location}`")
|
|
44
|
+
if f.suggestion:
|
|
45
|
+
parts.append(f" - Suggestion: {f.suggestion}")
|
|
46
|
+
|
|
47
|
+
return "\n".join(parts) if parts else "No findings."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@server.list_tools() # type: ignore[misc]
|
|
51
|
+
async def list_tools() -> list[Tool]:
|
|
52
|
+
"""List available tools."""
|
|
53
|
+
return [
|
|
54
|
+
Tool(
|
|
55
|
+
name="quick_review",
|
|
56
|
+
description="Run static analysis tools on code. Returns findings with domain metadata for skill selection.",
|
|
57
|
+
inputSchema={
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"path": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "File or directory path to scan",
|
|
63
|
+
},
|
|
64
|
+
"tools": {
|
|
65
|
+
"type": "array",
|
|
66
|
+
"items": {"type": "string"},
|
|
67
|
+
"description": "Tools to run (semgrep, ruff, slither, bandit). Default: auto-detect based on file type",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"required": ["path"],
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
Tool(
|
|
74
|
+
name="get_principles",
|
|
75
|
+
description="Load engineering principles by topic",
|
|
76
|
+
inputSchema={
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"topic": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "Topic filter (engineering, security, smart_contract, checklist)",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
),
|
|
86
|
+
Tool(
|
|
87
|
+
name="delegate_semgrep",
|
|
88
|
+
description="Run semgrep static analysis",
|
|
89
|
+
inputSchema={
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"path": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "File or directory to scan",
|
|
95
|
+
},
|
|
96
|
+
"config": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"description": "Semgrep config (auto, p/python, p/javascript, etc.)",
|
|
99
|
+
"default": "auto",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
"required": ["path"],
|
|
103
|
+
},
|
|
104
|
+
),
|
|
105
|
+
Tool(
|
|
106
|
+
name="delegate_ruff",
|
|
107
|
+
description="Run ruff Python linter",
|
|
108
|
+
inputSchema={
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"path": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"description": "File or directory to scan",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
"required": ["path"],
|
|
117
|
+
},
|
|
118
|
+
),
|
|
119
|
+
Tool(
|
|
120
|
+
name="delegate_slither",
|
|
121
|
+
description="Run slither Solidity analyzer",
|
|
122
|
+
inputSchema={
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": {
|
|
125
|
+
"path": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"description": "File or directory to scan",
|
|
128
|
+
},
|
|
129
|
+
"detectors": {
|
|
130
|
+
"type": "array",
|
|
131
|
+
"items": {"type": "string"},
|
|
132
|
+
"description": "Specific detectors to run",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
"required": ["path"],
|
|
136
|
+
},
|
|
137
|
+
),
|
|
138
|
+
Tool(
|
|
139
|
+
name="delegate_bandit",
|
|
140
|
+
description="Run bandit Python security analyzer",
|
|
141
|
+
inputSchema={
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"path": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"description": "File or directory to scan",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
"required": ["path"],
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
Tool(
|
|
153
|
+
name="check_tools",
|
|
154
|
+
description="Check which analysis tools are installed and available",
|
|
155
|
+
inputSchema={
|
|
156
|
+
"type": "object",
|
|
157
|
+
"properties": {},
|
|
158
|
+
},
|
|
159
|
+
),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _handle_get_principles(arguments: dict[str, Any]) -> list[TextContent]:
|
|
164
|
+
"""Handle get_principles tool."""
|
|
165
|
+
topic = arguments.get("topic")
|
|
166
|
+
result = load_principles(topic)
|
|
167
|
+
|
|
168
|
+
if result.is_ok:
|
|
169
|
+
return [TextContent(type="text", text=result.value)]
|
|
170
|
+
return [TextContent(type="text", text=f"Error: {result.error}")]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _handle_delegate_semgrep(arguments: dict[str, Any]) -> list[TextContent]:
|
|
174
|
+
"""Handle delegate_semgrep tool."""
|
|
175
|
+
path = arguments.get("path", "")
|
|
176
|
+
config = arguments.get("config", "auto")
|
|
177
|
+
result = delegate_semgrep(path, config)
|
|
178
|
+
|
|
179
|
+
if result.is_ok:
|
|
180
|
+
return [TextContent(type="text", text=_format_findings(result.value))]
|
|
181
|
+
return [TextContent(type="text", text=f"Error: {result.error}")]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _handle_delegate_ruff(arguments: dict[str, Any]) -> list[TextContent]:
|
|
185
|
+
"""Handle delegate_ruff tool."""
|
|
186
|
+
path = arguments.get("path", "")
|
|
187
|
+
result = delegate_ruff(path)
|
|
188
|
+
|
|
189
|
+
if result.is_ok:
|
|
190
|
+
return [TextContent(type="text", text=_format_findings(result.value))]
|
|
191
|
+
return [TextContent(type="text", text=f"Error: {result.error}")]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _handle_delegate_slither(arguments: dict[str, Any]) -> list[TextContent]:
|
|
195
|
+
"""Handle delegate_slither tool."""
|
|
196
|
+
path = arguments.get("path", "")
|
|
197
|
+
detectors = arguments.get("detectors")
|
|
198
|
+
result = delegate_slither(path, detectors)
|
|
199
|
+
|
|
200
|
+
if result.is_ok:
|
|
201
|
+
return [TextContent(type="text", text=_format_findings(result.value))]
|
|
202
|
+
return [TextContent(type="text", text=f"Error: {result.error}")]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _handle_delegate_bandit(arguments: dict[str, Any]) -> list[TextContent]:
|
|
206
|
+
"""Handle delegate_bandit tool."""
|
|
207
|
+
path = arguments.get("path", "")
|
|
208
|
+
result = delegate_bandit(path)
|
|
209
|
+
|
|
210
|
+
if result.is_ok:
|
|
211
|
+
return [TextContent(type="text", text=_format_findings(result.value))]
|
|
212
|
+
return [TextContent(type="text", text=f"Error: {result.error}")]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _handle_check_tools(arguments: dict[str, Any]) -> list[TextContent]:
|
|
216
|
+
"""Handle check_tools tool."""
|
|
217
|
+
statuses = check_all_tools()
|
|
218
|
+
|
|
219
|
+
parts: list[str] = ["# Tool Status\n"]
|
|
220
|
+
for name, status in statuses.items():
|
|
221
|
+
if status.installed:
|
|
222
|
+
version_str = f" ({status.version})" if status.version else ""
|
|
223
|
+
parts.append(f"- **{name}**: ✅ Installed{version_str}")
|
|
224
|
+
else:
|
|
225
|
+
parts.append(f"- **{name}**: ❌ Not found")
|
|
226
|
+
|
|
227
|
+
# Add install hints for missing tools
|
|
228
|
+
missing = [name for name, status in statuses.items() if not status.installed]
|
|
229
|
+
if missing:
|
|
230
|
+
parts.append("\n## Install Missing Tools\n")
|
|
231
|
+
install_cmds = {
|
|
232
|
+
"semgrep": "pip install semgrep",
|
|
233
|
+
"ruff": "pip install ruff",
|
|
234
|
+
"slither": "pip install slither-analyzer",
|
|
235
|
+
"bandit": "pip install bandit",
|
|
236
|
+
}
|
|
237
|
+
for name in missing:
|
|
238
|
+
if name in install_cmds:
|
|
239
|
+
parts.append(f"```bash\n{install_cmds[name]}\n```")
|
|
240
|
+
|
|
241
|
+
return [TextContent(type="text", text="\n".join(parts))]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _detect_domain(path: str) -> tuple[Domain, list[str]]:
|
|
245
|
+
"""Internal domain detection from file path.
|
|
246
|
+
|
|
247
|
+
Returns (domain, list of domain tags for skill matching).
|
|
248
|
+
"""
|
|
249
|
+
if path.endswith(".sol"):
|
|
250
|
+
return Domain.SMART_CONTRACT, ["solidity", "smart_contract", "web3"]
|
|
251
|
+
elif path.endswith(".vy"):
|
|
252
|
+
return Domain.SMART_CONTRACT, ["vyper", "smart_contract", "web3"]
|
|
253
|
+
elif path.endswith(".py"):
|
|
254
|
+
return Domain.BACKEND, ["python", "backend"]
|
|
255
|
+
elif path.endswith((".ts", ".tsx")):
|
|
256
|
+
return Domain.FRONTEND, ["typescript", "frontend"]
|
|
257
|
+
elif path.endswith((".js", ".jsx")):
|
|
258
|
+
return Domain.FRONTEND, ["javascript", "frontend"]
|
|
259
|
+
elif path.endswith(".go"):
|
|
260
|
+
return Domain.BACKEND, ["go", "backend"]
|
|
261
|
+
elif path.endswith(".rs"):
|
|
262
|
+
return Domain.BACKEND, ["rust", "backend"]
|
|
263
|
+
elif path.endswith((".tf", ".yaml", ".yml")):
|
|
264
|
+
return Domain.INFRASTRUCTURE, ["infrastructure", "devops"]
|
|
265
|
+
else:
|
|
266
|
+
return Domain.UNKNOWN, ["unknown"]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
|
|
270
|
+
"""Handle quick_review tool - returns findings with domain metadata."""
|
|
271
|
+
path = arguments.get("path", "")
|
|
272
|
+
tools = arguments.get("tools")
|
|
273
|
+
|
|
274
|
+
# Internal domain detection
|
|
275
|
+
domain, domain_tags = _detect_domain(path)
|
|
276
|
+
|
|
277
|
+
# Select tools based on domain
|
|
278
|
+
if domain == Domain.SMART_CONTRACT:
|
|
279
|
+
default_tools = ["slither", "semgrep"]
|
|
280
|
+
elif domain == Domain.BACKEND and "python" in domain_tags:
|
|
281
|
+
default_tools = ["ruff", "bandit", "semgrep"]
|
|
282
|
+
elif domain == Domain.FRONTEND:
|
|
283
|
+
default_tools = ["semgrep"]
|
|
284
|
+
else:
|
|
285
|
+
default_tools = ["semgrep"]
|
|
286
|
+
|
|
287
|
+
if not tools:
|
|
288
|
+
tools = default_tools
|
|
289
|
+
|
|
290
|
+
# Collect all findings
|
|
291
|
+
all_findings: list[ToolFinding] = []
|
|
292
|
+
tool_results: list[str] = []
|
|
293
|
+
|
|
294
|
+
if "semgrep" in tools:
|
|
295
|
+
config = get_semgrep_config(domain)
|
|
296
|
+
result = delegate_semgrep(path, config)
|
|
297
|
+
if result.is_ok:
|
|
298
|
+
all_findings.extend(result.value)
|
|
299
|
+
tool_results.append(f"## Semgrep\n{_format_findings(result.value)}")
|
|
300
|
+
else:
|
|
301
|
+
tool_results.append(f"## Semgrep\nError: {result.error}")
|
|
302
|
+
|
|
303
|
+
if "ruff" in tools:
|
|
304
|
+
result = delegate_ruff(path)
|
|
305
|
+
if result.is_ok:
|
|
306
|
+
all_findings.extend(result.value)
|
|
307
|
+
tool_results.append(f"## Ruff\n{_format_findings(result.value)}")
|
|
308
|
+
else:
|
|
309
|
+
tool_results.append(f"## Ruff\nError: {result.error}")
|
|
310
|
+
|
|
311
|
+
if "slither" in tools:
|
|
312
|
+
result = delegate_slither(path)
|
|
313
|
+
if result.is_ok:
|
|
314
|
+
all_findings.extend(result.value)
|
|
315
|
+
tool_results.append(f"## Slither\n{_format_findings(result.value)}")
|
|
316
|
+
else:
|
|
317
|
+
tool_results.append(f"## Slither\nError: {result.error}")
|
|
318
|
+
|
|
319
|
+
if "bandit" in tools:
|
|
320
|
+
result = delegate_bandit(path)
|
|
321
|
+
if result.is_ok:
|
|
322
|
+
all_findings.extend(result.value)
|
|
323
|
+
tool_results.append(f"## Bandit\n{_format_findings(result.value)}")
|
|
324
|
+
else:
|
|
325
|
+
tool_results.append(f"## Bandit\nError: {result.error}")
|
|
326
|
+
|
|
327
|
+
# Compute severity summary
|
|
328
|
+
severity_counts: dict[str, int] = {}
|
|
329
|
+
for f in all_findings:
|
|
330
|
+
sev = f.severity.value
|
|
331
|
+
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
332
|
+
|
|
333
|
+
# Build structured output
|
|
334
|
+
output_parts = [
|
|
335
|
+
"# Review Results\n",
|
|
336
|
+
f"**Domains detected:** {', '.join(domain_tags)}",
|
|
337
|
+
f"**Severity summary:** {severity_counts or 'No findings'}\n",
|
|
338
|
+
"\n".join(tool_results),
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@server.call_tool() # type: ignore[misc]
|
|
345
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
346
|
+
"""Handle tool calls."""
|
|
347
|
+
handlers = {
|
|
348
|
+
"get_principles": _handle_get_principles,
|
|
349
|
+
"delegate_semgrep": _handle_delegate_semgrep,
|
|
350
|
+
"delegate_ruff": _handle_delegate_ruff,
|
|
351
|
+
"delegate_slither": _handle_delegate_slither,
|
|
352
|
+
"delegate_bandit": _handle_delegate_bandit,
|
|
353
|
+
"quick_review": _handle_quick_review,
|
|
354
|
+
"check_tools": _handle_check_tools,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
handler = handlers.get(name)
|
|
358
|
+
if handler:
|
|
359
|
+
return handler(arguments)
|
|
360
|
+
|
|
361
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def main() -> None:
|
|
365
|
+
"""Run the MCP server."""
|
|
366
|
+
asyncio.run(run_server())
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def run_server() -> None:
|
|
370
|
+
"""Async server runner."""
|
|
371
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
372
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Review synthesis and output generation."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Tool delegation and review orchestration."""
|
|
2
|
+
|
|
3
|
+
from crucible.tools.delegation import (
|
|
4
|
+
ToolStatus,
|
|
5
|
+
check_all_tools,
|
|
6
|
+
check_tool,
|
|
7
|
+
delegate_ruff,
|
|
8
|
+
delegate_semgrep,
|
|
9
|
+
delegate_slither,
|
|
10
|
+
get_semgrep_config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ToolStatus",
|
|
15
|
+
"check_all_tools",
|
|
16
|
+
"check_tool",
|
|
17
|
+
"delegate_ruff",
|
|
18
|
+
"delegate_semgrep",
|
|
19
|
+
"delegate_slither",
|
|
20
|
+
"get_semgrep_config",
|
|
21
|
+
]
|