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.
@@ -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
+ ]