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,613 @@
1
+ """MCP server for security controls framework queries."""
2
+
3
+ import asyncio
4
+
5
+ from mcp.server import Server
6
+ from mcp.server.stdio import stdio_server
7
+ from mcp.types import TextContent, Tool
8
+
9
+ from .config import Config
10
+ from .data_loader import SCFData
11
+ from .legal_notice import print_legal_notice
12
+ from .registry import StandardRegistry
13
+
14
+ # Initialize data loader
15
+ scf_data = SCFData()
16
+
17
+ # Initialize configuration and registry for paid standards
18
+ config = Config()
19
+ registry = StandardRegistry(config)
20
+
21
+ # Create server instance
22
+ app = Server("security-controls-mcp")
23
+
24
+
25
+ @app.list_tools()
26
+ async def list_tools() -> list[Tool]:
27
+ """List available tools."""
28
+ return [
29
+ Tool(
30
+ name="get_control",
31
+ description="Get details about a specific SCF control by its ID (e.g., GOV-01, IAC-05)",
32
+ inputSchema={
33
+ "type": "object",
34
+ "properties": {
35
+ "control_id": {
36
+ "type": "string",
37
+ "description": "SCF control ID (e.g., GOV-01)",
38
+ },
39
+ "include_mappings": {
40
+ "type": "boolean",
41
+ "description": "Include framework mappings (default: true)",
42
+ "default": True,
43
+ },
44
+ },
45
+ "required": ["control_id"],
46
+ },
47
+ ),
48
+ Tool(
49
+ name="search_controls",
50
+ description=(
51
+ "Search for controls by keyword in name or description. "
52
+ "Returns relevant controls with snippets."
53
+ ),
54
+ inputSchema={
55
+ "type": "object",
56
+ "properties": {
57
+ "query": {
58
+ "type": "string",
59
+ "description": (
60
+ "Search query (e.g., 'encryption', 'access control', "
61
+ "'incident response')"
62
+ ),
63
+ },
64
+ "frameworks": {
65
+ "type": "array",
66
+ "items": {"type": "string"},
67
+ "description": (
68
+ "Optional: filter to controls that map to specific frameworks"
69
+ ),
70
+ },
71
+ "limit": {
72
+ "type": "integer",
73
+ "description": "Maximum number of results (default: 10)",
74
+ "default": 10,
75
+ },
76
+ },
77
+ "required": ["query"],
78
+ },
79
+ ),
80
+ Tool(
81
+ name="list_frameworks",
82
+ description="List all available security frameworks with metadata",
83
+ inputSchema={
84
+ "type": "object",
85
+ "properties": {
86
+ "detailed": {
87
+ "type": "boolean",
88
+ "description": "Include detailed information (default: false)",
89
+ "default": False,
90
+ },
91
+ },
92
+ },
93
+ ),
94
+ Tool(
95
+ name="get_framework_controls",
96
+ description="Get all SCF controls that map to a specific framework",
97
+ inputSchema={
98
+ "type": "object",
99
+ "properties": {
100
+ "framework": {
101
+ "type": "string",
102
+ "description": "Framework key (e.g., dora, iso_27001_2022, nist_csf_2_0)",
103
+ },
104
+ "include_descriptions": {
105
+ "type": "boolean",
106
+ "description": (
107
+ "Include control descriptions "
108
+ "(increases token usage, default: false)"
109
+ ),
110
+ "default": False,
111
+ },
112
+ },
113
+ "required": ["framework"],
114
+ },
115
+ ),
116
+ Tool(
117
+ name="map_frameworks",
118
+ description=(
119
+ "Map controls between two frameworks via SCF. Shows which target "
120
+ "framework requirements are satisfied by source framework controls."
121
+ ),
122
+ inputSchema={
123
+ "type": "object",
124
+ "properties": {
125
+ "source_framework": {
126
+ "type": "string",
127
+ "description": (
128
+ "Source framework key (what you HAVE, e.g., iso_27001_2022)"
129
+ ),
130
+ },
131
+ "source_control": {
132
+ "type": "string",
133
+ "description": (
134
+ "Optional: specific source control ID (e.g., A.5.15) "
135
+ "to filter results"
136
+ ),
137
+ },
138
+ "target_framework": {
139
+ "type": "string",
140
+ "description": (
141
+ "Target framework key (what you want to SATISFY, e.g., dora)"
142
+ ),
143
+ },
144
+ },
145
+ "required": ["source_framework", "target_framework"],
146
+ },
147
+ ),
148
+ Tool(
149
+ name="list_available_standards",
150
+ description=(
151
+ "List all available standards including SCF (built-in) and any "
152
+ "purchased standards the user has imported"
153
+ ),
154
+ inputSchema={
155
+ "type": "object",
156
+ "properties": {},
157
+ },
158
+ ),
159
+ Tool(
160
+ name="query_standard",
161
+ description=(
162
+ "Search for content within a specific purchased standard. "
163
+ "Requires the standard to be imported first."
164
+ ),
165
+ inputSchema={
166
+ "type": "object",
167
+ "properties": {
168
+ "standard": {
169
+ "type": "string",
170
+ "description": (
171
+ "Standard identifier (e.g., iso_27001_2022). "
172
+ "Use list_available_standards to see what's available."
173
+ ),
174
+ },
175
+ "query": {
176
+ "type": "string",
177
+ "description": (
178
+ "Search query (e.g., 'encryption key management', "
179
+ "'access control policy')"
180
+ ),
181
+ },
182
+ "limit": {
183
+ "type": "integer",
184
+ "description": "Maximum number of results (default: 10)",
185
+ "default": 10,
186
+ },
187
+ },
188
+ "required": ["standard", "query"],
189
+ },
190
+ ),
191
+ Tool(
192
+ name="get_clause",
193
+ description=(
194
+ "Get the full text of a specific clause/section from a purchased standard"
195
+ ),
196
+ inputSchema={
197
+ "type": "object",
198
+ "properties": {
199
+ "standard": {
200
+ "type": "string",
201
+ "description": "Standard identifier (e.g., iso_27001_2022)",
202
+ },
203
+ "clause_id": {
204
+ "type": "string",
205
+ "description": ("Clause/section identifier (e.g., '5.1.2', 'A.5.15')"),
206
+ },
207
+ },
208
+ "required": ["standard", "clause_id"],
209
+ },
210
+ ),
211
+ ]
212
+
213
+
214
+ @app.call_tool()
215
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
216
+ """Handle tool calls."""
217
+
218
+ if name == "get_control":
219
+ control_id = arguments["control_id"]
220
+ include_mappings = arguments.get("include_mappings", True)
221
+
222
+ control = scf_data.get_control(control_id)
223
+ if not control:
224
+ return [
225
+ TextContent(
226
+ type="text",
227
+ text=f"Control {control_id} not found. Use search_controls to find controls.",
228
+ )
229
+ ]
230
+
231
+ response = {
232
+ "id": control["id"],
233
+ "domain": control["domain"],
234
+ "name": control["name"],
235
+ "description": control["description"],
236
+ "weight": control["weight"],
237
+ "pptdf": control["pptdf"],
238
+ "validation_cadence": control["validation_cadence"],
239
+ }
240
+
241
+ if include_mappings:
242
+ response["framework_mappings"] = control["framework_mappings"]
243
+
244
+ # Format response
245
+ text = f"**{response['id']}: {response['name']}**\n\n"
246
+ text += f"**Domain:** {response['domain']}\n"
247
+ text += f"**Description:** {response['description']}\n\n"
248
+ text += f"**Weight:** {response['weight']}/10\n"
249
+ text += f"**PPTDF:** {response['pptdf']}\n"
250
+ text += f"**Validation Cadence:** {response['validation_cadence']}\n"
251
+
252
+ if include_mappings:
253
+ text += "\n**Framework Mappings:**\n"
254
+ for fw_key, mappings in response["framework_mappings"].items():
255
+ if mappings:
256
+ fw_name = scf_data.frameworks.get(fw_key, {}).get("name", fw_key)
257
+ text += f"- **{fw_name}:** {', '.join(mappings)}\n"
258
+
259
+ # Check if user has paid standards with official text for mapped frameworks
260
+ if include_mappings and registry.has_paid_standards():
261
+ official_texts = []
262
+
263
+ for fw_key, control_ids in response["framework_mappings"].items():
264
+ if not control_ids:
265
+ continue
266
+
267
+ # Check if we have a paid standard for this framework
268
+ provider = registry.get_provider(fw_key)
269
+ if not provider:
270
+ continue
271
+
272
+ # Try to get official text for the first mapped control ID
273
+ for control_id in control_ids[:1]: # Just show first mapping to avoid clutter
274
+ clause = provider.get_clause(control_id)
275
+ if clause:
276
+ metadata = provider.get_metadata()
277
+ official_texts.append(
278
+ {
279
+ "framework": fw_key,
280
+ "framework_name": scf_data.frameworks.get(fw_key, {}).get(
281
+ "name", fw_key
282
+ ),
283
+ "control_id": control_id,
284
+ "clause": clause,
285
+ "metadata": metadata,
286
+ }
287
+ )
288
+ break
289
+
290
+ # Display official texts if we found any
291
+ if official_texts:
292
+ text += "\n" + "=" * 80 + "\n"
293
+ text += "**📜 Official Text from Your Purchased Standards**\n"
294
+ text += "=" * 80 + "\n\n"
295
+
296
+ for item in official_texts:
297
+ text += f"### {item['framework_name']} - {item['control_id']}\n\n"
298
+ text += f"**{item['clause'].title}**\n\n"
299
+
300
+ # Show content (truncate if very long)
301
+ content = item["clause"].content
302
+ if len(content) > 1000:
303
+ content = (
304
+ content[:1000]
305
+ + "...\n\n*[Content truncated - use get_clause for full text]*"
306
+ )
307
+ text += f"{content}\n\n"
308
+
309
+ if item["clause"].page:
310
+ text += f"📄 Page {item['clause'].page}\n"
311
+
312
+ text += f"**Source:** {item['metadata'].title} (your licensed copy)\n"
313
+ text += "⚠️ Licensed content - do not redistribute\n\n"
314
+
315
+ return [TextContent(type="text", text=text)]
316
+
317
+ elif name == "search_controls":
318
+ query = arguments["query"]
319
+ frameworks = arguments.get("frameworks")
320
+ limit = arguments.get("limit", 10)
321
+
322
+ results = scf_data.search_controls(query, frameworks, limit)
323
+
324
+ if not results:
325
+ return [
326
+ TextContent(
327
+ type="text",
328
+ text=f"No controls found matching '{query}'. Try different keywords.",
329
+ )
330
+ ]
331
+
332
+ text = f"**Found {len(results)} control(s) matching '{query}'**\n\n"
333
+ for result in results:
334
+ text += f"**{result['control_id']}: {result['name']}**\n"
335
+ text += f"{result['snippet']}\n"
336
+ text += f"*Mapped to: {', '.join(result['mapped_frameworks'][:5])}*\n\n"
337
+
338
+ return [TextContent(type="text", text=text)]
339
+
340
+ elif name == "list_frameworks":
341
+ frameworks = list(scf_data.frameworks.values())
342
+ frameworks.sort(key=lambda x: x["controls_mapped"], reverse=True)
343
+
344
+ text = f"**Available Frameworks ({len(frameworks)} total)**\n\n"
345
+ for fw in frameworks:
346
+ text += f"- **{fw['key']}**: {fw['name']} ({fw['controls_mapped']} controls)\n"
347
+
348
+ return [TextContent(type="text", text=text)]
349
+
350
+ elif name == "get_framework_controls":
351
+ framework = arguments["framework"]
352
+ include_descriptions = arguments.get("include_descriptions", False)
353
+
354
+ if framework not in scf_data.frameworks:
355
+ available = ", ".join(scf_data.frameworks.keys())
356
+ return [
357
+ TextContent(
358
+ type="text",
359
+ text=f"Framework '{framework}' not found. Available: {available}",
360
+ )
361
+ ]
362
+
363
+ controls = scf_data.get_framework_controls(framework, include_descriptions)
364
+
365
+ fw_info = scf_data.frameworks[framework]
366
+ text = f"**{fw_info['name']}**\n"
367
+ text += f"**Total Controls:** {len(controls)}\n\n"
368
+
369
+ # Group by domain for readability
370
+ by_domain: dict[str, list] = {}
371
+ for ctrl in controls:
372
+ # Get full control to get domain
373
+ full_ctrl = scf_data.get_control(ctrl["scf_id"])
374
+ if full_ctrl:
375
+ domain = full_ctrl["domain"]
376
+ if domain not in by_domain:
377
+ by_domain[domain] = []
378
+ by_domain[domain].append(ctrl)
379
+
380
+ for domain, domain_ctrls in sorted(by_domain.items()):
381
+ text += f"\n**{domain}**\n"
382
+ for ctrl in domain_ctrls[:10]: # Limit per domain for readability
383
+ text += f"- **{ctrl['scf_id']}**: {ctrl['scf_name']}\n"
384
+ text += f" Maps to: {', '.join(ctrl['framework_control_ids'][:5])}\n"
385
+ if include_descriptions:
386
+ text += f" {ctrl['description'][:100]}...\n"
387
+
388
+ if len(domain_ctrls) > 10:
389
+ text += f" *... and {len(domain_ctrls) - 10} more controls*\n"
390
+
391
+ return [TextContent(type="text", text=text)]
392
+
393
+ elif name == "map_frameworks":
394
+ source_framework = arguments["source_framework"]
395
+ target_framework = arguments["target_framework"]
396
+ source_control = arguments.get("source_control")
397
+
398
+ # Validate frameworks exist
399
+ if source_framework not in scf_data.frameworks:
400
+ available = ", ".join(scf_data.frameworks.keys())
401
+ return [
402
+ TextContent(
403
+ type="text",
404
+ text=f"Source framework '{source_framework}' not found. Available: {available}",
405
+ )
406
+ ]
407
+
408
+ if target_framework not in scf_data.frameworks:
409
+ available = ", ".join(scf_data.frameworks.keys())
410
+ return [
411
+ TextContent(
412
+ type="text",
413
+ text=f"Target framework '{target_framework}' not found. Available: {available}",
414
+ )
415
+ ]
416
+
417
+ mappings = scf_data.map_frameworks(source_framework, target_framework, source_control)
418
+
419
+ if not mappings:
420
+ return [
421
+ TextContent(
422
+ type="text",
423
+ text=f"No mappings found between {source_framework} and {target_framework}",
424
+ )
425
+ ]
426
+
427
+ source_name = scf_data.frameworks[source_framework]["name"]
428
+ target_name = scf_data.frameworks[target_framework]["name"]
429
+
430
+ text = f"**Mapping: {source_name} → {target_name}**\n"
431
+ if source_control:
432
+ text += f"**Filtered to source control: {source_control}**\n"
433
+ text += f"**Found {len(mappings)} SCF controls**\n\n"
434
+
435
+ for mapping in mappings[:20]: # Limit for readability
436
+ text += (
437
+ f"**{mapping['scf_id']}: {mapping['scf_name']}** (weight: {mapping['weight']})\n"
438
+ )
439
+ text += f"- Source ({source_framework}): {', '.join(mapping['source_controls'][:5])}\n"
440
+ if mapping["target_controls"]:
441
+ text += (
442
+ f"- Target ({target_framework}): {', '.join(mapping['target_controls'][:5])}\n"
443
+ )
444
+ else:
445
+ text += f"- Target ({target_framework}): *No direct mapping*\n"
446
+ text += "\n"
447
+
448
+ if len(mappings) > 20:
449
+ text += f"\n*Showing first 20 of {len(mappings)} mappings*\n"
450
+
451
+ # Check if user has paid standards for source or target frameworks
452
+ if registry.has_paid_standards():
453
+ source_provider = registry.get_provider(source_framework)
454
+ target_provider = registry.get_provider(target_framework)
455
+
456
+ if source_provider or target_provider:
457
+ text += "\n" + "=" * 80 + "\n"
458
+ text += "**📜 Official Text from Your Purchased Standards**\n"
459
+ text += "=" * 80 + "\n\n"
460
+
461
+ # Show example from first mapping
462
+ if mappings:
463
+ example_mapping = mappings[0]
464
+
465
+ # Show source framework official text
466
+ if source_provider and example_mapping["source_controls"]:
467
+ for control_id in example_mapping["source_controls"][:1]:
468
+ clause = source_provider.get_clause(control_id)
469
+ if clause:
470
+ metadata = source_provider.get_metadata()
471
+ text += f"### {source_name} - {control_id}\n\n"
472
+ text += f"**{clause.title}**\n\n"
473
+
474
+ content = clause.content
475
+ if len(content) > 800:
476
+ content = content[:800] + "...\n\n*[Truncated]*"
477
+ text += f"{content}\n\n"
478
+
479
+ if clause.page:
480
+ text += f"📄 Page {clause.page} | "
481
+ text += f"**Source:** {metadata.title}\n\n"
482
+
483
+ # Show target framework official text
484
+ if target_provider and example_mapping["target_controls"]:
485
+ for control_id in example_mapping["target_controls"][:1]:
486
+ clause = target_provider.get_clause(control_id)
487
+ if clause:
488
+ metadata = target_provider.get_metadata()
489
+ text += f"### {target_name} - {control_id}\n\n"
490
+ text += f"**{clause.title}**\n\n"
491
+
492
+ content = clause.content
493
+ if len(content) > 800:
494
+ content = content[:800] + "...\n\n*[Truncated]*"
495
+ text += f"{content}\n\n"
496
+
497
+ if clause.page:
498
+ text += f"📄 Page {clause.page} | "
499
+ text += f"**Source:** {metadata.title}\n\n"
500
+
501
+ text += "⚠️ Licensed content - do not redistribute\n"
502
+ text += (
503
+ "\n*Showing example from first mapping. Use get_clause for specific clauses.*\n"
504
+ )
505
+
506
+ return [TextContent(type="text", text=text)]
507
+
508
+ elif name == "list_available_standards":
509
+ standards = registry.list_standards()
510
+
511
+ text = f"**Available Standards ({len(standards)} total)**\n\n"
512
+
513
+ for std in standards:
514
+ if std["type"] == "built-in":
515
+ text += f"### {std['title']} (Built-in)\n"
516
+ text += f"- **License:** {std['license']}\n"
517
+ text += f"- **Coverage:** {std['controls']}\n\n"
518
+ else:
519
+ text += f"### {std['title']} (Purchased)\n"
520
+ text += f"- **ID:** `{std['standard_id']}`\n"
521
+ text += f"- **Version:** {std['version']}\n"
522
+ text += f"- **License:** {std['license']}\n"
523
+ text += f"- **Purchased from:** {std['purchased_from']}\n"
524
+ text += f"- **Purchase date:** {std['purchase_date']}\n\n"
525
+
526
+ if not registry.has_paid_standards():
527
+ text += "\n*No purchased standards imported yet. Purchase a standard "
528
+ text += "(e.g., ISO 27001 from ISO.org) and use the import tool to add it.*\n"
529
+
530
+ return [TextContent(type="text", text=text)]
531
+
532
+ elif name == "query_standard":
533
+ standard = arguments["standard"]
534
+ query = arguments["query"]
535
+ limit = arguments.get("limit", 10)
536
+
537
+ provider = registry.get_provider(standard)
538
+ if not provider:
539
+ available = [s["standard_id"] for s in registry.list_standards() if s["type"] == "paid"]
540
+ if available:
541
+ text = f"Standard '{standard}' not found. Available: {', '.join(available)}"
542
+ else:
543
+ text = "No purchased standards available. Import a standard first using the import tool."
544
+ return [TextContent(type="text", text=text)]
545
+
546
+ results = provider.search(query, limit=limit)
547
+
548
+ if not results:
549
+ return [TextContent(type="text", text=f"No results found for '{query}' in {standard}")]
550
+
551
+ metadata = provider.get_metadata()
552
+ text = f"**{metadata.title} - Search Results for '{query}'**\n\n"
553
+ text += f"Found {len(results)} result(s)\n\n"
554
+
555
+ for result in results:
556
+ text += f"### {result.clause_id}: {result.title}\n"
557
+ if result.section_type:
558
+ text += f"*{result.section_type}*\n"
559
+ text += f"{result.content[:300]}...\n"
560
+ if result.page:
561
+ text += f"📄 Page {result.page}\n"
562
+ text += f"\n**Source:** {metadata.title} (your licensed copy)\n"
563
+ text += "⚠️ Licensed content - do not redistribute\n\n"
564
+
565
+ return [TextContent(type="text", text=text)]
566
+
567
+ elif name == "get_clause":
568
+ standard = arguments["standard"]
569
+ clause_id = arguments["clause_id"]
570
+
571
+ provider = registry.get_provider(standard)
572
+ if not provider:
573
+ available = [s["standard_id"] for s in registry.list_standards() if s["type"] == "paid"]
574
+ if available:
575
+ text = f"Standard '{standard}' not found. Available: {', '.join(available)}"
576
+ else:
577
+ text = "No purchased standards available. Import a standard first using the import tool."
578
+ return [TextContent(type="text", text=text)]
579
+
580
+ result = provider.get_clause(clause_id)
581
+
582
+ if not result:
583
+ return [TextContent(type="text", text=f"Clause '{clause_id}' not found in {standard}")]
584
+
585
+ metadata = provider.get_metadata()
586
+ text = f"**{metadata.title}**\n\n"
587
+ text += f"## {result.clause_id}: {result.title}\n\n"
588
+ if result.section_type:
589
+ text += f"*{result.section_type}*\n\n"
590
+ text += f"{result.content}\n\n"
591
+ if result.page:
592
+ text += f"📄 **Page:** {result.page}\n"
593
+ text += f"\n**Source:** {metadata.title} (your licensed copy, purchased {metadata.purchase_date})\n"
594
+ text += f"**License:** {metadata.license}\n"
595
+ text += "⚠️ **This content is from your personally licensed copy. Do not share or redistribute.**\n"
596
+
597
+ return [TextContent(type="text", text=text)]
598
+
599
+ else:
600
+ raise ValueError(f"Unknown tool: {name}")
601
+
602
+
603
+ async def main():
604
+ """Main entry point for the server."""
605
+ # Display legal notice on startup
606
+ print_legal_notice(registry)
607
+
608
+ async with stdio_server() as (read_stream, write_stream):
609
+ await app.run(read_stream, write_stream, app.create_initialization_options())
610
+
611
+
612
+ if __name__ == "__main__":
613
+ asyncio.run(main())