pmcp_server_toolkit/error.rs
1// Originated from pmcp-run/built-in/shared/mcp-server-common (https://github.com/guyernest/pmcp-run)
2// Promoted to rust-mcp-sdk workspace as a public SDK crate for Phase 83.
3
4//! Toolkit error type and crate-level `Result` alias.
5//!
6//! [`ToolkitError`] is `#[non_exhaustive]`: downstream crates must match with a
7//! catch-all arm so the toolkit can add variants without a breaking change.
8//! Phase 83 Plan 04 extends this enum with a `Validation` variant wrapping a
9//! [`ConfigValidationError`] (per review R8) which catches missing-required-value
10//! bugs the `Default` impls on sub-sections would otherwise silently hide.
11
12/// Crate-level result alias used by every public API in `pmcp-server-toolkit`.
13pub type Result<T> = std::result::Result<T, ToolkitError>;
14
15/// Errors surfaced by the `pmcp-server-toolkit` runtime.
16///
17/// The enum is `#[non_exhaustive]` — match callers must include a wildcard arm.
18///
19/// # Examples
20///
21/// ```
22/// use pmcp_server_toolkit::ToolkitError;
23/// use std::error::Error;
24///
25/// // ToolkitError is a real `std::error::Error`, with a usable `Display` impl.
26/// let err: ToolkitError = ToolkitError::MissingField("database.dsn".into());
27/// assert_eq!(err.to_string(), "missing required config field: database.dsn");
28/// // Implements `std::error::Error`, so it composes with `?` and `Box<dyn Error>`.
29/// let boxed: Box<dyn Error + Send + Sync> = Box::new(err);
30/// assert!(boxed.source().is_none());
31/// ```
32#[derive(Debug, thiserror::Error)]
33#[non_exhaustive]
34pub enum ToolkitError {
35 /// TOML parse failure while loading a `ServerConfig`.
36 #[error("failed to parse config TOML: {0}")]
37 Parse(#[from] toml::de::Error),
38
39 /// A required config field was absent during tool synthesis.
40 #[error("missing required config field: {0}")]
41 MissingField(String),
42
43 /// `[[tools]]` synthesis failed (covers Phase 83 TKIT-07 failure modes).
44 #[error("tool synthesis failed: {0}")]
45 Synth(String),
46
47 /// Code-mode wiring failed (covers Phase 83 TKIT-09 failure modes).
48 #[error("code-mode wiring failed: {0}")]
49 CodeMode(String),
50
51 /// Filesystem failure while reading a config or fixture.
52 #[error("I/O error: {0}")]
53 Io(#[from] std::io::Error),
54
55 /// Secret resolution failed (env var missing, AWS API error, etc.).
56 ///
57 /// Carries the secret name and a descriptive cause string; the underlying
58 /// raw value is NEVER carried in this variant — only the lookup-key
59 /// metadata and the error context. This preserves the `SecretValue`
60 /// negative-trait invariants at the error path (review R5 + T-83-02-02).
61 #[error("secret '{name}' not resolvable: {cause}")]
62 Secret {
63 /// The secret name that could not be resolved.
64 name: String,
65 /// Human-readable cause (provider name + underlying error).
66 cause: String,
67 },
68
69 /// Semantic validation of a parsed [`crate::config::ServerConfig`] failed.
70 ///
71 /// Wraps a [`ConfigValidationError`] surfaced by
72 /// [`crate::config::ServerConfig::validate`] /
73 /// [`crate::config::ServerConfig::from_toml_strict_validated`]. Per Phase 83
74 /// review R8 this catches the empty-required-value trap that the
75 /// `Default` impls on sub-sections would otherwise hide behind silent
76 /// successes (e.g. `server.name = ""` if the `[server]` header is typo'd).
77 #[error("config validation failed: {0}")]
78 Validation(#[from] ConfigValidationError),
79
80 /// A governed-Excel workbook bundle failed to load + integrity-verify at
81 /// boot (Phase 92, WBSV-08 fail-closed). Wraps a
82 /// [`pmcp_workbook_runtime::BundleLoadError`] — a source read failure, a
83 /// malformed/truncated artifact, or an integrity-hash mismatch (a tampered
84 /// or swapped bundle). Feature-gated on `workbook` so the no-`workbook`
85 /// build never names the runtime type.
86 #[cfg(feature = "workbook")]
87 #[error("workbook bundle load failed: {0}")]
88 Workbook(#[from] pmcp_workbook_runtime::BundleLoadError),
89}
90
91/// Semantic-validation errors surfaced by
92/// [`crate::config::ServerConfig::validate`].
93///
94/// Per Phase 83 review R8 — the `Default` impls on `ServerConfig` and its
95/// sub-sections deliberately allow `from_toml` to succeed even when required
96/// fields are missing (so partial configs can be merged programmatically). The
97/// [`crate::config::ServerConfig::validate`] entry-point catches these gaps at
98/// parse time and surfaces them as a typed enum variant per rule.
99///
100/// The enum is `#[non_exhaustive]` — match callers must include a wildcard arm
101/// so additional rules can be added without a breaking change.
102///
103/// # Examples
104///
105/// ```
106/// use pmcp_server_toolkit::ConfigValidationError;
107///
108/// // Each variant has a precise `Display` describing the rule violated.
109/// let err = ConfigValidationError::EmptyServerName;
110/// assert_eq!(err.to_string(), "server.name must be non-empty");
111/// let err = ConfigValidationError::EmptyToolName(3);
112/// assert_eq!(err.to_string(), "[[tools]] entry at index 3 has empty name");
113/// ```
114#[derive(Debug, thiserror::Error)]
115#[non_exhaustive]
116pub enum ConfigValidationError {
117 /// `[server] name` is missing or whitespace-only.
118 #[error("server.name must be non-empty")]
119 EmptyServerName,
120 /// `[server] version` is missing or whitespace-only.
121 #[error("server.version must be non-empty")]
122 EmptyServerVersion,
123 /// `[[tools]]` entry at `index` has an empty / whitespace-only `name`.
124 #[error("[[tools]] entry at index {0} has empty name")]
125 EmptyToolName(usize),
126 /// `[[database.tables]]` entry at `index` has an empty / whitespace-only `name`.
127 #[error("[[database.tables]] entry at index {0} has empty name")]
128 EmptyTableName(usize),
129 /// Per Phase 83 Plan 06 review R9: `[code_mode].token_secret` was given as
130 /// an inline literal (e.g. `token_secret = "raw-string"`) instead of the
131 /// `env:VAR_NAME` reference form, and the dev-only escape hatch
132 /// `allow_inline_token_secret_for_dev` was not set. Inline literals in
133 /// committed configs leak HMAC signing keys; the toolkit defaults to
134 /// rejecting them.
135 #[error(
136 "[code_mode].token_secret is an inline literal; use 'env:VAR_NAME' \
137 or set allow_inline_token_secret_for_dev=true (NEVER in production)"
138 )]
139 InlineSecretRejected,
140 /// Per Phase 90 Plan 02 (D-01, T-90-02-04): a `[[tools]]` entry declares
141 /// more than one mutually-exclusive tool kind. A tool is EITHER a SQL tool
142 /// (`sql`), a single-call HTTP tool (`path`/`method`), OR a script tool
143 /// (`script`) — never a mixture. The ambiguity is rejected rather than
144 /// resolved by a silent precedence rule. The `usize` is the entry index.
145 #[error(
146 "[[tools]] entry at index {0} declares ambiguous tool kind: set exactly \
147 one of `sql`, `path`/`method`, or `script` (not a mixture)"
148 )]
149 AmbiguousToolKind(usize),
150 /// Per Phase 90 gap-closure (GAP 3 / WR-02): a `[backend]` block is present
151 /// but its `base_url` is empty / whitespace-only (or the `base_url` key was
152 /// omitted, defaulting to `""` via `#[serde(default)]`). Without this
153 /// parse-time check a typo'd or missing `base_url` would validate cleanly
154 /// and then surface late as an opaque `DispatchError::Connector("invalid
155 /// base URL")` at the first backend request. Rejecting it here turns that
156 /// late opaque failure into an actionable, field-naming error.
157 #[error(
158 "[backend].base_url must be non-empty (set the REST API root URL, \
159 e.g. \"https://api.example.com\")"
160 )]
161 EmptyBackendBaseUrl,
162}