crowdsec-local-mcp 0.0.2__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.
Files changed (31) hide show
  1. crowdsec_local_mcp/__init__.py +5 -0
  2. crowdsec_local_mcp/__main__.py +24 -0
  3. crowdsec_local_mcp/compose/waf-test/.gitignore +3 -0
  4. crowdsec_local_mcp/compose/waf-test/crowdsec/acquis.d/appsec.yaml +8 -0
  5. crowdsec_local_mcp/compose/waf-test/crowdsec/appsec-configs/mcp-appsec.yaml.template +8 -0
  6. crowdsec_local_mcp/compose/waf-test/crowdsec/init-bouncer.sh +29 -0
  7. crowdsec_local_mcp/compose/waf-test/docker-compose.yml +68 -0
  8. crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +67 -0
  9. crowdsec_local_mcp/compose/waf-test/nginx/crowdsec/crowdsec-openresty-bouncer.conf +25 -0
  10. crowdsec_local_mcp/compose/waf-test/nginx/nginx.conf +25 -0
  11. crowdsec_local_mcp/compose/waf-test/nginx/site-enabled/default-site.conf +15 -0
  12. crowdsec_local_mcp/compose/waf-test/rules/.gitkeep +0 -0
  13. crowdsec_local_mcp/compose/waf-test/rules/base-config.yaml +11 -0
  14. crowdsec_local_mcp/mcp_core.py +151 -0
  15. crowdsec_local_mcp/mcp_scenarios.py +380 -0
  16. crowdsec_local_mcp/mcp_waf.py +1170 -0
  17. crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +27 -0
  18. crowdsec_local_mcp/prompts/prompt-scenario-examples.txt +237 -0
  19. crowdsec_local_mcp/prompts/prompt-scenario.txt +84 -0
  20. crowdsec_local_mcp/prompts/prompt-waf-deploy.txt +118 -0
  21. crowdsec_local_mcp/prompts/prompt-waf-examples.txt +401 -0
  22. crowdsec_local_mcp/prompts/prompt-waf.txt +343 -0
  23. crowdsec_local_mcp/setup_cli.py +306 -0
  24. crowdsec_local_mcp/yaml-schemas/appsec_rules_schema.yaml +343 -0
  25. crowdsec_local_mcp/yaml-schemas/scenario_schema.yaml +591 -0
  26. crowdsec_local_mcp-0.0.2.dist-info/METADATA +74 -0
  27. crowdsec_local_mcp-0.0.2.dist-info/RECORD +31 -0
  28. crowdsec_local_mcp-0.0.2.dist-info/WHEEL +5 -0
  29. crowdsec_local_mcp-0.0.2.dist-info/entry_points.txt +3 -0
  30. crowdsec_local_mcp-0.0.2.dist-info/licenses/LICENSE +21 -0
  31. crowdsec_local_mcp-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,380 @@
1
+ from pathlib import Path
2
+ from typing import Any, Callable, Dict, List, Optional
3
+
4
+ import jsonschema
5
+ import yaml
6
+
7
+ import mcp.types as types
8
+
9
+ from .mcp_core import LOGGER, PROMPTS_DIR, REGISTRY, SCRIPT_DIR, ToolHandler
10
+
11
+ SCENARIO_PROMPT_FILE = PROMPTS_DIR / "prompt-scenario.txt"
12
+ SCENARIO_EXAMPLES_FILE = PROMPTS_DIR / "prompt-scenario-examples.txt"
13
+ SCENARIO_SCHEMA_FILE = SCRIPT_DIR / "yaml-schemas" / "scenario_schema.yaml"
14
+ SCENARIO_DEPLOY_PROMPT_FILE = PROMPTS_DIR / "prompt-scenario-deploy.txt"
15
+
16
+ REQUIRED_SCENARIO_FIELDS = ["name", "description", "type"]
17
+ EXPECTED_TYPE_VALUES = {"leaky", "trigger", "counter", "conditional", "bayesian"}
18
+ RECOMMENDED_FIELDS = ["filter", "groupby", "leakspeed", "capacity", "labels"]
19
+ _SCENARIO_SCHEMA_CACHE: Optional[Dict[str, Any]] = None
20
+
21
+
22
+ def _read_text(path: Path) -> str:
23
+ return path.read_text(encoding="utf-8")
24
+
25
+
26
+ def _load_scenario_schema() -> Dict[str, Any]:
27
+ global _SCENARIO_SCHEMA_CACHE
28
+ if _SCENARIO_SCHEMA_CACHE is not None:
29
+ return _SCENARIO_SCHEMA_CACHE
30
+
31
+ if not SCENARIO_SCHEMA_FILE.exists():
32
+ raise FileNotFoundError(f"Scenario schema not found at {SCENARIO_SCHEMA_FILE}")
33
+
34
+ LOGGER.info("Loading scenario JSON schema from %s", SCENARIO_SCHEMA_FILE)
35
+ schema = yaml.safe_load(SCENARIO_SCHEMA_FILE.read_text(encoding="utf-8"))
36
+ if not isinstance(schema, dict):
37
+ raise ValueError("Scenario schema file did not contain a valid mapping")
38
+ _SCENARIO_SCHEMA_CACHE = schema
39
+ return schema
40
+
41
+
42
+ def _tool_get_scenario_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
43
+ try:
44
+ LOGGER.info("Serving scenario authoring prompt content")
45
+ return [
46
+ types.TextContent(
47
+ type="text",
48
+ text=_read_text(SCENARIO_PROMPT_FILE),
49
+ )
50
+ ]
51
+ except FileNotFoundError:
52
+ LOGGER.error("Scenario prompt file not found at %s", SCENARIO_PROMPT_FILE)
53
+ return [
54
+ types.TextContent(
55
+ type="text",
56
+ text="Error: Scenario authoring prompt file not found.",
57
+ )
58
+ ]
59
+ except Exception as exc:
60
+ LOGGER.error("Error reading scenario prompt: %s", exc)
61
+ return [
62
+ types.TextContent(
63
+ type="text",
64
+ text=f"Error reading scenario prompt: {str(exc)}",
65
+ )
66
+ ]
67
+
68
+
69
+ def _tool_get_scenario_examples(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
70
+ try:
71
+ LOGGER.info("Serving scenario example bundle")
72
+ return [
73
+ types.TextContent(
74
+ type="text",
75
+ text=_read_text(SCENARIO_EXAMPLES_FILE),
76
+ )
77
+ ]
78
+ except FileNotFoundError:
79
+ LOGGER.error("Scenario examples missing at %s", SCENARIO_EXAMPLES_FILE)
80
+ return [
81
+ types.TextContent(
82
+ type="text",
83
+ text="Error: Scenario examples file not found.",
84
+ )
85
+ ]
86
+ except Exception as exc:
87
+ LOGGER.error("Error reading scenario examples: %s", exc)
88
+ return [
89
+ types.TextContent(
90
+ type="text",
91
+ text=f"Error reading scenario examples: {str(exc)}",
92
+ )
93
+ ]
94
+
95
+
96
+ def _validate_scenario_yaml(raw_yaml: str) -> Dict[str, Any]:
97
+ """Return parsed scenario YAML or raise ValueError on validation failure."""
98
+ try:
99
+ parsed = yaml.safe_load(raw_yaml)
100
+ except yaml.YAMLError as exc:
101
+ raise ValueError(f"YAML syntax error: {exc}") from exc
102
+
103
+ if parsed is None:
104
+ raise ValueError("Empty YAML content")
105
+
106
+ if not isinstance(parsed, dict):
107
+ raise ValueError("Scenario YAML must define a mapping at the top level")
108
+
109
+ try:
110
+ schema = _load_scenario_schema()
111
+ except FileNotFoundError as exc:
112
+ LOGGER.error("Scenario schema missing: %s", exc)
113
+ raise ValueError(f"Schema file missing: {exc}") from exc
114
+ except Exception as exc:
115
+ LOGGER.error("Failed to load scenario schema: %s", exc)
116
+ raise ValueError(f"Unable to load scenario schema: {exc}") from exc
117
+
118
+ try:
119
+ jsonschema.validate(instance=parsed, schema=schema)
120
+ except jsonschema.ValidationError as exc:
121
+ path = " -> ".join(str(p) for p in exc.absolute_path) or "root"
122
+ raise ValueError(f"Schema validation error at {path}: {exc.message}") from exc
123
+ except jsonschema.SchemaError as exc:
124
+ LOGGER.error("Scenario schema is invalid: %s", exc)
125
+ raise ValueError(f"Scenario schema is invalid: {exc}") from exc
126
+
127
+ missing = [field for field in REQUIRED_SCENARIO_FIELDS if field not in parsed]
128
+ if missing:
129
+ raise ValueError(f"Missing required field(s): {', '.join(missing)}")
130
+
131
+ scenario_type = parsed.get("type")
132
+ if not isinstance(scenario_type, str):
133
+ raise ValueError("Field 'type' must be a string")
134
+
135
+ if scenario_type not in EXPECTED_TYPE_VALUES:
136
+ LOGGER.warning("Scenario type %s is not in the recognised set %s", scenario_type, EXPECTED_TYPE_VALUES)
137
+
138
+ labels = parsed.get("labels")
139
+ if labels is not None and not isinstance(labels, dict):
140
+ raise ValueError("Field 'labels' must be a dictionary when present")
141
+
142
+ return parsed
143
+
144
+
145
+ def _tool_validate_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
146
+ if not arguments or "scenario_yaml" not in arguments:
147
+ LOGGER.warning("Scenario validation requested without 'scenario_yaml'")
148
+ return [
149
+ types.TextContent(
150
+ type="text",
151
+ text="Error: scenario_yaml parameter is required",
152
+ )
153
+ ]
154
+
155
+ raw_yaml = arguments["scenario_yaml"]
156
+ LOGGER.info("Validating CrowdSec scenario YAML submission")
157
+ try:
158
+ parsed = _validate_scenario_yaml(raw_yaml)
159
+ scenario_type = parsed.get("type", "unknown")
160
+ return [
161
+ types.TextContent(
162
+ type="text",
163
+ text=f"✅ VALIDATION PASSED: Scenario type `{scenario_type}` conforms to schema.",
164
+ )
165
+ ]
166
+ except ValueError as exc:
167
+ return [
168
+ types.TextContent(
169
+ type="text",
170
+ text=f"❌ VALIDATION FAILED: {str(exc)}",
171
+ )
172
+ ]
173
+
174
+
175
+ def _tool_lint_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
176
+ if not arguments or "scenario_yaml" not in arguments:
177
+ LOGGER.warning("Scenario lint requested without 'scenario_yaml'")
178
+ return [
179
+ types.TextContent(
180
+ type="text",
181
+ text="Error: scenario_yaml parameter is required",
182
+ )
183
+ ]
184
+
185
+ raw_yaml = arguments["scenario_yaml"]
186
+ LOGGER.info("Linting CrowdSec scenario YAML submission")
187
+
188
+ try:
189
+ parsed = _validate_scenario_yaml(raw_yaml)
190
+ except ValueError as exc:
191
+ return [
192
+ types.TextContent(
193
+ type="text",
194
+ text=f"❌ LINT ERROR: {str(exc)}",
195
+ )
196
+ ]
197
+
198
+ warnings: List[str] = []
199
+ hints: List[str] = []
200
+
201
+ scenario_type = parsed.get("type")
202
+ if isinstance(scenario_type, str) and scenario_type not in EXPECTED_TYPE_VALUES:
203
+ warnings.append(
204
+ f"Scenario type '{scenario_type}' is unusual; expected one of {', '.join(sorted(EXPECTED_TYPE_VALUES))}."
205
+ )
206
+
207
+ for field in RECOMMENDED_FIELDS:
208
+ if field not in parsed:
209
+ hints.append(f"Consider adding '{field}' to improve scenario behaviour visibility.")
210
+
211
+ if "groupby" in parsed and not isinstance(parsed["groupby"], str):
212
+ warnings.append("Field 'groupby' should be a string expr that partitions buckets.")
213
+
214
+ if "filter" in parsed and not isinstance(parsed["filter"], str):
215
+ warnings.append("Field 'filter' should be a string expression.")
216
+
217
+ if "distinct" in parsed and not isinstance(parsed["distinct"], str):
218
+ warnings.append("Field 'distinct' should be a string expr returning a unique key.")
219
+
220
+ if "format" in parsed and parsed.get("format") not in (None, 2.0):
221
+ hints.append("Set `format: 2.0` to align with current scenario compatibility guidance.")
222
+
223
+ if "labels" in parsed and parsed.get("labels"):
224
+ label_values = parsed["labels"]
225
+ if isinstance(label_values, dict):
226
+ missing_values = [k for k, v in label_values.items() if not v]
227
+ if missing_values:
228
+ hints.append(
229
+ f"Provide values for label(s): {', '.join(missing_values)} for better observability."
230
+ )
231
+
232
+ result_lines: List[str] = []
233
+
234
+ if warnings:
235
+ result_lines.append("⚠️ WARNINGS:")
236
+ for item in warnings:
237
+ result_lines.append(f" - {item}")
238
+
239
+ if hints:
240
+ if warnings:
241
+ result_lines.append("")
242
+ result_lines.append("💡 HINTS:")
243
+ for item in hints:
244
+ result_lines.append(f" - {item}")
245
+
246
+ if not result_lines:
247
+ result_lines.append("✅ LINT PASSED: No structural issues detected.")
248
+
249
+ return [
250
+ types.TextContent(
251
+ type="text",
252
+ text="\n".join(result_lines),
253
+ )
254
+ ]
255
+
256
+
257
+ def _tool_deploy_scenario(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
258
+ LOGGER.info("Serving scenario deployment helper prompt")
259
+ try:
260
+ return [
261
+ types.TextContent(
262
+ type="text",
263
+ text=_read_text(SCENARIO_DEPLOY_PROMPT_FILE),
264
+ )
265
+ ]
266
+ except FileNotFoundError:
267
+ LOGGER.error("Scenario deployment prompt missing at %s", SCENARIO_DEPLOY_PROMPT_FILE)
268
+ return [
269
+ types.TextContent(
270
+ type="text",
271
+ text="Error: Scenario deployment prompt file not found.",
272
+ )
273
+ ]
274
+ except Exception as exc:
275
+ LOGGER.error("Failed to load scenario deployment prompt: %s", exc)
276
+ return [
277
+ types.TextContent(
278
+ type="text",
279
+ text=f"Error reading scenario deployment prompt: {str(exc)}",
280
+ )
281
+ ]
282
+
283
+
284
+ SCENARIO_TOOL_HANDLERS: Dict[str, ToolHandler] = {
285
+ "get_scenario_prompt": _tool_get_scenario_prompt,
286
+ "get_scenario_examples": _tool_get_scenario_examples,
287
+ "validate_scenario_yaml": _tool_validate_scenario,
288
+ "lint_scenario_yaml": _tool_lint_scenario,
289
+ "deploy_scenario": _tool_deploy_scenario,
290
+ }
291
+
292
+ SCENARIO_TOOLS: List[types.Tool] = [
293
+ types.Tool(
294
+ name="get_scenario_prompt",
295
+ description="Retrieve the base prompt for authoring CrowdSec scenarios",
296
+ inputSchema={
297
+ "type": "object",
298
+ "properties": {},
299
+ "additionalProperties": False,
300
+ },
301
+ ),
302
+ types.Tool(
303
+ name="get_scenario_examples",
304
+ description="Retrieve example CrowdSec scenarios and annotations",
305
+ inputSchema={
306
+ "type": "object",
307
+ "properties": {},
308
+ "additionalProperties": False,
309
+ },
310
+ ),
311
+ types.Tool(
312
+ name="validate_scenario_yaml",
313
+ description="Validate CrowdSec scenario YAML structure for required fields",
314
+ inputSchema={
315
+ "type": "object",
316
+ "properties": {
317
+ "scenario_yaml": {
318
+ "type": "string",
319
+ "description": "Scenario YAML to validate",
320
+ },
321
+ },
322
+ "required": ["scenario_yaml"],
323
+ "additionalProperties": False,
324
+ },
325
+ ),
326
+ types.Tool(
327
+ name="lint_scenario_yaml",
328
+ description="Lint CrowdSec scenario YAML and highlight potential improvements",
329
+ inputSchema={
330
+ "type": "object",
331
+ "properties": {
332
+ "scenario_yaml": {
333
+ "type": "string",
334
+ "description": "Scenario YAML to lint",
335
+ },
336
+ },
337
+ "required": ["scenario_yaml"],
338
+ "additionalProperties": False,
339
+ },
340
+ ),
341
+ types.Tool(
342
+ name="deploy_scenario",
343
+ description="Retrieve guidance for packaging and deploying a CrowdSec scenario",
344
+ inputSchema={
345
+ "type": "object",
346
+ "properties": {},
347
+ "additionalProperties": False,
348
+ },
349
+ ),
350
+ ]
351
+
352
+ SCENARIO_RESOURCES: List[types.Resource] = [
353
+ types.Resource(
354
+ uri="file://prompts/prompt-scenario.txt",
355
+ name="Scenario Authoring Prompt",
356
+ description="Foundation prompt to guide the authoring of CrowdSec detection scenarios",
357
+ mimeType="text/plain",
358
+ ),
359
+ types.Resource(
360
+ uri="file://prompts/prompt-scenario-examples.txt",
361
+ name="Scenario Examples",
362
+ description="Worked scenario examples with callouts",
363
+ mimeType="text/plain",
364
+ ),
365
+ types.Resource(
366
+ uri="file://prompts/prompt-scenario-deploy.txt",
367
+ name="Scenario Deployment Helper",
368
+ description="Guidance for packaging and deploying CrowdSec scenarios to local or hub environments",
369
+ mimeType="text/plain",
370
+ ),
371
+ ]
372
+
373
+ SCENARIO_RESOURCE_READERS: Dict[str, Callable[[], str]] = {
374
+ "file://prompts/prompt-scenario.txt": lambda: _read_text(SCENARIO_PROMPT_FILE),
375
+ "file://prompts/prompt-scenario-examples.txt": lambda: _read_text(SCENARIO_EXAMPLES_FILE),
376
+ "file://prompts/prompt-scenario-deploy.txt": lambda: _read_text(SCENARIO_DEPLOY_PROMPT_FILE),
377
+ }
378
+
379
+ REGISTRY.register_tools(SCENARIO_TOOL_HANDLERS, SCENARIO_TOOLS)
380
+ REGISTRY.register_resources(SCENARIO_RESOURCES, SCENARIO_RESOURCE_READERS)