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.
- crowdsec_local_mcp/__init__.py +6 -1
- crowdsec_local_mcp/__main__.py +1 -3
- 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 +112 -18
- 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 +98 -29
- 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.2.0.dist-info/METADATA +0 -74
- crowdsec_local_mcp-0.2.0.dist-info/RECORD +0 -31
- {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/WHEEL +0 -0
- {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/entry_points.txt +0 -0
- {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/licenses/LICENSE +0 -0
- {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/top_level.txt +0 -0
crowdsec_local_mcp/__init__.py
CHANGED
crowdsec_local_mcp/__main__.py
CHANGED
|
@@ -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:
|
|
File without changes
|
|
@@ -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=
|
|
15
|
-
- DISABLE_ONLINE_API=
|
|
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
|
-
-
|
|
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:
|
|
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 |
|
|
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
|
crowdsec_local_mcp/mcp_core.py
CHANGED
|
@@ -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
|
|
7
|
+
from typing import Any
|
|
8
|
+
from collections.abc import Callable
|
|
7
9
|
|
|
8
10
|
import mcp.server.stdio
|
|
9
|
-
|
|
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[[
|
|
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:
|
|
49
|
-
self._tools:
|
|
50
|
-
self._resources:
|
|
51
|
-
self._resource_readers:
|
|
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:
|
|
56
|
-
tool_definitions:
|
|
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:
|
|
71
|
-
readers:
|
|
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) ->
|
|
88
|
+
def tools(self) -> list[types.Tool]:
|
|
85
89
|
return list(self._tools.values())
|
|
86
90
|
|
|
87
91
|
@property
|
|
88
|
-
def resources(self) ->
|
|
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() ->
|
|
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:
|
|
116
|
-
) ->
|
|
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() ->
|
|
217
|
+
async def handle_list_resources() -> list[types.Resource]:
|
|
124
218
|
LOGGER.info("Listing available resources")
|
|
125
219
|
return REGISTRY.resources
|
|
126
220
|
|