cyntrisec 0.1.7__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.
- cyntrisec/__init__.py +3 -0
- cyntrisec/__main__.py +6 -0
- cyntrisec/aws/__init__.py +6 -0
- cyntrisec/aws/collectors/__init__.py +17 -0
- cyntrisec/aws/collectors/ec2.py +30 -0
- cyntrisec/aws/collectors/iam.py +116 -0
- cyntrisec/aws/collectors/lambda_.py +45 -0
- cyntrisec/aws/collectors/network.py +70 -0
- cyntrisec/aws/collectors/rds.py +38 -0
- cyntrisec/aws/collectors/s3.py +68 -0
- cyntrisec/aws/collectors/usage.py +188 -0
- cyntrisec/aws/credentials.py +153 -0
- cyntrisec/aws/normalizers/__init__.py +17 -0
- cyntrisec/aws/normalizers/ec2.py +115 -0
- cyntrisec/aws/normalizers/iam.py +182 -0
- cyntrisec/aws/normalizers/lambda_.py +83 -0
- cyntrisec/aws/normalizers/network.py +225 -0
- cyntrisec/aws/normalizers/rds.py +130 -0
- cyntrisec/aws/normalizers/s3.py +184 -0
- cyntrisec/aws/relationship_builder.py +1359 -0
- cyntrisec/aws/scanner.py +303 -0
- cyntrisec/cli/__init__.py +5 -0
- cyntrisec/cli/analyze.py +747 -0
- cyntrisec/cli/ask.py +412 -0
- cyntrisec/cli/can.py +307 -0
- cyntrisec/cli/comply.py +226 -0
- cyntrisec/cli/cuts.py +231 -0
- cyntrisec/cli/diff.py +332 -0
- cyntrisec/cli/errors.py +105 -0
- cyntrisec/cli/explain.py +348 -0
- cyntrisec/cli/main.py +114 -0
- cyntrisec/cli/manifest.py +893 -0
- cyntrisec/cli/output.py +117 -0
- cyntrisec/cli/remediate.py +643 -0
- cyntrisec/cli/report.py +462 -0
- cyntrisec/cli/scan.py +207 -0
- cyntrisec/cli/schemas.py +391 -0
- cyntrisec/cli/serve.py +164 -0
- cyntrisec/cli/setup.py +260 -0
- cyntrisec/cli/validate.py +101 -0
- cyntrisec/cli/waste.py +323 -0
- cyntrisec/core/__init__.py +31 -0
- cyntrisec/core/business_config.py +110 -0
- cyntrisec/core/business_logic.py +131 -0
- cyntrisec/core/compliance.py +437 -0
- cyntrisec/core/cost_estimator.py +301 -0
- cyntrisec/core/cuts.py +360 -0
- cyntrisec/core/diff.py +361 -0
- cyntrisec/core/graph.py +202 -0
- cyntrisec/core/paths.py +830 -0
- cyntrisec/core/schema.py +317 -0
- cyntrisec/core/simulator.py +371 -0
- cyntrisec/core/waste.py +309 -0
- cyntrisec/mcp/__init__.py +5 -0
- cyntrisec/mcp/server.py +862 -0
- cyntrisec/storage/__init__.py +7 -0
- cyntrisec/storage/filesystem.py +344 -0
- cyntrisec/storage/memory.py +113 -0
- cyntrisec/storage/protocol.py +92 -0
- cyntrisec-0.1.7.dist-info/METADATA +672 -0
- cyntrisec-0.1.7.dist-info/RECORD +65 -0
- cyntrisec-0.1.7.dist-info/WHEEL +4 -0
- cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
- cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
- cyntrisec-0.1.7.dist-info/licenses/NOTICE +5 -0
cyntrisec/mcp/server.py
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server - Model Context Protocol server for AI agent integration.
|
|
3
|
+
|
|
4
|
+
Exposes Cyntrisec capabilities as MCP tools that AI agents can invoke directly.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
cyntrisec serve # Start MCP server (stdio transport)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# MCP support - optional dependency
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
|
|
21
|
+
HAS_MCP = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
HAS_MCP = False
|
|
24
|
+
FastMCP = None
|
|
25
|
+
|
|
26
|
+
from cyntrisec.cli.remediate import _terraform_snippet
|
|
27
|
+
from cyntrisec.core.compliance import ComplianceChecker, Framework
|
|
28
|
+
from cyntrisec.core.cuts import MinCutFinder
|
|
29
|
+
from cyntrisec.core.diff import SnapshotDiff
|
|
30
|
+
from cyntrisec.core.graph import GraphBuilder
|
|
31
|
+
from cyntrisec.core.simulator import OfflineSimulator
|
|
32
|
+
from cyntrisec.core.waste import WasteAnalyzer
|
|
33
|
+
from cyntrisec.storage import FileSystemStorage
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Error codes for MCP responses (mirrors CLI error taxonomy)
|
|
39
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND = "SNAPSHOT_NOT_FOUND"
|
|
40
|
+
MCP_ERROR_INSUFFICIENT_DATA = "INSUFFICIENT_DATA"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mcp_error(error_code: str, message: str) -> dict[str, Any]:
|
|
44
|
+
"""Return a consistent error envelope for MCP tool responses."""
|
|
45
|
+
return {
|
|
46
|
+
"status": "error",
|
|
47
|
+
"error_code": error_code,
|
|
48
|
+
"message": message,
|
|
49
|
+
"data": None,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SessionState:
|
|
55
|
+
"""
|
|
56
|
+
Lightweight session cache for MCP server calls.
|
|
57
|
+
|
|
58
|
+
Caches scan data for the current snapshot to avoid repeated disk reads
|
|
59
|
+
and keeps track of the active snapshot id for successive tool calls.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
storage: FileSystemStorage = field(default_factory=FileSystemStorage)
|
|
63
|
+
snapshot_id: str | None = None
|
|
64
|
+
_cache: dict[tuple[str, str | None], object] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def set_snapshot(self, snapshot_id: str | None) -> str | None:
|
|
67
|
+
"""Set or update the active snapshot id and clear cache if changed."""
|
|
68
|
+
# Resolve the identifier to a scan_id (directory name)
|
|
69
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id)
|
|
70
|
+
if resolved_id and resolved_id != self.snapshot_id:
|
|
71
|
+
self._cache.clear()
|
|
72
|
+
self.snapshot_id = resolved_id
|
|
73
|
+
elif resolved_id is None and self.snapshot_id is None:
|
|
74
|
+
# Try to seed from latest snapshot if present
|
|
75
|
+
snap = self.storage.get_snapshot()
|
|
76
|
+
if snap:
|
|
77
|
+
self.snapshot_id = self.storage.resolve_scan_id(None)
|
|
78
|
+
return self.snapshot_id
|
|
79
|
+
|
|
80
|
+
def _key(self, kind: str, snapshot_id: str | None) -> tuple[str, str | None]:
|
|
81
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id) if snapshot_id else self.snapshot_id
|
|
82
|
+
return (kind, resolved_id or self.snapshot_id)
|
|
83
|
+
|
|
84
|
+
def get_snapshot(self, snapshot_id: str | None = None):
|
|
85
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
|
|
86
|
+
snap = self.storage.get_snapshot(resolved_id)
|
|
87
|
+
if snap and not self.snapshot_id:
|
|
88
|
+
self.snapshot_id = resolved_id or self.storage.resolve_scan_id(None)
|
|
89
|
+
return snap
|
|
90
|
+
|
|
91
|
+
def get_assets(self, snapshot_id: str | None = None):
|
|
92
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
|
|
93
|
+
key = self._key("assets", resolved_id)
|
|
94
|
+
if key not in self._cache:
|
|
95
|
+
self._cache[key] = self.storage.get_assets(resolved_id)
|
|
96
|
+
return self._cache[key]
|
|
97
|
+
|
|
98
|
+
def get_relationships(self, snapshot_id: str | None = None):
|
|
99
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
|
|
100
|
+
key = self._key("relationships", resolved_id)
|
|
101
|
+
if key not in self._cache:
|
|
102
|
+
self._cache[key] = self.storage.get_relationships(resolved_id)
|
|
103
|
+
return self._cache[key]
|
|
104
|
+
|
|
105
|
+
def get_paths(self, snapshot_id: str | None = None):
|
|
106
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
|
|
107
|
+
key = self._key("paths", resolved_id)
|
|
108
|
+
if key not in self._cache:
|
|
109
|
+
self._cache[key] = self.storage.get_attack_paths(resolved_id)
|
|
110
|
+
return self._cache[key]
|
|
111
|
+
|
|
112
|
+
def get_findings(self, snapshot_id: str | None = None):
|
|
113
|
+
resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
|
|
114
|
+
key = self._key("findings", resolved_id)
|
|
115
|
+
if key not in self._cache:
|
|
116
|
+
self._cache[key] = self.storage.get_findings(resolved_id)
|
|
117
|
+
return self._cache[key]
|
|
118
|
+
|
|
119
|
+
def clear_cache(self) -> None:
|
|
120
|
+
self._cache.clear()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_mcp_server() -> FastMCP:
|
|
124
|
+
"""
|
|
125
|
+
Create and configure the MCP server with all tools.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Configured FastMCP instance
|
|
129
|
+
"""
|
|
130
|
+
if not HAS_MCP:
|
|
131
|
+
raise ImportError("MCP SDK not installed. Run: pip install mcp")
|
|
132
|
+
|
|
133
|
+
mcp = FastMCP(
|
|
134
|
+
name="cyntrisec", instructions="AWS capability graph analysis and attack path discovery"
|
|
135
|
+
)
|
|
136
|
+
session = SessionState()
|
|
137
|
+
|
|
138
|
+
_register_session_tools(mcp, session)
|
|
139
|
+
_register_graph_tools(mcp, session)
|
|
140
|
+
_register_insight_tools(mcp, session)
|
|
141
|
+
|
|
142
|
+
return mcp
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _register_session_tools(mcp, session):
|
|
146
|
+
"""Register session and summary tools."""
|
|
147
|
+
|
|
148
|
+
@mcp.tool()
|
|
149
|
+
def get_findings(
|
|
150
|
+
severity: str | None = None,
|
|
151
|
+
max_findings: int = 20,
|
|
152
|
+
snapshot_id: str | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""
|
|
155
|
+
Get security findings from the scan.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
severity: Filter by severity (CRITICAL, HIGH, MEDIUM, LOW)
|
|
159
|
+
max_findings: Maximum number of findings to return (default: 20)
|
|
160
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of security findings with severity and descriptions.
|
|
164
|
+
"""
|
|
165
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
166
|
+
if not snapshot:
|
|
167
|
+
return mcp_error(
|
|
168
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
findings = session.get_findings(snapshot_id)
|
|
172
|
+
session.set_snapshot(snapshot_id)
|
|
173
|
+
|
|
174
|
+
# Filter by severity if specified
|
|
175
|
+
if severity:
|
|
176
|
+
severity_upper = severity.upper()
|
|
177
|
+
findings = [f for f in findings if f.severity.upper() == severity_upper]
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"total": len(findings),
|
|
181
|
+
"findings": [
|
|
182
|
+
{
|
|
183
|
+
"id": str(f.id),
|
|
184
|
+
"title": f.title,
|
|
185
|
+
"severity": f.severity,
|
|
186
|
+
"finding_type": f.finding_type,
|
|
187
|
+
"description": f.description,
|
|
188
|
+
"remediation": f.remediation,
|
|
189
|
+
}
|
|
190
|
+
for f in findings[:max_findings]
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def get_assets(
|
|
196
|
+
asset_type: str | None = None,
|
|
197
|
+
search: str | None = None,
|
|
198
|
+
max_assets: int = 50,
|
|
199
|
+
snapshot_id: str | None = None,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""
|
|
202
|
+
Get assets from the scan with optional filtering.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
asset_type: Filter by type (e.g., "iam:role", "ec2:instance", "s3:bucket")
|
|
206
|
+
search: Search by name or ARN (case-insensitive)
|
|
207
|
+
max_assets: Maximum number of assets to return (default: 50)
|
|
208
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of assets with their properties.
|
|
212
|
+
"""
|
|
213
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
214
|
+
if not snapshot:
|
|
215
|
+
return mcp_error(
|
|
216
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
assets = session.get_assets(snapshot_id)
|
|
220
|
+
session.set_snapshot(snapshot_id)
|
|
221
|
+
|
|
222
|
+
# Filter by type if specified
|
|
223
|
+
if asset_type:
|
|
224
|
+
assets = [a for a in assets if a.asset_type.lower() == asset_type.lower()]
|
|
225
|
+
|
|
226
|
+
# Search by name or ARN
|
|
227
|
+
if search:
|
|
228
|
+
search_lower = search.lower()
|
|
229
|
+
assets = [
|
|
230
|
+
a for a in assets
|
|
231
|
+
if search_lower in (a.name or "").lower()
|
|
232
|
+
or search_lower in (a.arn or "").lower()
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"total": len(assets),
|
|
237
|
+
"assets": [
|
|
238
|
+
{
|
|
239
|
+
"id": str(a.id),
|
|
240
|
+
"type": a.asset_type,
|
|
241
|
+
"name": a.name,
|
|
242
|
+
"arn": a.arn,
|
|
243
|
+
"region": a.aws_region,
|
|
244
|
+
"is_internet_facing": a.is_internet_facing,
|
|
245
|
+
"is_sensitive_target": a.is_sensitive_target,
|
|
246
|
+
}
|
|
247
|
+
for a in assets[:max_assets]
|
|
248
|
+
],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@mcp.tool()
|
|
252
|
+
def get_relationships(
|
|
253
|
+
relationship_type: str | None = None,
|
|
254
|
+
source_name: str | None = None,
|
|
255
|
+
target_name: str | None = None,
|
|
256
|
+
max_relationships: int = 50,
|
|
257
|
+
snapshot_id: str | None = None,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
"""
|
|
260
|
+
Get relationships between assets with optional filtering.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
relationship_type: Filter by type (e.g., "CAN_ASSUME", "CAN_REACH", "MAY_ACCESS")
|
|
264
|
+
source_name: Filter by source asset name
|
|
265
|
+
target_name: Filter by target asset name
|
|
266
|
+
max_relationships: Maximum number to return (default: 50)
|
|
267
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of relationships with source, target, and type.
|
|
271
|
+
"""
|
|
272
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
273
|
+
if not snapshot:
|
|
274
|
+
return mcp_error(
|
|
275
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
relationships = session.get_relationships(snapshot_id)
|
|
279
|
+
assets = session.get_assets(snapshot_id)
|
|
280
|
+
session.set_snapshot(snapshot_id)
|
|
281
|
+
|
|
282
|
+
# Build asset lookup for names
|
|
283
|
+
asset_map = {str(a.id): a for a in assets}
|
|
284
|
+
|
|
285
|
+
# Filter by relationship type
|
|
286
|
+
if relationship_type:
|
|
287
|
+
relationships = [
|
|
288
|
+
r for r in relationships
|
|
289
|
+
if r.relationship_type.upper() == relationship_type.upper()
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
# Filter by source name
|
|
293
|
+
if source_name:
|
|
294
|
+
source_lower = source_name.lower()
|
|
295
|
+
relationships = [
|
|
296
|
+
r for r in relationships
|
|
297
|
+
if (asset := asset_map.get(str(r.source_asset_id))) and
|
|
298
|
+
asset.name and source_lower in asset.name.lower()
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
# Filter by target name
|
|
302
|
+
if target_name:
|
|
303
|
+
target_lower = target_name.lower()
|
|
304
|
+
relationships = [
|
|
305
|
+
r for r in relationships
|
|
306
|
+
if (asset := asset_map.get(str(r.target_asset_id))) and
|
|
307
|
+
asset.name and target_lower in asset.name.lower()
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
def get_asset_name(asset_id):
|
|
311
|
+
asset = asset_map.get(str(asset_id))
|
|
312
|
+
return asset.name if asset else None
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
"total": len(relationships),
|
|
316
|
+
"relationships": [
|
|
317
|
+
{
|
|
318
|
+
"id": str(r.id),
|
|
319
|
+
"type": r.relationship_type,
|
|
320
|
+
"source_id": str(r.source_asset_id),
|
|
321
|
+
"source_name": get_asset_name(r.source_asset_id),
|
|
322
|
+
"target_id": str(r.target_asset_id),
|
|
323
|
+
"target_name": get_asset_name(r.target_asset_id),
|
|
324
|
+
"edge_kind": r.edge_kind.value if hasattr(r.edge_kind, 'value') else r.edge_kind,
|
|
325
|
+
}
|
|
326
|
+
for r in relationships[:max_relationships]
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@mcp.tool()
|
|
331
|
+
def get_scan_summary(snapshot_id: str | None = None) -> dict[str, Any]:
|
|
332
|
+
"""
|
|
333
|
+
Get summary of the latest AWS scan.
|
|
334
|
+
|
|
335
|
+
Returns asset counts, finding counts, and attack path counts.
|
|
336
|
+
"""
|
|
337
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
338
|
+
session.set_snapshot(snapshot_id or (snapshot and str(snapshot.id)))
|
|
339
|
+
|
|
340
|
+
if not snapshot:
|
|
341
|
+
return mcp_error(
|
|
342
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"snapshot_id": str(snapshot.id),
|
|
347
|
+
"account_id": snapshot.aws_account_id,
|
|
348
|
+
"regions": snapshot.regions,
|
|
349
|
+
"status": snapshot.status,
|
|
350
|
+
"started_at": snapshot.started_at.isoformat(),
|
|
351
|
+
"asset_count": snapshot.asset_count,
|
|
352
|
+
"relationship_count": snapshot.relationship_count,
|
|
353
|
+
"finding_count": snapshot.finding_count,
|
|
354
|
+
"attack_path_count": snapshot.path_count,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@mcp.tool()
|
|
358
|
+
def set_session_snapshot(snapshot_id: str | None = None) -> dict[str, Any]:
|
|
359
|
+
"""
|
|
360
|
+
Set or retrieve the active snapshot id used for subsequent calls.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
snapshot_id: Optional scan id/directory name. If omitted, returns current/ latest.
|
|
364
|
+
"""
|
|
365
|
+
sid = session.set_snapshot(snapshot_id)
|
|
366
|
+
snap = session.get_snapshot(sid)
|
|
367
|
+
return {
|
|
368
|
+
"snapshot_id": str(snap.id) if snap else sid,
|
|
369
|
+
"active": sid,
|
|
370
|
+
"available_scans": session.storage.list_scans(),
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@mcp.tool()
|
|
374
|
+
def list_tools() -> dict[str, Any]:
|
|
375
|
+
"""
|
|
376
|
+
List all available Cyntrisec tools.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
List of tools with descriptions.
|
|
380
|
+
"""
|
|
381
|
+
return {
|
|
382
|
+
"tools": [
|
|
383
|
+
# Discovery & Session
|
|
384
|
+
{"name": "list_tools", "description": "List all available Cyntrisec tools"},
|
|
385
|
+
{"name": "set_session_snapshot", "description": "Set active snapshot for session"},
|
|
386
|
+
{"name": "get_scan_summary", "description": "Get summary of latest AWS scan"},
|
|
387
|
+
# Assets & Relationships
|
|
388
|
+
{"name": "get_assets", "description": "Get assets with optional type/name filtering"},
|
|
389
|
+
{"name": "get_relationships", "description": "Get relationships between assets"},
|
|
390
|
+
{"name": "get_findings", "description": "Get security findings with severity filtering"},
|
|
391
|
+
# Attack Paths
|
|
392
|
+
{"name": "get_attack_paths", "description": "Get discovered attack paths with risk scores"},
|
|
393
|
+
{"name": "explain_path", "description": "Get detailed breakdown of an attack path"},
|
|
394
|
+
{"name": "explain_finding", "description": "Get detailed explanation of a security finding"},
|
|
395
|
+
# Remediation
|
|
396
|
+
{"name": "get_remediations", "description": "Find optimal fixes for attack paths"},
|
|
397
|
+
{"name": "get_terraform_snippet", "description": "Get Terraform code for a remediation"},
|
|
398
|
+
# Access & Permissions
|
|
399
|
+
{"name": "check_access", "description": "Test if principal can access resource"},
|
|
400
|
+
{"name": "get_unused_permissions", "description": "Find unused IAM permissions"},
|
|
401
|
+
# Compliance & Diff
|
|
402
|
+
{"name": "check_compliance", "description": "Check CIS AWS or SOC 2 compliance"},
|
|
403
|
+
{"name": "compare_scans", "description": "Compare latest scan to previous"},
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _register_graph_tools(mcp, session):
|
|
409
|
+
"""Register graph analysis tools."""
|
|
410
|
+
|
|
411
|
+
@mcp.tool()
|
|
412
|
+
def get_attack_paths(
|
|
413
|
+
max_paths: int = 10,
|
|
414
|
+
min_risk: float = 0.0,
|
|
415
|
+
snapshot_id: str | None = None,
|
|
416
|
+
) -> dict[str, Any]:
|
|
417
|
+
"""
|
|
418
|
+
Get discovered attack paths from the latest scan.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
max_paths: Maximum number of paths to return (default: 10)
|
|
422
|
+
min_risk: Minimum risk score filter (0.0-1.0, default: 0.0)
|
|
423
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
List of attack paths with risk scores, confidence, and traversed assets.
|
|
427
|
+
"""
|
|
428
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
429
|
+
if not snapshot:
|
|
430
|
+
return mcp_error(
|
|
431
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
paths = session.get_paths(snapshot_id)
|
|
435
|
+
assets = session.get_assets(snapshot_id)
|
|
436
|
+
session.set_snapshot(snapshot_id)
|
|
437
|
+
|
|
438
|
+
# Build asset lookup
|
|
439
|
+
asset_map = {str(a.id): a for a in assets}
|
|
440
|
+
|
|
441
|
+
# Filter by min risk
|
|
442
|
+
if min_risk > 0:
|
|
443
|
+
paths = [p for p in paths if p.risk_score >= min_risk]
|
|
444
|
+
|
|
445
|
+
def get_asset_name(asset_id):
|
|
446
|
+
if not asset_id:
|
|
447
|
+
return None
|
|
448
|
+
asset = asset_map.get(str(asset_id))
|
|
449
|
+
return asset.name if asset else str(asset_id)
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
"total": len(paths),
|
|
453
|
+
"paths": [
|
|
454
|
+
{
|
|
455
|
+
"id": str(p.id),
|
|
456
|
+
"attack_vector": p.attack_vector,
|
|
457
|
+
"risk_score": float(p.risk_score),
|
|
458
|
+
"confidence_level": (
|
|
459
|
+
p.confidence_level.value
|
|
460
|
+
if hasattr(p.confidence_level, "value")
|
|
461
|
+
else p.confidence_level
|
|
462
|
+
),
|
|
463
|
+
"source_name": get_asset_name(p.source_asset_id),
|
|
464
|
+
"target_name": get_asset_name(p.target_asset_id),
|
|
465
|
+
"path_length": len(p.path_asset_ids) if p.path_asset_ids else 0,
|
|
466
|
+
"path_assets": [get_asset_name(aid) for aid in (p.path_asset_ids or [])],
|
|
467
|
+
}
|
|
468
|
+
for p in paths[:max_paths]
|
|
469
|
+
],
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
@mcp.tool()
|
|
473
|
+
def explain_path(path_id: str, snapshot_id: str | None = None) -> dict[str, Any]:
|
|
474
|
+
"""
|
|
475
|
+
Get detailed explanation of an attack path.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
path_id: The attack path ID to explain
|
|
479
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Detailed breakdown of the attack path with each hop explained.
|
|
483
|
+
"""
|
|
484
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
485
|
+
if not snapshot:
|
|
486
|
+
return mcp_error(
|
|
487
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
paths = session.get_paths(snapshot_id)
|
|
491
|
+
assets = session.get_assets(snapshot_id)
|
|
492
|
+
relationships = session.get_relationships(snapshot_id)
|
|
493
|
+
session.set_snapshot(snapshot_id)
|
|
494
|
+
|
|
495
|
+
# Find the path
|
|
496
|
+
target_path = None
|
|
497
|
+
for p in paths:
|
|
498
|
+
if str(p.id) == path_id:
|
|
499
|
+
target_path = p
|
|
500
|
+
break
|
|
501
|
+
|
|
502
|
+
if not target_path:
|
|
503
|
+
return mcp_error("PATH_NOT_FOUND", f"Attack path {path_id} not found.")
|
|
504
|
+
|
|
505
|
+
# Build lookups
|
|
506
|
+
asset_map = {str(a.id): a for a in assets}
|
|
507
|
+
rel_map = {str(r.id): r for r in relationships}
|
|
508
|
+
|
|
509
|
+
# Build path explanation
|
|
510
|
+
hops = []
|
|
511
|
+
path_asset_ids = target_path.path_asset_ids or []
|
|
512
|
+
path_rel_ids = target_path.attack_chain_relationship_ids or []
|
|
513
|
+
|
|
514
|
+
for i, asset_id in enumerate(path_asset_ids):
|
|
515
|
+
asset = asset_map.get(str(asset_id))
|
|
516
|
+
hop = {
|
|
517
|
+
"step": i + 1,
|
|
518
|
+
"asset_name": asset.name if asset else str(asset_id),
|
|
519
|
+
"asset_type": asset.asset_type if asset else None,
|
|
520
|
+
"asset_arn": asset.arn if asset else None,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
# Add relationship to next hop if exists
|
|
524
|
+
if i < len(path_rel_ids):
|
|
525
|
+
rel = rel_map.get(str(path_rel_ids[i]))
|
|
526
|
+
if rel:
|
|
527
|
+
hop["next_via"] = rel.relationship_type
|
|
528
|
+
if rel.evidence and rel.evidence.permission:
|
|
529
|
+
hop["permission"] = rel.evidence.permission
|
|
530
|
+
|
|
531
|
+
hops.append(hop)
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
"path_id": path_id,
|
|
535
|
+
"attack_vector": target_path.attack_vector,
|
|
536
|
+
"risk_score": float(target_path.risk_score),
|
|
537
|
+
"confidence_level": (
|
|
538
|
+
target_path.confidence_level.value
|
|
539
|
+
if hasattr(target_path.confidence_level, "value")
|
|
540
|
+
else target_path.confidence_level
|
|
541
|
+
),
|
|
542
|
+
"summary": f"Attack path from {hops[0]['asset_name'] if hops else 'unknown'} to {hops[-1]['asset_name'] if hops else 'unknown'} via {len(hops)} hops",
|
|
543
|
+
"hops": hops,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@mcp.tool()
|
|
547
|
+
def explain_finding(finding_id: str, snapshot_id: str | None = None) -> dict[str, Any]:
|
|
548
|
+
"""
|
|
549
|
+
Get detailed explanation of a security finding.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
finding_id: The finding ID to explain
|
|
553
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
Detailed explanation with context, impact, and remediation steps.
|
|
557
|
+
"""
|
|
558
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
559
|
+
if not snapshot:
|
|
560
|
+
return mcp_error(
|
|
561
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
findings = session.get_findings(snapshot_id)
|
|
565
|
+
session.set_snapshot(snapshot_id)
|
|
566
|
+
|
|
567
|
+
# Find the finding
|
|
568
|
+
target_finding = None
|
|
569
|
+
for f in findings:
|
|
570
|
+
if str(f.id) == finding_id:
|
|
571
|
+
target_finding = f
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
if not target_finding:
|
|
575
|
+
return mcp_error("FINDING_NOT_FOUND", f"Finding {finding_id} not found.")
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
"finding_id": finding_id,
|
|
579
|
+
"title": target_finding.title,
|
|
580
|
+
"severity": target_finding.severity,
|
|
581
|
+
"finding_type": target_finding.finding_type,
|
|
582
|
+
"asset_id": str(target_finding.asset_id),
|
|
583
|
+
"description": target_finding.description,
|
|
584
|
+
"impact": f"This {target_finding.severity} severity finding affects asset {target_finding.asset_id}",
|
|
585
|
+
"remediation": target_finding.remediation,
|
|
586
|
+
"evidence": target_finding.evidence if hasattr(target_finding, 'evidence') else {},
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@mcp.tool()
|
|
590
|
+
def check_access(
|
|
591
|
+
principal: str, resource: str, snapshot_id: str | None = None
|
|
592
|
+
) -> dict[str, Any]:
|
|
593
|
+
"""
|
|
594
|
+
Test if a principal can access a resource.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
principal: IAM role or user name (e.g., "ECforS")
|
|
598
|
+
resource: Target resource (e.g., "s3://prod-bucket")
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
Whether access is allowed and via which relationship.
|
|
602
|
+
"""
|
|
603
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
604
|
+
assets = session.get_assets(snapshot_id)
|
|
605
|
+
relationships = session.get_relationships(snapshot_id)
|
|
606
|
+
session.set_snapshot(snapshot_id or (snapshot and str(snapshot.id)))
|
|
607
|
+
|
|
608
|
+
if not snapshot:
|
|
609
|
+
return mcp_error(MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found.")
|
|
610
|
+
|
|
611
|
+
# OfflineSimulator takes assets and relationships, not a graph
|
|
612
|
+
simulator = OfflineSimulator(assets=assets, relationships=relationships)
|
|
613
|
+
result = simulator.can_access(principal, resource)
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
"principal": result.principal_arn,
|
|
617
|
+
"resource": result.target_resource,
|
|
618
|
+
"can_access": result.can_access,
|
|
619
|
+
"via": result.proof.get("relationship_type", None),
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _register_insight_tools(mcp, session):
|
|
624
|
+
"""Register insight and remediation tools."""
|
|
625
|
+
|
|
626
|
+
@mcp.tool()
|
|
627
|
+
def get_remediations(max_cuts: int = 5, snapshot_id: str | None = None) -> dict[str, Any]:
|
|
628
|
+
"""
|
|
629
|
+
Find optimal remediations that block attack paths.
|
|
630
|
+
|
|
631
|
+
Uses min-cut algorithm to find smallest set of changes
|
|
632
|
+
that block all attack paths.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
max_cuts: Maximum number of remediations (default: 5)
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
List of remediations with coverage percentages.
|
|
639
|
+
"""
|
|
640
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
641
|
+
if not snapshot:
|
|
642
|
+
return mcp_error(
|
|
643
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
assets = session.get_assets(snapshot_id)
|
|
647
|
+
relationships = session.get_relationships(snapshot_id)
|
|
648
|
+
paths = session.get_paths(snapshot_id)
|
|
649
|
+
session.set_snapshot(snapshot_id)
|
|
650
|
+
|
|
651
|
+
if not paths:
|
|
652
|
+
return {"total_paths": 0, "remediations": []}
|
|
653
|
+
|
|
654
|
+
graph = GraphBuilder().build(assets=assets, relationships=relationships)
|
|
655
|
+
finder = MinCutFinder()
|
|
656
|
+
result = finder.find_cuts(graph, paths, max_cuts=max_cuts)
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
"total_paths": result.total_paths,
|
|
660
|
+
"paths_blocked": result.paths_blocked,
|
|
661
|
+
"coverage": float(result.coverage),
|
|
662
|
+
"remediations": [
|
|
663
|
+
{
|
|
664
|
+
"source": r.source_name,
|
|
665
|
+
"target": r.target_name,
|
|
666
|
+
"relationship_type": r.relationship_type,
|
|
667
|
+
"paths_blocked": len(r.paths_blocked),
|
|
668
|
+
"recommendation": r.description,
|
|
669
|
+
"estimated_savings": float(r.cost_savings),
|
|
670
|
+
"roi_score": float(r.roi_score),
|
|
671
|
+
}
|
|
672
|
+
for r in result.remediations
|
|
673
|
+
],
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
@mcp.tool()
|
|
677
|
+
def get_terraform_snippet(
|
|
678
|
+
source_name: str,
|
|
679
|
+
target_name: str,
|
|
680
|
+
relationship_type: str,
|
|
681
|
+
snapshot_id: str | None = None,
|
|
682
|
+
) -> dict[str, Any]:
|
|
683
|
+
"""
|
|
684
|
+
Get Terraform code snippet for a specific remediation.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
source_name: Name of the source asset
|
|
688
|
+
target_name: Name of the target asset
|
|
689
|
+
relationship_type: Type of relationship (e.g., "CAN_ASSUME", "ALLOWS_TRAFFIC_TO")
|
|
690
|
+
snapshot_id: Optional snapshot ID (default: latest)
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
Terraform HCL code snippet for the remediation.
|
|
694
|
+
"""
|
|
695
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
696
|
+
if not snapshot:
|
|
697
|
+
return mcp_error(
|
|
698
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
assets = session.get_assets(snapshot_id)
|
|
702
|
+
session.set_snapshot(snapshot_id)
|
|
703
|
+
|
|
704
|
+
# Find source and target assets to get ARNs
|
|
705
|
+
source_asset = None
|
|
706
|
+
target_asset = None
|
|
707
|
+
for a in assets:
|
|
708
|
+
if a.name and source_name.lower() in a.name.lower():
|
|
709
|
+
source_asset = a
|
|
710
|
+
if a.name and target_name.lower() in a.name.lower():
|
|
711
|
+
target_asset = a
|
|
712
|
+
|
|
713
|
+
terraform_code = _terraform_snippet(
|
|
714
|
+
action="restrict",
|
|
715
|
+
source=source_name,
|
|
716
|
+
target=target_name,
|
|
717
|
+
relationship_type=relationship_type.upper(),
|
|
718
|
+
source_arn=source_asset.arn if source_asset else None,
|
|
719
|
+
target_arn=target_asset.arn if target_asset else None,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
"source": source_name,
|
|
724
|
+
"target": target_name,
|
|
725
|
+
"relationship_type": relationship_type,
|
|
726
|
+
"terraform": terraform_code,
|
|
727
|
+
"note": "Review and customize this snippet before applying. This is a starting point.",
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
@mcp.tool()
|
|
731
|
+
def get_unused_permissions(
|
|
732
|
+
days_threshold: int = 90, snapshot_id: str | None = None
|
|
733
|
+
) -> dict[str, Any]:
|
|
734
|
+
"""
|
|
735
|
+
Find unused IAM permissions (blast radius reduction opportunities).
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
days_threshold: Days of inactivity to consider unused
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Unused permissions grouped by role with reduction percentages.
|
|
742
|
+
"""
|
|
743
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
744
|
+
if not snapshot:
|
|
745
|
+
return mcp_error(
|
|
746
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
assets = session.get_assets(snapshot_id)
|
|
750
|
+
session.set_snapshot(snapshot_id)
|
|
751
|
+
|
|
752
|
+
# WasteAnalyzer takes only days_threshold, then analyze_from_assets takes assets
|
|
753
|
+
analyzer = WasteAnalyzer(days_threshold=days_threshold)
|
|
754
|
+
report = analyzer.analyze_from_assets(assets=assets)
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
"total_unused": report.total_unused,
|
|
758
|
+
"total_reduction": float(report.blast_radius_reduction),
|
|
759
|
+
"roles": [
|
|
760
|
+
{
|
|
761
|
+
"role_name": r.role_name,
|
|
762
|
+
"unused_count": r.unused_services,
|
|
763
|
+
"blast_radius_reduction": float(r.blast_radius_reduction),
|
|
764
|
+
}
|
|
765
|
+
for r in report.role_reports[:10]
|
|
766
|
+
],
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
@mcp.tool()
|
|
770
|
+
def check_compliance(
|
|
771
|
+
framework: str = "cis-aws", snapshot_id: str | None = None
|
|
772
|
+
) -> dict[str, Any]:
|
|
773
|
+
"""
|
|
774
|
+
Check compliance against CIS AWS or SOC 2 framework.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
framework: "cis-aws" or "soc2"
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
Compliance score and failing controls.
|
|
781
|
+
"""
|
|
782
|
+
snapshot = session.get_snapshot(snapshot_id)
|
|
783
|
+
if not snapshot:
|
|
784
|
+
return mcp_error(
|
|
785
|
+
MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
findings = session.get_findings(snapshot_id)
|
|
789
|
+
assets = session.get_assets(snapshot_id)
|
|
790
|
+
session.set_snapshot(snapshot_id)
|
|
791
|
+
|
|
792
|
+
fw = Framework.CIS_AWS if "cis" in framework.lower() else Framework.SOC2
|
|
793
|
+
checker = ComplianceChecker()
|
|
794
|
+
report = checker.check(findings, assets, framework=fw, collection_errors=snapshot.errors)
|
|
795
|
+
summary = checker.summary(report)
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
"framework": fw.value,
|
|
799
|
+
"compliance_score": summary["compliance_score"],
|
|
800
|
+
"passing": summary["passing"],
|
|
801
|
+
"failing": summary["failing"],
|
|
802
|
+
"failing_controls": [
|
|
803
|
+
{"id": r.control.id, "title": r.control.title}
|
|
804
|
+
for r in report.results
|
|
805
|
+
if not r.is_passing
|
|
806
|
+
],
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
@mcp.tool()
|
|
810
|
+
def compare_scans() -> dict[str, Any]:
|
|
811
|
+
"""
|
|
812
|
+
Compare latest scan to previous scan.
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
Changes in assets, relationships, and attack paths.
|
|
816
|
+
"""
|
|
817
|
+
scan_ids = session.storage.list_scans()
|
|
818
|
+
|
|
819
|
+
if len(scan_ids) < 2:
|
|
820
|
+
return mcp_error(MCP_ERROR_INSUFFICIENT_DATA, "Need at least 2 scans to compare.")
|
|
821
|
+
|
|
822
|
+
new_id, old_id = scan_ids[0], scan_ids[1]
|
|
823
|
+
|
|
824
|
+
old_snapshot = session.storage.get_snapshot(old_id)
|
|
825
|
+
new_snapshot = session.storage.get_snapshot(new_id)
|
|
826
|
+
if not old_snapshot or not new_snapshot:
|
|
827
|
+
return mcp_error(MCP_ERROR_SNAPSHOT_NOT_FOUND, "Could not load snapshots for comparison.")
|
|
828
|
+
|
|
829
|
+
differ = SnapshotDiff()
|
|
830
|
+
result = differ.diff(
|
|
831
|
+
old_assets=session.storage.get_assets(old_id),
|
|
832
|
+
old_relationships=session.storage.get_relationships(old_id),
|
|
833
|
+
old_paths=session.storage.get_attack_paths(old_id),
|
|
834
|
+
old_findings=session.storage.get_findings(old_id),
|
|
835
|
+
new_assets=session.storage.get_assets(new_id),
|
|
836
|
+
new_relationships=session.storage.get_relationships(new_id),
|
|
837
|
+
new_paths=session.storage.get_attack_paths(new_id),
|
|
838
|
+
new_findings=session.storage.get_findings(new_id),
|
|
839
|
+
old_snapshot_id=old_snapshot.id,
|
|
840
|
+
new_snapshot_id=new_snapshot.id,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
return {
|
|
844
|
+
"has_regressions": result.has_regressions,
|
|
845
|
+
"has_improvements": result.has_improvements,
|
|
846
|
+
"summary": result.summary,
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def run_mcp_server():
|
|
851
|
+
"""Run the MCP server with stdio transport."""
|
|
852
|
+
if not HAS_MCP:
|
|
853
|
+
print("Error: MCP SDK not installed. Run: pip install mcp", file=sys.stderr)
|
|
854
|
+
sys.exit(1)
|
|
855
|
+
|
|
856
|
+
# Configure logging to stderr to avoid corrupting stdio
|
|
857
|
+
logging.basicConfig(
|
|
858
|
+
level=logging.WARNING, stream=sys.stderr, format="%(levelname)s: %(message)s"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
mcp = create_mcp_server()
|
|
862
|
+
mcp.run(transport="stdio")
|