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,3 @@
1
+ """Security Controls MCP Server - Query security framework controls and mappings."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,8 @@
1
+ """Entry point for the MCP server."""
2
+
3
+ import asyncio
4
+
5
+ from .server import main
6
+
7
+ if __name__ == "__main__":
8
+ asyncio.run(main())
@@ -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
+ )