security-controls-mcp 0.2.0__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.
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env python3
2
+ """HTTP Server Entry Point for Security Controls MCP.
3
+
4
+ This provides HTTP transport (Server-Sent Events) for remote MCP clients.
5
+ Compatible with Ansvar platform's HTTP MCP client.
6
+ """
7
+ import json
8
+ import os
9
+ from typing import Dict
10
+
11
+ import uvicorn
12
+ from mcp.server import Server
13
+ from mcp.types import TextContent, Tool
14
+ from starlette.applications import Starlette
15
+ from starlette.responses import JSONResponse, StreamingResponse
16
+ from starlette.routing import Route
17
+
18
+ from .data_loader import SCFData
19
+ from .legal_notice import print_legal_notice
20
+
21
+ # Initialize data loader
22
+ scf_data = SCFData()
23
+
24
+ # Create MCP server instance
25
+ mcp_server = Server("security-controls-mcp")
26
+
27
+
28
+ @mcp_server.list_tools()
29
+ async def list_tools() -> list[Tool]:
30
+ """List available tools."""
31
+ return [
32
+ Tool(
33
+ name="get_control",
34
+ description="Get details about a specific SCF control by its ID (e.g., GOV-01, IAC-05)",
35
+ inputSchema={
36
+ "type": "object",
37
+ "properties": {
38
+ "control_id": {
39
+ "type": "string",
40
+ "description": "SCF control ID (e.g., GOV-01)",
41
+ },
42
+ "include_mappings": {
43
+ "type": "boolean",
44
+ "description": "Include framework mappings (default: true)",
45
+ "default": True,
46
+ },
47
+ },
48
+ "required": ["control_id"],
49
+ },
50
+ ),
51
+ Tool(
52
+ name="search_controls",
53
+ description=(
54
+ "Search for controls by keyword in name or description. "
55
+ "Returns relevant controls with snippets."
56
+ ),
57
+ inputSchema={
58
+ "type": "object",
59
+ "properties": {
60
+ "query": {
61
+ "type": "string",
62
+ "description": (
63
+ "Search query (e.g., 'encryption', 'access control', "
64
+ "'incident response')"
65
+ ),
66
+ },
67
+ "frameworks": {
68
+ "type": "array",
69
+ "items": {"type": "string"},
70
+ "description": (
71
+ "Optional: filter to controls that map to specific frameworks"
72
+ ),
73
+ },
74
+ "limit": {
75
+ "type": "integer",
76
+ "description": "Maximum number of results (default: 10)",
77
+ "default": 10,
78
+ },
79
+ },
80
+ "required": ["query"],
81
+ },
82
+ ),
83
+ Tool(
84
+ name="list_frameworks",
85
+ description="List all available security frameworks with metadata",
86
+ inputSchema={
87
+ "type": "object",
88
+ "properties": {
89
+ "detailed": {
90
+ "type": "boolean",
91
+ "description": "Include detailed information (default: false)",
92
+ "default": False,
93
+ },
94
+ },
95
+ },
96
+ ),
97
+ Tool(
98
+ name="get_framework_controls",
99
+ description="Get all SCF controls that map to a specific framework",
100
+ inputSchema={
101
+ "type": "object",
102
+ "properties": {
103
+ "framework": {
104
+ "type": "string",
105
+ "description": "Framework key (e.g., dora, iso_27001_2022, nist_csf_2_0)",
106
+ },
107
+ "include_descriptions": {
108
+ "type": "boolean",
109
+ "description": (
110
+ "Include control descriptions "
111
+ "(increases token usage, default: false)"
112
+ ),
113
+ "default": False,
114
+ },
115
+ },
116
+ "required": ["framework"],
117
+ },
118
+ ),
119
+ Tool(
120
+ name="map_frameworks",
121
+ description=(
122
+ "Map controls between two frameworks via SCF. Shows which target "
123
+ "framework requirements are satisfied by source framework controls."
124
+ ),
125
+ inputSchema={
126
+ "type": "object",
127
+ "properties": {
128
+ "source_framework": {
129
+ "type": "string",
130
+ "description": (
131
+ "Source framework key (what you HAVE, e.g., iso_27001_2022)"
132
+ ),
133
+ },
134
+ "source_control": {
135
+ "type": "string",
136
+ "description": (
137
+ "Optional: specific source control ID (e.g., A.5.15) "
138
+ "to filter results"
139
+ ),
140
+ },
141
+ "target_framework": {
142
+ "type": "string",
143
+ "description": (
144
+ "Target framework key (what you want to SATISFY, e.g., dora)"
145
+ ),
146
+ },
147
+ },
148
+ "required": ["source_framework", "target_framework"],
149
+ },
150
+ ),
151
+ ]
152
+
153
+
154
+ @mcp_server.call_tool()
155
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
156
+ """Handle tool calls."""
157
+
158
+ if name == "get_control":
159
+ control_id = arguments["control_id"]
160
+ include_mappings = arguments.get("include_mappings", True)
161
+
162
+ control = scf_data.get_control(control_id)
163
+ if not control:
164
+ return [
165
+ TextContent(
166
+ type="text",
167
+ text=f"Control {control_id} not found. Use search_controls to find controls.",
168
+ )
169
+ ]
170
+
171
+ response = {
172
+ "id": control["id"],
173
+ "domain": control["domain"],
174
+ "name": control["name"],
175
+ "description": control["description"],
176
+ "weight": control["weight"],
177
+ "pptdf": control["pptdf"],
178
+ "validation_cadence": control["validation_cadence"],
179
+ }
180
+
181
+ if include_mappings:
182
+ response["framework_mappings"] = control["framework_mappings"]
183
+
184
+ # Format response
185
+ text = f"**{response['id']}: {response['name']}**\n\n"
186
+ text += f"**Domain:** {response['domain']}\n"
187
+ text += f"**Description:** {response['description']}\n\n"
188
+ text += f"**Weight:** {response['weight']}/10\n"
189
+ text += f"**PPTDF:** {response['pptdf']}\n"
190
+ text += f"**Validation Cadence:** {response['validation_cadence']}\n"
191
+
192
+ if include_mappings:
193
+ text += "\n**Framework Mappings:**\n"
194
+ for fw_key, mappings in response["framework_mappings"].items():
195
+ if mappings:
196
+ fw_name = scf_data.frameworks.get(fw_key, {}).get("name", fw_key)
197
+ text += f"- **{fw_name}:** {', '.join(mappings)}\n"
198
+
199
+ return [TextContent(type="text", text=text)]
200
+
201
+ elif name == "search_controls":
202
+ query = arguments["query"]
203
+ frameworks = arguments.get("frameworks")
204
+ limit = arguments.get("limit", 10)
205
+
206
+ results = scf_data.search_controls(query, frameworks, limit)
207
+
208
+ if not results:
209
+ return [
210
+ TextContent(
211
+ type="text",
212
+ text=f"No controls found matching '{query}'. Try different keywords.",
213
+ )
214
+ ]
215
+
216
+ text = f"**Found {len(results)} control(s) matching '{query}'**\n\n"
217
+ for result in results:
218
+ text += f"**{result['control_id']}: {result['name']}**\n"
219
+ text += f"{result['snippet']}\n"
220
+ text += f"*Mapped to: {', '.join(result['mapped_frameworks'][:5])}*\n\n"
221
+
222
+ return [TextContent(type="text", text=text)]
223
+
224
+ elif name == "list_frameworks":
225
+ frameworks = list(scf_data.frameworks.values())
226
+ frameworks.sort(key=lambda x: x["controls_mapped"], reverse=True)
227
+
228
+ text = f"**Available Frameworks ({len(frameworks)} total)**\n\n"
229
+ for fw in frameworks:
230
+ text += f"- **{fw['key']}**: {fw['name']} ({fw['controls_mapped']} controls)\n"
231
+
232
+ return [TextContent(type="text", text=text)]
233
+
234
+ elif name == "get_framework_controls":
235
+ framework = arguments["framework"]
236
+ include_descriptions = arguments.get("include_descriptions", False)
237
+
238
+ if framework not in scf_data.frameworks:
239
+ available = ", ".join(scf_data.frameworks.keys())
240
+ return [
241
+ TextContent(
242
+ type="text",
243
+ text=f"Framework '{framework}' not found. Available: {available}",
244
+ )
245
+ ]
246
+
247
+ controls = scf_data.get_framework_controls(framework, include_descriptions)
248
+
249
+ fw_info = scf_data.frameworks[framework]
250
+ text = f"**{fw_info['name']}**\n"
251
+ text += f"**Total Controls:** {len(controls)}\n\n"
252
+
253
+ # Group by domain for readability
254
+ by_domain: Dict[str, list] = {}
255
+ for ctrl in controls:
256
+ # Get full control to get domain
257
+ full_ctrl = scf_data.get_control(ctrl["scf_id"])
258
+ if full_ctrl:
259
+ domain = full_ctrl["domain"]
260
+ if domain not in by_domain:
261
+ by_domain[domain] = []
262
+ by_domain[domain].append(ctrl)
263
+
264
+ for domain, domain_ctrls in sorted(by_domain.items()):
265
+ text += f"\n**{domain}**\n"
266
+ for ctrl in domain_ctrls[:10]: # Limit per domain for readability
267
+ text += f"- **{ctrl['scf_id']}**: {ctrl['scf_name']}\n"
268
+ text += f" Maps to: {', '.join(ctrl['framework_control_ids'][:5])}\n"
269
+ if include_descriptions:
270
+ text += f" {ctrl['description'][:100]}...\n"
271
+
272
+ if len(domain_ctrls) > 10:
273
+ text += f" *... and {len(domain_ctrls) - 10} more controls*\n"
274
+
275
+ return [TextContent(type="text", text=text)]
276
+
277
+ elif name == "map_frameworks":
278
+ source_framework = arguments["source_framework"]
279
+ target_framework = arguments["target_framework"]
280
+ source_control = arguments.get("source_control")
281
+
282
+ # Validate frameworks exist
283
+ if source_framework not in scf_data.frameworks:
284
+ available = ", ".join(scf_data.frameworks.keys())
285
+ return [
286
+ TextContent(
287
+ type="text",
288
+ text=f"Source framework '{source_framework}' not found. Available: {available}",
289
+ )
290
+ ]
291
+
292
+ if target_framework not in scf_data.frameworks:
293
+ available = ", ".join(scf_data.frameworks.keys())
294
+ return [
295
+ TextContent(
296
+ type="text",
297
+ text=f"Target framework '{target_framework}' not found. Available: {available}",
298
+ )
299
+ ]
300
+
301
+ mappings = scf_data.map_frameworks(source_framework, target_framework, source_control)
302
+
303
+ if not mappings:
304
+ return [
305
+ TextContent(
306
+ type="text",
307
+ text=f"No mappings found between {source_framework} and {target_framework}",
308
+ )
309
+ ]
310
+
311
+ source_name = scf_data.frameworks[source_framework]["name"]
312
+ target_name = scf_data.frameworks[target_framework]["name"]
313
+
314
+ text = f"**Mapping: {source_name} → {target_name}**\n"
315
+ if source_control:
316
+ text += f"**Filtered to source control: {source_control}**\n"
317
+ text += f"**Found {len(mappings)} SCF controls**\n\n"
318
+
319
+ for mapping in mappings[:20]: # Limit for readability
320
+ text += (
321
+ f"**{mapping['scf_id']}: {mapping['scf_name']}** (weight: {mapping['weight']})\n"
322
+ )
323
+ text += f"- Source ({source_framework}): {', '.join(mapping['source_controls'][:5])}\n"
324
+ if mapping["target_controls"]:
325
+ text += (
326
+ f"- Target ({target_framework}): {', '.join(mapping['target_controls'][:5])}\n"
327
+ )
328
+ else:
329
+ text += f"- Target ({target_framework}): *No direct mapping*\n"
330
+ text += "\n"
331
+
332
+ if len(mappings) > 20:
333
+ text += f"\n*Showing first 20 of {len(mappings)} mappings*\n"
334
+
335
+ return [TextContent(type="text", text=text)]
336
+
337
+ else:
338
+ raise ValueError(f"Unknown tool: {name}")
339
+
340
+
341
+ async def health_check(request):
342
+ """Health check endpoint."""
343
+ return JSONResponse(
344
+ {
345
+ "status": "ok",
346
+ "server": "security-controls-mcp",
347
+ "database_version": "SCF 2025.4",
348
+ "controls_count": len(scf_data.controls),
349
+ "frameworks_count": len(scf_data.frameworks),
350
+ }
351
+ )
352
+
353
+
354
+ async def mcp_endpoint(request):
355
+ """MCP endpoint - accepts JSON-RPC requests."""
356
+ try:
357
+ # Parse JSON-RPC request
358
+ body = await request.json()
359
+ method = body.get("method")
360
+ params = body.get("params", {})
361
+ request_id = body.get("id", 1)
362
+
363
+ # Handle initialize
364
+ if method == "initialize":
365
+ response = {
366
+ "jsonrpc": "2.0",
367
+ "id": request_id,
368
+ "result": {
369
+ "protocolVersion": "2024-11-05",
370
+ "capabilities": {"tools": {}},
371
+ "serverInfo": {"name": "security-controls-mcp", "version": "0.1.0"},
372
+ },
373
+ }
374
+ return StreamingResponse(
375
+ iter([f"event: message\ndata: {json.dumps(response)}\n\n"]),
376
+ media_type="text/event-stream",
377
+ )
378
+
379
+ # Handle list tools
380
+ elif method == "tools/list":
381
+ tools = await list_tools()
382
+ response = {
383
+ "jsonrpc": "2.0",
384
+ "id": request_id,
385
+ "result": {
386
+ "tools": [
387
+ {
388
+ "name": tool.name,
389
+ "description": tool.description,
390
+ "inputSchema": tool.inputSchema,
391
+ }
392
+ for tool in tools
393
+ ]
394
+ },
395
+ }
396
+ return StreamingResponse(
397
+ iter([f"event: message\ndata: {json.dumps(response)}\n\n"]),
398
+ media_type="text/event-stream",
399
+ )
400
+
401
+ # Handle tool call
402
+ elif method == "tools/call":
403
+ tool_name = params.get("name")
404
+ arguments = params.get("arguments", {})
405
+
406
+ # Call the tool
407
+ result = await call_tool(tool_name, arguments)
408
+
409
+ response = {
410
+ "jsonrpc": "2.0",
411
+ "id": request_id,
412
+ "result": {"content": [{"type": "text", "text": item.text} for item in result]},
413
+ }
414
+ return StreamingResponse(
415
+ iter([f"event: message\ndata: {json.dumps(response)}\n\n"]),
416
+ media_type="text/event-stream",
417
+ )
418
+
419
+ else:
420
+ # Unknown method
421
+ response = {
422
+ "jsonrpc": "2.0",
423
+ "id": request_id,
424
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
425
+ }
426
+ return StreamingResponse(
427
+ iter([f"event: message\ndata: {json.dumps(response)}\n\n"]),
428
+ media_type="text/event-stream",
429
+ )
430
+
431
+ except Exception as e:
432
+ # Error response
433
+ response = {
434
+ "jsonrpc": "2.0",
435
+ "id": 1,
436
+ "error": {"code": -32603, "message": f"Internal error: {str(e)}"},
437
+ }
438
+ return StreamingResponse(
439
+ iter([f"event: message\ndata: {json.dumps(response)}\n\n"]),
440
+ media_type="text/event-stream",
441
+ status_code=500,
442
+ )
443
+
444
+
445
+ # Starlette app
446
+ app = Starlette(
447
+ routes=[
448
+ Route("/health", health_check),
449
+ Route("/mcp", mcp_endpoint, methods=["POST"]),
450
+ ],
451
+ )
452
+
453
+
454
+ def main():
455
+ """Start HTTP server."""
456
+ # Display legal notice on startup
457
+ print_legal_notice()
458
+
459
+ # Get port from environment
460
+ port = int(os.getenv("PORT", "3000"))
461
+
462
+ print(f"\n✓ Security Controls MCP HTTP server starting on port {port}")
463
+ print(
464
+ f"✓ Loaded {len(scf_data.controls)} controls across {len(scf_data.frameworks)} frameworks\n"
465
+ )
466
+
467
+ # Run server
468
+ uvicorn.run(
469
+ app,
470
+ host="0.0.0.0",
471
+ port=port,
472
+ log_level="info",
473
+ )
474
+
475
+
476
+ if __name__ == "__main__":
477
+ main()
@@ -0,0 +1,82 @@
1
+ """Legal compliance notices for SCF data usage."""
2
+
3
+ import sys
4
+
5
+ LEGAL_NOTICE = """
6
+ ╔════════════════════════════════════════════════════════════════════════════╗
7
+ ║ SECURITY CONTROLS MCP SERVER ║
8
+ ║ LEGAL USAGE NOTICE ║
9
+ ╚════════════════════════════════════════════════════════════════════════════╝
10
+
11
+ This server provides Secure Controls Framework (SCF) data licensed under
12
+ CC BY-ND 4.0 by ComplianceForge.
13
+
14
+ ⚠️ AI DERIVATIVE CONTENT RESTRICTION:
15
+
16
+ The SCF license PROHIBITS using AI systems (including Claude) to generate
17
+ derivative content such as policies, standards, procedures, or metrics
18
+ based on SCF controls.
19
+
20
+ ✓ PERMITTED USES:
21
+ • Query control details and mappings
22
+ • Map between frameworks (ISO 27001 → DORA, etc.)
23
+ • Reference controls in your work (with attribution)
24
+ • Understand compliance requirements
25
+
26
+ ✗ PROHIBITED USES:
27
+ • Asking Claude to write policies/procedures from SCF controls
28
+ • Creating derivative frameworks for distribution
29
+ • Generating automated compliance content using AI
30
+
31
+ Full terms: https://securecontrolsframework.com/terms-conditions/
32
+
33
+ This is not legal advice. Consult legal professionals for compliance guidance.
34
+
35
+ ════════════════════════════════════════════════════════════════════════════
36
+ """
37
+
38
+
39
+ def print_legal_notice(registry=None):
40
+ """Print legal notice to stderr on server startup.
41
+
42
+ Args:
43
+ registry: Optional StandardRegistry to show information about paid standards
44
+ """
45
+ print(LEGAL_NOTICE, file=sys.stderr)
46
+
47
+ # If paid standards are loaded, show additional notice
48
+ if registry and registry.has_paid_standards():
49
+ paid_notice = (
50
+ "\n╔════════════════════════════════════════════════════════════════════════════╗\n"
51
+ )
52
+ paid_notice += (
53
+ "║ PAID STANDARDS LOADED ║\n"
54
+ )
55
+ paid_notice += (
56
+ "╚════════════════════════════════════════════════════════════════════════════╝\n\n"
57
+ )
58
+
59
+ standards = registry.list_standards()
60
+ paid_standards = [s for s in standards if s["type"] == "paid"]
61
+
62
+ if paid_standards:
63
+ paid_notice += "Loaded purchased standards:\n\n"
64
+ for std in paid_standards:
65
+ paid_notice += f" ✓ {std['title']}\n"
66
+ paid_notice += f" License: {std['license']}\n"
67
+ paid_notice += f" Purchased: {std['purchase_date']}\n\n"
68
+
69
+ paid_notice += "⚠️ IMPORTANT LICENSE RESTRICTIONS:\n\n"
70
+ paid_notice += " • Licensed for your personal use only\n"
71
+ paid_notice += " • No redistribution or sharing of content\n"
72
+ paid_notice += (
73
+ " • Do not share extracted text or create derivatives for distribution\n"
74
+ )
75
+ paid_notice += " • Consult your purchase agreement for full terms\n\n"
76
+ paid_notice += "This tool provides query access only. Ensure your use complies with\n"
77
+ paid_notice += "all applicable license agreements.\n\n"
78
+ paid_notice += (
79
+ "════════════════════════════════════════════════════════════════════════════\n"
80
+ )
81
+
82
+ print(paid_notice, file=sys.stderr)