Skip to content

Commit 8dcca18

Browse files
Add exception framework for better error reporting (#114)
* Add exception framework for better error reporting * resolve comments * fix ci * Add new error codes for runtime component * resolve comments * Update config and transport
1 parent 02a5faf commit 8dcca18

File tree

24 files changed

+943
-238
lines changed

24 files changed

+943
-238
lines changed

docs/error-codes.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# MCP Fuzzer Error Codes
2+
3+
The MCP Fuzzer surfaces every user-facing failure through numbered error codes.
4+
Each code follows the `CCTTT` format:
5+
6+
- `CC` (two digits) identifies the category (e.g., `10` for Transport, `20` for Auth)
7+
- `TTT` (three digits) identifies the specific error within that category
8+
9+
This keeps diagnostics easy to scan while preserving enough room for future error
10+
types. Refer to the table below for the current registry.
11+
12+
| Code | Category | Description |
13+
|-------|-----------|----------------------------------------------------|
14+
| 10001 | Transport | Transport failure |
15+
| 10002 | Transport | Unable to establish connection with the server |
16+
| 10003 | Transport | Malformed or unexpected server response |
17+
| 10004 | Transport | Authentication with the server failed |
18+
| 10005 | Transport | Network connectivity or policy failure |
19+
| 10006 | Transport | Invalid transport payload |
20+
| 10007 | Transport | Transport registration or selection error |
21+
| 20001 | Auth | Authentication subsystem error |
22+
| 20002 | Auth | Authentication configuration is invalid |
23+
| 20003 | Auth | Authentication provider is misconfigured |
24+
| 30001 | Timeout | Operation timed out |
25+
| 30002 | Timeout | Subprocess execution timed out |
26+
| 30003 | Timeout | Network request timed out |
27+
| 40001 | Safety | Safety policy violated |
28+
| 40002 | Safety | Network access blocked by safety policy |
29+
| 40003 | Safety | System command blocked by safety policy |
30+
| 40004 | Safety | Filesystem access blocked by safety policy |
31+
| 50001 | Server | Server returned an error |
32+
| 50002 | Server | Server is unavailable or not responding |
33+
| 50003 | Server | Protocol negotiation failed |
34+
| 60001 | CLI | CLI error |
35+
| 60002 | CLI | Invalid CLI arguments |
36+
| 70001 | Reporting | Reporting error |
37+
| 70002 | Reporting | Report validation failed |
38+
| 80001 | Config | Configuration error |
39+
| 80002 | Config | Configuration file could not be read |
40+
| 80003 | Config | Configuration validation failed |
41+
| 90001 | Fuzzing | Fuzzing engine error |
42+
| 90002 | Fuzzing | Fuzzing strategy failed |
43+
| 90003 | Fuzzing | Async executor encountered an error |
44+
| 95001 | Runtime | Runtime management error |
45+
| 95002 | Runtime | Failed to start managed process |
46+
| 95003 | Runtime | Failed to stop managed process |
47+
| 95004 | Runtime | Failed to send process signal |
48+
| 95005 | Runtime | Process registration failed |
49+
| 95006 | Runtime | Process watchdog failed to start |
50+
51+
Each exception class in `mcp_fuzzer/exceptions.py` sets its `code` and
52+
`description` fields so CLI output, logs, and structured reports can reference
53+
these values. Use `MCPError.to_metadata()` or `get_error_registry()` to fetch
54+
the mapping programmatically.

mcp_fuzzer/auth/loaders.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44

5+
from ..exceptions import AuthConfigError, AuthProviderError
56
from .manager import AuthManager
67
from .providers import (
78
create_api_key_auth,
@@ -81,7 +82,7 @@ def load_auth_config(config_file: str) -> AuthManager:
8182
providers = config.get("providers", {})
8283
for name, provider_config in providers.items():
8384
if not isinstance(provider_config, dict):
84-
raise ValueError(
85+
raise AuthProviderError(
8586
f"Error configuring auth provider '{name}': "
8687
f"expected an object, got {type(provider_config).__name__}"
8788
)
@@ -90,7 +91,7 @@ def load_auth_config(config_file: str) -> AuthManager:
9091
try:
9192
if provider_type == "api_key":
9293
if "api_key" not in provider_config:
93-
raise ValueError(
94+
raise AuthProviderError(
9495
f"Provider '{name}' is type 'api_key' but missing "
9596
"required field 'api_key'. Expected: "
9697
"{'type': 'api_key', 'api_key': 'YOUR_API_KEY'}"
@@ -105,13 +106,13 @@ def load_auth_config(config_file: str) -> AuthManager:
105106
)
106107
elif provider_type == "basic":
107108
if "username" not in provider_config:
108-
raise ValueError(
109+
raise AuthProviderError(
109110
f"Provider '{name}' is type 'basic' but missing "
110111
"required field 'username'. Expected: "
111112
"{'type': 'basic', 'username': 'user', 'password': 'pass'}"
112113
)
113114
if "password" not in provider_config:
114-
raise ValueError(
115+
raise AuthProviderError(
115116
f"Provider '{name}' is type 'basic' but missing "
116117
"required field 'password'. Expected: "
117118
"{'type': 'basic', 'username': 'user', 'password': 'pass'}"
@@ -124,7 +125,7 @@ def load_auth_config(config_file: str) -> AuthManager:
124125
)
125126
elif provider_type == "oauth":
126127
if "token" not in provider_config:
127-
raise ValueError(
128+
raise AuthProviderError(
128129
f"Provider '{name}' is type 'oauth' but missing "
129130
"required field 'token'. Expected: "
130131
"{'type': 'oauth', 'token': 'YOUR_TOKEN'}"
@@ -139,13 +140,13 @@ def load_auth_config(config_file: str) -> AuthManager:
139140
elif provider_type == "custom":
140141
headers = provider_config.get("headers")
141142
if not headers:
142-
raise ValueError(
143+
raise AuthProviderError(
143144
f"Provider '{name}' is type 'custom' but missing "
144145
"required field 'headers'. Expected: "
145146
"{'type': 'custom', 'headers': {'X-Header': 'value'}}"
146147
)
147148
if not isinstance(headers, dict):
148-
raise ValueError(
149+
raise AuthProviderError(
149150
f"Provider '{name}' custom headers must be a dict, "
150151
f"got {type(headers).__name__}"
151152
)
@@ -156,24 +157,28 @@ def load_auth_config(config_file: str) -> AuthManager:
156157
name, create_custom_header_auth(headers_str)
157158
)
158159
else:
159-
raise ValueError(
160+
raise AuthProviderError(
160161
f"Unknown provider type: '{provider_type}' for provider '{name}'. "
161162
f"Supported types: api_key, basic, oauth, custom"
162163
)
163-
except (KeyError, ValueError) as e:
164-
raise ValueError(f"Error configuring auth provider '{name}': {str(e)}")
164+
except AuthProviderError:
165+
raise
166+
except (KeyError, ValueError, TypeError) as e:
167+
raise AuthProviderError(
168+
f"Error configuring auth provider '{name}': {str(e)}"
169+
) from e
165170

166171
tool_mappings = config.get("tool_mapping")
167172
legacy_tool_mappings = config.get("tool_mappings")
168173
if tool_mappings and legacy_tool_mappings:
169-
raise ValueError(
174+
raise AuthConfigError(
170175
"Both 'tool_mapping' and legacy 'tool_mappings' are defined. "
171176
"Please use only 'tool_mapping'."
172177
)
173178

174179
final_tool_mappings = tool_mappings or legacy_tool_mappings or {}
175180
if final_tool_mappings and not isinstance(final_tool_mappings, dict):
176-
raise ValueError(
181+
raise AuthConfigError(
177182
f"'tool_mapping' must be a dict, "
178183
f"got {type(final_tool_mappings).__name__}"
179184
)

mcp_fuzzer/cli/args.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from rich.console import Console
88

99
from ..config import config, load_config_file, apply_config_file
10+
from ..exceptions import ArgumentValidationError
1011

1112
def create_argument_parser() -> argparse.ArgumentParser:
1213
parser = argparse.ArgumentParser(
@@ -643,26 +644,28 @@ def validate_arguments(args: argparse.Namespace) -> None:
643644

644645
# Require endpoint for non-utility commands
645646
if not is_utility_command and not getattr(args, 'endpoint', None):
646-
raise ValueError("--endpoint is required for fuzzing operations")
647+
raise ArgumentValidationError("--endpoint is required for fuzzing operations")
647648

648649
if args.mode == "protocol" and not args.protocol_type:
649650
pass
650651

651652
if args.protocol_type and args.mode != "protocol":
652-
raise ValueError("--protocol-type can only be used with --mode protocol")
653+
raise ArgumentValidationError(
654+
"--protocol-type can only be used with --mode protocol"
655+
)
653656

654657
if hasattr(args, "runs") and args.runs is not None:
655658
if not isinstance(args.runs, int) or args.runs < 1:
656-
raise ValueError("--runs must be at least 1")
659+
raise ArgumentValidationError("--runs must be at least 1")
657660

658661
if hasattr(args, "runs_per_type") and args.runs_per_type is not None:
659662
if not isinstance(args.runs_per_type, int) or args.runs_per_type < 1:
660-
raise ValueError("--runs-per-type must be at least 1")
663+
raise ArgumentValidationError("--runs-per-type must be at least 1")
661664

662665
if hasattr(args, "timeout") and args.timeout is not None:
663666
if not isinstance(args.timeout, (int, float)) or args.timeout <= 0:
664-
raise ValueError("--timeout must be positive")
667+
raise ArgumentValidationError("--timeout must be positive")
665668

666669
if hasattr(args, "endpoint") and args.endpoint is not None:
667670
if not args.endpoint.strip():
668-
raise ValueError("--endpoint cannot be empty")
671+
raise ArgumentValidationError("--endpoint cannot be empty")

mcp_fuzzer/cli/main.py

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
start_safety_if_enabled,
2020
stop_safety_if_started,
2121
)
22+
from ..exceptions import (
23+
ArgumentValidationError,
24+
CLIError,
25+
MCPError,
26+
TransportError,
27+
)
2228

2329

2430
def _get_cli_helpers() -> tuple[Any, Any, Any, Any, Any, Any]:
@@ -59,24 +65,15 @@ def _get_cli_helpers() -> tuple[Any, Any, Any, Any, Any, Any]:
5965
def _handle_validate_config(args) -> None:
6066
"""Handle --validate-config flag."""
6167
from ..config import load_config_file
62-
try:
63-
load_config_file(args.validate_config)
64-
console = Console()
65-
config_file = args.validate_config
66-
success_msg = (
67-
"[green]:heavy_check_mark: Configuration file '"
68-
f"{config_file}' is valid[/green]"
69-
)
70-
console.print(emoji.emojize(success_msg, language='alias'))
71-
sys.exit(0)
72-
except Exception as e:
73-
console = Console()
74-
error_msg = (
75-
"[red]:heavy_multiplication_x: Configuration validation failed: "
76-
f"{e}[/red]"
77-
)
78-
console.print(emoji.emojize(error_msg, language='alias'))
79-
sys.exit(1)
68+
load_config_file(args.validate_config)
69+
console = Console()
70+
config_file = args.validate_config
71+
success_msg = (
72+
"[green]:heavy_check_mark: Configuration file "
73+
f"'{config_file}' is valid[/green]"
74+
)
75+
console.print(emoji.emojize(success_msg, language='alias'))
76+
sys.exit(0)
8077

8178

8279
def _handle_check_env() -> None:
@@ -122,12 +119,10 @@ def _handle_check_env() -> None:
122119

123120
if all_valid:
124121
console.print("[green]All environment variables are valid[/green]")
125-
else:
126-
console.print(
127-
"[red]Some environment variables have invalid values[/red]"
128-
)
129-
sys.exit(1)
130-
sys.exit(0)
122+
sys.exit(0)
123+
124+
console.print("[red]Some environment variables have invalid values[/red]")
125+
raise ArgumentValidationError("Invalid environment variable values")
131126

132127

133128
def _validate_transport(args, cli_module) -> None:
@@ -156,10 +151,13 @@ def _validate_transport(args, cli_module) -> None:
156151
args.endpoint,
157152
timeout=args.timeout,
158153
)
154+
except MCPError:
155+
raise
159156
except Exception as transport_error:
160-
console = Console()
161-
console.print(f"[bold red]Error:[/bold red] {transport_error}")
162-
sys.exit(1)
157+
raise TransportError(
158+
"Failed to initialize transport",
159+
context={"protocol": args.protocol, "endpoint": args.endpoint},
160+
) from transport_error
163161

164162

165163
def _run_fuzzing(args, cli_module) -> None:
@@ -209,19 +207,33 @@ def run_cli() -> None:
209207
_validate_transport(args, cli_module)
210208
_run_fuzzing(args, cli_module)
211209

212-
except ValueError as e:
213-
console = Console()
214-
console.print(f"[bold red]Error:[/bold red] {e}")
215-
sys.exit(1)
216210
except KeyboardInterrupt:
217211
console = Console()
218212
console.print("\n[yellow]Fuzzing interrupted by user[/yellow]")
219213
sys.exit(0)
220-
except Exception as e:
221-
console = Console()
222-
console.print(f"[bold red]Unexpected error:[/bold red] {e}")
214+
except MCPError as err:
215+
_print_mcp_error(err)
216+
sys.exit(1)
217+
except ValueError as exc:
218+
error = ArgumentValidationError(str(exc))
219+
_print_mcp_error(error)
220+
sys.exit(1)
221+
except Exception as exc:
222+
error = CLIError(
223+
"Unexpected CLI failure",
224+
context={"stage": "run_cli", "details": str(exc)},
225+
)
226+
_print_mcp_error(error)
223227
if logging.getLogger().level <= logging.DEBUG:
224228
import traceback
225229

226-
console.print(traceback.format_exc())
230+
Console().print(traceback.format_exc())
227231
sys.exit(1)
232+
233+
234+
def _print_mcp_error(error: MCPError) -> None:
235+
"""Render MCP errors consistently for the CLI."""
236+
console = Console()
237+
console.print(f"[bold red]Error ({error.code}):[/bold red] {error}")
238+
if error.context:
239+
console.print(f"[dim]Context: {error.context}[/dim]")

mcp_fuzzer/config/loader.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import yaml
1313

1414
from .manager import config
15-
from ..exceptions import ConfigFileError
15+
from ..exceptions import ConfigFileError, MCPError
1616
from ..transport.custom import register_custom_transport
1717
from ..transport.base import TransportProtocol
1818
import importlib
@@ -114,26 +114,21 @@ def apply_config_file(
114114
Returns:
115115
True if configuration was loaded and applied, False otherwise
116116
"""
117+
# Find config file
118+
file_path = find_config_file(config_path, search_paths, file_names)
119+
if not file_path:
120+
logger.debug("No configuration file found")
121+
return False
122+
123+
logger.info(f"Loading configuration from {file_path}")
117124
try:
118-
# Find config file
119-
file_path = find_config_file(config_path, search_paths, file_names)
120-
if not file_path:
121-
logger.debug("No configuration file found")
122-
return False
123-
124-
# Load config file
125-
logger.info(f"Loading configuration from {file_path}")
126125
config_data = load_config_file(file_path)
127-
128-
# Load custom transports if configured
129126
load_custom_transports(config_data)
130-
131-
# Apply configuration
132-
config.update(config_data)
133-
return True
134-
except Exception as e:
135-
logger.error(f"Error loading configuration file: {str(e)}")
127+
except (ConfigFileError, MCPError):
128+
logger.exception("Failed to load configuration from %s", file_path)
136129
return False
130+
config.update(config_data)
131+
return True
137132

138133
def get_config_schema() -> dict[str, Any]:
139134
"""Get the configuration schema.
@@ -380,8 +375,10 @@ def load_custom_transports(config_data: dict[str, Any]) -> None:
380375
f"{module_path}.{class_name}"
381376
)
382377

378+
except MCPError:
379+
raise
383380
except Exception as e:
384381
logger.error(f"Failed to load custom transport '{transport_name}': {e}")
385382
raise ConfigFileError(
386383
f"Failed to load custom transport '{transport_name}': {e}"
387-
)
384+
) from e

0 commit comments

Comments
 (0)