crowdsec-local-mcp 0.1.0__py3-none-any.whl → 0.7.0.post1.dev0__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 (26) hide show
  1. crowdsec_local_mcp/__init__.py +6 -1
  2. crowdsec_local_mcp/__main__.py +1 -1
  3. crowdsec_local_mcp/_version.py +1 -0
  4. crowdsec_local_mcp/compose/scenario-test/.gitignore +1 -0
  5. crowdsec_local_mcp/compose/scenario-test/docker-compose.yml +19 -0
  6. crowdsec_local_mcp/compose/scenario-test/scenarios/.gitkeep +0 -0
  7. crowdsec_local_mcp/compose/waf-test/docker-compose.yml +5 -6
  8. crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +3 -2
  9. crowdsec_local_mcp/mcp_core.py +114 -19
  10. crowdsec_local_mcp/mcp_scenarios.py +579 -23
  11. crowdsec_local_mcp/mcp_waf.py +567 -337
  12. crowdsec_local_mcp/prompts/prompt-expr-helpers.txt +514 -0
  13. crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +70 -21
  14. crowdsec_local_mcp/prompts/prompt-scenario.txt +26 -2
  15. crowdsec_local_mcp/prompts/prompt-waf-tests.txt +101 -0
  16. crowdsec_local_mcp/prompts/prompt-waf-top-level.txt +31 -0
  17. crowdsec_local_mcp/prompts/prompt-waf.txt +0 -26
  18. crowdsec_local_mcp/setup_cli.py +375 -0
  19. crowdsec_local_mcp-0.7.0.post1.dev0.dist-info/METADATA +114 -0
  20. crowdsec_local_mcp-0.7.0.post1.dev0.dist-info/RECORD +38 -0
  21. {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/entry_points.txt +1 -0
  22. crowdsec_local_mcp-0.1.0.dist-info/METADATA +0 -93
  23. crowdsec_local_mcp-0.1.0.dist-info/RECORD +0 -30
  24. {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/WHEEL +0 -0
  25. {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/licenses/LICENSE +0 -0
  26. {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/top_level.txt +0 -0
@@ -1,29 +1,37 @@
1
1
  from pathlib import Path
2
- from typing import Any, Callable, Dict, List, Optional
2
+ from typing import Any
3
+ from collections.abc import Callable
4
+ import json
5
+ import subprocess
6
+ import time
3
7
 
4
8
  import jsonschema
5
9
  import yaml
6
10
 
7
- import mcp.types as types
11
+ from mcp import types
8
12
 
9
- from .mcp_core import LOGGER, PROMPTS_DIR, REGISTRY, SCRIPT_DIR, ToolHandler
13
+ from .mcp_core import LOGGER, PROMPTS_DIR, REGISTRY, SCRIPT_DIR, ToolHandler, ensure_docker_compose_cli
10
14
 
11
15
  SCENARIO_PROMPT_FILE = PROMPTS_DIR / "prompt-scenario.txt"
12
16
  SCENARIO_EXAMPLES_FILE = PROMPTS_DIR / "prompt-scenario-examples.txt"
13
17
  SCENARIO_SCHEMA_FILE = SCRIPT_DIR / "yaml-schemas" / "scenario_schema.yaml"
14
18
  SCENARIO_DEPLOY_PROMPT_FILE = PROMPTS_DIR / "prompt-scenario-deploy.txt"
19
+ SCENARIO_EXPR_HELPERS_PROMPT_FILE = PROMPTS_DIR / "prompt-expr-helpers.txt"
20
+ SCENARIO_COMPOSE_DIR = SCRIPT_DIR / "compose" / "scenario-test"
21
+ SCENARIO_COMPOSE_FILE = SCENARIO_COMPOSE_DIR / "docker-compose.yml"
22
+ SCENARIO_PROJECT_NAME = "crowdsec-mcp-scenario"
15
23
 
16
24
  REQUIRED_SCENARIO_FIELDS = ["name", "description", "type"]
17
25
  EXPECTED_TYPE_VALUES = {"leaky", "trigger", "counter", "conditional", "bayesian"}
18
26
  RECOMMENDED_FIELDS = ["filter", "groupby", "leakspeed", "capacity", "labels"]
19
- _SCENARIO_SCHEMA_CACHE: Optional[Dict[str, Any]] = None
27
+ _SCENARIO_SCHEMA_CACHE: dict[str, Any] | None = None
20
28
 
21
29
 
22
30
  def _read_text(path: Path) -> str:
23
31
  return path.read_text(encoding="utf-8")
24
32
 
25
33
 
26
- def _load_scenario_schema() -> Dict[str, Any]:
34
+ def _load_scenario_schema() -> dict[str, Any]:
27
35
  global _SCENARIO_SCHEMA_CACHE
28
36
  if _SCENARIO_SCHEMA_CACHE is not None:
29
37
  return _SCENARIO_SCHEMA_CACHE
@@ -39,7 +47,7 @@ def _load_scenario_schema() -> Dict[str, Any]:
39
47
  return schema
40
48
 
41
49
 
42
- def _tool_get_scenario_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
50
+ def _tool_get_scenario_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
43
51
  try:
44
52
  LOGGER.info("Serving scenario authoring prompt content")
45
53
  return [
@@ -61,12 +69,12 @@ def _tool_get_scenario_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextCon
61
69
  return [
62
70
  types.TextContent(
63
71
  type="text",
64
- text=f"Error reading scenario prompt: {str(exc)}",
72
+ text=f"Error reading scenario prompt: {exc!s}",
65
73
  )
66
74
  ]
67
75
 
68
76
 
69
- def _tool_get_scenario_examples(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
77
+ def _tool_get_scenario_examples(_: dict[str, Any] | None) -> list[types.TextContent]:
70
78
  try:
71
79
  LOGGER.info("Serving scenario example bundle")
72
80
  return [
@@ -88,12 +96,38 @@ def _tool_get_scenario_examples(_: Optional[Dict[str, Any]]) -> List[types.TextC
88
96
  return [
89
97
  types.TextContent(
90
98
  type="text",
91
- text=f"Error reading scenario examples: {str(exc)}",
99
+ text=f"Error reading scenario examples: {exc!s}",
92
100
  )
93
101
  ]
94
102
 
103
+ def _tool_get_expr_helpers(_: dict[str, Any] | None) -> list[types.TextContent]:
104
+ try:
105
+ LOGGER.info("Serving scenario expression helpers bundle")
106
+ return [
107
+ types.TextContent(
108
+ type="text",
109
+ text=_read_text(SCENARIO_EXPR_HELPERS_PROMPT_FILE),
110
+ )
111
+ ]
112
+ except FileNotFoundError:
113
+ LOGGER.error("Scenario expression helpers missing at %s", SCENARIO_EXPR_HELPERS_PROMPT_FILE)
114
+ return [
115
+ types.TextContent(
116
+ type="text",
117
+ text="Error: Scenario expression helpers file not found.",
118
+ )
119
+ ]
120
+ except Exception as exc:
121
+ LOGGER.error("Error reading scenario expression helpers: %s", exc)
122
+ return [
123
+ types.TextContent(
124
+ type="text",
125
+ text=f"Error reading scenario expression helpers: {exc!s}",
126
+ )
127
+ ]
95
128
 
96
- def _validate_scenario_yaml(raw_yaml: str) -> Dict[str, Any]:
129
+
130
+ def _validate_scenario_yaml(raw_yaml: str) -> dict[str, Any]:
97
131
  """Return parsed scenario YAML or raise ValueError on validation failure."""
98
132
  try:
99
133
  parsed = yaml.safe_load(raw_yaml)
@@ -142,7 +176,7 @@ def _validate_scenario_yaml(raw_yaml: str) -> Dict[str, Any]:
142
176
  return parsed
143
177
 
144
178
 
145
- def _tool_validate_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
179
+ def _tool_validate_scenario(arguments: dict[str, Any] | None) -> list[types.TextContent]:
146
180
  if not arguments or "scenario_yaml" not in arguments:
147
181
  LOGGER.warning("Scenario validation requested without 'scenario_yaml'")
148
182
  return [
@@ -167,12 +201,12 @@ def _tool_validate_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.T
167
201
  return [
168
202
  types.TextContent(
169
203
  type="text",
170
- text=f"❌ VALIDATION FAILED: {str(exc)}",
204
+ text=f"❌ VALIDATION FAILED: {exc!s}",
171
205
  )
172
206
  ]
173
207
 
174
208
 
175
- def _tool_lint_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
209
+ def _tool_lint_scenario(arguments: dict[str, Any] | None) -> list[types.TextContent]:
176
210
  if not arguments or "scenario_yaml" not in arguments:
177
211
  LOGGER.warning("Scenario lint requested without 'scenario_yaml'")
178
212
  return [
@@ -191,12 +225,12 @@ def _tool_lint_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextC
191
225
  return [
192
226
  types.TextContent(
193
227
  type="text",
194
- text=f"❌ LINT ERROR: {str(exc)}",
228
+ text=f"❌ LINT ERROR: {exc!s}",
195
229
  )
196
230
  ]
197
231
 
198
- warnings: List[str] = []
199
- hints: List[str] = []
232
+ warnings: list[str] = []
233
+ hints: list[str] = []
200
234
 
201
235
  scenario_type = parsed.get("type")
202
236
  if isinstance(scenario_type, str) and scenario_type not in EXPECTED_TYPE_VALUES:
@@ -229,7 +263,7 @@ def _tool_lint_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextC
229
263
  f"Provide values for label(s): {', '.join(missing_values)} for better observability."
230
264
  )
231
265
 
232
- result_lines: List[str] = []
266
+ result_lines: list[str] = []
233
267
 
234
268
  if warnings:
235
269
  result_lines.append("⚠️ WARNINGS:")
@@ -254,7 +288,7 @@ def _tool_lint_scenario(arguments: Optional[Dict[str, Any]]) -> List[types.TextC
254
288
  ]
255
289
 
256
290
 
257
- def _tool_deploy_scenario(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
291
+ def _tool_deploy_scenario(_: dict[str, Any] | None) -> list[types.TextContent]:
258
292
  LOGGER.info("Serving scenario deployment helper prompt")
259
293
  try:
260
294
  return [
@@ -276,20 +310,446 @@ def _tool_deploy_scenario(_: Optional[Dict[str, Any]]) -> List[types.TextContent
276
310
  return [
277
311
  types.TextContent(
278
312
  type="text",
279
- text=f"Error reading scenario deployment prompt: {str(exc)}",
313
+ text=f"Error reading scenario deployment prompt: {exc!s}",
314
+ )
315
+ ]
316
+
317
+ def _run_scenario_compose_command(
318
+ args: list[str],
319
+ capture_output: bool = True,
320
+ check: bool = True,
321
+ input_text: str | None = None,
322
+ ) -> subprocess.CompletedProcess:
323
+ """Run a docker compose command within the scenario test harness directory."""
324
+ if not SCENARIO_COMPOSE_FILE.exists():
325
+ raise RuntimeError(
326
+ f"Scenario docker-compose file not found at {SCENARIO_COMPOSE_FILE}"
327
+ )
328
+
329
+ base_cmd = ensure_docker_compose_cli()
330
+ full_cmd = base_cmd + ["-p", SCENARIO_PROJECT_NAME, "-f", str(SCENARIO_COMPOSE_FILE)] + args
331
+ LOGGER.info("Executing scenario compose command: %s", " ".join(full_cmd))
332
+
333
+ try:
334
+ return subprocess.run(
335
+ full_cmd,
336
+ cwd=str(SCENARIO_COMPOSE_DIR),
337
+ capture_output=capture_output,
338
+ text=True,
339
+ check=check,
340
+ input=input_text,
341
+ )
342
+ except FileNotFoundError as error:
343
+ LOGGER.error("Scenario compose command failed to start: %s", error)
344
+ raise RuntimeError(f"Failed to run {' '.join(base_cmd)}: {error}") from error
345
+ except subprocess.CalledProcessError as error:
346
+ stdout = (error.stdout or "").strip()
347
+ stderr = (error.stderr or "").strip()
348
+ combined = "\n".join(part for part in (stdout, stderr) if part) or str(error)
349
+ LOGGER.error(
350
+ "Scenario compose command exited with %s: %s",
351
+ error.returncode,
352
+ combined.splitlines()[0] if combined else "no output",
353
+ )
354
+ raise RuntimeError(
355
+ f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}"
356
+ ) from error
357
+
358
+ def _run_scenario_compose_exec(
359
+ args: list[str],
360
+ capture_output: bool = True,
361
+ check: bool = True,
362
+ input_text: str | None = None,
363
+ ) -> subprocess.CompletedProcess:
364
+ """Run docker compose exec against the CrowdSec scenario container."""
365
+ exec_args = ["exec", "-T"] + args
366
+ return _run_scenario_compose_command(
367
+ exec_args,
368
+ capture_output=capture_output,
369
+ check=check,
370
+ input_text=input_text,
371
+ )
372
+
373
+ def _compose_stack_running() -> bool:
374
+ if not SCENARIO_COMPOSE_FILE.exists():
375
+ LOGGER.warning(
376
+ "Scenario stack status requested but compose file missing at %s", SCENARIO_COMPOSE_FILE
377
+ )
378
+ return False
379
+
380
+ result = _run_scenario_compose_command(["ps", "-q"], capture_output=True, check=False)
381
+ if result.returncode != 0:
382
+ stdout = (result.stdout or "").strip()
383
+ stderr = (result.stderr or "").strip()
384
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
385
+ raise RuntimeError(
386
+ f"docker compose ps failed (exit code {result.returncode}):\n{combined}"
387
+ )
388
+ return bool((result.stdout or "").strip())
389
+
390
+ def _compose_stack_start() -> bool:
391
+ if _compose_stack_running():
392
+ LOGGER.info("Scenario stack already running; skipping start request")
393
+ return False
394
+
395
+ LOGGER.info("Starting scenario test stack")
396
+ _run_scenario_compose_command(["up", "-d"], capture_output=True, check=True)
397
+ return True
398
+
399
+ def _compose_stack_stop() -> None:
400
+ if not SCENARIO_COMPOSE_FILE.exists():
401
+ LOGGER.warning(
402
+ "Scenario stack stop requested but compose file missing at %s", SCENARIO_COMPOSE_FILE
403
+ )
404
+ return
405
+
406
+ LOGGER.info("Stopping scenario test stack")
407
+ _run_scenario_compose_command(["down"], capture_output=True, check=True)
408
+
409
+ def _compose_stack_reload_crowdsec() -> None:
410
+ if not _compose_stack_running():
411
+ raise RuntimeError("Scenario stack is not running; start it before reloading CrowdSec.")
412
+
413
+ LOGGER.info("Reloading CrowdSec process inside scenario test stack")
414
+ result = _run_scenario_compose_command(
415
+ ["exec", "-T", "crowdsec", "sh", "-c", "kill -HUP 1"],
416
+ capture_output=True,
417
+ check=False,
418
+ )
419
+ if result.returncode != 0:
420
+ stdout = (result.stdout or "").strip()
421
+ stderr = (result.stderr or "").strip()
422
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
423
+ raise RuntimeError(
424
+ f"Failed to reload CrowdSec via SIGHUP (exit code {result.returncode}):\n{combined}"
425
+ )
426
+
427
+ #ruff: noqa: RUF001
428
+ def _tool_manage_scenario_stack(arguments: dict[str, Any] | None) -> list[types.TextContent]:
429
+ if not arguments:
430
+ LOGGER.warning("manage_scenario_stack called without arguments")
431
+ raise ValueError("Missing arguments payload")
432
+
433
+ action = arguments.get("action")
434
+ if action not in {"start", "stop", "reload"}:
435
+ LOGGER.warning("manage_scenario_stack received invalid action: %s", action)
436
+ raise ValueError("Action must be one of: start, stop, reload")
437
+
438
+ if action == "start":
439
+ started = _compose_stack_start()
440
+ message = (
441
+ "✅ Scenario stack started. CrowdSec container is running."
442
+ if started
443
+ else "ℹ️ Scenario stack already running; reusing existing containers."
444
+ )
445
+ return [types.TextContent(type="text", text=message)]
446
+
447
+ if action == "stop":
448
+ if _compose_stack_running():
449
+ _compose_stack_stop()
450
+ message = "🛑 Scenario stack stopped and containers removed."
451
+ else:
452
+ LOGGER.info("Scenario stack stop requested but stack was not running")
453
+ _compose_stack_stop()
454
+ message = "ℹ️ Scenario stack was already stopped."
455
+ return [types.TextContent(type="text", text=message)]
456
+
457
+ _compose_stack_reload_crowdsec()
458
+ return [
459
+ types.TextContent(
460
+ type="text",
461
+ text="🔄 CrowdSec process reloaded inside the scenario stack.",
462
+ )
463
+ ]
464
+
465
+
466
+ def _tool_explain_scenario(arguments: dict[str, Any] | None) -> list[types.TextContent]:
467
+ required_keys = {"scenario_yaml", "log_line", "log_type", "collections"}
468
+ if not arguments:
469
+ LOGGER.warning("Scenario explanation requested without arguments")
470
+ raise ValueError("Arguments are required for scenario explanation")
471
+
472
+ missing = required_keys.difference(arguments.keys())
473
+ if missing:
474
+ LOGGER.warning("Scenario explanation missing required keys: %s", ", ".join(sorted(missing)))
475
+ raise ValueError(
476
+ "scenario_yaml, log_line, log_type, and collections are required arguments"
477
+ )
478
+
479
+ scenario_yaml = arguments.get("scenario_yaml")
480
+ log_line = arguments.get("log_line")
481
+ log_type = arguments.get("log_type")
482
+ collections = arguments.get("collections")
483
+
484
+ if not isinstance(scenario_yaml, str) or not scenario_yaml.strip():
485
+ raise ValueError("'scenario_yaml' must be a non-empty string")
486
+ if not isinstance(log_line, str) or not log_line.strip():
487
+ raise ValueError("'log_line' must be a non-empty string")
488
+ if not isinstance(log_type, str) or not log_type.strip():
489
+ raise ValueError("'log_type' must be a non-empty string")
490
+ if not isinstance(collections, list) or not all(isinstance(c, str) and c.strip() for c in collections):
491
+ raise ValueError("'collections' must be a list of non-empty strings")
492
+
493
+ if not _compose_stack_running():
494
+ LOGGER.warning("Scenario explain requested but stack is not running")
495
+ raise RuntimeError("Scenario stack is not running. Start it with manage_scenario_stack(action='start').")
496
+
497
+ scenario_path = SCENARIO_COMPOSE_DIR / "scenarios" / "custom.yaml"
498
+ scenario_path.parent.mkdir(parents=True, exist_ok=True)
499
+ scenario_path.write_text(scenario_yaml, encoding="utf-8")
500
+ LOGGER.info("Wrote scenario YAML to %s", scenario_path)
501
+
502
+ for collection in collections:
503
+ collection_name = collection.strip()
504
+ LOGGER.info("Installing collection %s for scenario explain", collection_name)
505
+ install_result = _run_scenario_compose_exec(
506
+ ["crowdsec", "cscli", "collections", "install", collection_name],
507
+ capture_output=True,
508
+ check=False,
509
+ )
510
+ if install_result.returncode != 0:
511
+ stdout = (install_result.stdout or "").strip()
512
+ stderr = (install_result.stderr or "").strip()
513
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
514
+ LOGGER.error("Collection install failed for %s: %s", collection_name, combined)
515
+ raise RuntimeError(
516
+ f"Failed to install collection '{collection_name}' (exit code {install_result.returncode}):\n{combined}"
517
+ )
518
+
519
+ _compose_stack_reload_crowdsec()
520
+ LOGGER.info("Waiting for CrowdSec reload to settle")
521
+ time.sleep(3)
522
+
523
+ LOGGER.info("Executing cscli explain with provided log line and type")
524
+ explain_result = _run_scenario_compose_exec(
525
+ ["crowdsec", "cscli", "explain", "--log", log_line.strip(), "--type", log_type.strip(), "-v"],
526
+ capture_output=True,
527
+ check=False,
528
+ )
529
+
530
+ stdout = (explain_result.stdout or "").strip()
531
+ stderr = (explain_result.stderr or "").strip()
532
+ combined_output = "\n".join(part for part in (stdout, stderr) if part).strip()
533
+
534
+ if explain_result.returncode != 0:
535
+ message = combined_output or f"cscli explain failed with exit code {explain_result.returncode}"
536
+ LOGGER.error("cscli explain failed: %s", message)
537
+ raise RuntimeError(message)
538
+
539
+ response_text = combined_output or "cscli explain completed with no output."
540
+ return [
541
+ types.TextContent(
542
+ type="text",
543
+ text=f"✅ cscli explain succeeded:\n{response_text}",
544
+ )
545
+ ]
546
+
547
+ #ruff: noqa: PLR0912
548
+ #ruff: noqa: PLR0915
549
+ def _tool_test_scenario(arguments: dict[str, Any] | None) -> list[types.TextContent]:
550
+ required_keys = {"scenario_yaml", "log_lines", "log_type"}
551
+ if not arguments:
552
+ LOGGER.warning("Scenario test requested without arguments")
553
+ raise ValueError("Arguments are required for scenario testing")
554
+
555
+ missing = required_keys.difference(arguments.keys())
556
+ if missing:
557
+ LOGGER.warning("Scenario test missing required keys: %s", ", ".join(sorted(missing)))
558
+ raise ValueError("scenario_yaml, log_lines, and log_type are required arguments")
559
+
560
+ scenario_yaml = arguments.get("scenario_yaml")
561
+ log_lines_arg = arguments.get("log_lines")
562
+ log_type = arguments.get("log_type")
563
+ collections = arguments.get("collections")
564
+
565
+ if not isinstance(scenario_yaml, str) or not scenario_yaml.strip():
566
+ raise ValueError("'scenario_yaml' must be a non-empty string")
567
+ if isinstance(log_lines_arg, str):
568
+ if not log_lines_arg.strip():
569
+ raise ValueError("'log_lines' must contain at least one non-empty log line")
570
+ log_lines = [log_lines_arg]
571
+ elif (
572
+ isinstance(log_lines_arg, list)
573
+ and log_lines_arg
574
+ and all(isinstance(line, str) and line.strip() for line in log_lines_arg)
575
+ ):
576
+ log_lines = log_lines_arg
577
+ else:
578
+ raise ValueError("'log_lines' must be a non-empty string or list of non-empty strings")
579
+ if not isinstance(log_type, str) or not log_type.strip():
580
+ raise ValueError("'log_type' must be a non-empty string")
581
+ if collections is None:
582
+ raise ValueError("'collections' must be provided and contain at least one collection name")
583
+ if isinstance(collections, str):
584
+ if not collections.strip():
585
+ raise ValueError("'collections' must contain at least one non-empty collection name")
586
+ collections_list = [collections.strip()]
587
+ else:
588
+ if not isinstance(collections, list) or not collections:
589
+ raise ValueError("'collections' must be a non-empty string or list of non-empty strings")
590
+ if not all(isinstance(item, str) and item.strip() for item in collections):
591
+ raise ValueError("'collections' must be a non-empty string or list of non-empty strings")
592
+ collections_list = [item.strip() for item in collections if isinstance(item, str)]
593
+
594
+ if not _compose_stack_running():
595
+ LOGGER.warning("Scenario test requested but stack is not running")
596
+ raise RuntimeError("Scenario stack is not running. Start it with manage_scenario_stack(action='start').")
597
+
598
+ scenario_path = SCENARIO_COMPOSE_DIR / "scenarios" / "custom.yaml"
599
+ scenario_path.parent.mkdir(parents=True, exist_ok=True)
600
+ scenario_path.write_text(scenario_yaml, encoding="utf-8")
601
+ LOGGER.info("Scenario under test written to %s", scenario_path)
602
+
603
+ reload_required = False
604
+ for collection in collections_list:
605
+ collection_name = collection.strip()
606
+ LOGGER.info("Installing collection %s for scenario test", collection_name)
607
+ install_result = _run_scenario_compose_exec(
608
+ ["crowdsec", "cscli", "collections", "install", collection_name],
609
+ capture_output=True,
610
+ check=False,
611
+ )
612
+ if install_result.returncode != 0:
613
+ stdout = (install_result.stdout or "").strip()
614
+ stderr = (install_result.stderr or "").strip()
615
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
616
+ LOGGER.error("Failed to install collection %s: %s", collection_name, combined)
617
+ raise RuntimeError(
618
+ f"Failed to install collection '{collection_name}' (exit code {install_result.returncode}):\n{combined}"
619
+ )
620
+ combined_output = "\n".join(
621
+ part.strip()
622
+ for part in ((install_result.stdout or ""), (install_result.stderr or ""))
623
+ if part
624
+ ).lower()
625
+ if not ("already" in combined_output and "installed" in combined_output):
626
+ reload_required = True
627
+
628
+ if reload_required:
629
+ _compose_stack_reload_crowdsec()
630
+ LOGGER.info("Waiting for CrowdSec reload post collection install")
631
+ time.sleep(3)
632
+
633
+ # ruff: noqa: S108
634
+ mktemp_result = _run_scenario_compose_exec(
635
+ ["crowdsec", "mktemp", "/tmp/mcp-scenario-logs.XXXXXX"],
636
+ capture_output=True,
637
+ check=False,
638
+ )
639
+ if mktemp_result.returncode != 0:
640
+ stdout = (mktemp_result.stdout or "").strip()
641
+ stderr = (mktemp_result.stderr or "").strip()
642
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
643
+ LOGGER.error("mktemp failed: %s", combined)
644
+ raise RuntimeError(f"Failed to create temporary logs file: {combined}")
645
+ temp_path = (mktemp_result.stdout or "").strip()
646
+ if not temp_path:
647
+ raise RuntimeError("mktemp did not return a temporary file path")
648
+
649
+ log_payload = "".join(line.rstrip("\n") + "\n" for line in log_lines)
650
+ write_result = _run_scenario_compose_exec(
651
+ ["crowdsec", "sh", "-c", f"cat > {temp_path}"],
652
+ capture_output=True,
653
+ check=False,
654
+ input_text=log_payload,
655
+ )
656
+ if write_result.returncode != 0:
657
+ stdout = (write_result.stdout or "").strip()
658
+ stderr = (write_result.stderr or "").strip()
659
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
660
+ LOGGER.error("Failed to write log payload to %s: %s", temp_path, combined)
661
+ raise RuntimeError(f"Failed to write log payload to {temp_path}: {combined}")
662
+
663
+ try:
664
+ delete_result = _run_scenario_compose_exec(
665
+ ["crowdsec", "cscli", "alerts", "delete", "--all"],
666
+ capture_output=True,
667
+ check=False,
668
+ )
669
+ if delete_result.returncode != 0:
670
+ stdout = (delete_result.stdout or "").strip()
671
+ stderr = (delete_result.stderr or "").strip()
672
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
673
+ LOGGER.error("Failed to purge alerts: %s", combined)
674
+ raise RuntimeError(f"Failed to clear existing alerts: {combined}")
675
+
676
+ crowdsec_cmd = [
677
+ "crowdsec",
678
+ "crowdsec",
679
+ "--dsn",
680
+ f"file://{temp_path}",
681
+ "--type",
682
+ log_type.strip(),
683
+ "-no-api",
684
+ ]
685
+ LOGGER.info("Running CrowdSec replay command: %s", " ".join(crowdsec_cmd))
686
+ crowdsec_result = _run_scenario_compose_exec(
687
+ crowdsec_cmd,
688
+ capture_output=True,
689
+ check=False,
690
+ )
691
+ if crowdsec_result.returncode != 0:
692
+ stdout = (crowdsec_result.stdout or "").strip()
693
+ stderr = (crowdsec_result.stderr or "").strip()
694
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
695
+ LOGGER.error("CrowdSec replay failed: %s", combined)
696
+ raise RuntimeError(
697
+ f"CrowdSec replay failed (exit code {crowdsec_result.returncode}):\n{combined}"
698
+ )
699
+
700
+ alerts_result = _run_scenario_compose_exec(
701
+ ["crowdsec", "cscli", "alerts", "list", "-o", "json"],
702
+ capture_output=True,
703
+ check=False,
704
+ )
705
+ if alerts_result.returncode != 0:
706
+ stdout = (alerts_result.stdout or "").strip()
707
+ stderr = (alerts_result.stderr or "").strip()
708
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
709
+ LOGGER.error("Failed to list alerts: %s", combined)
710
+ raise RuntimeError(f"Failed to list alerts: {combined}")
711
+
712
+ alerts_output = (alerts_result.stdout or "").strip()
713
+ try:
714
+ alerts_json = json.loads(alerts_output) if alerts_output else []
715
+ except json.JSONDecodeError as exc:
716
+ LOGGER.error("Failed to decode alerts JSON: %s", exc)
717
+ raise RuntimeError(f"alerts list returned invalid JSON: {exc}") from exc
718
+
719
+ rendered_alerts = json.dumps(alerts_json, indent=2)
720
+ LOGGER.info("Scenario test produced alerts: %s", rendered_alerts)
721
+ return [
722
+ types.TextContent(
723
+ type="text",
724
+ text=f"✅ Scenario test completed. Alerts:\n{rendered_alerts}",
280
725
  )
281
726
  ]
727
+ finally:
728
+ cleanup_result = _run_scenario_compose_exec(
729
+ ["crowdsec", "rm", "-f", temp_path],
730
+ capture_output=True,
731
+ check=False,
732
+ )
733
+ if cleanup_result.returncode != 0:
734
+ stdout = (cleanup_result.stdout or "").strip()
735
+ stderr = (cleanup_result.stderr or "").strip()
736
+ combined = "\n".join(part for part in (stdout, stderr) if part) or "no output"
737
+ LOGGER.warning("Failed to remove temp file %s: %s", temp_path, combined)
282
738
 
283
739
 
284
- SCENARIO_TOOL_HANDLERS: Dict[str, ToolHandler] = {
740
+ SCENARIO_TOOL_HANDLERS: dict[str, ToolHandler] = {
285
741
  "get_scenario_prompt": _tool_get_scenario_prompt,
286
742
  "get_scenario_examples": _tool_get_scenario_examples,
287
743
  "validate_scenario_yaml": _tool_validate_scenario,
288
744
  "lint_scenario_yaml": _tool_lint_scenario,
289
745
  "deploy_scenario": _tool_deploy_scenario,
746
+ "explain_scenario": _tool_explain_scenario,
747
+ "manage_scenario_stack": _tool_manage_scenario_stack,
748
+ "test_scenario": _tool_test_scenario,
749
+ "get_scenario_expr_helpers": _tool_get_expr_helpers,
290
750
  }
291
751
 
292
- SCENARIO_TOOLS: List[types.Tool] = [
752
+ SCENARIO_TOOLS: list[types.Tool] = [
293
753
  types.Tool(
294
754
  name="get_scenario_prompt",
295
755
  description="Retrieve the base prompt for authoring CrowdSec scenarios",
@@ -308,6 +768,15 @@ SCENARIO_TOOLS: List[types.Tool] = [
308
768
  "additionalProperties": False,
309
769
  },
310
770
  ),
771
+ types.Tool(
772
+ name="get_scenario_expr_helpers",
773
+ description="Retrieve helper expressions for CrowdSec scenario authoring",
774
+ inputSchema={
775
+ "type": "object",
776
+ "properties": {},
777
+ "additionalProperties": False,
778
+ },
779
+ ),
311
780
  types.Tool(
312
781
  name="validate_scenario_yaml",
313
782
  description="Validate CrowdSec scenario YAML structure for required fields",
@@ -347,9 +816,89 @@ SCENARIO_TOOLS: List[types.Tool] = [
347
816
  "additionalProperties": False,
348
817
  },
349
818
  ),
819
+ types.Tool(
820
+ name="manage_scenario_stack",
821
+ description="Manage the lifecycle of the scenario testing stack (ONLY USE FOR TESTING SCENARIOS)",
822
+ inputSchema={
823
+ "type": "object",
824
+ "properties": {
825
+ "action": {
826
+ "type": "string",
827
+ "enum": ["start", "stop", "reload"],
828
+ "description": "Action to perform on the scenario testing stack",
829
+ },
830
+ },
831
+ "required": ["action"],
832
+ "additionalProperties": False,
833
+ },
834
+ ),
835
+ types.Tool(
836
+ name="explain_scenario",
837
+ description="""
838
+ Shows how crowdsec processes a single log line: what is extracted by the parsers, and which scenarios match.
839
+ A match does not mean an alert is generated, only that the event was of interest for the scenario.
840
+ This tool MUST NEVER be called with multiple log lines. If you need to test whether a scenario generates an alert, use the `test_scenario` tool instead.
841
+ The scenario stack (manage_scenario_stack) must be running to use this tool.
842
+ """,
843
+ inputSchema={
844
+ "type": "object",
845
+ "properties": {
846
+ "scenario_yaml": {
847
+ "type": "string",
848
+ "description": "Scenario YAML to explain",
849
+ },
850
+ "log_type": {
851
+ "type": "string",
852
+ "description": "Type of logs the scenario is intended to analyze",
853
+ },
854
+ "collections": {
855
+ "type": "array",
856
+ "items": {"type": "string"},
857
+ "description": "List of CrowdSec collections to install alongside the scenario",
858
+ },
859
+ "log_line": {
860
+ "type": "string",
861
+ "description": "A single example log line that should trigger the scenario",
862
+ },
863
+ },
864
+ "required": ["scenario_yaml", "log_line", "log_type", "collections"],
865
+ "additionalProperties": False,
866
+ },
867
+ ),
868
+ types.Tool(
869
+ name="test_scenario",
870
+ description="""
871
+ Test a CrowdSec scenario against multiple log lines (effectively replaying the events as if they were occurring in real-time).
872
+ """,
873
+ inputSchema={
874
+ "type": "object",
875
+ "properties": {
876
+ "scenario_yaml": {
877
+ "type": "string",
878
+ "description": "Scenario YAML to test",
879
+ },
880
+ "log_lines": {
881
+ "type": "array",
882
+ "items": {"type": "string"},
883
+ "description": "List of log lines to test against the scenario",
884
+ },
885
+ "log_type": {
886
+ "type": "string",
887
+ "description": "Type of logs the scenario is intended to analyze",
888
+ },
889
+ "collections": {
890
+ "type": "array",
891
+ "items": {"type": "string"},
892
+ "description": "List of CrowdSec collections to install alongside the scenario",
893
+ },
894
+ },
895
+ "required": ["scenario_yaml", "log_lines", "log_type", "collections"],
896
+ "additionalProperties": False,
897
+ },
898
+ ),
350
899
  ]
351
900
 
352
- SCENARIO_RESOURCES: List[types.Resource] = [
901
+ SCENARIO_RESOURCES: list[types.Resource] = [
353
902
  types.Resource(
354
903
  uri="file://prompts/prompt-scenario.txt",
355
904
  name="Scenario Authoring Prompt",
@@ -368,12 +917,19 @@ SCENARIO_RESOURCES: List[types.Resource] = [
368
917
  description="Guidance for packaging and deploying CrowdSec scenarios to local or hub environments",
369
918
  mimeType="text/plain",
370
919
  ),
920
+ types.Resource(
921
+ uri="file://prompts/prompt-expr-helpers.txt",
922
+ name="Scenario Expression Helpers",
923
+ description="List of supported expression helpers when writing CrowdSec scenarios",
924
+ mimeType="text/plain",
925
+ ),
371
926
  ]
372
927
 
373
- SCENARIO_RESOURCE_READERS: Dict[str, Callable[[], str]] = {
928
+ SCENARIO_RESOURCE_READERS: dict[str, Callable[[], str]] = {
374
929
  "file://prompts/prompt-scenario.txt": lambda: _read_text(SCENARIO_PROMPT_FILE),
375
930
  "file://prompts/prompt-scenario-examples.txt": lambda: _read_text(SCENARIO_EXAMPLES_FILE),
376
931
  "file://prompts/prompt-scenario-deploy.txt": lambda: _read_text(SCENARIO_DEPLOY_PROMPT_FILE),
932
+ "file://prompts/prompt-expr-helpers.txt": lambda: _read_text(SCENARIO_EXPR_HELPERS_PROMPT_FILE),
377
933
  }
378
934
 
379
935
  REGISTRY.register_tools(SCENARIO_TOOL_HANDLERS, SCENARIO_TOOLS)