Skip to main content

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    /// `[backend].base_url` holds a `${VAR}` / `env:VAR` reference that could
81    /// not be resolved: the named environment variable is unset, or is set to
82    /// an empty / whitespace-only value.
83    ///
84    /// Filed HERE and NOT under [`ConfigValidationError`] deliberately (Phase
85    /// 120 Plan 04, cross-AI review LOW). `ConfigValidationError` is the
86    /// semantic validation surfaced by
87    /// [`crate::config::ServerConfig::validate`] — i.e. PARSE time. This lookup
88    /// happens at DISPATCH time, long after `validate()` returned `Ok` (the
89    /// literal `"${TFL_BASE_URL}"` is non-empty, so the emptiness rule passes).
90    /// Filing a runtime lookup failure under parse-time validation would make
91    /// that enum's own documentation false and would let a caller matching
92    /// `ToolkitError::Validation(..)` believe the config was malformed.
93    ///
94    /// # Security (T-120-17)
95    ///
96    /// The message names the FIELD and the environment-variable NAME only. It
97    /// MUST NOT echo a resolved URL, the config's contents, or any credential
98    /// substring.
99    #[error(
100        "[backend].base_url references environment variable '{var}', which is \
101         unset or empty (set it to the REST API root URL)"
102    )]
103    UnresolvedBaseUrlRef {
104        /// The environment-variable name the `base_url` reference points at.
105        /// Never the resolved value.
106        var: String,
107    },
108
109    /// A governed-Excel workbook bundle failed to load + integrity-verify at
110    /// boot (Phase 92, WBSV-08 fail-closed). Wraps a
111    /// [`pmcp_workbook_runtime::BundleLoadError`] — a source read failure, a
112    /// malformed/truncated artifact, or an integrity-hash mismatch (a tampered
113    /// or swapped bundle). Feature-gated on `workbook` so the no-`workbook`
114    /// build never names the runtime type.
115    #[cfg(feature = "workbook")]
116    #[error("workbook bundle load failed: {0}")]
117    Workbook(#[from] pmcp_workbook_runtime::BundleLoadError),
118}
119
120/// Semantic-validation errors surfaced by
121/// [`crate::config::ServerConfig::validate`].
122///
123/// Per Phase 83 review R8 — the `Default` impls on `ServerConfig` and its
124/// sub-sections deliberately allow `from_toml` to succeed even when required
125/// fields are missing (so partial configs can be merged programmatically). The
126/// [`crate::config::ServerConfig::validate`] entry-point catches these gaps at
127/// parse time and surfaces them as a typed enum variant per rule.
128///
129/// The enum is `#[non_exhaustive]` — match callers must include a wildcard arm
130/// so additional rules can be added without a breaking change.
131///
132/// # Examples
133///
134/// ```
135/// use pmcp_server_toolkit::ConfigValidationError;
136///
137/// // Each variant has a precise `Display` describing the rule violated.
138/// let err = ConfigValidationError::EmptyServerName;
139/// assert_eq!(err.to_string(), "server.name must be non-empty");
140/// let err = ConfigValidationError::EmptyToolName(3);
141/// assert_eq!(err.to_string(), "[[tools]] entry at index 3 has empty name");
142/// ```
143#[derive(Debug, thiserror::Error)]
144#[non_exhaustive]
145pub enum ConfigValidationError {
146    /// `[server] name` is missing or whitespace-only.
147    #[error("server.name must be non-empty")]
148    EmptyServerName,
149    /// `[server] version` is missing or whitespace-only.
150    #[error("server.version must be non-empty")]
151    EmptyServerVersion,
152    /// `[[tools]]` entry at `index` has an empty / whitespace-only `name`.
153    #[error("[[tools]] entry at index {0} has empty name")]
154    EmptyToolName(usize),
155    /// `[[database.tables]]` entry at `index` has an empty / whitespace-only `name`.
156    #[error("[[database.tables]] entry at index {0} has empty name")]
157    EmptyTableName(usize),
158    /// Per Phase 83 Plan 06 review R9: `[code_mode].token_secret` was given as
159    /// an inline literal (e.g. `token_secret = "raw-string"`) instead of the
160    /// `env:VAR_NAME` reference form, and the dev-only escape hatch
161    /// `allow_inline_token_secret_for_dev` was not set. Inline literals in
162    /// committed configs leak HMAC signing keys; the toolkit defaults to
163    /// rejecting them.
164    #[error(
165        "[code_mode].token_secret is an inline literal; use 'env:VAR_NAME' \
166         or set allow_inline_token_secret_for_dev=true (NEVER in production)"
167    )]
168    InlineSecretRejected,
169    /// Per Phase 90 Plan 02 (D-01, T-90-02-04): a `[[tools]]` entry declares
170    /// more than one mutually-exclusive tool kind. A tool is EITHER a SQL tool
171    /// (`sql`), a single-call HTTP tool (`path`/`method`), OR a script tool
172    /// (`script`) — never a mixture. The ambiguity is rejected rather than
173    /// resolved by a silent precedence rule. The `usize` is the entry index.
174    #[error(
175        "[[tools]] entry at index {0} declares ambiguous tool kind: set exactly \
176         one of `sql`, `path`/`method`, or `script` (not a mixture)"
177    )]
178    AmbiguousToolKind(usize),
179    /// Per Phase 90 gap-closure (GAP 3 / WR-02): a `[backend]` block is present
180    /// but its `base_url` is empty / whitespace-only (or the `base_url` key was
181    /// omitted, defaulting to `""` via `#[serde(default)]`). Without this
182    /// parse-time check a typo'd or missing `base_url` would validate cleanly
183    /// and then surface late as an opaque `DispatchError::Connector("invalid
184    /// base URL")` at the first backend request. Rejecting it here turns that
185    /// late opaque failure into an actionable, field-naming error.
186    #[error(
187        "[backend].base_url must be non-empty (set the REST API root URL, \
188         e.g. \"https://api.example.com\")"
189    )]
190    EmptyBackendBaseUrl,
191    /// `[backend].base_url` is REFERENCE-shaped but does not name exactly one
192    /// environment variable — the empty `${}` form, or a multi-placeholder
193    /// composition like `"${SCHEME}://${HOST}"`. The grammar
194    /// ([`crate::env_ref::parse_env_ref`]) resolves one whole-value `${VAR}` /
195    /// `env:VAR` reference; it does not interpolate inside a larger string, so
196    /// no environment could ever satisfy such a value. Without this check the
197    /// config loads cleanly and every boot fails with an
198    /// `UnresolvedBaseUrlRef` naming an empty variable.
199    #[error(
200        "[backend].base_url is a malformed environment reference; a reference must be \
201         exactly one `${{VAR}}` or `env:VAR` naming a single variable — inline \
202         compositions like \"${{SCHEME}}://${{HOST}}\" cannot be resolved by any \
203         environment, so compose the full URL in ONE variable instead"
204    )]
205    MalformedBackendBaseUrlRef,
206    /// A `[backend.auth]` credential field is REFERENCE-shaped but does not name
207    /// exactly one environment variable — the empty `${}` form, a
208    /// multi-placeholder composition like `"${SCHEME}://${HOST}"`, or a
209    /// non-portable name like `"${TFL-APP-KEY}"` (a `${...}` name must match
210    /// `[A-Za-z0-9_]+`; `env:VAR` remains the escape hatch for exotic names).
211    /// The `String` is the offending field path within `[backend.auth]` — e.g.
212    /// `"token"`, `"password"`, `"query_params.app_key"`.
213    ///
214    /// This is the credential sibling of [`Self::MalformedBackendBaseUrlRef`],
215    /// and it exists because the two paths resolve UNSET references differently
216    /// on purpose: a credential resolves an unset variable to the empty string
217    /// so an optional credential is OMITTED. A MALFORMED reference is not an
218    /// unset variable — no environment can ever satisfy it — so applying the
219    /// omission rule to it silently sent every backend request UNAUTHENTICATED,
220    /// with no error and no log line. Refusing it at load time turns that into
221    /// an actionable, field-naming failure before the server ever boots.
222    ///
223    /// The message names the FIELD only and deliberately never echoes the
224    /// configured value: a malformed value is by definition not a resolvable
225    /// reference, so it may well be a mistyped literal secret.
226    #[error(
227        "[backend.auth].{0} is a malformed environment reference; a reference must be \
228         exactly one `${{VAR}}` (name matching [A-Za-z0-9_]+) or `env:VAR` naming a single \
229         variable — no environment can satisfy this value, so the credential would be \
230         silently omitted and every backend request sent unauthenticated"
231    )]
232    MalformedBackendAuthRef(String),
233    /// Per Phase 120 Plan 04 (PKG-03): a `[[config_slots]]` entry at `index`
234    /// has an empty / whitespace-only `key` or `name`. A slot declaration whose
235    /// key names no config path — or whose name names no environment variable —
236    /// claims coverage it cannot deliver, and the package side would compare
237    /// against an empty string.
238    ///
239    /// The sibling "unrecognized `kind`" check is NOT here: `kind` is the
240    /// closed [`crate::config::ConfigSlotKind`] enum, so serde rejects an
241    /// unknown discriminator at PARSE time (naming the accepted set) before
242    /// `validate()` is ever called.
243    #[error("[[config_slots]] entry at index {0} has an empty key or name")]
244    EmptyConfigSlotField(usize),
245    /// A `[[config_slots]]` entry at `index` is `kind = "secret"` but carries a
246    /// `tested_value`. Identity-bearing slots structurally carry no value — the
247    /// `tested_value` field on a secret declaration is the one place a REAL
248    /// credential could sit in a config that is served but never packed (the
249    /// pack-time agreement gate only runs on packaging), so the rule is
250    /// enforced at validation time rather than trusted as prose. The message
251    /// deliberately does not echo the value.
252    #[error(
253        "[[config_slots]] entry at index {0} is kind = \"secret\" but carries a tested_value; \
254         identity-bearing slots record no value — remove it (a credential must never sit in \
255         the config file)"
256    )]
257    SecretSlotCarriesTestedValue(usize),
258    /// Per Phase 128 SC-2: a `[[tools]]` entry's synthesized `inputSchema` does
259    /// not compile as a Draft 2020-12 schema — in practice always a
260    /// `[[tools.parameters]]` `pattern` that is not a valid regular expression.
261    ///
262    /// Caught at CONFIG time rather than at call time, because a single
263    /// non-compiling `pattern` fails the whole document: the tool's validator
264    /// never builds, so every call to it is refused (or, if the compile error were
265    /// swallowed, every call passes unchecked). Neither outcome should first be
266    /// discovered by a client.
267    ///
268    /// # Why quoting `detail` is safe here
269    ///
270    /// `detail` is the engine's own compile-error text, which quotes the offending
271    /// SCHEMA — author-supplied config, never caller-supplied argument data. This
272    /// error is raised by [`crate::config::ServerConfig::validate`], which runs at
273    /// load time with no request in scope, and its audience is the config author,
274    /// who needs the detail to fix the regex. The SC-7 no-echo rule governs the
275    /// CLIENT-facing `tools/call` refusal path, where a non-compiling schema still
276    /// yields a detail-free message.
277    ///
278    /// `position` is the compile error's JSON schema path — e.g.
279    /// `/properties/region/pattern` — so it names the offending parameter directly.
280    #[error(
281        "[[tools]] '{tool}' has a declared parameter schema that does not compile at \
282         {position}: {detail}"
283    )]
284    UncompilableParamSchema {
285        /// The `[[tools]]` `name` whose parameter schema failed to compile.
286        tool: String,
287        /// JSON schema path of the offending declaration, e.g.
288        /// `/properties/region/pattern`.
289        position: String,
290        /// The engine's compile-error text. Schema-derived, never caller data.
291        detail: String,
292    },
293    /// Per Phase 128 SC-2: a `[[tools.parameters]]` `pattern` was declared as the
294    /// empty string.
295    ///
296    /// An empty `pattern` is a valid regular expression that matches every input,
297    /// so it buys no enforcement at all while reading — in a config review, in a
298    /// diff — exactly like a rule. Refused as a likely author error rather than
299    /// accepted as a no-op.
300    #[error(
301        "[[tools]] '{tool}' parameter '{param}' declares an empty pattern; an empty pattern \
302         matches every value and enforces nothing — remove the key or write the rule"
303    )]
304    EmptyParamPattern {
305        /// The `[[tools]]` `name` carrying the offending parameter.
306        tool: String,
307        /// The `[[tools.parameters]]` `name` whose `pattern` is empty.
308        param: String,
309    },
310    /// Per Phase 128 D3: a `[[tools.parameters]]` `minimum` or `maximum` is
311    /// non-finite (`NaN` / infinity) or has a magnitude EXCEEDING 2^53.
312    ///
313    /// # What this establishes, precisely
314    ///
315    /// [`crate::config::ParamDecl::minimum`] and
316    /// [`crate::config::ParamDecl::maximum`] are `f64`. A TOML integer above 2^53
317    /// has therefore ALREADY been rounded by the time this check runs, so the check
318    /// cannot see that rounding happened and does NOT promise to catch a bound
319    /// sitting one unit past the boundary. It catches the non-finite and the wildly
320    /// out-of-range cases, which is where a silently-mangled bound is most likely
321    /// to be load-bearing.
322    ///
323    /// The honest contract, stated on the field itself as well: `minimum` /
324    /// `maximum` are not a safe way to bound a 64-bit integer ID. Use a `pattern`
325    /// over the string form for that.
326    #[error(
327        "[[tools]] '{tool}' parameter '{param}' declares a minimum/maximum that is \
328         non-finite or exceeds 2^53; bounds are stored as f64, so such a value cannot be \
329         represented exactly — bound a large integer ID with a `pattern` instead"
330    )]
331    NonFiniteParamBound {
332        /// The `[[tools]]` `name` carrying the offending parameter.
333        tool: String,
334        /// The `[[tools.parameters]]` `name` whose bound cannot be represented.
335        param: String,
336    },
337    /// Per Phase 128 D3 / D-07: a string parameter declares no `max_length` AND no
338    /// default cap reaches it, and `[server.validation]` `strict = true` promotes
339    /// that lint finding into a hard failure.
340    ///
341    /// Deliberately NOT described as "body-position". That is the usual case but not
342    /// the only one: `[server.validation] default_max_length = 0` is a supported
343    /// opt-out that switches the D3 cap off for EVERY position, so a PATH or QUERY
344    /// parameter reaches this variant too. Naming a position the variant does not
345    /// actually pin sent the operator looking at the wrong parameter. The position
346    /// and the reason are carried by the paired
347    /// [`crate::config::ServerConfig::lint`] finding, which has both in scope.
348    ///
349    /// Only reachable under `strict`. With `strict = false` (the default) the same
350    /// config validates cleanly and the finding is reported by `lint` instead — a
351    /// running server must never refuse to boot over an uncapped free-text field,
352    /// which is the whole reason the lint channel exists separately from `validate`.
353    #[error(
354        "[[tools]] '{tool}' parameter '{param}' is an uncapped string — no declared \
355         max_length and no default cap reaches it — and [server.validation] strict = true; \
356         declare a max_length, or clear the strict flag (the paired lint finding names why \
357         no cap reached it)"
358    )]
359    UncappedStringParam {
360        /// The `[[tools]]` `name` carrying the uncapped parameter.
361        tool: String,
362        /// The `[[tools.parameters]]` `name` with no `max_length`.
363        param: String,
364    },
365    /// Per Phase 128 D4(b): a single-call `[[tools]]` `path` carries a
366    /// `/`-delimited segment that is not a supported placeholder shape.
367    ///
368    /// # The supported shape, and why anything else is an author error
369    ///
370    /// On the curated single-call surface a placeholder is a WHOLE segment: the
371    /// `path_placeholder_names` helper recognizes `{name}` spanning an
372    /// entire `/`-delimited segment and nothing else. A segment that contains a
373    /// brace but is not exactly `{name}` therefore takes one of two bad routes at
374    /// call time, neither of which is what the author meant:
375    ///
376    /// - `/search/{a}{b}` parses to the single parameter name `a}{b`, which no
377    ///   `[[tools.parameters]]` entry can match, so nothing is substituted;
378    /// - `/prefix-{id}` is not recognized as carrying a placeholder at all, so the
379    ///   literal text `{id}` is what would travel toward the backend.
380    ///
381    /// Both used to pass config validation and fail obscurely later. Refusing here
382    /// turns a silently-wrong request into a startup error naming the segment. The
383    /// segment text is author-written configuration, so echoing it is safe and is
384    /// what makes the error actionable — it carries no caller data.
385    #[error(
386        "[[tools]] '{tool}' path template segment '{segment}' is not a supported \
387         placeholder shape: a segment either contains no braces at all, or is \
388         exactly one non-empty '{{name}}' spanning the whole segment"
389    )]
390    MalformedPathTemplateSegment {
391        /// The `[[tools]]` `name` whose `path` carries the offending segment.
392        tool: String,
393        /// The offending `/`-delimited segment, verbatim (author-written config).
394        segment: String,
395    },
396    /// A `[code_mode]` key that only means something on the other kind of
397    /// server: a SQL key on an OpenAPI server (one with a `[backend]`), or an
398    /// operation-class key on a server with no `[backend]`. It would be
399    /// silently ignored, so the server refuses to boot instead.
400    #[error("[code_mode] key `{key}` does not apply to {server_kind} server; {hint}")]
401    CodeModeKeyWrongBackend {
402        /// The config key.
403        key: &'static str,
404        /// `"an OpenAPI"` or `"a SQL"`.
405        server_kind: &'static str,
406        /// What to use instead.
407        hint: &'static str,
408    },
409    /// A class mode that needs a list that is empty: `allowlist` with no
410    /// `allowed_operations`, or `blocklist` with no `blocked_operations`.
411    #[error("[code_mode] `{class}_mode = \"{mode}\"` needs a non-empty `{list}`")]
412    ClassModeNeedsList {
413        /// `read`, `write`, `delete` or `admin`.
414        class: &'static str,
415        /// The mode.
416        mode: &'static str,
417        /// The list key it needs.
418        list: &'static str,
419    },
420    /// A `[[code_mode.operations]]` entry with an empty `id` or `path`
421    /// (index into the list).
422    #[error("[[code_mode.operations]] entry at index {0} has an empty id or path")]
423    EmptyOperationField(usize),
424    /// Two `[[code_mode.operations]]` entries share an `id`.
425    #[error("[[code_mode.operations]] id '{0}' is declared more than once")]
426    DuplicateOperationId(String),
427    /// A `[code_mode] auto_approve_levels` entry that is not `low`, `medium`,
428    /// `high` or `critical`. It used to be skipped, which made a typo read as
429    /// "nothing auto-approved".
430    #[error(
431        "[code_mode] auto_approve_levels entry '{0}' is not one of low, medium, high, critical"
432    )]
433    UnknownAutoApproveLevel(String),
434    /// Operation-class keys were set, but this build cannot enforce them (the
435    /// `openapi-code-mode` feature is off). Raised by the HTTP tool synthesizer
436    /// when it would otherwise serve curated tools unchecked, never by
437    /// [`crate::config::ServerConfig::validate`], whose verdict does not depend on
438    /// build features.
439    #[error(
440        "[code_mode] key `{0}` needs the `openapi-code-mode` feature, which this build \
441         does not have, so it could not be enforced"
442    )]
443    ClassKeysUnenforceable(&'static str),
444    /// A curated `[[tools]]` entry calls an operation the `[code_mode]` class
445    /// policy refuses. The tool could never succeed, and a reader of the policy
446    /// would assume it is blocked, so the contradiction fails the boot.
447    #[error("[[tools]] '{tool}' is refused by the [code_mode] class policy: {reason}")]
448    CuratedToolRefusedByPolicy {
449        /// The `[[tools]]` `name`.
450        tool: String,
451        /// The policy's violation message (names the class and mode).
452        reason: String,
453    },
454}
455
456/// One non-fatal finding from [`crate::config::ServerConfig::lint`]
457/// (Phase 128, D-07).
458///
459/// # Why this exists instead of a `validate()` variant
460///
461/// [`ConfigValidationError`] is first-error-wins `Result<(), _>` with no warning
462/// channel, so D-07's "warns" is unexpressible in that signature. A running server
463/// must NOT refuse to boot because a free-text body parameter has no `max_length`
464/// (D-05) — but the author still has to be told, and the startup log is what makes
465/// a later regression traceable. Hence a separate additive `-> Vec<ConfigWarning>`
466/// channel that leaves `validate`'s existing behaviour untouched.
467///
468/// `[server.validation] strict = true` is what promotes a finding into a
469/// [`ConfigValidationError::UncappedStringParam`].
470#[non_exhaustive]
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct ConfigWarning {
473    /// The `[[tools]]` `name` the finding concerns; `None` for a server-level
474    /// finding — an active `[server.validation]` opt-out belongs to the server, not
475    /// to a tool.
476    pub tool: Option<String>,
477    /// The `[[tools.parameters]]` `name` the finding concerns; `None` when the
478    /// finding is not about one parameter.
479    ///
480    /// A server-level finding has neither `tool` nor `param`. A TOOL-level finding,
481    /// one that concerns the `[[tools]]` entry as a whole rather than any one
482    /// parameter (`lint_against_spec`'s `configured-template-not-in-spec` is the
483    /// live case), has a `tool` and no `param`. A parameter-level finding has both.
484    /// `None` is the only "absent" value: an empty string is a name, not a scope.
485    pub param: Option<String>,
486    /// Stable machine-readable rule identifier, e.g. `"uncapped-string"`.
487    ///
488    /// A `&'static str` rather than an enum so plan 07's CLI and plan 09's startup
489    /// log can group and filter on it without either taking a dependency on a
490    /// closed set that every new rule would widen.
491    pub rule: &'static str,
492    /// Human-readable explanation, including the remedy.
493    ///
494    /// Author-facing, like [`ConfigValidationError`]'s messages and unlike a
495    /// client-facing refusal: it may name config keys and declared limits. It never
496    /// contains caller data — `lint` runs at load time with no request in scope.
497    pub detail: String,
498}
499
500impl std::fmt::Display for ConfigWarning {
501    /// Renders the three scopes the two optional fields encode: server-level,
502    /// tool-level (no parameter) and parameter-level. Branching on `tool` alone
503    /// would render a tool-level finding as `... 'get_cui' parameter '': ...`,
504    /// which `lint_against_spec` can produce and `pmcp-openapi-server` prints
505    /// verbatim to the deploy log.
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        match (self.tool.as_deref(), self.param.as_deref()) {
508            // Server-level: an active `[server.validation]` opt-out belongs to the
509            // server, not to any tool.
510            (None, _) => write!(f, "[{}] {}", self.rule, self.detail),
511            // Tool-level: the finding concerns the `[[tools]]` entry as a whole.
512            (Some(tool), None) => {
513                write!(f, "[{}] [[tools]] '{tool}': {}", self.rule, self.detail)
514            },
515            // Parameter-level.
516            (Some(tool), Some(param)) => write!(
517                f,
518                "[{}] [[tools]] '{tool}' parameter '{param}': {}",
519                self.rule, self.detail
520            ),
521        }
522    }
523}
524
525#[cfg(test)]
526mod config_warning_display {
527    use super::ConfigWarning;
528
529    fn warning(tool: &str, param: &str) -> ConfigWarning {
530        // The test helper keeps "" as shorthand for "no such scope".
531        let scope = |s: &str| (!s.is_empty()).then(|| s.to_string());
532        ConfigWarning {
533            tool: scope(tool),
534            param: scope(param),
535            rule: "a-rule",
536            detail: "a detail".to_string(),
537        }
538    }
539
540    /// The two sentinel fields encode THREE scopes. Asserted because nothing else
541    /// in the tree renders a `ConfigWarning`: `lint()`'s own tests check `.rule`
542    /// and `.detail` only, and the sole production consumer is a
543    /// `tracing::warn!("{finding}")` in `pmcp-openapi-server`'s deploy log — so a
544    /// regression in this `match` is invisible to every other test.
545    #[test]
546    fn renders_server_tool_and_parameter_scopes_distinctly() {
547        assert_eq!(warning("", "").to_string(), "[a-rule] a detail");
548        assert_eq!(
549            warning("get_cui", "").to_string(),
550            "[a-rule] [[tools]] 'get_cui': a detail"
551        );
552        assert_eq!(
553            warning("get_cui", "version").to_string(),
554            "[a-rule] [[tools]] 'get_cui' parameter 'version': a detail"
555        );
556    }
557
558    /// The specific regression the three-arm form exists to prevent: a tool-level
559    /// finding must not be rendered as a parameter-level one with an empty name.
560    #[test]
561    fn a_tool_level_finding_never_renders_an_empty_parameter_name() {
562        let rendered = warning("get_cui", "").to_string();
563        assert!(
564            !rendered.contains("parameter"),
565            "a tool-level finding must not claim a parameter: {rendered}"
566        );
567        assert!(
568            !rendered.contains("''"),
569            "no empty-name sentinel: {rendered}"
570        );
571    }
572}