crowdsec-local-mcp 0.2.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 -3
  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 +112 -18
  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 +98 -29
  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.2.0.dist-info/METADATA +0 -74
  22. crowdsec_local_mcp-0.2.0.dist-info/RECORD +0 -31
  23. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/WHEEL +0 -0
  24. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/entry_points.txt +0 -0
  25. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/licenses/LICENSE +0 -0
  26. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/top_level.txt +0 -0
@@ -2,4 +2,9 @@
2
2
 
3
3
  from .mcp_core import main
4
4
 
5
- __all__ = ["main"]
5
+ try:
6
+ from ._version import __version__
7
+ except ModuleNotFoundError: # pragma: no cover - generated during release
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__", "main"]
@@ -1,6 +1,4 @@
1
- #!/usr/bin/env python3
2
-
3
- # Use `uv run --project . <command>` to run this module directly for testing.
1
+ # Use `uv run --project . crowdsec-mcp` to run this module directly for testing.
4
2
 
5
3
  import asyncio
6
4
 
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0.post1.dev0"
@@ -0,0 +1 @@
1
+ scenarios/*.yaml
@@ -0,0 +1,19 @@
1
+ services:
2
+ crowdsec:
3
+ image: crowdsecurity/crowdsec:latest
4
+ hostname: crowdsec
5
+ container_name: crowdsec-scenario-test
6
+ pull_policy: always
7
+ restart: "no"
8
+ environment:
9
+ - DISABLE_ONLINE_API=true
10
+ - DISABLE_AGENT=true
11
+ volumes:
12
+ # Persist CrowdSec data (buckets, alerts, etc.) between restarts.
13
+ - crowdsec-data:/var/lib/crowdsec/data
14
+ # No need for acquisition
15
+ # The MCP tooling will drop the user-provided rule in this folder as current-rule.yaml
16
+ - ./scenarios:/etc/crowdsec/scenarios/custom
17
+
18
+ volumes:
19
+ crowdsec-data:
@@ -3,6 +3,7 @@ version: "3.9"
3
3
  services:
4
4
  crowdsec:
5
5
  image: crowdsecurity/crowdsec:latest
6
+ pull_policy: always
6
7
  hostname: crowdsec
7
8
  container_name: crowdsec-appsec
8
9
  restart: "no"
@@ -11,10 +12,8 @@ services:
11
12
  - /usr/local/bin/init-bouncer.sh
12
13
  environment:
13
14
  # Ensure the local API stays accessible for the nginx bouncer.
14
- - DISABLE_LOCAL_API=0
15
- - DISABLE_ONLINE_API=1
16
- # Turn on AppSec mode inside the CrowdSec container.
17
- - ENABLE_APPSEC=1
15
+ - DISABLE_LOCAL_API=false
16
+ - DISABLE_ONLINE_API=true
18
17
  volumes:
19
18
  # Persist CrowdSec data (buckets, alerts, etc.) between restarts.
20
19
  - crowdsec-data:/var/lib/crowdsec/data
@@ -43,7 +42,7 @@ services:
43
42
  command:
44
43
  - openresty
45
44
  - -g
46
- - 'daemon off;'
45
+ - "daemon off;"
47
46
  volumes:
48
47
  - ./nginx/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro
49
48
  # Site config enabling the CrowdSec module and proxying to the backend.
@@ -54,7 +53,7 @@ services:
54
53
  - waf-net
55
54
 
56
55
  backend:
57
- image: nginxdemos/hello:latest
56
+ image: nginx:alpine
58
57
  container_name: app-backend
59
58
  restart: unless-stopped
60
59
  networks:
@@ -12,8 +12,9 @@ RUN apt-get update && apt-get install -y \
12
12
  gettext \
13
13
  curl
14
14
 
15
- RUN wget -O - https://openresty.org/package/pubkey.gpg | apt-key add -
16
- RUN echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main"| tee /etc/apt/sources.list.d/openresty.list
15
+ RUN wget -O - https://openresty.org/package/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/openresty.gpg
16
+ RUN echo "deb [arch=amd64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main"| tee /etc/apt/sources.list.d/openresty.list
17
+ RUN echo "deb [arch=arm64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/arm64/ubuntu $(lsb_release -sc) main"| tee /etc/apt/sources.list.d/openresty.list
17
18
  RUN curl -s https://install.crowdsec.net | bash
18
19
 
19
20
  RUN apt update
@@ -1,18 +1,22 @@
1
- import asyncio
2
1
  import logging
2
+ import shutil
3
+ import subprocess
3
4
  import tempfile
4
5
  from collections import OrderedDict
5
6
  from pathlib import Path
6
- from typing import Any, Callable, Dict, List, Optional
7
+ from typing import Any
8
+ from collections.abc import Callable
7
9
 
8
10
  import mcp.server.stdio
9
- import mcp.types as types
11
+ from mcp import types
10
12
  from mcp.server import NotificationOptions, Server
11
13
  from mcp.server.models import InitializationOptions
12
14
 
13
15
  SCRIPT_DIR = Path(__file__).parent
14
16
  PROMPTS_DIR = SCRIPT_DIR / "prompts"
15
17
  LOG_FILE_PATH = Path(tempfile.gettempdir()) / "crowdsec-mcp.log"
18
+ _DOCKER_CLI_CHECK: bool | None = None
19
+ _DOCKER_COMPOSE_CMD: list[str] | None = None
16
20
 
17
21
 
18
22
  def _configure_logger() -> logging.Logger:
@@ -37,7 +41,7 @@ def _configure_logger() -> logging.Logger:
37
41
  LOGGER = _configure_logger()
38
42
  server = Server("crowdsec-prompt-server")
39
43
 
40
- ToolHandler = Callable[[Optional[Dict[str, Any]]], List[types.TextContent]]
44
+ ToolHandler = Callable[[dict[str, Any] | None], list[types.TextContent]]
41
45
  ResourceReader = Callable[[], str]
42
46
 
43
47
 
@@ -45,15 +49,15 @@ class MCPRegistry:
45
49
  """Central registry for tool and resource integrations."""
46
50
 
47
51
  def __init__(self) -> None:
48
- self._tool_handlers: Dict[str, ToolHandler] = {}
49
- self._tools: "OrderedDict[str, types.Tool]" = OrderedDict()
50
- self._resources: "OrderedDict[str, types.Resource]" = OrderedDict()
51
- self._resource_readers: Dict[str, ResourceReader] = {}
52
+ self._tool_handlers: dict[str, ToolHandler] = {}
53
+ self._tools: OrderedDict[str, types.Tool] = OrderedDict()
54
+ self._resources: OrderedDict[str, types.Resource] = OrderedDict()
55
+ self._resource_readers: dict[str, ResourceReader] = {}
52
56
 
53
57
  def register_tools(
54
58
  self,
55
- handlers: Dict[str, ToolHandler],
56
- tool_definitions: List[types.Tool],
59
+ handlers: dict[str, ToolHandler],
60
+ tool_definitions: list[types.Tool],
57
61
  ) -> None:
58
62
  for name, handler in handlers.items():
59
63
  if name in self._tool_handlers:
@@ -67,8 +71,8 @@ class MCPRegistry:
67
71
 
68
72
  def register_resources(
69
73
  self,
70
- resources: List[types.Resource],
71
- readers: Dict[str, ResourceReader],
74
+ resources: list[types.Resource],
75
+ readers: dict[str, ResourceReader],
72
76
  ) -> None:
73
77
  for resource in resources:
74
78
  if resource.uri in self._resources:
@@ -81,11 +85,11 @@ class MCPRegistry:
81
85
  self._resource_readers[uri] = reader
82
86
 
83
87
  @property
84
- def tools(self) -> List[types.Tool]:
88
+ def tools(self) -> list[types.Tool]:
85
89
  return list(self._tools.values())
86
90
 
87
91
  @property
88
- def resources(self) -> List[types.Resource]:
92
+ def resources(self) -> list[types.Resource]:
89
93
  return list(self._resources.values())
90
94
 
91
95
  def get_tool_handler(self, name: str) -> ToolHandler:
@@ -104,23 +108,113 @@ class MCPRegistry:
104
108
  REGISTRY = MCPRegistry()
105
109
 
106
110
 
111
+ def ensure_docker_cli() -> None:
112
+ """Ensure the Docker CLI is available and executable."""
113
+ global _DOCKER_CLI_CHECK
114
+ if _DOCKER_CLI_CHECK:
115
+ return
116
+
117
+ docker_path = shutil.which("docker")
118
+ if not docker_path:
119
+ raise RuntimeError(
120
+ "Docker is required but the `docker` executable was not found on PATH. "
121
+ "Install Docker Desktop or Docker Engine and ensure the `docker` CLI is accessible."
122
+ )
123
+
124
+ try:
125
+ subprocess.run(
126
+ ["docker", "info"], #noqa: S607
127
+ check=True,
128
+ capture_output=True,
129
+ text=True,
130
+ )
131
+ except FileNotFoundError as exc:
132
+ raise RuntimeError(
133
+ "Docker is required but the `docker` executable could not be executed. "
134
+ "Install Docker and ensure the CLI is on PATH."
135
+ ) from exc
136
+ except PermissionError as exc:
137
+ raise RuntimeError(
138
+ "Docker was found but is not executable by the current process. "
139
+ "Adjust permissions or run as a user allowed to execute Docker commands."
140
+ ) from exc
141
+ except subprocess.CalledProcessError as exc:
142
+ detail = (exc.stderr or exc.stdout or "").strip()
143
+ hint = (
144
+ "Docker appears to be installed but `docker info` failed. "
145
+ "Ensure the Docker daemon is installed correctly and the current user can execute Docker commands."
146
+ )
147
+ if detail:
148
+ hint = f"{hint} Details: {detail}"
149
+ raise RuntimeError(hint) from exc
150
+
151
+ LOGGER.info("Docker CLI detected at %s", docker_path)
152
+ _DOCKER_CLI_CHECK = True
153
+
154
+
155
+ def ensure_docker_compose_cli() -> list[str]:
156
+ """Ensure a Docker Compose CLI is available and executable; return the command."""
157
+ global _DOCKER_COMPOSE_CMD
158
+ if _DOCKER_COMPOSE_CMD is not None:
159
+ return _DOCKER_COMPOSE_CMD
160
+
161
+ ensure_docker_cli()
162
+
163
+ candidates = [["docker", "compose"], ["docker-compose"]]
164
+ errors: list[str] = []
165
+
166
+ for candidate in candidates:
167
+ command_display = " ".join(candidate)
168
+ try:
169
+ result = subprocess.run(
170
+ candidate + ["version"],
171
+ check=True,
172
+ capture_output=True,
173
+ text=True,
174
+ )
175
+ except FileNotFoundError as exc:
176
+ errors.append(f"`{command_display}` command not found: {exc}")
177
+ continue
178
+ except PermissionError as exc:
179
+ errors.append(
180
+ f"`{command_display}` is present but not executable by the current user: {exc}"
181
+ )
182
+ continue
183
+ except subprocess.CalledProcessError as exc:
184
+ detail = (exc.stderr or exc.stdout or str(exc)).strip()
185
+ message = f"`{command_display}` failed to run: {detail or 'unknown error'}"
186
+ errors.append(message)
187
+ continue
188
+
189
+ if result.returncode == 0:
190
+ _DOCKER_COMPOSE_CMD = candidate
191
+ LOGGER.info("Docker Compose CLI detected: %s", command_display)
192
+ return candidate
193
+
194
+ detail_suffix = f" Details: {'; '.join(errors)}" if errors else ""
195
+ raise RuntimeError(
196
+ "Docker Compose is required but could not be executed. Install Docker Desktop or Docker Engine and ensure "
197
+ "`docker compose` or `docker-compose` is available on PATH." + detail_suffix
198
+ )
199
+
200
+
107
201
  @server.list_tools()
108
- async def handle_list_tools() -> List[types.Tool]:
202
+ async def handle_list_tools() -> list[types.Tool]:
109
203
  LOGGER.info("Listing available tools")
110
204
  return REGISTRY.tools
111
205
 
112
206
 
113
207
  @server.call_tool()
114
208
  async def handle_call_tool(
115
- name: str, arguments: Optional[Dict[str, Any]]
116
- ) -> List[types.TextContent]:
209
+ name: str, arguments: dict[str, Any] | None
210
+ ) -> list[types.TextContent]:
117
211
  LOGGER.info("handle_call_tool invoked for tool '%s'", name)
118
212
  handler = REGISTRY.get_tool_handler(name)
119
213
  return handler(arguments)
120
214
 
121
215
 
122
216
  @server.list_resources()
123
- async def handle_list_resources() -> List[types.Resource]:
217
+ async def handle_list_resources() -> list[types.Resource]:
124
218
  LOGGER.info("Listing available resources")
125
219
  return REGISTRY.resources
126
220