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.
- pwndoc_mcp_server/__init__.py +57 -0
- pwndoc_mcp_server/cli.py +441 -0
- pwndoc_mcp_server/client.py +870 -0
- pwndoc_mcp_server/config.py +411 -0
- pwndoc_mcp_server/logging_config.py +329 -0
- pwndoc_mcp_server/server.py +950 -0
- pwndoc_mcp_server/version.py +26 -0
- pwndoc_mcp_server-1.0.2.dist-info/METADATA +110 -0
- pwndoc_mcp_server-1.0.2.dist-info/RECORD +11 -0
- pwndoc_mcp_server-1.0.2.dist-info/WHEEL +4 -0
- pwndoc_mcp_server-1.0.2.dist-info/entry_points.txt +2 -0
|
@@ -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
|