pwndoc-mcp-server 1.0.8__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,414 @@
1
+ """
2
+ Configuration management for PwnDoc MCP Server.
3
+
4
+ Supports multiple configuration sources:
5
+ 1. Environment variables (highest priority)
6
+ 2. Configuration file (~/.pwndoc-mcp/config.yaml or config.json)
7
+ 3. Command-line arguments
8
+ 4. Default values
9
+
10
+ Environment Variables:
11
+ PWNDOC_URL - PwnDoc server URL (e.g., https://pwndoc.example.com)
12
+ PWNDOC_USERNAME - PwnDoc username
13
+ PWNDOC_PASSWORD - PwnDoc password
14
+ PWNDOC_TOKEN - Pre-authenticated JWT token (alternative to user/pass)
15
+ PWNDOC_VERIFY_SSL - Verify SSL certificates (default: true)
16
+ PWNDOC_TIMEOUT - Request timeout in seconds (default: 30)
17
+ PWNDOC_LOG_LEVEL - Logging level (DEBUG, INFO, WARNING, ERROR)
18
+ PWNDOC_LOG_FILE - Path to log file (optional)
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Dict, Optional
27
+
28
+ import yaml # type: ignore[import-untyped]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Default configuration directory
33
+ DEFAULT_CONFIG_DIR = Path.home() / ".pwndoc-mcp"
34
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.yaml"
35
+
36
+
37
+ @dataclass
38
+ class Config:
39
+ """Configuration for PwnDoc MCP Server."""
40
+
41
+ # Connection settings
42
+ url: str = ""
43
+ username: str = ""
44
+ password: str = ""
45
+ token: str = ""
46
+
47
+ # SSL and network settings
48
+ verify_ssl: bool = True
49
+ timeout: int = 30
50
+ max_retries: int = 3
51
+ retry_delay: float = 1.0
52
+
53
+ # Rate limiting
54
+ rate_limit_requests: int = 100
55
+ rate_limit_period: int = 60 # seconds
56
+
57
+ # Logging settings
58
+ log_level: str = "INFO"
59
+ log_file: str = ""
60
+ log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
61
+
62
+ # MCP settings
63
+ mcp_transport: str = "stdio" # stdio, sse, websocket
64
+ mcp_host: str = "127.0.0.1"
65
+ mcp_port: int = 8080
66
+
67
+ # Feature flags
68
+ enable_caching: bool = True
69
+ cache_ttl: int = 300 # seconds
70
+ enable_metrics: bool = False
71
+
72
+ # Custom fields
73
+ extra: Dict[str, Any] = field(default_factory=dict)
74
+
75
+ def __post_init__(self):
76
+ """Validate configuration after initialization."""
77
+ self._validate()
78
+
79
+ def _validate(self) -> None:
80
+ """Validate configuration values."""
81
+ if self.url and not self.url.startswith(("http://", "https://")):
82
+ raise ValueError(f"Invalid URL format: {self.url}")
83
+
84
+ if self.timeout < 1:
85
+ raise ValueError(f"Timeout must be positive: {self.timeout}")
86
+
87
+ valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
88
+ if self.log_level.upper() not in valid_log_levels:
89
+ raise ValueError(f"Invalid log level: {self.log_level}")
90
+
91
+ valid_transports = {"stdio", "sse", "websocket"}
92
+ if self.mcp_transport not in valid_transports:
93
+ raise ValueError(f"Invalid MCP transport: {self.mcp_transport}")
94
+
95
+ @property
96
+ def is_configured(self) -> bool:
97
+ """Check if minimum configuration is present."""
98
+ has_url = bool(self.url)
99
+ has_auth = bool(self.token) or (bool(self.username) and bool(self.password))
100
+ return has_url and has_auth
101
+
102
+ @property
103
+ def auth_method(self) -> str:
104
+ """Return the authentication method being used.
105
+
106
+ Priority: username/password (preferred) > token > none
107
+ """
108
+ if self.username and self.password:
109
+ return "credentials"
110
+ elif self.token:
111
+ return "token"
112
+ return "none"
113
+
114
+ def to_dict(self, include_secrets: bool = True) -> Dict[str, Any]:
115
+ """Convert config to dictionary.
116
+
117
+ Args:
118
+ include_secrets: If True, include actual passwords and tokens.
119
+ If False, mask them with "***"
120
+
121
+ Returns:
122
+ Dictionary representation of config
123
+ """
124
+ d = {
125
+ "url": self.url,
126
+ "username": self.username,
127
+ "verify_ssl": self.verify_ssl,
128
+ "timeout": self.timeout,
129
+ "log_level": self.log_level,
130
+ "mcp_transport": self.mcp_transport,
131
+ "auth_method": self.auth_method,
132
+ }
133
+
134
+ if include_secrets:
135
+ d["password"] = self.password
136
+ d["token"] = self.token
137
+ else:
138
+ d["password"] = "***" if self.password else ""
139
+ d["token"] = "***" if self.token else ""
140
+
141
+ return d
142
+
143
+ def to_safe_string(self) -> str:
144
+ """Return string representation without sensitive data."""
145
+ return (
146
+ f"Config(url={self.url!r}, username={self.username!r}, "
147
+ f"auth_method={self.auth_method!r}, verify_ssl={self.verify_ssl})"
148
+ )
149
+
150
+ def validate(self) -> list:
151
+ """Validate configuration and return list of errors.
152
+
153
+ Returns:
154
+ List of error messages, empty if valid
155
+ """
156
+ errors = []
157
+
158
+ if not self.url:
159
+ errors.append("PWNDOC_URL is required")
160
+ elif not self.url.startswith(("http://", "https://")):
161
+ errors.append(f"Invalid URL format: {self.url}")
162
+
163
+ if not self.token and not (self.username and self.password):
164
+ errors.append(
165
+ "Authentication required: provide either PWNDOC_TOKEN or PWNDOC_USERNAME/PWNDOC_PASSWORD"
166
+ )
167
+
168
+ if self.timeout < 1:
169
+ errors.append(f"Timeout must be positive: {self.timeout}")
170
+
171
+ return errors
172
+
173
+ def is_valid(self) -> bool:
174
+ """Check if configuration is valid.
175
+
176
+ Returns:
177
+ True if configuration is valid
178
+ """
179
+ return len(self.validate()) == 0
180
+
181
+ @classmethod
182
+ def from_env(cls) -> "Config":
183
+ """Create Config from environment variables.
184
+
185
+ Returns:
186
+ Config object populated from environment
187
+ """
188
+ env_config = _load_from_env()
189
+ return cls(**env_config)
190
+
191
+
192
+ def _load_from_env() -> Dict[str, Any]:
193
+ """Load configuration from environment variables."""
194
+ env_mapping = {
195
+ "PWNDOC_URL": ("url", str),
196
+ "PWNDOC_USERNAME": ("username", str),
197
+ "PWNDOC_PASSWORD": ("password", str),
198
+ "PWNDOC_TOKEN": ("token", str),
199
+ "PWNDOC_VERIFY_SSL": ("verify_ssl", lambda x: x.lower() in ("true", "1", "yes")),
200
+ "PWNDOC_TIMEOUT": ("timeout", int),
201
+ "PWNDOC_MAX_RETRIES": ("max_retries", int),
202
+ "PWNDOC_LOG_LEVEL": ("log_level", str),
203
+ "PWNDOC_LOG_FILE": ("log_file", str),
204
+ "PWNDOC_MCP_TRANSPORT": ("mcp_transport", str),
205
+ "PWNDOC_MCP_HOST": ("mcp_host", str),
206
+ "PWNDOC_MCP_PORT": ("mcp_port", int),
207
+ "PWNDOC_ENABLE_CACHING": ("enable_caching", lambda x: x.lower() in ("true", "1", "yes")),
208
+ "PWNDOC_CACHE_TTL": ("cache_ttl", int),
209
+ }
210
+
211
+ config = {}
212
+ for env_var, (key, converter) in env_mapping.items():
213
+ value = os.environ.get(env_var)
214
+ if value is not None:
215
+ try:
216
+ config[key] = converter(value) # type: ignore[operator,misc]
217
+ except (ValueError, TypeError) as e:
218
+ logger.warning(f"Invalid value for {env_var}: {value} ({e})")
219
+
220
+ return config
221
+
222
+
223
+ def _load_from_file(config_path: Path) -> Dict[str, Any]:
224
+ """Load configuration from YAML or JSON file."""
225
+ if not config_path.exists():
226
+ return {}
227
+
228
+ try:
229
+ content = config_path.read_text()
230
+
231
+ if config_path.suffix in (".yaml", ".yml"):
232
+ data = yaml.safe_load(content)
233
+ return dict(data) if data else {}
234
+ elif config_path.suffix == ".json":
235
+ return dict(json.loads(content))
236
+ else:
237
+ # Try YAML first, then JSON
238
+ try:
239
+ data = yaml.safe_load(content)
240
+ return dict(data) if data else {}
241
+ except yaml.YAMLError:
242
+ return dict(json.loads(content))
243
+ except Exception as e:
244
+ logger.warning(f"Failed to load config from {config_path}: {e}")
245
+ return {}
246
+
247
+
248
+ def load_config(config_file: Optional[Path] = None, **overrides: Any) -> Config:
249
+ """
250
+ Load configuration from multiple sources.
251
+
252
+ Priority (highest to lowest):
253
+ 1. Explicit overrides passed as kwargs
254
+ 2. Environment variables
255
+ 3. Configuration file
256
+ 4. Default values
257
+
258
+ Args:
259
+ config_file: Path to configuration file (optional)
260
+ **overrides: Explicit configuration overrides
261
+
262
+ Returns:
263
+ Config: Loaded configuration object
264
+
265
+ Example:
266
+ >>> config = load_config()
267
+ >>> config = load_config(url="https://pwndoc.local")
268
+ >>> config = load_config(config_file=Path("/etc/pwndoc-mcp/config.yaml"))
269
+ """
270
+ # Start with empty config dict
271
+ config_dict: Dict[str, Any] = {}
272
+
273
+ # Load from file (lowest priority after defaults)
274
+ if config_file is None:
275
+ # Check PWNDOC_CONFIG_FILE environment variable first
276
+ config_file_env = os.getenv("PWNDOC_CONFIG_FILE")
277
+ if config_file_env:
278
+ config_file = Path(config_file_env).expanduser()
279
+ else:
280
+ # Check default locations
281
+ for path in [DEFAULT_CONFIG_FILE, DEFAULT_CONFIG_DIR / "config.json"]:
282
+ if path.exists():
283
+ config_file = path
284
+ break
285
+
286
+ if config_file and Path(config_file).exists():
287
+ file_config = _load_from_file(Path(config_file))
288
+ config_dict.update(file_config)
289
+ logger.debug(f"Loaded config from {config_file}")
290
+
291
+ # Load from environment (higher priority)
292
+ env_config = _load_from_env()
293
+ config_dict.update(env_config)
294
+
295
+ # Apply explicit overrides (highest priority)
296
+ config_dict.update(overrides)
297
+
298
+ # Create and return Config object
299
+ return Config(**config_dict)
300
+
301
+
302
+ def save_config(config: Config, config_file: Optional[Path] = None) -> Path:
303
+ """
304
+ Save configuration to file.
305
+
306
+ WARNING: This will save sensitive data. Ensure proper file permissions.
307
+
308
+ Args:
309
+ config: Configuration object to save
310
+ config_file: Target file path (default: ~/.pwndoc-mcp/config.yaml)
311
+
312
+ Returns:
313
+ Path: Path to saved configuration file
314
+ """
315
+ if config_file is None:
316
+ config_file = DEFAULT_CONFIG_FILE
317
+ elif isinstance(config_file, str):
318
+ config_file = Path(config_file)
319
+
320
+ # Ensure directory exists
321
+ config_file.parent.mkdir(parents=True, exist_ok=True)
322
+
323
+ # Convert to dict
324
+ data = {
325
+ "url": config.url,
326
+ "username": config.username,
327
+ "password": config.password,
328
+ "token": config.token,
329
+ "verify_ssl": config.verify_ssl,
330
+ "timeout": config.timeout,
331
+ "log_level": config.log_level,
332
+ "log_file": config.log_file,
333
+ "mcp_transport": config.mcp_transport,
334
+ }
335
+
336
+ # Save based on file extension
337
+ if config_file.suffix == ".json":
338
+ config_file.write_text(json.dumps(data, indent=2))
339
+ else:
340
+ config_file.write_text(yaml.dump(data, default_flow_style=False))
341
+
342
+ # Set restrictive permissions (owner read/write only)
343
+ config_file.chmod(0o600)
344
+
345
+ logger.info(f"Configuration saved to {config_file}")
346
+ return config_file
347
+
348
+
349
+ def get_config_path() -> str:
350
+ """
351
+ Get the path to the configuration file.
352
+
353
+ Checks PWNDOC_CONFIG_FILE environment variable first,
354
+ then returns the default path.
355
+
356
+ Returns:
357
+ Path to configuration file as string
358
+ """
359
+ config_path_env = os.getenv("PWNDOC_CONFIG_FILE")
360
+ if config_path_env:
361
+ return str(Path(config_path_env).expanduser())
362
+ return str(DEFAULT_CONFIG_FILE)
363
+
364
+
365
+ def init_config_interactive() -> Config:
366
+ """
367
+ Interactive configuration wizard.
368
+
369
+ Prompts user for configuration values and saves to default location.
370
+ """
371
+ print("\n" + "=" * 60)
372
+ print(" PwnDoc MCP Server - Configuration Wizard")
373
+ print("=" * 60 + "\n")
374
+
375
+ url = input("PwnDoc URL (e.g., https://pwndoc.example.com): ").strip()
376
+
377
+ print("\nAuthentication method:")
378
+ print(" 1. Username/Password")
379
+ print(" 2. JWT Token")
380
+ auth_choice = input("Choose (1/2): ").strip()
381
+
382
+ username = ""
383
+ password = ""
384
+ token = ""
385
+
386
+ if auth_choice == "2":
387
+ token = input("JWT Token: ").strip()
388
+ else:
389
+ username = input("Username: ").strip()
390
+ import getpass
391
+
392
+ password = getpass.getpass("Password: ")
393
+
394
+ verify_ssl = input("\nVerify SSL certificates? (Y/n): ").strip().lower() != "n"
395
+
396
+ log_level = input("Log level (DEBUG/INFO/WARNING/ERROR) [INFO]: ").strip().upper()
397
+ if not log_level:
398
+ log_level = "INFO"
399
+
400
+ config = Config(
401
+ url=url,
402
+ username=username,
403
+ password=password,
404
+ token=token,
405
+ verify_ssl=verify_ssl,
406
+ log_level=log_level,
407
+ )
408
+
409
+ save_choice = input("\nSave configuration? (Y/n): ").strip().lower()
410
+ if save_choice != "n":
411
+ save_config(config)
412
+ print(f"\n✓ Configuration saved to {DEFAULT_CONFIG_FILE}")
413
+
414
+ return config