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.
- crowdsec_local_mcp/__init__.py +6 -1
- crowdsec_local_mcp/__main__.py +1 -1
- crowdsec_local_mcp/_version.py +1 -0
- crowdsec_local_mcp/compose/scenario-test/.gitignore +1 -0
- crowdsec_local_mcp/compose/scenario-test/docker-compose.yml +19 -0
- crowdsec_local_mcp/compose/scenario-test/scenarios/.gitkeep +0 -0
- crowdsec_local_mcp/compose/waf-test/docker-compose.yml +5 -6
- crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +3 -2
- crowdsec_local_mcp/mcp_core.py +114 -19
- crowdsec_local_mcp/mcp_scenarios.py +579 -23
- crowdsec_local_mcp/mcp_waf.py +567 -337
- crowdsec_local_mcp/prompts/prompt-expr-helpers.txt +514 -0
- crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +70 -21
- crowdsec_local_mcp/prompts/prompt-scenario.txt +26 -2
- crowdsec_local_mcp/prompts/prompt-waf-tests.txt +101 -0
- crowdsec_local_mcp/prompts/prompt-waf-top-level.txt +31 -0
- crowdsec_local_mcp/prompts/prompt-waf.txt +0 -26
- crowdsec_local_mcp/setup_cli.py +375 -0
- crowdsec_local_mcp-0.7.0.post1.dev0.dist-info/METADATA +114 -0
- crowdsec_local_mcp-0.7.0.post1.dev0.dist-info/RECORD +38 -0
- {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/entry_points.txt +1 -0
- crowdsec_local_mcp-0.1.0.dist-info/METADATA +0 -93
- crowdsec_local_mcp-0.1.0.dist-info/RECORD +0 -30
- {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/WHEEL +0 -0
- {crowdsec_local_mcp-0.1.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
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:
|
|
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() ->
|
|
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(_:
|
|
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: {
|
|
72
|
+
text=f"Error reading scenario prompt: {exc!s}",
|
|
65
73
|
)
|
|
66
74
|
]
|
|
67
75
|
|
|
68
76
|
|
|
69
|
-
def _tool_get_scenario_examples(_:
|
|
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: {
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
204
|
+
text=f"❌ VALIDATION FAILED: {exc!s}",
|
|
171
205
|
)
|
|
172
206
|
]
|
|
173
207
|
|
|
174
208
|
|
|
175
|
-
def _tool_lint_scenario(arguments:
|
|
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: {
|
|
228
|
+
text=f"❌ LINT ERROR: {exc!s}",
|
|
195
229
|
)
|
|
196
230
|
]
|
|
197
231
|
|
|
198
|
-
warnings:
|
|
199
|
-
hints:
|
|
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:
|
|
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(_:
|
|
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: {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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)
|