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
@@ -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,4 +1,4 @@
1
- #!/usr/bin/env python3
1
+ # Use `uv run --project . crowdsec-mcp` to run this module directly for testing.
2
2
 
3
3
  import asyncio
4
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,17 +1,22 @@
1
- import asyncio
2
1
  import logging
2
+ import shutil
3
+ import subprocess
4
+ import tempfile
3
5
  from collections import OrderedDict
4
6
  from pathlib import Path
5
- from typing import Any, Callable, Dict, List, Optional
7
+ from typing import Any
8
+ from collections.abc import Callable
6
9
 
7
10
  import mcp.server.stdio
8
- import mcp.types as types
11
+ from mcp import types
9
12
  from mcp.server import NotificationOptions, Server
10
13
  from mcp.server.models import InitializationOptions
11
14
 
12
15
  SCRIPT_DIR = Path(__file__).parent
13
16
  PROMPTS_DIR = SCRIPT_DIR / "prompts"
14
- LOG_FILE_PATH = SCRIPT_DIR / "crowdsec-mcp.log"
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
15
20
 
16
21
 
17
22
  def _configure_logger() -> logging.Logger:
@@ -36,7 +41,7 @@ def _configure_logger() -> logging.Logger:
36
41
  LOGGER = _configure_logger()
37
42
  server = Server("crowdsec-prompt-server")
38
43
 
39
- ToolHandler = Callable[[Optional[Dict[str, Any]]], List[types.TextContent]]
44
+ ToolHandler = Callable[[dict[str, Any] | None], list[types.TextContent]]
40
45
  ResourceReader = Callable[[], str]
41
46
 
42
47
 
@@ -44,15 +49,15 @@ class MCPRegistry:
44
49
  """Central registry for tool and resource integrations."""
45
50
 
46
51
  def __init__(self) -> None:
47
- self._tool_handlers: Dict[str, ToolHandler] = {}
48
- self._tools: "OrderedDict[str, types.Tool]" = OrderedDict()
49
- self._resources: "OrderedDict[str, types.Resource]" = OrderedDict()
50
- 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] = {}
51
56
 
52
57
  def register_tools(
53
58
  self,
54
- handlers: Dict[str, ToolHandler],
55
- tool_definitions: List[types.Tool],
59
+ handlers: dict[str, ToolHandler],
60
+ tool_definitions: list[types.Tool],
56
61
  ) -> None:
57
62
  for name, handler in handlers.items():
58
63
  if name in self._tool_handlers:
@@ -66,8 +71,8 @@ class MCPRegistry:
66
71
 
67
72
  def register_resources(
68
73
  self,
69
- resources: List[types.Resource],
70
- readers: Dict[str, ResourceReader],
74
+ resources: list[types.Resource],
75
+ readers: dict[str, ResourceReader],
71
76
  ) -> None:
72
77
  for resource in resources:
73
78
  if resource.uri in self._resources:
@@ -80,11 +85,11 @@ class MCPRegistry:
80
85
  self._resource_readers[uri] = reader
81
86
 
82
87
  @property
83
- def tools(self) -> List[types.Tool]:
88
+ def tools(self) -> list[types.Tool]:
84
89
  return list(self._tools.values())
85
90
 
86
91
  @property
87
- def resources(self) -> List[types.Resource]:
92
+ def resources(self) -> list[types.Resource]:
88
93
  return list(self._resources.values())
89
94
 
90
95
  def get_tool_handler(self, name: str) -> ToolHandler:
@@ -103,23 +108,113 @@ class MCPRegistry:
103
108
  REGISTRY = MCPRegistry()
104
109
 
105
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
+
106
201
  @server.list_tools()
107
- async def handle_list_tools() -> List[types.Tool]:
202
+ async def handle_list_tools() -> list[types.Tool]:
108
203
  LOGGER.info("Listing available tools")
109
204
  return REGISTRY.tools
110
205
 
111
206
 
112
207
  @server.call_tool()
113
208
  async def handle_call_tool(
114
- name: str, arguments: Optional[Dict[str, Any]]
115
- ) -> List[types.TextContent]:
209
+ name: str, arguments: dict[str, Any] | None
210
+ ) -> list[types.TextContent]:
116
211
  LOGGER.info("handle_call_tool invoked for tool '%s'", name)
117
212
  handler = REGISTRY.get_tool_handler(name)
118
213
  return handler(arguments)
119
214
 
120
215
 
121
216
  @server.list_resources()
122
- async def handle_list_resources() -> List[types.Resource]:
217
+ async def handle_list_resources() -> list[types.Resource]:
123
218
  LOGGER.info("Listing available resources")
124
219
  return REGISTRY.resources
125
220