pwndoc-mcp-server 1.0.2__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.

Potentially problematic release.


This version of pwndoc-mcp-server might be problematic. Click here for more details.

@@ -0,0 +1,411 @@
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
+ if self.token:
106
+ return "token"
107
+ elif self.username and self.password:
108
+ return "credentials"
109
+ return "none"
110
+
111
+ def to_dict(self, include_secrets: bool = True) -> Dict[str, Any]:
112
+ """Convert config to dictionary.
113
+
114
+ Args:
115
+ include_secrets: If True, include actual passwords and tokens.
116
+ If False, mask them with "***"
117
+
118
+ Returns:
119
+ Dictionary representation of config
120
+ """
121
+ d = {
122
+ "url": self.url,
123
+ "username": self.username,
124
+ "verify_ssl": self.verify_ssl,
125
+ "timeout": self.timeout,
126
+ "log_level": self.log_level,
127
+ "mcp_transport": self.mcp_transport,
128
+ "auth_method": self.auth_method,
129
+ }
130
+
131
+ if include_secrets:
132
+ d["password"] = self.password
133
+ d["token"] = self.token
134
+ else:
135
+ d["password"] = "***" if self.password else ""
136
+ d["token"] = "***" if self.token else ""
137
+
138
+ return d
139
+
140
+ def to_safe_string(self) -> str:
141
+ """Return string representation without sensitive data."""
142
+ return (
143
+ f"Config(url={self.url!r}, username={self.username!r}, "
144
+ f"auth_method={self.auth_method!r}, verify_ssl={self.verify_ssl})"
145
+ )
146
+
147
+ def validate(self) -> list:
148
+ """Validate configuration and return list of errors.
149
+
150
+ Returns:
151
+ List of error messages, empty if valid
152
+ """
153
+ errors = []
154
+
155
+ if not self.url:
156
+ errors.append("PWNDOC_URL is required")
157
+ elif not self.url.startswith(("http://", "https://")):
158
+ errors.append(f"Invalid URL format: {self.url}")
159
+
160
+ if not self.token and not (self.username and self.password):
161
+ errors.append(
162
+ "Authentication required: provide either PWNDOC_TOKEN or PWNDOC_USERNAME/PWNDOC_PASSWORD"
163
+ )
164
+
165
+ if self.timeout < 1:
166
+ errors.append(f"Timeout must be positive: {self.timeout}")
167
+
168
+ return errors
169
+
170
+ def is_valid(self) -> bool:
171
+ """Check if configuration is valid.
172
+
173
+ Returns:
174
+ True if configuration is valid
175
+ """
176
+ return len(self.validate()) == 0
177
+
178
+ @classmethod
179
+ def from_env(cls) -> "Config":
180
+ """Create Config from environment variables.
181
+
182
+ Returns:
183
+ Config object populated from environment
184
+ """
185
+ env_config = _load_from_env()
186
+ return cls(**env_config)
187
+
188
+
189
+ def _load_from_env() -> Dict[str, Any]:
190
+ """Load configuration from environment variables."""
191
+ env_mapping = {
192
+ "PWNDOC_URL": ("url", str),
193
+ "PWNDOC_USERNAME": ("username", str),
194
+ "PWNDOC_PASSWORD": ("password", str),
195
+ "PWNDOC_TOKEN": ("token", str),
196
+ "PWNDOC_VERIFY_SSL": ("verify_ssl", lambda x: x.lower() in ("true", "1", "yes")),
197
+ "PWNDOC_TIMEOUT": ("timeout", int),
198
+ "PWNDOC_MAX_RETRIES": ("max_retries", int),
199
+ "PWNDOC_LOG_LEVEL": ("log_level", str),
200
+ "PWNDOC_LOG_FILE": ("log_file", str),
201
+ "PWNDOC_MCP_TRANSPORT": ("mcp_transport", str),
202
+ "PWNDOC_MCP_HOST": ("mcp_host", str),
203
+ "PWNDOC_MCP_PORT": ("mcp_port", int),
204
+ "PWNDOC_ENABLE_CACHING": ("enable_caching", lambda x: x.lower() in ("true", "1", "yes")),
205
+ "PWNDOC_CACHE_TTL": ("cache_ttl", int),
206
+ }
207
+
208
+ config = {}
209
+ for env_var, (key, converter) in env_mapping.items():
210
+ value = os.environ.get(env_var)
211
+ if value is not None:
212
+ try:
213
+ config[key] = converter(value) # type: ignore[operator,misc]
214
+ except (ValueError, TypeError) as e:
215
+ logger.warning(f"Invalid value for {env_var}: {value} ({e})")
216
+
217
+ return config
218
+
219
+
220
+ def _load_from_file(config_path: Path) -> Dict[str, Any]:
221
+ """Load configuration from YAML or JSON file."""
222
+ if not config_path.exists():
223
+ return {}
224
+
225
+ try:
226
+ content = config_path.read_text()
227
+
228
+ if config_path.suffix in (".yaml", ".yml"):
229
+ data = yaml.safe_load(content)
230
+ return dict(data) if data else {}
231
+ elif config_path.suffix == ".json":
232
+ return dict(json.loads(content))
233
+ else:
234
+ # Try YAML first, then JSON
235
+ try:
236
+ data = yaml.safe_load(content)
237
+ return dict(data) if data else {}
238
+ except yaml.YAMLError:
239
+ return dict(json.loads(content))
240
+ except Exception as e:
241
+ logger.warning(f"Failed to load config from {config_path}: {e}")
242
+ return {}
243
+
244
+
245
+ def load_config(config_file: Optional[Path] = None, **overrides: Any) -> Config:
246
+ """
247
+ Load configuration from multiple sources.
248
+
249
+ Priority (highest to lowest):
250
+ 1. Explicit overrides passed as kwargs
251
+ 2. Environment variables
252
+ 3. Configuration file
253
+ 4. Default values
254
+
255
+ Args:
256
+ config_file: Path to configuration file (optional)
257
+ **overrides: Explicit configuration overrides
258
+
259
+ Returns:
260
+ Config: Loaded configuration object
261
+
262
+ Example:
263
+ >>> config = load_config()
264
+ >>> config = load_config(url="https://pwndoc.local")
265
+ >>> config = load_config(config_file=Path("/etc/pwndoc-mcp/config.yaml"))
266
+ """
267
+ # Start with empty config dict
268
+ config_dict: Dict[str, Any] = {}
269
+
270
+ # Load from file (lowest priority after defaults)
271
+ if config_file is None:
272
+ # Check PWNDOC_CONFIG_FILE environment variable first
273
+ config_file_env = os.getenv("PWNDOC_CONFIG_FILE")
274
+ if config_file_env:
275
+ config_file = Path(config_file_env).expanduser()
276
+ else:
277
+ # Check default locations
278
+ for path in [DEFAULT_CONFIG_FILE, DEFAULT_CONFIG_DIR / "config.json"]:
279
+ if path.exists():
280
+ config_file = path
281
+ break
282
+
283
+ if config_file and Path(config_file).exists():
284
+ file_config = _load_from_file(Path(config_file))
285
+ config_dict.update(file_config)
286
+ logger.debug(f"Loaded config from {config_file}")
287
+
288
+ # Load from environment (higher priority)
289
+ env_config = _load_from_env()
290
+ config_dict.update(env_config)
291
+
292
+ # Apply explicit overrides (highest priority)
293
+ config_dict.update(overrides)
294
+
295
+ # Create and return Config object
296
+ return Config(**config_dict)
297
+
298
+
299
+ def save_config(config: Config, config_file: Optional[Path] = None) -> Path:
300
+ """
301
+ Save configuration to file.
302
+
303
+ WARNING: This will save sensitive data. Ensure proper file permissions.
304
+
305
+ Args:
306
+ config: Configuration object to save
307
+ config_file: Target file path (default: ~/.pwndoc-mcp/config.yaml)
308
+
309
+ Returns:
310
+ Path: Path to saved configuration file
311
+ """
312
+ if config_file is None:
313
+ config_file = DEFAULT_CONFIG_FILE
314
+ elif isinstance(config_file, str):
315
+ config_file = Path(config_file)
316
+
317
+ # Ensure directory exists
318
+ config_file.parent.mkdir(parents=True, exist_ok=True)
319
+
320
+ # Convert to dict
321
+ data = {
322
+ "url": config.url,
323
+ "username": config.username,
324
+ "password": config.password,
325
+ "token": config.token,
326
+ "verify_ssl": config.verify_ssl,
327
+ "timeout": config.timeout,
328
+ "log_level": config.log_level,
329
+ "log_file": config.log_file,
330
+ "mcp_transport": config.mcp_transport,
331
+ }
332
+
333
+ # Save based on file extension
334
+ if config_file.suffix == ".json":
335
+ config_file.write_text(json.dumps(data, indent=2))
336
+ else:
337
+ config_file.write_text(yaml.dump(data, default_flow_style=False))
338
+
339
+ # Set restrictive permissions (owner read/write only)
340
+ config_file.chmod(0o600)
341
+
342
+ logger.info(f"Configuration saved to {config_file}")
343
+ return config_file
344
+
345
+
346
+ def get_config_path() -> str:
347
+ """
348
+ Get the path to the configuration file.
349
+
350
+ Checks PWNDOC_CONFIG_FILE environment variable first,
351
+ then returns the default path.
352
+
353
+ Returns:
354
+ Path to configuration file as string
355
+ """
356
+ config_path_env = os.getenv("PWNDOC_CONFIG_FILE")
357
+ if config_path_env:
358
+ return str(Path(config_path_env).expanduser())
359
+ return str(DEFAULT_CONFIG_FILE)
360
+
361
+
362
+ def init_config_interactive() -> Config:
363
+ """
364
+ Interactive configuration wizard.
365
+
366
+ Prompts user for configuration values and saves to default location.
367
+ """
368
+ print("\n" + "=" * 60)
369
+ print(" PwnDoc MCP Server - Configuration Wizard")
370
+ print("=" * 60 + "\n")
371
+
372
+ url = input("PwnDoc URL (e.g., https://pwndoc.example.com): ").strip()
373
+
374
+ print("\nAuthentication method:")
375
+ print(" 1. Username/Password")
376
+ print(" 2. JWT Token")
377
+ auth_choice = input("Choose (1/2): ").strip()
378
+
379
+ username = ""
380
+ password = ""
381
+ token = ""
382
+
383
+ if auth_choice == "2":
384
+ token = input("JWT Token: ").strip()
385
+ else:
386
+ username = input("Username: ").strip()
387
+ import getpass
388
+
389
+ password = getpass.getpass("Password: ")
390
+
391
+ verify_ssl = input("\nVerify SSL certificates? (Y/n): ").strip().lower() != "n"
392
+
393
+ log_level = input("Log level (DEBUG/INFO/WARNING/ERROR) [INFO]: ").strip().upper()
394
+ if not log_level:
395
+ log_level = "INFO"
396
+
397
+ config = Config(
398
+ url=url,
399
+ username=username,
400
+ password=password,
401
+ token=token,
402
+ verify_ssl=verify_ssl,
403
+ log_level=log_level,
404
+ )
405
+
406
+ save_choice = input("\nSave configuration? (Y/n): ").strip().lower()
407
+ if save_choice != "n":
408
+ save_config(config)
409
+ print(f"\n✓ Configuration saved to {DEFAULT_CONFIG_FILE}")
410
+
411
+ return config