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.
- security_controls_mcp/__init__.py +3 -0
- security_controls_mcp/__main__.py +8 -0
- security_controls_mcp/cli.py +255 -0
- security_controls_mcp/config.py +145 -0
- security_controls_mcp/data/framework-to-scf.json +13986 -0
- security_controls_mcp/data/scf-controls.json +50162 -0
- security_controls_mcp/data_loader.py +180 -0
- security_controls_mcp/extractors/__init__.py +5 -0
- security_controls_mcp/extractors/pdf_extractor.py +248 -0
- security_controls_mcp/http_server.py +477 -0
- security_controls_mcp/legal_notice.py +82 -0
- security_controls_mcp/providers.py +238 -0
- security_controls_mcp/registry.py +132 -0
- security_controls_mcp/server.py +613 -0
- security_controls_mcp-0.2.0.dist-info/METADATA +467 -0
- security_controls_mcp-0.2.0.dist-info/RECORD +21 -0
- security_controls_mcp-0.2.0.dist-info/WHEEL +5 -0
- security_controls_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- security_controls_mcp-0.2.0.dist-info/licenses/LICENSE +17 -0
- security_controls_mcp-0.2.0.dist-info/licenses/LICENSE-DATA.md +61 -0
- security_controls_mcp-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Command-line interface for security-controls-mcp."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import click
|
|
8
|
+
import pdfplumber # noqa: F401
|
|
9
|
+
except ImportError:
|
|
10
|
+
print(
|
|
11
|
+
"Error: Import tools not installed. Install with:\n" " pip install -e '.[import-tools]'",
|
|
12
|
+
file=sys.stderr,
|
|
13
|
+
)
|
|
14
|
+
sys.exit(1)
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .extractors import extract_standard
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
def main():
|
|
22
|
+
"""Security Controls MCP - Command-line tools."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@main.command("import-standard")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--file",
|
|
29
|
+
"-f",
|
|
30
|
+
"pdf_file",
|
|
31
|
+
required=True,
|
|
32
|
+
type=click.Path(exists=True, path_type=Path),
|
|
33
|
+
help="Path to the PDF file to import",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--type",
|
|
37
|
+
"-t",
|
|
38
|
+
"standard_type",
|
|
39
|
+
required=True,
|
|
40
|
+
help="Standard type (e.g., iso_27001_2022, nist_800_53_r5, pci_dss_4.0.1)",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--title",
|
|
44
|
+
required=True,
|
|
45
|
+
help="Full title of the standard (e.g., 'ISO/IEC 27001:2022')",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--purchased-from",
|
|
49
|
+
help="Where the standard was purchased from (e.g., 'ISO.org', 'NIST')",
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--purchase-date",
|
|
53
|
+
help="Date the standard was purchased (YYYY-MM-DD format)",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--version",
|
|
57
|
+
help="Version of the standard (e.g., '2022', '4.0.1')",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--force",
|
|
61
|
+
is_flag=True,
|
|
62
|
+
help="Overwrite existing standard if it exists",
|
|
63
|
+
)
|
|
64
|
+
def import_standard(
|
|
65
|
+
pdf_file: Path,
|
|
66
|
+
standard_type: str,
|
|
67
|
+
title: str,
|
|
68
|
+
purchased_from: str,
|
|
69
|
+
purchase_date: str,
|
|
70
|
+
version: str,
|
|
71
|
+
force: bool,
|
|
72
|
+
):
|
|
73
|
+
"""Import a purchased standard from PDF file.
|
|
74
|
+
|
|
75
|
+
This command extracts text, structure, and metadata from a PDF standard
|
|
76
|
+
and saves it in the user-local standards directory for querying via MCP.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
scf-mcp import-standard \\
|
|
80
|
+
--file ~/Downloads/ISO-27001-2022.pdf \\
|
|
81
|
+
--type iso_27001_2022 \\
|
|
82
|
+
--title "ISO/IEC 27001:2022" \\
|
|
83
|
+
--purchased-from "ISO.org" \\
|
|
84
|
+
--purchase-date "2026-01-29"
|
|
85
|
+
"""
|
|
86
|
+
from datetime import datetime
|
|
87
|
+
|
|
88
|
+
click.echo("=" * 80)
|
|
89
|
+
click.echo("Security Controls MCP - Standard Import Tool")
|
|
90
|
+
click.echo("=" * 80)
|
|
91
|
+
click.echo()
|
|
92
|
+
|
|
93
|
+
# Safety check: Verify we're not in a git repo or standards dir is gitignored
|
|
94
|
+
_check_git_safety()
|
|
95
|
+
|
|
96
|
+
# Initialize config
|
|
97
|
+
config = Config()
|
|
98
|
+
|
|
99
|
+
# Check if standard already exists
|
|
100
|
+
if not force and standard_type in config.data.get("standards", {}):
|
|
101
|
+
click.echo(
|
|
102
|
+
f"❌ Error: Standard '{standard_type}' already exists. " "Use --force to overwrite.",
|
|
103
|
+
err=True,
|
|
104
|
+
)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
click.echo(f"📄 PDF File: {pdf_file}")
|
|
108
|
+
click.echo(f"🏷️ Standard Type: {standard_type}")
|
|
109
|
+
click.echo(f"📋 Title: {title}")
|
|
110
|
+
click.echo()
|
|
111
|
+
|
|
112
|
+
# Extract standard
|
|
113
|
+
click.echo("🔍 Extracting PDF content...")
|
|
114
|
+
click.echo()
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
result = extract_standard(
|
|
118
|
+
pdf_path=pdf_file,
|
|
119
|
+
standard_id=standard_type,
|
|
120
|
+
title=title,
|
|
121
|
+
version=version or "unknown",
|
|
122
|
+
purchased_from=purchased_from or "unknown",
|
|
123
|
+
purchase_date=purchase_date or datetime.now().strftime("%Y-%m-%d"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Save to config directory
|
|
127
|
+
output_dir = config.standards_dir / standard_type
|
|
128
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
# Save files
|
|
131
|
+
import json
|
|
132
|
+
|
|
133
|
+
metadata_file = output_dir / "metadata.json"
|
|
134
|
+
full_text_file = output_dir / "full_text.json"
|
|
135
|
+
|
|
136
|
+
with open(metadata_file, "w") as f:
|
|
137
|
+
json.dump(result["metadata"], f, indent=2)
|
|
138
|
+
|
|
139
|
+
with open(full_text_file, "w") as f:
|
|
140
|
+
json.dump(result["structure"], f, indent=2)
|
|
141
|
+
|
|
142
|
+
click.echo("✅ Extraction complete!")
|
|
143
|
+
click.echo()
|
|
144
|
+
click.echo("📊 Extraction Summary:")
|
|
145
|
+
click.echo(f" • Pages extracted: {result['stats']['pages']}")
|
|
146
|
+
click.echo(f" • Sections found: {result['stats']['sections']}")
|
|
147
|
+
click.echo(f" • Total clauses: {result['stats']['total_clauses']}")
|
|
148
|
+
click.echo()
|
|
149
|
+
|
|
150
|
+
# Add to config
|
|
151
|
+
config.add_standard(
|
|
152
|
+
standard_id=standard_type,
|
|
153
|
+
path=standard_type,
|
|
154
|
+
enabled=True,
|
|
155
|
+
show_license_warnings=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
click.echo(f"💾 Saved to: {output_dir}")
|
|
159
|
+
click.echo()
|
|
160
|
+
click.echo("✓ Standard successfully imported!")
|
|
161
|
+
click.echo()
|
|
162
|
+
click.echo("Next steps:")
|
|
163
|
+
click.echo(" 1. Restart your MCP server")
|
|
164
|
+
click.echo(f" 2. Use list_available_standards to verify '{standard_type}' is loaded")
|
|
165
|
+
click.echo(" 3. Query the standard with query_standard or get_clause")
|
|
166
|
+
click.echo()
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
click.echo(f"❌ Error during extraction: {e}", err=True)
|
|
170
|
+
import traceback
|
|
171
|
+
|
|
172
|
+
traceback.print_exc()
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _check_git_safety():
|
|
177
|
+
"""Check that we're not accidentally going to commit paid content."""
|
|
178
|
+
import subprocess
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Check if we're in a git repo
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if result.returncode == 0:
|
|
190
|
+
# We're in a git repo - check if standards dir is ignored
|
|
191
|
+
config = Config()
|
|
192
|
+
|
|
193
|
+
# Check if standards dir is inside the current repo
|
|
194
|
+
try:
|
|
195
|
+
standards_dir_rel = str(config.standards_dir.relative_to(Path.cwd()))
|
|
196
|
+
except ValueError:
|
|
197
|
+
# Standards dir is outside the repo (e.g., in home directory)
|
|
198
|
+
# This is safe - can't be accidentally committed
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
["git", "check-ignore", "-q", standards_dir_rel],
|
|
203
|
+
check=False,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
click.echo(
|
|
208
|
+
"⚠️ WARNING: You are in a git repository and the standards "
|
|
209
|
+
"directory is NOT gitignored!",
|
|
210
|
+
err=True,
|
|
211
|
+
)
|
|
212
|
+
click.echo(
|
|
213
|
+
" This could lead to accidental redistribution of licensed content.",
|
|
214
|
+
err=True,
|
|
215
|
+
)
|
|
216
|
+
click.echo()
|
|
217
|
+
click.echo(" Add this to your .gitignore:", err=True)
|
|
218
|
+
click.echo(f" {standards_dir_rel}/", err=True)
|
|
219
|
+
click.echo()
|
|
220
|
+
if not click.confirm("Continue anyway?", default=False):
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
except FileNotFoundError:
|
|
224
|
+
# Git not installed, skip check
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@main.command("list-standards")
|
|
229
|
+
def list_standards():
|
|
230
|
+
"""List all imported standards."""
|
|
231
|
+
from .registry import StandardRegistry
|
|
232
|
+
|
|
233
|
+
config = Config()
|
|
234
|
+
registry = StandardRegistry(config)
|
|
235
|
+
|
|
236
|
+
standards = registry.list_standards()
|
|
237
|
+
|
|
238
|
+
click.echo("Available Standards:")
|
|
239
|
+
click.echo()
|
|
240
|
+
|
|
241
|
+
for std in standards:
|
|
242
|
+
if std["type"] == "built-in":
|
|
243
|
+
click.echo(f"✓ {std['title']} (Built-in)")
|
|
244
|
+
click.echo(f" License: {std['license']}")
|
|
245
|
+
click.echo(f" Coverage: {std['controls']}")
|
|
246
|
+
else:
|
|
247
|
+
click.echo(f"✓ {std['title']} (Purchased)")
|
|
248
|
+
click.echo(f" ID: {std['standard_id']}")
|
|
249
|
+
click.echo(f" Version: {std['version']}")
|
|
250
|
+
click.echo(f" Purchased: {std['purchase_date']}")
|
|
251
|
+
click.echo()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
main()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Configuration management for security controls MCP server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Config:
|
|
9
|
+
"""Manages configuration for the security controls MCP server."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
12
|
+
"""Initialize configuration.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
config_dir: Optional path to config directory. Defaults to ~/.security-controls-mcp/
|
|
16
|
+
"""
|
|
17
|
+
if config_dir is None:
|
|
18
|
+
self.config_dir = Path.home() / ".security-controls-mcp"
|
|
19
|
+
else:
|
|
20
|
+
self.config_dir = Path(config_dir)
|
|
21
|
+
|
|
22
|
+
self.config_file = self.config_dir / "config.json"
|
|
23
|
+
self.standards_dir = self.config_dir / "standards"
|
|
24
|
+
|
|
25
|
+
# Ensure directories exist
|
|
26
|
+
self._ensure_directories()
|
|
27
|
+
|
|
28
|
+
# Load or create config
|
|
29
|
+
self.data = self._load_config()
|
|
30
|
+
|
|
31
|
+
def _ensure_directories(self) -> None:
|
|
32
|
+
"""Ensure config and standards directories exist."""
|
|
33
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
self.standards_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
def _load_config(self) -> Dict[str, Any]:
|
|
37
|
+
"""Load configuration from file or create default."""
|
|
38
|
+
if self.config_file.exists():
|
|
39
|
+
with open(self.config_file, "r") as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
else:
|
|
42
|
+
# Create default config
|
|
43
|
+
default_config = {
|
|
44
|
+
"standards": {},
|
|
45
|
+
"query_settings": {
|
|
46
|
+
"always_show_attribution": True,
|
|
47
|
+
"include_page_numbers": True,
|
|
48
|
+
"max_results_per_query": 20,
|
|
49
|
+
},
|
|
50
|
+
"legal": {
|
|
51
|
+
"acknowledged_scf_restrictions": False,
|
|
52
|
+
"acknowledged_paid_licenses": False,
|
|
53
|
+
"last_acknowledgment_date": None,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
self._save_config(default_config)
|
|
57
|
+
return default_config
|
|
58
|
+
|
|
59
|
+
def _save_config(self, data: Dict[str, Any]) -> None:
|
|
60
|
+
"""Save configuration to file."""
|
|
61
|
+
with open(self.config_file, "w") as f:
|
|
62
|
+
json.dump(data, f, indent=2)
|
|
63
|
+
|
|
64
|
+
def get_enabled_standards(self) -> Dict[str, Dict[str, Any]]:
|
|
65
|
+
"""Get all enabled paid standards.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dictionary of enabled standards with their configuration
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
standard_id: config
|
|
72
|
+
for standard_id, config in self.data.get("standards", {}).items()
|
|
73
|
+
if config.get("enabled", True)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def add_standard(
|
|
77
|
+
self,
|
|
78
|
+
standard_id: str,
|
|
79
|
+
path: str,
|
|
80
|
+
enabled: bool = True,
|
|
81
|
+
show_license_warnings: bool = True,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Add a new standard to configuration.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
standard_id: Unique identifier for the standard
|
|
87
|
+
path: Relative path to standard data (from standards_dir)
|
|
88
|
+
enabled: Whether the standard is enabled
|
|
89
|
+
show_license_warnings: Whether to show license warnings for this standard
|
|
90
|
+
"""
|
|
91
|
+
if "standards" not in self.data:
|
|
92
|
+
self.data["standards"] = {}
|
|
93
|
+
|
|
94
|
+
self.data["standards"][standard_id] = {
|
|
95
|
+
"enabled": enabled,
|
|
96
|
+
"path": path,
|
|
97
|
+
"show_license_warnings": show_license_warnings,
|
|
98
|
+
}
|
|
99
|
+
self._save_config(self.data)
|
|
100
|
+
|
|
101
|
+
def remove_standard(self, standard_id: str) -> None:
|
|
102
|
+
"""Remove a standard from configuration.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
standard_id: Unique identifier for the standard to remove
|
|
106
|
+
"""
|
|
107
|
+
if standard_id in self.data.get("standards", {}):
|
|
108
|
+
del self.data["standards"][standard_id]
|
|
109
|
+
self._save_config(self.data)
|
|
110
|
+
|
|
111
|
+
def get_standard_path(self, standard_id: str) -> Optional[Path]:
|
|
112
|
+
"""Get the full path to a standard's data directory.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
standard_id: Unique identifier for the standard
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Full path to standard data directory, or None if not found
|
|
119
|
+
"""
|
|
120
|
+
standard_config = self.data.get("standards", {}).get(standard_id)
|
|
121
|
+
if not standard_config:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return self.standards_dir / standard_config["path"]
|
|
125
|
+
|
|
126
|
+
def acknowledge_legal_notices(self) -> None:
|
|
127
|
+
"""Mark legal notices as acknowledged."""
|
|
128
|
+
from datetime import datetime
|
|
129
|
+
|
|
130
|
+
self.data["legal"]["acknowledged_scf_restrictions"] = True
|
|
131
|
+
self.data["legal"]["acknowledged_paid_licenses"] = True
|
|
132
|
+
self.data["legal"]["last_acknowledgment_date"] = datetime.now().isoformat()
|
|
133
|
+
self._save_config(self.data)
|
|
134
|
+
|
|
135
|
+
def needs_legal_acknowledgment(self) -> bool:
|
|
136
|
+
"""Check if legal notices need to be acknowledged.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if user needs to acknowledge legal notices
|
|
140
|
+
"""
|
|
141
|
+
legal = self.data.get("legal", {})
|
|
142
|
+
return not (
|
|
143
|
+
legal.get("acknowledged_scf_restrictions", False)
|
|
144
|
+
and legal.get("acknowledged_paid_licenses", False)
|
|
145
|
+
)
|