Skip to main content

pmcp_server_toolkit/
config.rs

1// Originated from pmcp-run/built-in/shared/mcp-server-common/src/config.rs
2// (https://github.com/guyernest/pmcp-run). Lifted into rust-mcp-sdk for Phase 83.
3
4//! `ServerConfig` + sub-sections. Strict `#[serde(deny_unknown_fields)]` per D-13.
5//!
6//! # Strict-parse discipline (D-13)
7//!
8//! Every struct in this module carries `#[serde(deny_unknown_fields)]`. A typo
9//! in any key (e.g. `auto_aprove_levels` for `auto_approve_levels`) is a
10//! **parse error**, not a silent default. This is the defence-in-depth path
11//! against the Tampering threat documented in `83-04-PLAN.md` T-83-04-02 —
12//! mis-spelled keys MUST NOT degrade security policy.
13//!
14//! # REF-01 superset invariant
15//!
16//! `ServerConfig` is a strict **superset** of every key emitted by the three
17//! reference config.tomls (`tests/fixtures/{open-images,imdb,msr-vtt}-config.toml`,
18//! lifted in Plan 01 Task 4). When a fixture grows a new key, the toolkit grows
19//! a new field — typed if known, `toml::Value` if heterogeneous. The invariant
20//! is enforced empirically by the [`tests/reference_configs.rs`] integration
21//! test (REF-01 superset, D-13, ROADMAP SC-2).
22//!
23//! **Anti-pattern (RESEARCH §Pitfall 1, PATTERNS §8):** Do NOT loosen
24//! `deny_unknown_fields` to make a fixture parse. Always ADD the missing field.
25//!
26//! # Three entry points
27//!
28//! | Method | Returns | Use case |
29//! |--------|---------|----------|
30//! | [`ServerConfig::from_toml`] | `Result<Self, ToolkitError::Parse>` | Programmatic partial-config merge; no semantic checks |
31//! | [`ServerConfig::validate`] | `Result<(), ConfigValidationError>` | Post-parse semantic check (run after a merge) |
32//! | [`ServerConfig::from_toml_strict_validated`] | `Result<Self, ToolkitError>` | Production entry: parse + validate in one call |
33//!
34//! Per Phase 83 review R8, `validate()` exists because the `Default` impls on
35//! `ServerSection` etc. would otherwise let `[server]` typos land empty
36//! `name`/`version` strings without an error. The strict-validated convenience
37//! is what production callers should reach for.
38//!
39//! REF-01 superset enumeration (from `tests/fixtures/{open-images,imdb,msr-vtt,reference}-config.toml`;
40//! the SQLite Chinook `reference-config.toml` was lifted in Plan 85-01):
41//!
42//! ```text
43//! [server]            : id, name, description, type, version, is_reference
44//! [metadata]          : display_name, short_description, description, tags, author, visibility
45//! [database]          : type, database, output_location, workgroup, query_timeout_ms,
46//!                       url, file_path, [[database.tables]], [database.pool]
47//! [[database.tables]] : name, description
48//! [database.pool]     : max_connections, connection_timeout_seconds
49//! [code_mode]         : enabled, server_id, allow_writes, allow_deletes, allow_ddl,
50//!                       require_limit, max_limit, blocked_tables, sensitive_columns,
51//!                       auto_approve_levels, token_ttl_seconds, token_secret,
52//!                       [code_mode.limits]
53//! [code_mode.limits]  : max_tables_per_query, max_join_depth, max_subquery_depth
54//! [shared_policy_store] : creates_shared_store, export_to_ssm, ssm_path, templates
55//! [[tools]]           : name, description, sql, ui_resource_uri,
56//!                       [[tools.parameters]], [tools.annotations]
57//! [[tools.parameters]] : name, type, description, required, default, max_length,
58//!                       minimum, maximum, enum
59//! [tools.annotations] : read_only_hint, destructive_hint, idempotent_hint,
60//!                       open_world_hint, cost_hint
61//! [[prompts]]         : name, description, include_resources, arguments
62//! [[resources]]       : uri, name, description, mime_type, content
63//! ```
64
65use serde::{Deserialize, Serialize};
66
67use crate::error::{ConfigValidationError, ConfigWarning, Result, ToolkitError};
68
69// -----------------------------------------------------------------------------
70// Top-level
71// -----------------------------------------------------------------------------
72
73/// Top-level `pmcp-server-toolkit` configuration parsed from a `config.toml`.
74///
75/// One struct parses the entire file in one shot (per D-13). All sub-sections
76/// carry `#[serde(deny_unknown_fields)]` — a typo anywhere in the file is a
77/// hard parse error.
78///
79/// # Entry points
80///
81/// Use [`ServerConfig::from_toml_strict_validated`] for production callers.
82/// [`ServerConfig::from_toml`] is the no-validation variant for programmatic
83/// merges; [`ServerConfig::validate`] runs the semantic checks separately.
84///
85/// # Examples
86///
87/// ```
88/// use pmcp_server_toolkit::config::ServerConfig;
89///
90/// let toml = r#"
91///     [server]
92///     name = "demo"
93///     version = "0.1.0"
94/// "#;
95/// let cfg = ServerConfig::from_toml_strict_validated(toml)
96///     .expect("valid minimum config");
97/// assert_eq!(cfg.server.name, "demo");
98/// assert_eq!(cfg.server.version, "0.1.0");
99/// ```
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
101#[serde(deny_unknown_fields)]
102pub struct ServerConfig {
103    /// `[server]` — identity and version metadata.
104    #[serde(default)]
105    pub server: ServerSection,
106
107    /// `[metadata]` — admin-facing display defaults.
108    #[serde(default)]
109    pub metadata: MetadataSection,
110
111    /// `[database]` — backend connection + tables.
112    #[serde(default)]
113    pub database: DatabaseSection,
114
115    /// `[backend]` (optional, `http` feature) — OpenAPI/REST HTTP backend
116    /// declaration (`base_url` + `[backend.auth]` + `[backend.http]`).
117    ///
118    /// Additive per the REF-01 superset invariant (D-06): a pure-SQL config
119    /// omits `[backend]` and this field parses to `None`. The whole section is
120    /// gated behind the `http` feature — a no-http build has no OpenAPI backend,
121    /// so exposing an unusable stub type would be misleading. See
122    /// [`BackendSection`].
123    #[cfg(feature = "http")]
124    #[serde(default)]
125    pub backend: Option<BackendSection>,
126
127    /// `[code_mode]` (optional) — code-mode policy and limits.
128    #[serde(default)]
129    pub code_mode: Option<CodeModeSection>,
130
131    /// `[[tools]]` — declarative tool surface (TOML-defined handlers).
132    #[serde(default)]
133    pub tools: Vec<ToolDecl>,
134
135    /// `[[config_slots]]` — declared config slots the TARGET environment must
136    /// fill (PKG-03). Additive per the REF-01 superset invariant: a config
137    /// omitting the block parses to an empty vec.
138    ///
139    /// Deliberately NOT gated on the `http` feature — a SQL or workbook Shape A
140    /// server declares slots too, and gating it would make the field vanish in
141    /// the toolkit's own default build.
142    #[serde(default)]
143    pub config_slots: Vec<ConfigSlotDecl>,
144
145    /// `[[prompts]]` — declarative prompt surface.
146    #[serde(default)]
147    pub prompts: Vec<PromptDecl>,
148
149    /// `[[resources]]` — declarative resource surface.
150    #[serde(default)]
151    pub resources: Vec<ResourceDecl>,
152
153    /// `[shared_policy_store]` (optional) — AVP/Cedar shared-policy-store
154    /// declaration emitted by the reference SQL server (`is_reference = true`),
155    /// which provisions the policy store all sibling SQL servers attach to.
156    /// Additive per the REF-01 superset invariant (Plan 85-01); parsed
157    /// verbatim — the toolkit does not provision SSM at parse time.
158    #[serde(default)]
159    pub shared_policy_store: Option<SharedPolicyStoreSection>,
160}
161
162impl ServerConfig {
163    /// Parse `ServerConfig` from a TOML config string.
164    ///
165    /// Performs **strict parsing** (`#[serde(deny_unknown_fields)]` on every
166    /// section, per D-13). Does **not** run semantic validation — callers
167    /// wanting required-field guarantees should use
168    /// [`Self::from_toml_strict_validated`] instead.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`ToolkitError::Parse`] on syntax error or unknown field. A
173    /// mis-spelled key (e.g. `auto_aprove_levels` for `auto_approve_levels`)
174    /// produces a parse error here, not a silent default.
175    ///
176    /// # Example
177    ///
178    /// ```
179    /// use pmcp_server_toolkit::config::ServerConfig;
180    ///
181    /// let toml = r#"
182    ///     [server]
183    ///     id = "demo"
184    ///     name = "Demo"
185    ///     version = "0.1.0"
186    /// "#;
187    /// let cfg = ServerConfig::from_toml(toml).expect("parse");
188    /// assert_eq!(cfg.server.name, "Demo");
189    /// ```
190    pub fn from_toml(toml_str: &str) -> Result<Self> {
191        toml::from_str(toml_str).map_err(ToolkitError::Parse)
192    }
193
194    /// Parse + validate. Per Phase 83 review R8 — guards against the
195    /// missing-required-value trap that the `Default` impls on sub-sections
196    /// would otherwise hide behind silent empty strings (e.g. a typo'd
197    /// `[serever]` header makes `server.name` default to `""`).
198    ///
199    /// # Errors
200    ///
201    /// Returns [`ToolkitError::Parse`] on TOML syntax / unknown-field errors,
202    /// or [`ToolkitError::Validation`] (wrapping
203    /// [`ConfigValidationError`]) on missing required values
204    /// (empty `server.name`, empty `server.version`, empty tool name, empty
205    /// table name).
206    ///
207    /// # Example
208    ///
209    /// ```
210    /// use pmcp_server_toolkit::config::ServerConfig;
211    /// let toml = r#"
212    ///     [server]
213    ///     name = "demo"
214    ///     version = "0.1.0"
215    /// "#;
216    /// let cfg = ServerConfig::from_toml_strict_validated(toml).expect("valid");
217    /// # let _ = cfg;
218    /// ```
219    pub fn from_toml_strict_validated(toml_str: &str) -> Result<Self> {
220        let cfg = Self::from_toml(toml_str)?;
221        cfg.validate()?;
222        Ok(cfg)
223    }
224
225    /// Validate required-field semantics that `#[serde(default)]` would
226    /// otherwise mask. Per Phase 83 review R8.
227    ///
228    /// Rules checked, in order:
229    /// 1. `server.name` is non-empty (trimmed).
230    /// 2. `server.version` is non-empty (trimmed).
231    /// 3. Every `[[tools]]` entry has a non-empty `name`.
232    /// 4. No `[[tools]]` entry mixes tool kinds (`sql` / `path`+`method` /
233    ///    `script`) — D-01 / T-90-02-04.
234    /// 5. Every `[[database.tables]]` entry has a non-empty `name`.
235    /// 6. Every `[[config_slots]]` entry has a non-empty `key` AND `name`
236    ///    (PKG-03). The entry's `kind` needs no rule here — it is the closed
237    ///    [`ConfigSlotKind`] enum, so serde rejects an unknown discriminator at
238    ///    parse time, before `validate()` is called.
239    /// 7. When a `[backend]` block is present (`http` feature), its `base_url`
240    ///    is non-empty (trimmed) — GAP 3 / WR-02. Absent on no-http builds.
241    /// 8. Every `[[tools.parameters]]` declaration is well-formed (Phase 128
242    ///    D2 / SC-2): no empty `pattern`, no `minimum`/`maximum` outside the
243    ///    exactly-representable `f64` integer range, and the tool's synthesized
244    ///    `inputSchema` COMPILES as a Draft 2020-12 schema. The compile check
245    ///    requires the `input-validation` feature; on a build without it the check
246    ///    is skipped and a `tracing::warn!` says so once, because an enforcement
247    ///    that is off must never read as on.
248    ///
249    /// # Errors
250    ///
251    /// Returns a [`ConfigValidationError`] variant identifying the
252    /// first rule violated. Iteration order matches struct field order.
253    pub fn validate(&self) -> std::result::Result<(), ConfigValidationError> {
254        warn_if_pattern_checking_unavailable(&self.tools);
255        if self.server.name.trim().is_empty() {
256            return Err(ConfigValidationError::EmptyServerName);
257        }
258        if self.server.version.trim().is_empty() {
259            return Err(ConfigValidationError::EmptyServerVersion);
260        }
261        for (i, tool) in self.tools.iter().enumerate() {
262            if tool.name.trim().is_empty() {
263                return Err(ConfigValidationError::EmptyToolName(i));
264            }
265            // D-01 / T-90-02-04: a tool is EITHER sql, single-call (path/method),
266            // OR script — never a mixture. Reject ambiguity instead of letting a
267            // silent "script wins" precedence hide a config mistake.
268            if tool.declared_kind_count() > 1 {
269                return Err(ConfigValidationError::AmbiguousToolKind(i));
270            }
271            // Phase 128 D2 / SC-2. Deliberately placed AFTER the name and
272            // kind arms so no pre-existing test's expected variant changes.
273            validate_tool_parameters(tool, &self.server.validation)?;
274        }
275        for (i, table) in self.database.tables.iter().enumerate() {
276            if table.name.trim().is_empty() {
277                return Err(ConfigValidationError::EmptyTableName(i));
278            }
279        }
280        // PKG-03 (Phase 120 Plan 04): a declared slot must actually name a
281        // config path AND a variable. An empty `key`/`name` claims coverage the
282        // declaration cannot deliver. Deliberately NOT a completeness
283        // heuristic — a "this literal looks secret, so a slot is missing" check
284        // would flag the london-tube fixture's guarded dev `token_secret`, and
285        // a check that cries wolf is worse than none.
286        for (i, slot) in self.config_slots.iter().enumerate() {
287            if slot.key.trim().is_empty() || slot.name.trim().is_empty() {
288                return Err(ConfigValidationError::EmptyConfigSlotField(i));
289            }
290            // Identity-bearing slots structurally carry no value (the whole
291            // "secrets never travel" premise) — a `tested_value` on a `secret`
292            // declaration is the one field where a REAL credential could sit in
293            // a config that is served but never packed, so the doc-comment rule
294            // is enforced here rather than trusted.
295            if slot.kind == ConfigSlotKind::Secret && slot.tested_value.is_some() {
296                return Err(ConfigValidationError::SecretSlotCarriesTestedValue(i));
297            }
298        }
299        // Phase 90 gap-closure (GAP 3 / WR-02): when a `[backend]` block is
300        // declared, its `base_url` must be non-empty. Catch a typo'd / omitted
301        // URL here (the field is `#[serde(default)]` -> `""`) rather than
302        // letting it surface late as an opaque DispatchError at request time.
303        // Gated on `http` because the `backend` field itself is http-only; the
304        // block simply vanishes in a no-http build (SQL configs unaffected).
305        #[cfg(feature = "http")]
306        if let Some(backend) = &self.backend {
307            if backend.base_url.trim().is_empty() {
308                return Err(ConfigValidationError::EmptyBackendBaseUrl);
309            }
310            // Phase 120 follow-up: a reference-shaped base_url must name
311            // exactly ONE variable. The grammar maps every malformed brace
312            // form — the empty `${}` and multi-placeholder compositions like
313            // `${SCHEME}://${HOST}` — to the empty name; catching that here
314            // turns a boot-time `UnresolvedBaseUrlRef` with an empty variable
315            // name into a load-time error naming the actual mistake.
316            if crate::env_ref::parse_env_ref(&backend.base_url) == Some("") {
317                return Err(ConfigValidationError::MalformedBackendBaseUrlRef);
318            }
319            // The same rule for `[backend.auth]` credentials. It is NOT the
320            // same consequence: an unresolvable base_url breaks every request
321            // loudly, while an unresolvable CREDENTIAL was silently omitted
322            // (`expand_api_key_map` drops the entry; the scalar variants
323            // collapse to `NoAuth`), so the server booted and sent every
324            // backend request unauthenticated. Catching it here is what makes
325            // that failure visible at all.
326            if let Some(field) = backend.auth.malformed_env_ref_field() {
327                return Err(ConfigValidationError::MalformedBackendAuthRef(field));
328            }
329        }
330        Ok(())
331    }
332
333    /// Non-fatal configuration findings (Phase 128, D-07).
334    ///
335    /// Returns, in `[[tools]]` then `[[tools.parameters]]` DECLARATION order:
336    ///
337    /// 1. one `uncapped-string` finding per BODY-position string parameter with no
338    ///    `max_length` — the residual D-05 accepts, surfaced rather than refused;
339    /// 2. one `declared-max-length-above-placeholder-cap` finding per path- or
340    ///    query-position parameter whose declared `max_length` EXCEEDS
341    ///    `pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH`, because such a
342    ///    parameter publishes a limit in `inputSchema` that the always-on
343    ///    placeholder floor will not honour — so a refusal would name a rule the
344    ///    client was never told about;
345    ///
346    /// and then one finding per ACTIVE `[server.validation]` opt-out.
347    ///
348    /// Never returns an error and never refuses anything. A config with zero tools
349    /// and no active opt-out returns an empty `Vec`. Consumed by
350    /// `cargo pmcp validate config` and by the once-at-startup log.
351    #[must_use]
352    pub fn lint(&self) -> Vec<ConfigWarning> {
353        let mut out = Vec::new();
354        for tool in &self.tools {
355            lint_tool(tool, &self.server.validation, &mut out);
356        }
357        lint_opt_outs(&self.server.validation, &mut out);
358        out
359    }
360
361    /// The findings that need the operator's OpenAPI document to be computable
362    /// (Phase 128, D4(b) / T-128-36a).
363    ///
364    /// Separate from [`Self::lint`] rather than folded into it, because `lint`
365    /// takes only `&self` and a config is meaningful with no spec at all — a
366    /// spec-less deployment is supported and must produce no findings from its own
367    /// absence.
368    ///
369    /// Returns, in `[[tools]]` declaration order, one
370    /// [`CONFIGURED_TEMPLATE_NOT_IN_SPEC`] finding per single-call tool whose
371    /// `(method, path)` matches no operation the spec declares. Such a tool reaches
372    /// its endpoint and keeps the unconditional character floor and the always-on
373    /// length cap, but the spec's declared `pattern`/`maxLength` narrowing for its
374    /// placeholders is silently not applied — the `/users/{alias}` versus
375    /// `/users/{id}` drift. That is an author error an operator can fix before
376    /// deploy, which is the whole reason this runs at config time.
377    ///
378    /// An author-written query string on the configured `path` is stripped before
379    /// the lookup, because an OpenAPI path template never carries one — so
380    /// `/content/{version}/CUI?string=x` is matched as `/content/{version}/CUI`
381    /// rather than reported as drift.
382    ///
383    /// # The bound on this guard, stated
384    ///
385    /// It covers a template written in the CONFIG. A template a Code Mode script
386    /// COMPOSES at runtime is not visible here and cannot be, which is why the
387    /// runtime miss is additionally reported once per `(method, template)` pair by
388    /// `crate::code_mode`'s `log_spec_lookup_miss`. Neither signal is a refusal:
389    /// this returns findings, and never an error.
390    ///
391    /// Tools with no `path`/`method` pair — SQL tools, script tools — are skipped:
392    /// they address no single spec operation.
393    #[cfg(feature = "http")]
394    #[must_use]
395    pub fn lint_against_spec(&self, spec: &crate::http::OpenApiSchema) -> Vec<ConfigWarning> {
396        let mut out = Vec::new();
397        for tool in &self.tools {
398            let (Some(path), Some(method)) = (tool.path.as_deref(), tool.method.as_deref()) else {
399                continue;
400            };
401            // An OpenAPI path template never carries a query string; the curated
402            // surface permits one (plan 06's `?` narrowing), so strip it first.
403            let template = path.split_once('?').map_or(path, |(p, _)| p);
404            if spec.operation_for(template, method).is_some() {
405                continue;
406            }
407            out.push(ConfigWarning {
408                tool: Some(tool.name.clone()),
409                param: None,
410                rule: CONFIGURED_TEMPLATE_NOT_IN_SPEC,
411                detail: format!(
412                    "declares `method = \"{method}\"` and `path = \"{path}\"`, which matches no \
413                     operation in the supplied OpenAPI document. The tool still works and its \
414                     path placeholders still face the unconditional character floor and the \
415                     always-on length cap, but the spec's declared pattern/maxLength narrowing \
416                     is NOT applied to them — a placeholder named differently from the spec's \
417                     own (`{{alias}}` against a declared `{{id}}`) reaches the same endpoint \
418                     with its declaration silently dropped. Spell the path and method exactly \
419                     as the spec declares them, or remove the spec if this endpoint is \
420                     deliberately undocumented."
421                ),
422            });
423        }
424        out
425    }
426
427    /// A structured account of what THIS config actually enforces, for the
428    /// once-at-startup log (Phase 128 D-07 / `<specifics>`).
429    ///
430    /// The startup log is the regression-tracing mechanism, not optional polish: it
431    /// is how an operator discovers, from a deploy log alone, that a server is
432    /// running with schema enforcement off or with the cap disabled.
433    #[must_use]
434    pub(crate) fn validation_report(&self) -> ValidationReport {
435        let validation = &self.server.validation;
436        let mut opt_outs = Vec::new();
437        lint_opt_outs(validation, &mut opt_outs);
438        ValidationReport {
439            enforce_input_schema: validation.enforce_input_schema,
440            default_max_length: validation.default_max_length,
441            additional_properties: validation.additional_properties,
442            strict: validation.strict,
443            tools: self
444                .tools
445                .iter()
446                .map(|t| tool_validation_report(t, validation))
447                .collect(),
448            opt_outs: opt_outs.iter().map(ToString::to_string).collect(),
449        }
450    }
451}
452
453/// What [`ServerConfig::validation_report`] returns: the enforcement actually in
454/// effect, per server and per tool.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub(crate) struct ValidationReport {
457    /// Effective [`ValidationSection::enforce_input_schema`].
458    pub enforce_input_schema: bool,
459    /// Effective [`ValidationSection::default_max_length`].
460    pub default_max_length: u64,
461    /// Effective [`ValidationSection::additional_properties`].
462    pub additional_properties: bool,
463    /// Effective [`ValidationSection::strict`].
464    pub strict: bool,
465    /// One entry per `[[tools]]`, in declaration order.
466    pub tools: Vec<ToolValidationReport>,
467    /// Rendered active opt-outs — EMPTY when the server enforces everything it can.
468    pub opt_outs: Vec<String>,
469}
470
471/// One tool's row in a [`ValidationReport`].
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub(crate) struct ToolValidationReport {
474    /// The `[[tools]]` `name`.
475    pub tool: String,
476    /// Rendered per-parameter rules in declaration order, e.g.
477    /// `"region: path, pattern, maxLength=256 (default)"`. Reads as a log line.
478    pub rules: Vec<String>,
479}
480
481// -----------------------------------------------------------------------------
482// Phase 128 D3 / D-07 — `lint()` internals
483// -----------------------------------------------------------------------------
484
485/// Whether a parameter's EFFECTIVE JSON Schema type is `string`.
486///
487/// `param_type` defaults to `"string"` when omitted, matching
488/// `tools.rs::build_param_property`, so an omitted `type` is a string here too. A
489/// divergence would make the cap apply to a different set of parameters than the
490/// one the schema builder emits it for.
491pub(crate) fn is_string_param(p: &ParamDecl) -> bool {
492    p.param_type.as_deref().unwrap_or("string") == "string"
493}
494
495/// Whether the D3 default cap applies to a parameter at `position`.
496///
497/// All four conditions, in one place so `lint()` and
498/// `tools.rs::apply_position_cap` cannot disagree about which parameters are
499/// covered: the effective type is `string`, no `max_length` is declared, the
500/// configured default is non-zero, and the position is `Path` or `Query`.
501pub(crate) fn default_cap_applies(
502    p: &ParamDecl,
503    position: ParamPosition,
504    validation: &ValidationSection,
505) -> bool {
506    is_string_param(p) && p.max_length.is_none() && cap_position_applies(position, validation)
507}
508
509/// The POSITION half of [`default_cap_applies`]: whether the D3 default cap
510/// reaches `position` at all, independent of any particular parameter.
511///
512/// Split out because the two conditions are about different things — this one is
513/// "does the cap reach here", the other two are "does this parameter need it" —
514/// and because [`is_uncapped_string`] has to negate THIS half while asserting the
515/// other two. Negating the whole of `default_cap_applies` instead is a double
516/// negative over a predicate that redundantly re-checks its own premises.
517///
518/// Private: both callers are in this module. `default_cap_applies` is the
519/// `pub(crate)` face of the same question.
520fn cap_position_applies(position: ParamPosition, validation: &ValidationSection) -> bool {
521    validation.default_max_length != 0
522        && matches!(position, ParamPosition::Path | ParamPosition::Query)
523}
524
525/// Whether `p` is a string parameter that ends up with NO length bound at all —
526/// neither a declared `max_length` nor the D3 default cap.
527///
528/// The single definition behind both the `uncapped-string` `lint()` finding and
529/// the `strict`-mode [`ConfigValidationError::UncappedStringParam`] refusal, which
530/// must cover exactly the same parameters: a warning that `strict` would not
531/// refuse, or a refusal that `lint()` never warned about, is a drift defect.
532fn is_uncapped_string(
533    p: &ParamDecl,
534    position: ParamPosition,
535    validation: &ValidationSection,
536) -> bool {
537    is_string_param(p) && p.max_length.is_none() && !cap_position_applies(position, validation)
538}
539
540/// WHY a parameter ends up with no length bound. [`is_uncapped_string`] is
541/// position-BLIND — `cap_position_applies` is false either because the position is
542/// outside the cap's reach OR because `default_max_length = 0` switched the cap off
543/// for every position — and the two reasons need different prose.
544///
545/// Split out because the finding used to name only the first reason, rendering
546/// `... is in Path position, where the [server.validation] default_max_length cap
547/// deliberately does not apply` for a PATH parameter under
548/// `default_max_length = 0`. That is false twice over: the cap does reach Path, and
549/// what actually disabled it was the operator's own server-level opt-out — which
550/// emits its own [`OPT_OUT_DEFAULT_MAX_LENGTH_ZERO`] warning and is a supported
551/// configuration, not an unreachable one. A finding that misnames its own cause
552/// sends the operator to declare a `max_length` per parameter when one config key
553/// explains all of them.
554fn uncapped_reason(position: ParamPosition, validation: &ValidationSection) -> String {
555    if validation.default_max_length == 0 {
556        "[server.validation] default_max_length = 0 switches the default cap off for \
557         EVERY position on this server"
558            .to_string()
559    } else {
560        format!(
561            "it is in {position:?} position, which the [server.validation] \
562             default_max_length cap deliberately does not reach (free text must keep \
563             working)"
564        )
565    }
566}
567
568/// Per-tool `lint()` findings, appended in `[[tools.parameters]]` declaration
569/// order.
570fn lint_tool(tool: &ToolDecl, validation: &ValidationSection, out: &mut Vec<ConfigWarning>) {
571    for p in &tool.parameters {
572        let position = tool.param_position(&p.name);
573        if is_uncapped_string(p, position, validation) {
574            out.push(ConfigWarning {
575                tool: Some(tool.name.clone()),
576                param: Some(p.name.clone()),
577                rule: UNCAPPED_STRING,
578                detail: format!(
579                    "declares no max_length and no default cap reaches it, so it is \
580                     unbounded: {} — declare an explicit max_length, or set \
581                     [server.validation] strict = true to make this an error",
582                    uncapped_reason(position, validation)
583                ),
584            });
585        }
586        if let Some(declared) = p.max_length {
587            lint_declared_cap_above_placeholder_floor(tool, p, position, declared, out);
588        }
589    }
590}
591
592/// SC-7 shape mismatch: a path- or query-position parameter declaring a
593/// `max_length` ABOVE the always-on placeholder floor publishes a limit in
594/// `inputSchema` that `validate_path_placeholder` will not honour, so the call is
595/// refused against a rule the client was never told about.
596///
597/// The floor is read from
598/// `pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH` — the ONE copy of
599/// that number (D-08) — and is therefore only available under `input-validation`.
600/// See the `cfg(not(...))` sibling for why its absence makes this finding vacuous
601/// rather than merely unavailable.
602#[cfg(feature = "input-validation")]
603fn lint_declared_cap_above_placeholder_floor(
604    tool: &ToolDecl,
605    p: &ParamDecl,
606    position: ParamPosition,
607    declared: u64,
608    out: &mut Vec<ConfigWarning>,
609) {
610    if !matches!(position, ParamPosition::Path | ParamPosition::Query) {
611        return;
612    }
613    let floor = pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH as u64;
614    if declared <= floor {
615        return;
616    }
617    out.push(ConfigWarning {
618        tool: Some(tool.name.clone()),
619        param: Some(p.name.clone()),
620        rule: DECLARED_MAX_LENGTH_ABOVE_PLACEHOLDER_CAP,
621        detail: format!(
622            "declares max_length = {declared} in {position:?} position, but the always-on \
623             path-placeholder floor refuses at {floor} code points regardless — so the \
624             effective limit is {floor}, and a refusal would name a limit the published \
625             inputSchema never advertised. Lower the declared max_length to {floor} or below."
626        ),
627    });
628}
629
630/// The `input-validation`-off half of the placeholder-floor lint.
631//
632// Why a no-op rather than a duplicated `256`: the floor this finding warns about
633// is `pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH`, which is a module
634// constant precisely so exactly one copy of the number exists (D-08). On a build
635// without `input-validation` that floor is not compiled and not enforced, so there
636// is no shape MISMATCH to report — the declared `max_length` is the only limit
637// there is, and it is honoured. Hard-coding a second `256` here to keep the
638// finding alive would report a rule that this build does not apply.
639//
640// The genuinely-missing enforcement on such a build is reported once per
641// `validate()` by `warn_if_pattern_checking_unavailable`, not here.
642#[cfg(not(feature = "input-validation"))]
643fn lint_declared_cap_above_placeholder_floor(
644    _tool: &ToolDecl,
645    _p: &ParamDecl,
646    _position: ParamPosition,
647    _declared: u64,
648    _out: &mut Vec<ConfigWarning>,
649) {
650}
651
652/// One finding per ACTIVE `[server.validation]` opt-out, so a switched-off
653/// enforcement can never read as switched on.
654fn lint_opt_outs(validation: &ValidationSection, out: &mut Vec<ConfigWarning>) {
655    if !validation.enforce_input_schema {
656        out.push(server_warning(
657            OPT_OUT_ENFORCE_INPUT_SCHEMA,
658            "[server.validation] enforce_input_schema = false: declared inputSchema values \
659             are NOT checked at tools/call time. Explicitly-registered argument validators \
660             still run — this flag does not disable them."
661                .to_string(),
662        ));
663    }
664    if validation.default_max_length == 0 {
665        out.push(server_warning(
666            OPT_OUT_DEFAULT_MAX_LENGTH_ZERO,
667            "[server.validation] default_max_length = 0: no default maxLength is emitted in \
668             ANY position, so a path or query string parameter that declares no max_length \
669             is unbounded in the published schema. The always-on path-placeholder floor is \
670             unaffected."
671                .to_string(),
672        ));
673    }
674    if validation.additional_properties {
675        out.push(server_warning(
676            OPT_OUT_ADDITIONAL_PROPERTIES,
677            "[server.validation] additional_properties = true: UNDECLARED arguments are \
678             accepted, re-opening the unknown-argument class for every tool on this server."
679                .to_string(),
680        ));
681    }
682}
683
684/// A server-level [`ConfigWarning`] — no `tool`, no `param`.
685fn server_warning(rule: &'static str, detail: String) -> ConfigWarning {
686    ConfigWarning {
687        tool: None,
688        param: None,
689        rule,
690        detail,
691    }
692}
693
694/// One tool's [`ToolValidationReport`] row.
695fn tool_validation_report(tool: &ToolDecl, validation: &ValidationSection) -> ToolValidationReport {
696    ToolValidationReport {
697        tool: tool.name.clone(),
698        rules: tool
699            .parameters
700            .iter()
701            .map(|p| render_param_rules(tool, p, validation))
702            .collect(),
703    }
704}
705
706/// Render ONE parameter's effective rules as a log-readable line.
707fn render_param_rules(tool: &ToolDecl, p: &ParamDecl, validation: &ValidationSection) -> String {
708    let position = tool.param_position(&p.name);
709    let mut parts = vec![format!("{position:?}")];
710    if p.required {
711        parts.push("required".to_string());
712    }
713    if p.pattern.is_some() {
714        parts.push("pattern".to_string());
715    }
716    if let Some(format) = &p.format {
717        parts.push(format!("format={format}"));
718    }
719    if let Some(min) = p.min_length {
720        parts.push(format!("minLength={min}"));
721    }
722    if let Some(max) = p.max_length {
723        parts.push(format!("maxLength={max} (declared)"));
724    } else if default_cap_applies(p, position, validation) {
725        parts.push(format!(
726            "maxLength={} (default)",
727            validation.default_max_length
728        ));
729    }
730    format!("{}: {}", p.name, parts.join(", "))
731}
732
733/// Machine-readable [`ConfigWarning::rule`] identifier: an uncapped body string.
734pub const UNCAPPED_STRING: &str = "uncapped-string";
735/// Machine-readable [`ConfigWarning::rule`] identifier: a declared `max_length`
736/// above the always-on path-placeholder floor.
737pub const DECLARED_MAX_LENGTH_ABOVE_PLACEHOLDER_CAP: &str =
738    "declared-max-length-above-placeholder-cap";
739/// Machine-readable [`ConfigWarning::rule`] identifier: a configured single-call
740/// `(method, path)` that matches no operation in the supplied OpenAPI document, so
741/// the spec's declared placeholder narrowing is not applied to that tool
742/// (Phase 128, D4(b) / T-128-36a). Emitted by
743/// [`ServerConfig::lint_against_spec`](crate::config::ServerConfig::lint_against_spec).
744pub const CONFIGURED_TEMPLATE_NOT_IN_SPEC: &str = "configured-template-not-in-spec";
745/// Machine-readable [`ConfigWarning::rule`] identifier: schema enforcement off.
746pub const OPT_OUT_ENFORCE_INPUT_SCHEMA: &str = "opt-out-enforce-input-schema";
747/// Machine-readable [`ConfigWarning::rule`] identifier: default cap disabled.
748pub const OPT_OUT_DEFAULT_MAX_LENGTH_ZERO: &str = "opt-out-default-max-length-zero";
749/// Machine-readable [`ConfigWarning::rule`] identifier: unknown arguments accepted.
750pub const OPT_OUT_ADDITIONAL_PROPERTIES: &str = "opt-out-additional-properties";
751
752// -----------------------------------------------------------------------------
753// Phase 128 D2 / SC-2 — per-parameter declaration checks
754// -----------------------------------------------------------------------------
755
756/// The largest integer magnitude an `f64` represents exactly (2^53).
757///
758/// [`ParamDecl::minimum`] / [`ParamDecl::maximum`] are `f64`, so a declared bound
759/// above this cannot round-trip. See [`ConfigValidationError::NonFiniteParamBound`]
760/// for exactly what a magnitude check on the already-parsed value can and cannot
761/// establish.
762const MAX_EXACT_INTEGER_BOUND: f64 = 9_007_199_254_740_992.0;
763
764/// Run the Phase 128 D2 / SC-2 declaration checks for ONE `[[tools]]` entry.
765///
766/// Split out of [`ServerConfig::validate`] so that function stays well under the
767/// cog-25 gate as rules accumulate.
768///
769/// # Errors
770///
771/// [`ConfigValidationError::EmptyParamPattern`],
772/// [`ConfigValidationError::NonFiniteParamBound`], or
773/// [`ConfigValidationError::UncompilableParamSchema`] — first rule violated, in
774/// `[[tools.parameters]]` declaration order.
775fn validate_tool_parameters(
776    tool: &ToolDecl,
777    validation: &ValidationSection,
778) -> std::result::Result<(), ConfigValidationError> {
779    for p in &tool.parameters {
780        check_param_patterns_non_empty(tool, p)?;
781        check_param_bounds_representable(tool, p)?;
782        if validation.strict {
783            check_param_capped_under_strict(tool, p, validation)?;
784        }
785    }
786    check_path_template_segments(tool)?;
787    check_tool_input_schema_compiles(tool, validation)
788}
789
790/// Refuse a single-call `path` template segment the curated substitution parser
791/// cannot recognize (Phase 128, D4(b)).
792///
793/// # Why this is a hard error rather than a `lint()` finding
794///
795/// A malformed segment has no working interpretation. `/search/{a}{b}` yields the
796/// parameter name `a}{b`, which no `[[tools.parameters]]` entry can match, and
797/// `/prefix-{id}` is not recognized as carrying a placeholder at all — so in both
798/// cases literal braces are what would travel toward the backend. That request is
799/// already refused at call time by the composed-path check in
800/// `crate::http::HttpClient`, so the choice here is only between failing loudly at
801/// startup and failing obscurely on every call. Nothing that worked before loses a
802/// working behaviour; a silently-broken tool gains a message naming the segment.
803///
804/// Contrast [`ConfigValidationError::UncappedStringParam`], which is `strict`-only
805/// precisely because an uncapped free-text field DOES have a working
806/// interpretation (D-05).
807///
808/// # Errors
809///
810/// [`ConfigValidationError::MalformedPathTemplateSegment`], naming the first
811/// offending segment in left-to-right order.
812fn check_path_template_segments(tool: &ToolDecl) -> std::result::Result<(), ConfigValidationError> {
813    let Some(path) = tool.path.as_deref() else {
814        // No `path` — a SQL or script tool carries no template.
815        return Ok(());
816    };
817    for segment in path.split('/') {
818        if is_supported_path_segment(segment) {
819            continue;
820        }
821        return Err(ConfigValidationError::MalformedPathTemplateSegment {
822            tool: tool.name.clone(),
823            segment: segment.to_string(),
824        });
825    }
826    Ok(())
827}
828
829/// Whether ONE `/`-delimited path-template segment is a shape
830/// [`path_placeholder_names`] recognizes.
831///
832/// Exactly two shapes are supported: a segment containing no brace at all, and a
833/// segment that is exactly one non-empty `{name}` spanning the whole segment with
834/// no further brace inside the name. This predicate is the inverse of
835/// [`path_placeholder_names`]'s filter, extended to also catch the
836/// carries-a-brace-but-is-not-a-placeholder cases that filter silently drops.
837fn is_supported_path_segment(segment: &str) -> bool {
838    if !segment.contains('{') && !segment.contains('}') {
839        return true;
840    }
841    // `{` and `}` are single-byte, so the inner slice is always a char boundary.
842    segment.starts_with('{')
843        && segment.ends_with('}')
844        && segment.len() > 2
845        && !segment[1..segment.len() - 1].contains('{')
846        && !segment[1..segment.len() - 1].contains('}')
847}
848
849/// D-07 strict mode: promote an `uncapped-string` lint finding into a hard
850/// `validate()` failure.
851///
852/// Gated on `[server.validation] strict` by the caller, because a running server
853/// must NOT refuse to boot over an uncapped free-text body parameter (D-05). This
854/// path exists for a CI config check, where refusing is exactly right.
855///
856/// # Errors
857///
858/// [`ConfigValidationError::UncappedStringParam`].
859fn check_param_capped_under_strict(
860    tool: &ToolDecl,
861    p: &ParamDecl,
862    validation: &ValidationSection,
863) -> std::result::Result<(), ConfigValidationError> {
864    let position = tool.param_position(&p.name);
865    if is_uncapped_string(p, position, validation) {
866        return Err(ConfigValidationError::UncappedStringParam {
867            tool: tool.name.clone(),
868            param: p.name.clone(),
869        });
870    }
871    Ok(())
872}
873
874/// SC-2 edge (empty): refuse `pattern = ""` on the parameter OR on its
875/// `[tools.parameters.items]` sub-table.
876///
877/// # Errors
878///
879/// [`ConfigValidationError::EmptyParamPattern`].
880fn check_param_patterns_non_empty(
881    tool: &ToolDecl,
882    p: &ParamDecl,
883) -> std::result::Result<(), ConfigValidationError> {
884    let declared = [
885        p.pattern.as_deref(),
886        p.items.as_ref().and_then(|i| i.pattern.as_deref()),
887    ];
888    if declared.into_iter().flatten().any(str::is_empty) {
889        return Err(ConfigValidationError::EmptyParamPattern {
890            tool: tool.name.clone(),
891            param: p.name.clone(),
892        });
893    }
894    Ok(())
895}
896
897/// D3 edge (precision): refuse a `minimum`/`maximum` that an `f64` cannot carry
898/// exactly. See [`ConfigValidationError::NonFiniteParamBound`] for what this does
899/// and does not establish.
900///
901/// # Errors
902///
903/// [`ConfigValidationError::NonFiniteParamBound`].
904fn check_param_bounds_representable(
905    tool: &ToolDecl,
906    p: &ParamDecl,
907) -> std::result::Result<(), ConfigValidationError> {
908    let unrepresentable = [p.minimum, p.maximum]
909        .into_iter()
910        .flatten()
911        .any(|b| !b.is_finite() || b.abs() > MAX_EXACT_INTEGER_BOUND);
912    if unrepresentable {
913        return Err(ConfigValidationError::NonFiniteParamBound {
914            tool: tool.name.clone(),
915            param: p.name.clone(),
916        });
917    }
918    Ok(())
919}
920
921/// SC-2: compile the tool's synthesized `inputSchema` at CONFIG time, so a
922/// non-compiling `pattern` fails here — naming the parameter — rather than at call
923/// time, where it would take the tool's entire validator down.
924///
925/// Reuses `crate::tools::build_input_schema`, the same constructor the runtime
926/// serves from, so the gate cannot pass a schema the server never actually uses.
927/// `jsonschema::meta::is_valid` is deliberately NOT the check: it returns `true`
928/// for a schema whose nested `pattern` does not compile (measured — RESEARCH
929/// Finding 1e), which is precisely the mistake this gate exists to catch.
930///
931/// # Errors
932///
933/// [`ConfigValidationError::UncompilableParamSchema`] carrying the compile error's
934/// own schema path and detail. Quoting the detail is safe here and only here: this
935/// runs at load time with no request in scope and its audience is the config author.
936#[cfg(feature = "input-validation")]
937fn check_tool_input_schema_compiles(
938    tool: &ToolDecl,
939    validation: &ValidationSection,
940) -> std::result::Result<(), ConfigValidationError> {
941    let schema = crate::tools::build_input_schema(tool, validation);
942    pmcp::server::schema_validation::check_input_schema_compiles(&schema).map_err(|violation| {
943        ConfigValidationError::UncompilableParamSchema {
944            tool: tool.name.clone(),
945            position: violation.pointer,
946            detail: violation.expected,
947        }
948    })
949}
950
951/// The `input-validation`-off half of the SC-2 gate.
952//
953// Why this arm exists at all, rather than an ungated call: `ServerConfig::validate`
954// compiles in EVERY toolkit feature set, while
955// `pmcp::server::schema_validation::check_input_schema_compiles` exists only under
956// `pmcp/schema-validation` (forwarded by the toolkit's `input-validation`). An
957// ungated call breaks `cargo build -p pmcp-server-toolkit --no-default-features
958// --features http`, which is a real supported configuration.
959//
960// Why it is not a SILENT skip: on this build a declared `pattern` is neither
961// verified here nor enforced at call time, so an author who sees `validate()`
962// return `Ok(())` would reasonably believe their rule was checked. That is exactly
963// the "an enforcement that is off must never read as on" prohibition. The warning
964// is emitted once per `validate()` by `warn_if_pattern_checking_unavailable`, not
965// per tool, so a large config does not bury it.
966#[cfg(not(feature = "input-validation"))]
967fn check_tool_input_schema_compiles(
968    _tool: &ToolDecl,
969    _validation: &ValidationSection,
970) -> std::result::Result<(), ConfigValidationError> {
971    Ok(())
972}
973
974/// Emit the once-per-`validate()` warning when this build cannot check declared
975/// `pattern` values. A no-op when the `input-validation` feature is on, and a
976/// no-op when the config declares no `pattern` at all (there is nothing unchecked
977/// to report).
978#[cfg(not(feature = "input-validation"))]
979fn warn_if_pattern_checking_unavailable(tools: &[ToolDecl]) {
980    let declares_a_pattern = tools.iter().any(|t| {
981        t.parameters
982            .iter()
983            .any(|p| p.pattern.is_some() || p.items.as_ref().is_some_and(|i| i.pattern.is_some()))
984    });
985    if declares_a_pattern {
986        tracing::warn!(
987            "this build lacks the `input-validation` feature: declared \
988             [[tools.parameters]] `pattern` values were NOT checked for compilability at \
989             config time, and will NOT be enforced at tools/call time either — enable \
990             `input-validation` to get either"
991        );
992    }
993}
994
995/// See the `cfg(not(...))` sibling. Under `input-validation` the patterns ARE
996/// checked, so there is nothing to warn about.
997#[cfg(feature = "input-validation")]
998#[allow(clippy::missing_const_for_fn)] // Why: mirrors the cfg(not(...)) sibling's signature, which cannot be const.
999fn warn_if_pattern_checking_unavailable(_tools: &[ToolDecl]) {}
1000
1001// -----------------------------------------------------------------------------
1002// [server]
1003// -----------------------------------------------------------------------------
1004
1005/// `[server]` section — identity and version metadata.
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1007#[serde(deny_unknown_fields)]
1008pub struct ServerSection {
1009    /// Stable server identifier (e.g. `"open-images"`). Optional in the TOML;
1010    /// callers that need it should fall back to deriving from `name`.
1011    #[serde(default)]
1012    pub id: Option<String>,
1013    /// Human-readable server name (required for production via [`ServerConfig::validate`]).
1014    #[serde(default)]
1015    pub name: String,
1016    /// Short server description.
1017    #[serde(default)]
1018    pub description: Option<String>,
1019    /// Server flavour (e.g. `"sql-api"`). Free-form for now; future plans may tighten.
1020    #[serde(default, rename = "type")]
1021    pub server_type: Option<String>,
1022    /// Semver version string (required for production via [`ServerConfig::validate`]).
1023    #[serde(default)]
1024    pub version: String,
1025    /// Whether this server is the **reference** server that provisions shared
1026    /// infrastructure (the `[shared_policy_store]` for all sibling SQL servers).
1027    /// Additive per the REF-01 superset invariant (Plan 85-01); the SQLite
1028    /// Chinook reference config sets `is_reference = true`.
1029    #[serde(default)]
1030    pub is_reference: bool,
1031    /// `[server.validation]` — input-enforcement policy (Phase 128, D3 / D-06).
1032    ///
1033    /// Absent in TOML yields [`ValidationSection::default`], i.e. enforcement ON
1034    /// with a 256-code-point default cap in path and query position.
1035    #[serde(default)]
1036    pub validation: ValidationSection,
1037}
1038
1039/// `[server.validation]` — how strictly a config-declared tool's inputs are
1040/// enforced (Phase 128, D3 / D-06 / D-07).
1041///
1042/// Every field here is an OPT-OUT knob, and every active opt-out is reported by
1043/// [`ServerConfig::lint`] and by [`ServerConfig::validation_report`] so it appears
1044/// in the startup log. That is deliberate: a validation rule switched off by a
1045/// configuration value must never read as switched on.
1046///
1047/// # Forward incompatibility (D-15)
1048///
1049/// [`ServerSection`] and [`ServerConfig`] both carry
1050/// `#[serde(deny_unknown_fields)]`, so a config carrying a `[server.validation]`
1051/// section fails to PARSE on toolkit 0.1.3 rather than having the section ignored.
1052/// Named in the CHANGELOG, exactly as the [`ParamDecl`] D2 keys are.
1053///
1054/// # Examples
1055///
1056/// ```
1057/// use pmcp_server_toolkit::config::ValidationSection;
1058///
1059/// let v = ValidationSection::default();
1060/// assert!(v.enforce_input_schema);
1061/// assert_eq!(v.default_max_length, 256);
1062/// assert!(!v.additional_properties);
1063/// assert!(!v.strict);
1064/// ```
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066#[serde(deny_unknown_fields)]
1067pub struct ValidationSection {
1068    /// Whether a declared `inputSchema` is CHECKED at `tools/call` time.
1069    ///
1070    /// The checker is `pmcp::server::schema_validation::validate_input`, called
1071    /// from the `ValidatingToolHandler` decorator in [`crate::tools`] — in THIS
1072    /// crate, before the backend call. Not core `pmcp`'s `tools/call` dispatch,
1073    /// which does not validate request arguments against a declared `inputSchema`
1074    /// (Phase 128 D-01 defers that wiring). Naming the enforcer is this phase's
1075    /// SC-6 convention; the sweep that produced it found the unqualified form of
1076    /// this sentence three times in `tools.rs` alone.
1077    ///
1078    /// Default `true`. Setting it `false` skips the schema check only — it does
1079    /// NOT disable an explicitly-registered argument validator. Turning off one
1080    /// enforcement must never silently turn off another, so the two live on
1081    /// separate switches and the decorator is still constructed whenever a
1082    /// validator is registered for the tool.
1083    #[serde(default = "default_enforce_input_schema")]
1084    pub enforce_input_schema: bool,
1085    /// Default `maxLength`, in Unicode code points, emitted for a PATH- or
1086    /// QUERY-position string parameter that declares no `max_length` of its own
1087    /// (D-06).
1088    ///
1089    /// Default `256`. A declared `max_length` is never overridden and never merged
1090    /// with this value. Body-position strings are deliberately NOT capped by it
1091    /// (D-05) — they are surfaced by [`ServerConfig::lint`] instead.
1092    ///
1093    /// `0` DISABLES the cap in every position and is reported as an active opt-out.
1094    /// It does not disable `pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH`,
1095    /// which is a module constant precisely so the length half of the path-traversal
1096    /// fix cannot be configured away (D-08).
1097    #[serde(default = "default_default_max_length")]
1098    pub default_max_length: u64,
1099    /// Whether to permit UNDECLARED arguments, by emitting
1100    /// `additionalProperties: true` instead of `false`.
1101    ///
1102    /// Default `false` (unknown arguments are refused). `true` re-opens the
1103    /// unknown-argument class for this server and is reported as an active opt-out.
1104    #[serde(default)]
1105    pub additional_properties: bool,
1106    /// Whether [`ServerConfig::lint`] findings are promoted into hard
1107    /// [`ServerConfig::validate`] failures.
1108    ///
1109    /// Default `false`: a running server never refuses to boot over an uncapped
1110    /// free-text body parameter (D-07). `true` turns each such parameter into
1111    /// [`ConfigValidationError::UncappedStringParam`], which is what a CI config
1112    /// check wants and what a production boot does not.
1113    #[serde(default)]
1114    pub strict: bool,
1115}
1116
1117/// The shipped default cap, in Unicode code points (D-06).
1118const DEFAULT_MAX_LENGTH: u64 = 256;
1119
1120/// serde default for [`ValidationSection::enforce_input_schema`]. Enforcement is
1121/// ON unless an operator explicitly opts out.
1122const fn default_enforce_input_schema() -> bool {
1123    true
1124}
1125
1126/// serde default for [`ValidationSection::default_max_length`].
1127const fn default_default_max_length() -> u64 {
1128    DEFAULT_MAX_LENGTH
1129}
1130
1131impl Default for ValidationSection {
1132    fn default() -> Self {
1133        Self {
1134            enforce_input_schema: default_enforce_input_schema(),
1135            default_max_length: default_default_max_length(),
1136            additional_properties: false,
1137            strict: false,
1138        }
1139    }
1140}
1141
1142// -----------------------------------------------------------------------------
1143// [metadata]
1144// -----------------------------------------------------------------------------
1145
1146/// `[metadata]` section — admin-facing display defaults (visible in the
1147/// pmcp.run UI before an operator customises them).
1148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1149#[serde(deny_unknown_fields)]
1150pub struct MetadataSection {
1151    /// Long-form display name shown in the UI.
1152    #[serde(default)]
1153    pub display_name: Option<String>,
1154    /// One-line summary for list views.
1155    #[serde(default)]
1156    pub short_description: Option<String>,
1157    /// Multi-line description for detail pages.
1158    #[serde(default)]
1159    pub description: Option<String>,
1160    /// Tag list for filtering / discovery.
1161    #[serde(default)]
1162    pub tags: Vec<String>,
1163    /// Server author (organisation or individual).
1164    #[serde(default)]
1165    pub author: Option<String>,
1166    /// Visibility flag (e.g. `"public"`, `"private"`).
1167    #[serde(default)]
1168    pub visibility: Option<String>,
1169}
1170
1171// -----------------------------------------------------------------------------
1172// [database]
1173// -----------------------------------------------------------------------------
1174
1175/// `[database]` section — backend identification and table catalogue.
1176///
1177/// Includes Athena-specific keys (`output_location`, `workgroup`) as optional
1178/// fields per the REF-01 superset invariant — non-Athena backends omit them.
1179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1180#[serde(deny_unknown_fields)]
1181pub struct DatabaseSection {
1182    /// Backend type (`"athena"`, `"postgres"`, `"mysql"`, `"sqlite"`, …).
1183    #[serde(default, rename = "type")]
1184    pub backend_type: Option<String>,
1185    /// Database / schema name.
1186    #[serde(default)]
1187    pub database: Option<String>,
1188    /// Athena S3 output location for query results.
1189    #[serde(default)]
1190    pub output_location: Option<String>,
1191    /// Athena workgroup name.
1192    #[serde(default)]
1193    pub workgroup: Option<String>,
1194    /// Per-query timeout in milliseconds.
1195    #[serde(default)]
1196    pub query_timeout_ms: Option<u64>,
1197    /// `[[database.tables]]` — declared table catalogue for schema enrichment.
1198    #[serde(default)]
1199    pub tables: Vec<DatabaseTableDecl>,
1200    /// Connection URL for Postgres / MySQL backends. Supports `env:VAR_NAME`
1201    /// indirection at the consumer-resolution layer (the toolkit parses the
1202    /// string as-is and leaves resolution to the per-backend connector or
1203    /// the secret-resolution machinery from P83 R6/R9). Optional/unused for
1204    /// Athena (uses `region` + `workgroup` + `output_location`) and SQLite
1205    /// (uses `database` for the file path or `:memory:` literal).
1206    #[serde(default)]
1207    pub url: Option<String>,
1208    /// Filesystem path to a SQLite database file (e.g.
1209    /// `"/var/task/assets/chinook.db"` for a Lambda-bundled asset). Additive per
1210    /// the REF-01 superset invariant (Plan 85-01). Distinct from `database`
1211    /// (which carries the `:memory:` literal or a schema name) and `url` (used
1212    /// by Postgres / MySQL). Stored verbatim; the SQLite connector resolves it.
1213    #[serde(default)]
1214    pub file_path: Option<String>,
1215    /// `[database.pool]` — connection-pool tuning (optional).
1216    #[serde(default)]
1217    pub pool: Option<DatabasePoolSection>,
1218}
1219
1220/// Single `[[database.tables]]` entry.
1221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1222#[serde(deny_unknown_fields)]
1223pub struct DatabaseTableDecl {
1224    /// Table or view name (required for production via [`ServerConfig::validate`]).
1225    #[serde(default)]
1226    pub name: String,
1227    /// Human-readable table description for schema enrichment.
1228    #[serde(default)]
1229    pub description: Option<String>,
1230}
1231
1232/// `[database.pool]` connection-pool tuning.
1233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1234#[serde(deny_unknown_fields)]
1235pub struct DatabasePoolSection {
1236    /// Maximum concurrent connections.
1237    #[serde(default)]
1238    pub max_connections: Option<u32>,
1239    /// Connection-acquisition timeout, in seconds.
1240    #[serde(default)]
1241    pub connection_timeout_seconds: Option<u64>,
1242}
1243
1244// -----------------------------------------------------------------------------
1245// [backend] (http feature)
1246// -----------------------------------------------------------------------------
1247
1248/// Re-export of the outgoing-HTTP authentication config (owned by
1249/// [`crate::http::auth`], Plan 90-01). Callers may also reach it via the
1250/// `crate::http` module path; this re-export keeps `[backend.auth]` named
1251/// alongside the `ServerConfig` types it deserializes into.
1252#[cfg(feature = "http")]
1253pub use crate::http::auth::AuthConfig;
1254
1255/// Re-export of the HTTP client tuning config (owned by [`crate::http::client`],
1256/// Plan 90-01) used by `[backend.http]`.
1257#[cfg(feature = "http")]
1258pub use crate::http::client::HttpConfig;
1259
1260/// `[backend]` section — the OpenAPI/REST HTTP backend declaration (D-06).
1261///
1262/// This is the HTTP analog of [`DatabaseSection`]: it identifies the upstream
1263/// REST API the synthesized tools call. `base_url` is the API root; the optional
1264/// `[backend.auth]` sub-table selects an [`AuthConfig`] variant (`type = "..."`)
1265/// and `[backend.http]` carries [`HttpConfig`] tuning (timeout / retries / …).
1266///
1267/// Gated behind the `http` feature — the whole section (and the
1268/// [`ServerConfig::backend`] field) is absent in a no-http build so there is no
1269/// dead stub type. `AuthConfig` and `HttpConfig` are DEFINED in
1270/// [`crate::http`] (Plan 90-01) and re-exported here, not redefined (H3).
1271///
1272/// Strict-parse discipline (D-13) is preserved: `#[serde(deny_unknown_fields)]`
1273/// rejects a typo'd key under `[backend]` or `[backend.http]`.
1274///
1275/// Secrets posture (T-90-02-02): inline token fields under `[backend.auth]`
1276/// hold operator references (`${ENV}` / `env:VAR`) resolved upstream by the
1277/// Phase 83 secrets machinery — config parsing stores the string verbatim and
1278/// never the resolved value.
1279#[cfg(feature = "http")]
1280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1281#[serde(deny_unknown_fields)]
1282pub struct BackendSection {
1283    /// REST API root URL (e.g. `"https://api.tfl.gov.uk"`). Single-call tools
1284    /// concatenate their `path` onto this (an empty per-tool `base_url`
1285    /// inherits this value).
1286    #[serde(default)]
1287    pub base_url: String,
1288    /// `[backend.auth]` — outgoing authentication ([`AuthConfig`], six modes).
1289    /// Defaults to [`AuthConfig::None`] when the sub-table is omitted.
1290    #[serde(default)]
1291    pub auth: AuthConfig,
1292    /// `[backend.http]` — client tuning ([`HttpConfig`]: timeout / retries /
1293    /// backoff / user-agent / default headers). Defaults to [`HttpConfig`]'s
1294    /// defaults when the sub-table is omitted.
1295    #[serde(default)]
1296    pub http: HttpConfig,
1297}
1298
1299#[cfg(feature = "http")]
1300impl BackendSection {
1301    /// Resolve [`Self::base_url`], expanding a `${VAR}` / `env:VAR` reference
1302    /// from the process environment. Callers MUST use this rather than reading
1303    /// `base_url` directly — the raw field may hold an unresolved placeholder.
1304    ///
1305    /// A Shape A server's endpoint is frequently a slot the target environment
1306    /// fills, so the config records `base_url = "${TFL_BASE_URL}"` and the
1307    /// package digest stays environment-independent. Without expansion that
1308    /// literal `${...}` parses, VALIDATES (it is non-empty, so the emptiness
1309    /// rule passes) and is then sent as the request URL.
1310    ///
1311    /// Resolution rules — the grammar is [`crate::env_ref::parse_env_ref`], the
1312    /// single toolkit-wide chokepoint:
1313    /// - a plain literal (no `${...}` / `env:` prefix) is returned VERBATIM;
1314    /// - `${VAR}` / `env:VAR` reads `VAR` from the process environment;
1315    /// - a MALFORMED reference — the empty `${}`, or a multi-placeholder
1316    ///   composition like `${A}://${B}` (a brace reference names exactly ONE
1317    ///   variable) — is an error;
1318    /// - an UNSET variable, or one set to an empty / whitespace-only value, is
1319    ///   an error.
1320    ///
1321    /// # Deliberate divergence from credential resolution
1322    ///
1323    /// A credential resolves an unset reference to the empty string so an
1324    /// optional credential is OMITTED (see `crate::http::auth`). An endpoint
1325    /// does NOT get that treatment: an empty credential yields a degraded
1326    /// request, but an empty endpoint yields a broken one, and
1327    /// [`ServerConfig::validate`] only checks emptiness at parse time — an
1328    /// empty resolution would sail through and then break every request. This
1329    /// uses the error-on-unset semantics of `code_mode`'s `token_secret`
1330    /// resolution instead.
1331    ///
1332    /// # Errors
1333    ///
1334    /// Returns [`ToolkitError::UnresolvedBaseUrlRef`] when the reference cannot
1335    /// be resolved. Per T-120-17 the error names the FIELD and the
1336    /// environment-variable NAME only — never a resolved URL or credential.
1337    ///
1338    /// # Examples
1339    ///
1340    /// ```
1341    /// use pmcp_server_toolkit::config::ServerConfig;
1342    ///
1343    /// let cfg = ServerConfig::from_toml_strict_validated(
1344    ///     "[server]\nname = \"demo\"\nversion = \"0.1.0\"\n\
1345    ///      [backend]\nbase_url = \"https://api.example.com\"\n",
1346    /// )
1347    /// .expect("valid config");
1348    /// let backend = cfg.backend.as_ref().expect("[backend] present");
1349    /// // A plain literal is used verbatim.
1350    /// assert_eq!(backend.resolved_base_url().unwrap(), "https://api.example.com");
1351    /// ```
1352    pub fn resolved_base_url(&self) -> std::result::Result<String, ToolkitError> {
1353        match crate::env_ref::parse_env_ref(&self.base_url) {
1354            // Plain literal — used verbatim (every existing [backend] config
1355            // and the four SQL reference configs land here, unchanged).
1356            None => Ok(self.base_url.clone()),
1357            // Malformed `${}` — a reference to an empty name. A credential
1358            // treats this as "omit"; an endpoint cannot be omitted.
1359            Some("") => Err(ToolkitError::UnresolvedBaseUrlRef { var: String::new() }),
1360            Some(name) => match std::env::var(name) {
1361                Ok(value) if !value.trim().is_empty() => Ok(value),
1362                // Unset, or set-but-empty/whitespace — the same error either
1363                // way. The VALUE is never carried into the error.
1364                _ => Err(ToolkitError::UnresolvedBaseUrlRef {
1365                    var: name.to_string(),
1366                }),
1367            },
1368        }
1369    }
1370}
1371
1372// -----------------------------------------------------------------------------
1373// [code_mode]
1374// -----------------------------------------------------------------------------
1375
1376/// `[code_mode]` section — code-mode policy + complexity limits.
1377///
1378/// The toolkit uses **unprefixed** field names (REF-01 invariant); the mapping
1379/// to `pmcp_code_mode::CodeModeConfig`'s prefixed names (`sql_allow_writes`,
1380/// etc.) is handled by Plan 06's executor wiring.
1381#[allow(clippy::struct_excessive_bools)]
1382// Why: REF-01 superset — these bools mirror the reference servers' [code_mode] block 1:1 (CONTEXT.md D-13). Grouping into a sub-struct would break REF-01.
1383#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1384#[serde(deny_unknown_fields)]
1385pub struct CodeModeSection {
1386    /// Master enable flag for code-mode.
1387    #[serde(default)]
1388    pub enabled: bool,
1389    /// Server identifier used by AVP / Cedar policy resolution.
1390    #[serde(default)]
1391    pub server_id: Option<String>,
1392    /// Whether INSERT / UPDATE / MERGE statements are allowed.
1393    #[serde(default)]
1394    pub allow_writes: bool,
1395    /// Whether DELETE statements are allowed.
1396    #[serde(default)]
1397    pub allow_deletes: bool,
1398    /// Whether DDL (CREATE / ALTER / DROP) is allowed.
1399    #[serde(default)]
1400    pub allow_ddl: bool,
1401    /// Whether `SELECT` queries must declare a `LIMIT`.
1402    #[serde(default)]
1403    pub require_limit: bool,
1404    /// Maximum allowed `LIMIT` value.
1405    #[serde(default)]
1406    pub max_limit: Option<u64>,
1407    /// Table names blocked from any query (denylist).
1408    #[serde(default)]
1409    pub blocked_tables: Vec<String>,
1410    /// `table.column` strings stripped from query output.
1411    #[serde(default)]
1412    pub sensitive_columns: Vec<String>,
1413    /// Risk levels eligible for auto-approval (e.g. `["low"]`).
1414    #[serde(default)]
1415    pub auto_approve_levels: Vec<String>,
1416    /// Token TTL, in seconds, for HMAC-signed approval tokens.
1417    #[serde(default)]
1418    pub token_ttl_seconds: Option<u64>,
1419    /// Secret reference (e.g. `"${CODE_MODE_SECRET}"`) for HMAC signing — resolved
1420    /// at runtime by `SecretsProvider`. NEVER a raw secret value (review R6 +
1421    /// T-83-04-04 in the plan threat model).
1422    #[serde(default)]
1423    pub token_secret: Option<String>,
1424    /// Per Phase 83 review R9: inline `token_secret = "raw-string"` is REJECTED
1425    /// by default to prevent secrets from being committed to source-controlled
1426    /// configs. Set this flag to `true` ONLY in dev/test configs where the
1427    /// operator explicitly accepts the risk. NEVER set this in a committed
1428    /// production config — production must use the `env:VAR_NAME` syntax that
1429    /// resolves at runtime through `SecretsProvider`.
1430    #[serde(default)]
1431    pub allow_inline_token_secret_for_dev: bool,
1432    /// `[code_mode.limits]` — query-complexity caps.
1433    #[serde(default)]
1434    pub limits: Option<CodeModeLimits>,
1435    /// Operator text appended to BOTH the `validate_code` and `execute_code` tool
1436    /// descriptions, after the SDK's own.
1437    ///
1438    /// The place to tell the model what this deployment enforces that its own
1439    /// tool descriptions cannot know: "responses are de-identified", "queries
1440    /// over 100 rows are refused", "paths under /admin are blocked". The model
1441    /// reads a tool's description before it calls the tool, so a rule stated here
1442    /// is followed instead of discovered through a refusal.
1443    ///
1444    /// Appended verbatim after a blank line. Unset (the default) leaves both
1445    /// descriptions exactly as the SDK writes them.
1446    #[serde(default)]
1447    pub description_notice: Option<String>,
1448}
1449
1450/// `[code_mode.limits]` — query-complexity caps.
1451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1452#[serde(deny_unknown_fields)]
1453pub struct CodeModeLimits {
1454    /// Maximum number of distinct tables referenced in a single query.
1455    #[serde(default)]
1456    pub max_tables_per_query: Option<u32>,
1457    /// Maximum JOIN nesting depth.
1458    #[serde(default)]
1459    pub max_join_depth: Option<u32>,
1460    /// Maximum subquery nesting depth.
1461    #[serde(default)]
1462    pub max_subquery_depth: Option<u32>,
1463}
1464
1465// -----------------------------------------------------------------------------
1466// [shared_policy_store]
1467// -----------------------------------------------------------------------------
1468
1469/// `[shared_policy_store]` section — AVP/Cedar shared-policy-store declaration.
1470///
1471/// Emitted only by the **reference** SQL server (`[server] is_reference = true`),
1472/// which provisions a single shared policy store + a set of Cedar templates that
1473/// all sibling SQL servers attach to (rather than each minting its own store).
1474///
1475/// Additive per the REF-01 superset invariant (Plan 85-01). The toolkit parses
1476/// this verbatim — SSM export and store provisioning are deployment-time
1477/// concerns handled outside config parsing (D-02 parse-only + lazy startup).
1478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1479#[serde(deny_unknown_fields)]
1480pub struct SharedPolicyStoreSection {
1481    /// Whether this server creates the shared policy store for all SQL servers.
1482    #[serde(default)]
1483    pub creates_shared_store: bool,
1484    /// Whether the created store's identifier is exported to SSM Parameter Store.
1485    #[serde(default)]
1486    pub export_to_ssm: bool,
1487    /// SSM Parameter Store path the store identifier is exported to (when
1488    /// `export_to_ssm = true`).
1489    #[serde(default)]
1490    pub ssm_path: Option<String>,
1491    /// Cedar policy-template names included in the shared store (e.g.
1492    /// `"PermitAllSelects"`, `"ForbidAllDeletes"`).
1493    #[serde(default)]
1494    pub templates: Vec<String>,
1495}
1496
1497// -----------------------------------------------------------------------------
1498// [[config_slots]]
1499// -----------------------------------------------------------------------------
1500
1501/// The kind of a declared `[[config_slots]]` entry — a CLOSED vocabulary.
1502///
1503/// Deliberately an enum rather than a free `String`. A free string lets a typo
1504/// (`kind = "endpont"`) parse cleanly, survive
1505/// [`ServerConfig::validate`], and fail only at package time when it maps to no
1506/// slot type — the failure surfacing two crates away from its cause. As a closed
1507/// enum, an unrecognized discriminator is a serde parse error naming the
1508/// accepted set, and a fourth kind becomes a deliberate addition here rather
1509/// than a silent pass-through.
1510///
1511/// # Why this type is toolkit-LOCAL
1512///
1513/// The three `snake_case` discriminators (`endpoint`, `secret`, `auth_mode`)
1514/// are deliberately the same strings the `pmcp-package` slot-type discriminator
1515/// uses for the corresponding variants, so a packaging tool can compare a
1516/// declaration against a package slot **without either crate depending on the
1517/// other**. The toolkit must NOT depend on `pmcp-package`: that crate is the
1518/// workspace-excluded leaf, and a toolkit dependency on it inverts the layering.
1519/// The agreement is enforced by the package side re-parsing the SAME config
1520/// bytes, not by a shared type.
1521#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1522#[serde(rename_all = "snake_case")]
1523pub enum ConfigSlotKind {
1524    /// A network endpoint the target environment must supply (e.g. the backend
1525    /// API root). Behaviour-relevant: its `tested_value` records the endpoint
1526    /// the package was tested against.
1527    #[default]
1528    Endpoint,
1529    /// A named secret the target environment must supply (e.g. an API key).
1530    /// Identity-bearing: it structurally carries no `tested_value`.
1531    Secret,
1532    /// The backend authentication MODE. Structural rather than value-bearing:
1533    /// the auth-mode key is a serde tag, so no `${VAR}` placeholder form of it
1534    /// can deserialize — the baked literal IS the default and deviation
1535    /// surfaces through slot classification, not through a placeholder.
1536    AuthMode,
1537}
1538
1539/// Single `[[config_slots]]` entry — a config value the TARGET environment must
1540/// fill for this server to run.
1541///
1542/// A Shape A server's whole identity is its config, so "what must the operator
1543/// supply?" has to be declarable IN that config rather than discovered by
1544/// grepping for `${...}`. This block is that declaration: it names the config
1545/// path, the kind of thing it is, and the value exercised when the server was
1546/// tested.
1547///
1548/// Additive per the REF-01 superset invariant — a config omitting the block
1549/// parses to an empty [`ServerConfig::config_slots`]. Strict-parse discipline
1550/// (D-13) applies: `#[serde(deny_unknown_fields)]` rejects a typo'd inner key.
1551///
1552/// # Example
1553///
1554/// ```toml
1555/// [[config_slots]]
1556/// key = "backend.base_url"
1557/// kind = "endpoint"
1558/// name = "TFL_BASE_URL"
1559/// tested_value = "https://api.tfl.gov.uk"
1560/// ```
1561/// Who fills a config slot's value.
1562///
1563/// Mirrors `pmcp-package`'s `SuppliedBy` **by TOML value name, never by shared
1564/// type** — the same arrangement as [`ConfigSlotKind`], and for the same
1565/// reason: the toolkit must NOT depend on `pmcp-package` (the
1566/// workspace-excluded leaf), and a dependency the other way inverts the
1567/// layering. The agreement is enforced by the package side re-parsing these
1568/// same config bytes, not by a shared definition.
1569///
1570/// Defaults to [`Environment`](Self::Environment), so every config written
1571/// before this field existed keeps its exact meaning: the operator supplies it.
1572#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1573#[serde(rename_all = "snake_case")]
1574pub enum ConfigSlotSuppliedBy {
1575    /// The operator supplies it in the target environment. The default, and the
1576    /// only class a package enumerates as REQUIRED of an operator.
1577    #[default]
1578    Environment,
1579    /// The hosting platform injects it at deploy time.
1580    Platform,
1581    /// The execution environment injects it (e.g. `AWS_LAMBDA_FUNCTION_NAME`);
1582    /// neither the operator nor the platform supplies it.
1583    Runtime,
1584}
1585
1586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1587#[serde(deny_unknown_fields)]
1588pub struct ConfigSlotDecl {
1589    /// The dotted TOML path this slot fills, e.g. `backend.base_url`,
1590    /// `backend.auth.query_params.app_key`, `backend.auth.type`.
1591    #[serde(default)]
1592    pub key: String,
1593    /// The slot kind ([`ConfigSlotKind`] — a closed vocabulary). REQUIRED: an
1594    /// entry omitting `kind` is a parse error, because a defaulted kind would
1595    /// silently mis-classify the slot.
1596    pub kind: ConfigSlotKind,
1597    /// The slot's declared name — for a `secret`, the environment-variable
1598    /// name; for an `endpoint`, the variable the `${VAR}` placeholder reads.
1599    #[serde(default)]
1600    pub name: String,
1601    /// The value exercised when the server was tested. `None` for
1602    /// identity-bearing slots (a secret), which structurally carry no value —
1603    /// ENFORCED by [`ServerConfig::validate`], not just stated: a `secret`
1604    /// entry carrying a `tested_value` is refused, because that field is the
1605    /// one place a real credential could sit in a config that is served but
1606    /// never packed.
1607    #[serde(default)]
1608    pub tested_value: Option<String>,
1609    /// Who fills this slot — see [`ConfigSlotSuppliedBy`]. Defaults to
1610    /// `environment` (the operator supplies it), so a config written before this
1611    /// field existed is unchanged in meaning.
1612    ///
1613    /// This field is why the toolkit had to move in the same change as the
1614    /// packer: `deny_unknown_fields` above means a config carrying
1615    /// `supplied_by` would FAIL TO BOOT if only the package side learned it,
1616    /// and `pmcp-package` refuses to pack a config it knows the server cannot
1617    /// parse. Both sides accept it, or neither does.
1618    #[serde(default)]
1619    pub supplied_by: ConfigSlotSuppliedBy,
1620}
1621
1622// -----------------------------------------------------------------------------
1623// [[tools]]
1624// -----------------------------------------------------------------------------
1625
1626/// Single `[[tools]]` entry — a declaratively-defined tool surface.
1627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1628#[serde(deny_unknown_fields)]
1629pub struct ToolDecl {
1630    /// Tool name (required for production via [`ServerConfig::validate`]).
1631    #[serde(default)]
1632    pub name: String,
1633    /// Human-readable tool description.
1634    #[serde(default)]
1635    pub description: Option<String>,
1636    /// SQL template (uses `:param` placeholders bound by [`ParamDecl`]).
1637    #[serde(default)]
1638    pub sql: Option<String>,
1639    /// HTTP request path for a **single-call** OpenAPI/REST tool (D-01), e.g.
1640    /// `"/Line/Mode/tube/Status"`. Concatenated onto the backend `base_url`
1641    /// (or this tool's [`Self::base_url`] override). Additive per REF-01 — `None`
1642    /// for SQL / script tools.
1643    #[serde(default)]
1644    pub path: Option<String>,
1645    /// HTTP method for a single-call tool (`"GET"`, `"POST"`, …). Pairs with
1646    /// [`Self::path`] (D-01). Additive; `None` for SQL / script tools.
1647    #[serde(default)]
1648    pub method: Option<String>,
1649    /// Per-tool backend base-URL override. When absent a single-call tool
1650    /// inherits `[backend].base_url`. Additive; `None` for SQL / script tools.
1651    #[serde(default)]
1652    pub base_url: Option<String>,
1653    /// JavaScript body for a **script** tool (D-01) — a code-mode snippet that
1654    /// orchestrates multiple backend calls and binds `[[tools.parameters]]` to
1655    /// `args`. When set, this entry is a script tool ([`Self::is_script_tool`]).
1656    /// Additive; `None` for SQL / single-call tools.
1657    #[serde(default)]
1658    pub script: Option<String>,
1659    /// Optional UI-resource URI for `structuredContent` widgets.
1660    #[serde(default)]
1661    pub ui_resource_uri: Option<String>,
1662    /// `[[tools.parameters]]` — declared input parameters.
1663    #[serde(default)]
1664    pub parameters: Vec<ParamDecl>,
1665    /// `[tools.annotations]` — MCP `toolAnnotations`.
1666    #[serde(default)]
1667    pub annotations: Option<AnnotationsDecl>,
1668}
1669
1670impl ToolDecl {
1671    /// Whether this `[[tools]]` entry is a **script** tool (D-01 detection rule).
1672    ///
1673    /// The detection rule is: `script.is_some()` ⇒ script tool; otherwise a
1674    /// `path` + `method` pair ⇒ single-call HTTP tool; otherwise (a `sql`
1675    /// field) ⇒ SQL tool. Plan 03/05 synthesizers branch on this method so the
1676    /// rule lives in exactly one place. Mutual-exclusivity is enforced at
1677    /// [`ServerConfig::validate`] (an entry mixing kinds is rejected, not
1678    /// silently resolved by precedence).
1679    ///
1680    /// # Examples
1681    ///
1682    /// ```
1683    /// use pmcp_server_toolkit::config::ToolDecl;
1684    ///
1685    /// let script = ToolDecl { script: Some("await api.get('/x')".into()), ..Default::default() };
1686    /// assert!(script.is_script_tool());
1687    ///
1688    /// let single = ToolDecl {
1689    ///     path: Some("/Line/Mode/tube/Status".into()),
1690    ///     method: Some("GET".into()),
1691    ///     ..Default::default()
1692    /// };
1693    /// assert!(!single.is_script_tool());
1694    /// ```
1695    #[must_use]
1696    pub fn is_script_tool(&self) -> bool {
1697        self.script.is_some()
1698    }
1699
1700    /// Number of distinct mutually-exclusive tool kinds declared on this entry.
1701    ///
1702    /// Used by [`ServerConfig::validate`] to reject an ambiguous `[[tools]]`
1703    /// entry (D-01 / T-90-02-04). A well-formed entry declares exactly one kind
1704    /// (count `1`); count `> 1` is ambiguous; count `0` is a kind-less stub
1705    /// (left to other validation rules).
1706    fn declared_kind_count(&self) -> usize {
1707        let is_sql = self.sql.is_some();
1708        let is_single_call = self.path.is_some() || self.method.is_some();
1709        let is_script = self.script.is_some();
1710        usize::from(is_sql) + usize::from(is_single_call) + usize::from(is_script)
1711    }
1712
1713    /// Where `param_name` sits for the purposes of the D3 default length cap
1714    /// (Phase 128).
1715    ///
1716    /// # Derivation
1717    ///
1718    /// - The name appears as a `{name}` segment of [`Self::path`] -> [`ParamPosition::Path`].
1719    /// - Else [`Self::method`] carries a request body (`method_carries_request_body`:
1720    ///   `POST`, `PUT`, `PATCH`) -> [`ParamPosition::Body`].
1721    /// - Else there IS a method -> [`ParamPosition::Query`]. A method with no
1722    ///   request body has nowhere but the URL to carry a non-path input, `OPTIONS`
1723    ///   included.
1724    /// - Else (no method at all) -> [`ParamPosition::Body`]: a SQL named bind or a
1725    ///   script-tool argument.
1726    ///
1727    /// # Coupling with `tools.rs::build_operation` — structural, not documentary
1728    ///
1729    /// Both of this function's splits agree with `build_operation` because both
1730    /// read the same helper, not because a comment says so:
1731    ///
1732    /// - the PATH arm and `build_operation`'s `path_param_names` both read
1733    ///   `path_placeholder_names`;
1734    /// - the QUERY/BODY arms and `build_operation`'s `ParameterLocation` assignment
1735    ///   both read `method_carries_request_body`, which is also the sole source of
1736    ///   `Operation::has_request_body`.
1737    ///
1738    /// The second agreement is Phase 128 CR-02/CR-03, and it replaced a documented
1739    /// "deliberate divergence" that did not survive measurement. `build_operation`
1740    /// used to mark every non-path declared parameter `ParameterLocation::Query`
1741    /// regardless of method while this function answered `Body` for a mutating tool
1742    /// — justified on the grounds that the two questions ("where does the value
1743    /// TRAVEL" versus "where is LENGTH dangerous") are different. They are, but the
1744    /// answers were both wrong: the value travelled in the query string, so the D3
1745    /// cap was withheld from a genuine request-line input on the stated grounds
1746    /// that it was a payload field, and `build_body` — which collected only args
1747    /// absent from `operation.parameters` — sent no payload at all.
1748    ///
1749    /// D-05 is still honoured, and now BY the routing rather than despite it: a
1750    /// `Body` position means the value really is a JSON payload field, and
1751    /// `default_cap_applies` emits no default `maxLength` for it. The escape for
1752    /// a long `Query` value remains an explicit per-parameter `max_length`.
1753    ///
1754    /// # Examples
1755    ///
1756    /// ```
1757    /// use pmcp_server_toolkit::config::{ParamPosition, ToolDecl};
1758    ///
1759    /// let search = ToolDecl {
1760    ///     path: Some("/lines/{line_id}/status".into()),
1761    ///     method: Some("GET".into()),
1762    ///     ..Default::default()
1763    /// };
1764    /// assert_eq!(search.param_position("line_id"), ParamPosition::Path);
1765    /// assert_eq!(search.param_position("detail"), ParamPosition::Query);
1766    ///
1767    /// let comment = ToolDecl {
1768    ///     path: Some("/issues/{id}/comments".into()),
1769    ///     method: Some("POST".into()),
1770    ///     ..Default::default()
1771    /// };
1772    /// assert_eq!(comment.param_position("id"), ParamPosition::Path);
1773    /// // Free text on a mutating method is NOT capped — D-05.
1774    /// assert_eq!(comment.param_position("body_text"), ParamPosition::Body);
1775    ///
1776    /// let sql = ToolDecl { sql: Some("SELECT 1".into()), ..Default::default() };
1777    /// assert_eq!(sql.param_position("anything"), ParamPosition::Body);
1778    /// ```
1779    #[must_use]
1780    pub fn param_position(&self, param_name: &str) -> ParamPosition {
1781        if let Some(path) = self.path.as_deref() {
1782            if path_placeholder_names(path).any(|n| n == param_name) {
1783                return ParamPosition::Path;
1784            }
1785        }
1786        match self.method.as_deref() {
1787            // A body-bearing method routes its non-path inputs into the JSON
1788            // payload, so length there is D-05 free text.
1789            Some(m) if method_carries_request_body(m) => ParamPosition::Body,
1790            // Every OTHER HTTP method has nowhere but the URL to put them.
1791            Some(_) => ParamPosition::Query,
1792            // No method at all: a SQL named bind or a script-tool argument.
1793            None => ParamPosition::Body,
1794        }
1795    }
1796}
1797
1798/// HTTP methods whose non-path inputs travel as fields of the JSON request body.
1799///
1800/// The ONE definition of that rule. `tools.rs::build_operation` reads it through
1801/// [`method_carries_request_body`] for BOTH `Operation::has_request_body` and the
1802/// `ParameterLocation::Body` assignment, and [`ToolDecl::param_position`] reads the
1803/// same predicate to decide where LENGTH is dangerous — so the request's routing
1804/// and the D3 cap's scope cannot drift apart. Phase 128 CR-02/CR-03 is the record
1805/// of what that drift cost: a `POST` tool's declared parameters were marked
1806/// `ParameterLocation::Query` while being classified `ParamPosition::Body`, so they
1807/// travelled in the URL uncapped and no payload ever reached the backend.
1808const BODY_BEARING_METHODS: [&str; 3] = ["POST", "PUT", "PATCH"];
1809
1810/// Whether `method` carries a JSON request body — the one predicate behind
1811/// [`BODY_BEARING_METHODS`]. Case-insensitive on the method name.
1812pub(crate) fn method_carries_request_body(method: &str) -> bool {
1813    BODY_BEARING_METHODS.contains(&method.to_uppercase().as_str())
1814}
1815
1816/// The `{name}` placeholder segments of a single-call tool's `path` template.
1817///
1818/// The ONE definition of that rule. `tools.rs::build_operation` reads it to build
1819/// its `Parameter` list and [`ToolDecl::param_position`] reads it to decide PATH
1820/// position, so the two cannot drift — which matters because a drift would
1821/// silently mis-scope the D3 cap and the D4 placeholder rules.
1822///
1823/// Matches a whole `/`-delimited segment only, and requires at least one character
1824/// between the braces (`{}` is not a placeholder).
1825pub(crate) fn path_placeholder_names(path: &str) -> impl Iterator<Item = &str> {
1826    path.split('/')
1827        .filter(|s| s.starts_with('{') && s.ends_with('}') && s.len() > 2)
1828        .map(|s| &s[1..s.len() - 1])
1829}
1830
1831/// Where a declared parameter's value lands, for the purposes of the D3 default
1832/// length cap (Phase 128).
1833///
1834/// For a single-call HTTP tool this is the SAME answer
1835/// `tools.rs::build_operation` gives as a `crate::http::ParameterLocation` — both
1836/// derive it from `path_placeholder_names` and `method_carries_request_body`.
1837/// See [`ToolDecl::param_position`] for the derivation and for what the previous
1838/// divergence between the two cost (CR-02/CR-03).
1839#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1840pub enum ParamPosition {
1841    /// A `{name}` segment of the tool's `path` template. Capped by default: a long
1842    /// value here has no legitimate use and is where the path-traversal class lives.
1843    Path,
1844    /// A non-path parameter of a tool whose method carries NO request body —
1845    /// `GET`, `HEAD`, `DELETE`, `OPTIONS` — so the value travels in the query
1846    /// string. `tools.rs::build_operation` gives it
1847    /// `crate::http::ParameterLocation::Query` and
1848    /// `crate::http::HttpClient::build_query` appends it to the URL.
1849    ///
1850    /// Capped at `[server.validation] default_max_length` code points by default,
1851    /// by `default_cap_applies`, because an unbounded query value is an unbounded
1852    /// request line: a 414 on some gateways, a truncation on others, and an
1853    /// access-log amplification everywhere. If a search tool starts refusing long
1854    /// queries after upgrading, this is why — declare an explicit `max_length` on
1855    /// that parameter to raise the limit.
1856    Query,
1857    /// A `POST` / `PUT` / `PATCH` payload field, a SQL named bind, or a script-tool
1858    /// argument.
1859    ///
1860    /// For a single-call HTTP tool this is a genuine JSON payload field:
1861    /// `tools.rs::build_operation` gives it
1862    /// `crate::http::ParameterLocation::Body` and
1863    /// `crate::http::HttpClient::build_body` folds it into the request body. It
1864    /// reaches no request line, which is why it is NOT capped by default
1865    /// (D-05 — free text must keep working). Surfaced by [`ServerConfig::lint`] and
1866    /// promotable to an error by `[server.validation] strict`.
1867    Body,
1868}
1869
1870/// Single `[[tools.parameters]]` entry.
1871///
1872/// The `default` and `enum` fields use [`toml::Value`] because they are
1873/// heterogeneous in the reference configs (a `default` may be an integer,
1874/// a string, or a boolean depending on the parameter type).
1875///
1876/// # Forward incompatibility (Phase 128, D-15)
1877///
1878/// This struct carries `#[serde(deny_unknown_fields)]`, so a config declaring any
1879/// of the Phase 128 D2 keys — `pattern`, `min_length`, `format`, `max_items`,
1880/// `allow_slash`, or an `[tools.parameters.items]` table — fails to PARSE on
1881/// toolkit 0.1.3 rather than degrading to "the key was ignored". That is
1882/// deliberate (a silently-ignored validation rule is the class this phase closes)
1883/// but it means a config written for this release cannot be loaded by an older
1884/// toolkit. The same applies to the `[server.validation]` section. Named in the
1885/// CHANGELOG.
1886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1887#[serde(deny_unknown_fields)]
1888pub struct ParamDecl {
1889    /// Parameter name (the `:param` token used in the tool's `sql`).
1890    #[serde(default)]
1891    pub name: String,
1892    /// JSON-schema type (`"string"`, `"integer"`, `"number"`, `"boolean"`).
1893    #[serde(default, rename = "type")]
1894    pub param_type: Option<String>,
1895    /// Human-readable parameter description.
1896    #[serde(default)]
1897    pub description: Option<String>,
1898    /// Whether the parameter is required.
1899    #[serde(default)]
1900    pub required: bool,
1901    /// Optional default value (any TOML type).
1902    #[serde(default)]
1903    pub default: Option<toml::Value>,
1904    /// Maximum string length (string parameters only).
1905    #[serde(default)]
1906    pub max_length: Option<u64>,
1907    /// Inclusive minimum (integer / number parameters only).
1908    ///
1909    /// Stored as `f64`. See [`Self::maximum`] for the precision limit that applies
1910    /// to both bounds.
1911    #[serde(default)]
1912    pub minimum: Option<f64>,
1913    /// Inclusive maximum (integer / number parameters only).
1914    ///
1915    /// # Not a safe way to bound a 64-bit integer ID
1916    ///
1917    /// Both bounds are stored as `f64`, so an integer magnitude above 2^53
1918    /// (`9007199254740992`) cannot be represented exactly. `9007199254740993`
1919    /// written in TOML has ALREADY become `9007199254740992` by the time any code
1920    /// in this crate sees it, and no post-parse check can recover the fact that it
1921    /// was rounded. [`ServerConfig::validate`] therefore refuses a bound that is
1922    /// non-finite or whose magnitude EXCEEDS 2^53
1923    /// ([`ConfigValidationError::NonFiniteParamBound`]) — which catches the wildly
1924    /// out-of-range case, and deliberately does not claim to catch a value sitting
1925    /// one unit past the boundary.
1926    ///
1927    /// If you need to bound a `u64` identifier, express the rule as a
1928    /// [`Self::pattern`] over its string form instead. This limitation is a
1929    /// documented one, not an oversight: adding an `i64`-typed bound vocabulary is
1930    /// out of scope for D2.
1931    #[serde(default)]
1932    pub maximum: Option<f64>,
1933    /// Closed set of allowed values (any TOML scalar).
1934    #[serde(default, rename = "enum")]
1935    pub enum_values: Option<Vec<toml::Value>>,
1936    /// Regular expression the value must match, emitted as JSON Schema `pattern`
1937    /// (string parameters only).
1938    ///
1939    /// # It is UNANCHORED
1940    ///
1941    /// JSON Schema `pattern` is a SUBSTRING search, exactly as ECMA-262
1942    /// `RegExp.prototype.test` is. A rule written as a bare character class such as
1943    /// `[A-Z]{3}` matches `"../../etc/passwd-ABC"` and therefore buys no
1944    /// enforcement whatsoever. Anchor every rule you mean as a whole-value rule:
1945    /// `^[A-Z]{3}$`.
1946    ///
1947    /// # `\s` and `\S` do not mean one thing here
1948    ///
1949    /// Two regex engines are live inside one `jsonschema` 0.49.2 process, and which
1950    /// one evaluates your pattern depends on the pattern's own syntax:
1951    ///
1952    /// - A plain pattern takes the linear-time engine, whose `\s` is a PARTIAL
1953    ///   ECMA-262 set — measured as
1954    ///   `{U+0009, U+000A, U+000B, U+000C, U+000D, U+0020, U+00A0, U+2029, U+FEFF}`.
1955    ///   It does NOT include U+3000 IDEOGRAPHIC SPACE, U+0085 NEL, U+1680,
1956    ///   U+2000, U+2007, U+2028 or U+202F, and it DOES include the byte-order mark.
1957    /// - A pattern containing a lookaround or a backreference takes the
1958    ///   backtracking engine, where `\s` is exactly `\p{White_Space}` — so it DOES
1959    ///   match U+3000, and does NOT match U+FEFF.
1960    ///
1961    /// Adding a lookahead to a pattern therefore silently changes what `\s` means
1962    /// in it. For anything security-relevant, spell out an explicit character class
1963    /// (e.g. `[^\p{White_Space}]`) rather than using the shorthand.
1964    #[serde(default)]
1965    pub pattern: Option<String>,
1966    /// Minimum string length in Unicode code points, emitted as JSON Schema
1967    /// `minLength` (string parameters only).
1968    ///
1969    /// Counted in code points — not bytes and not grapheme clusters — matching
1970    /// `maxLength`'s unit so a `min_length == max_length` pair names exactly one
1971    /// length.
1972    #[serde(default)]
1973    pub min_length: Option<u64>,
1974    /// JSON Schema `format` assertion, e.g. `"uuid"`, `"email"`, `"date-time"`.
1975    ///
1976    /// # It IS enforced on inputs in this SDK
1977    ///
1978    /// `format` is ANNOTATIVE by default in `jsonschema` 0.49 — a bare
1979    /// `draft202012` validator accepts `"!!!not-a-uuid!!!"` against
1980    /// `format = "uuid"`. Declared inputs do not take that path: core `pmcp`
1981    /// compiles a tool's `inputSchema` through a format-ASSERTING builder
1982    /// (Phase 128, Q1), so a declared `format` refuses a non-conforming value at
1983    /// `tools/call` time.
1984    ///
1985    /// Measured under this workspace's pinned `jsonschema` configuration
1986    /// (`0.49`, `default-features = false`), all NINETEEN standard Draft 2020-12
1987    /// format names assert: `date-time`, `date`, `time`, `duration`, `email`,
1988    /// `idn-email`, `hostname`, `idn-hostname`, `ipv4`, `ipv6`, `uri`,
1989    /// `uri-reference`, `iri`, `iri-reference`, `uuid`, `uri-template`,
1990    /// `json-pointer`, `relative-json-pointer`, `regex`. A format name OUTSIDE
1991    /// that list is accepted-and-ignored, per JSON Schema's own rule that an
1992    /// unknown format is an annotation — so a typo such as `"uid"` for `"uuid"`
1993    /// silently enforces nothing.
1994    ///
1995    /// `format` is NOT enforced on OUTPUTS: `structuredContent` validation is
1996    /// deliberately annotative there, and only warns.
1997    #[serde(default)]
1998    pub format: Option<String>,
1999    /// `[tools.parameters.items]` — the element schema for an array parameter,
2000    /// emitted as JSON Schema `items`.
2001    ///
2002    /// Emitted in OBJECT form only. Array-form `items` (the draft-07 tuple
2003    /// construct) does not compile under the Draft 2020-12 pin and would take the
2004    /// whole tool's validator down with it.
2005    #[serde(default)]
2006    pub items: Option<ItemsDecl>,
2007    /// Maximum number of array elements, emitted as JSON Schema `maxItems`
2008    /// (array parameters only).
2009    #[serde(default)]
2010    pub max_items: Option<u64>,
2011    /// Permit `/` inside this parameter's value when it is interpolated into a
2012    /// single-call tool's path template (Phase 128, D-11).
2013    ///
2014    /// Path-placeholder values are refused for path separators by default, because
2015    /// a `/` in a `{segment}` lets a caller reshape the request target. This
2016    /// per-parameter opt-in is the ONLY legitimate source of that permission — it
2017    /// exists for the genuine case of a parameter that names a multi-segment
2018    /// resource path.
2019    ///
2020    /// An OpenAPI spec's `allowReserved` must NEVER be wired to this field. That
2021    /// keyword describes URL percent-encoding latitude in the spec author's
2022    /// serialization rules; it is not a statement that the value may restructure
2023    /// the path, and treating it as one would turn a routine spec detail into a
2024    /// silent path-traversal opening.
2025    #[serde(default)]
2026    pub allow_slash: bool,
2027}
2028
2029/// `[tools.parameters.items]` — the element schema of an array parameter
2030/// (Phase 128, D2).
2031///
2032/// Emitted into `inputSchema` as an OBJECT-form JSON Schema `items` value. The
2033/// array form of `items` is a draft-07 tuple construct that does not compile under
2034/// the Draft 2020-12 pin, so this struct has no way to express it by design.
2035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2036#[serde(deny_unknown_fields)]
2037pub struct ItemsDecl {
2038    /// Element type (`"string"`, `"integer"`, …). Defaults to `"string"` when
2039    /// omitted, matching [`ParamDecl::param_type`]'s convention.
2040    #[serde(default, rename = "type")]
2041    pub item_type: Option<String>,
2042    /// Maximum element length in Unicode code points (string elements only).
2043    #[serde(default)]
2044    pub max_length: Option<u64>,
2045    /// Regular expression each element must match. UNANCHORED — see
2046    /// [`ParamDecl::pattern`] for the anchoring and two-engine `\s` caveats, which
2047    /// apply identically here.
2048    #[serde(default)]
2049    pub pattern: Option<String>,
2050}
2051
2052/// `[tools.annotations]` — MCP `toolAnnotations` hints.
2053#[allow(clippy::struct_excessive_bools)] // Why: REF-01 superset — mirrors the MCP `toolAnnotations` flag set 1:1.
2054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2055#[serde(deny_unknown_fields)]
2056pub struct AnnotationsDecl {
2057    /// Whether the tool only reads (never mutates) state.
2058    #[serde(default)]
2059    pub read_only_hint: bool,
2060    /// Whether the tool may destroy data.
2061    #[serde(default)]
2062    pub destructive_hint: bool,
2063    /// Whether repeated calls with the same args produce the same result.
2064    #[serde(default)]
2065    pub idempotent_hint: bool,
2066    /// Whether the tool interacts with an open-world (external) service.
2067    #[serde(default)]
2068    pub open_world_hint: bool,
2069    /// Cost hint (`"low"`, `"medium"`, `"high"`).
2070    #[serde(default)]
2071    pub cost_hint: Option<String>,
2072}
2073
2074// -----------------------------------------------------------------------------
2075// [[prompts]]
2076// -----------------------------------------------------------------------------
2077
2078/// Single `[[prompts]]` entry.
2079#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2080#[serde(deny_unknown_fields)]
2081pub struct PromptDecl {
2082    /// Prompt name (the identifier MCP clients call by).
2083    #[serde(default)]
2084    pub name: String,
2085    /// Human-readable prompt description.
2086    #[serde(default)]
2087    pub description: Option<String>,
2088    /// Resource URIs to include in the prompt's assembled body.
2089    #[serde(default)]
2090    pub include_resources: Vec<String>,
2091    /// Declared prompt arguments (MCP `PromptArgument`).
2092    #[serde(default)]
2093    pub arguments: Vec<PromptArgumentDecl>,
2094}
2095
2096/// Single argument under `[[prompts.arguments]]`.
2097#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2098#[serde(deny_unknown_fields)]
2099pub struct PromptArgumentDecl {
2100    /// Argument name.
2101    #[serde(default)]
2102    pub name: String,
2103    /// Human-readable description.
2104    #[serde(default)]
2105    pub description: Option<String>,
2106    /// Whether the argument is required.
2107    #[serde(default)]
2108    pub required: bool,
2109}
2110
2111// -----------------------------------------------------------------------------
2112// [[resources]]
2113// -----------------------------------------------------------------------------
2114
2115/// Single `[[resources]]` entry — a statically-shipped resource.
2116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2117#[serde(deny_unknown_fields)]
2118pub struct ResourceDecl {
2119    /// Resource URI (e.g. `"docs://open-images/schema"`).
2120    #[serde(default)]
2121    pub uri: String,
2122    /// Human-readable resource name.
2123    #[serde(default)]
2124    pub name: Option<String>,
2125    /// Resource description.
2126    #[serde(default)]
2127    pub description: Option<String>,
2128    /// MIME type (e.g. `"text/markdown"`).
2129    #[serde(default)]
2130    pub mime_type: Option<String>,
2131    /// Inline resource content (or `"loaded from path.md"` placeholder string —
2132    /// the toolkit treats the value verbatim; resolution to filesystem reads
2133    /// is the caller's responsibility).
2134    #[serde(default)]
2135    pub content: Option<String>,
2136}
2137
2138// -----------------------------------------------------------------------------
2139// Tests
2140// -----------------------------------------------------------------------------
2141
2142#[cfg(test)]
2143mod tests {
2144    use super::*;
2145    use proptest::prelude::*;
2146
2147    const MINIMAL: &str = r#"
2148        [server]
2149        name = "demo"
2150        version = "0.1.0"
2151    "#;
2152
2153    #[test]
2154    fn parse_minimal_config_succeeds() {
2155        let cfg = ServerConfig::from_toml(MINIMAL).expect("minimal must parse");
2156        assert_eq!(cfg.server.name, "demo");
2157        assert_eq!(cfg.server.version, "0.1.0");
2158        assert!(cfg.tools.is_empty());
2159        assert!(cfg.code_mode.is_none());
2160    }
2161
2162    #[test]
2163    fn parse_unknown_field_fails() {
2164        let toml = r#"
2165            [server]
2166            name = "demo"
2167            version = "0.1.0"
2168            unknown_field = "x"
2169        "#;
2170        let err = ServerConfig::from_toml(toml).expect_err("unknown field must fail");
2171        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
2172    }
2173
2174    #[test]
2175    fn parse_typo_in_code_mode_key_fails() {
2176        // T-83-04-02: defence-in-depth against silent policy widening.
2177        let toml = r#"
2178            [server]
2179            name = "demo"
2180            version = "0.1.0"
2181            [code_mode]
2182            enabled = true
2183            auto_aprove_levels = ["low"]
2184        "#;
2185        let err = ServerConfig::from_toml(toml).expect_err("typo'd code_mode key must be rejected");
2186        assert!(matches!(err, ToolkitError::Parse(_)));
2187    }
2188
2189    #[test]
2190    fn code_mode_section_optional() {
2191        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
2192        assert!(cfg.code_mode.is_none());
2193    }
2194
2195    #[test]
2196    fn validate_accepts_valid_config() {
2197        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
2198        cfg.validate().expect("minimal config must validate");
2199    }
2200
2201    #[test]
2202    fn validate_rejects_empty_server_name() {
2203        let toml = r#"
2204            [server]
2205            name = ""
2206            version = "0.1.0"
2207        "#;
2208        let cfg = ServerConfig::from_toml(toml).expect("parse");
2209        match cfg.validate() {
2210            Err(ConfigValidationError::EmptyServerName) => {},
2211            other => panic!("expected EmptyServerName, got {other:?}"),
2212        }
2213    }
2214
2215    #[test]
2216    fn validate_rejects_empty_server_version() {
2217        let toml = r#"
2218            [server]
2219            name = "demo"
2220            version = ""
2221        "#;
2222        let cfg = ServerConfig::from_toml(toml).expect("parse");
2223        match cfg.validate() {
2224            Err(ConfigValidationError::EmptyServerVersion) => {},
2225            other => panic!("expected EmptyServerVersion, got {other:?}"),
2226        }
2227    }
2228
2229    #[test]
2230    fn validate_rejects_empty_tool_name() {
2231        let toml = r#"
2232            [server]
2233            name = "demo"
2234            version = "0.1.0"
2235
2236            [[tools]]
2237            name = "ok"
2238            description = "first"
2239
2240            [[tools]]
2241            name = ""
2242            description = "second-is-empty"
2243        "#;
2244        let cfg = ServerConfig::from_toml(toml).expect("parse");
2245        match cfg.validate() {
2246            Err(ConfigValidationError::EmptyToolName(1)) => {},
2247            other => panic!("expected EmptyToolName(1), got {other:?}"),
2248        }
2249    }
2250
2251    #[test]
2252    fn validate_rejects_empty_table_name() {
2253        let toml = r#"
2254            [server]
2255            name = "demo"
2256            version = "0.1.0"
2257
2258            [[database.tables]]
2259            name = ""
2260            description = "missing-name"
2261        "#;
2262        let cfg = ServerConfig::from_toml(toml).expect("parse");
2263        match cfg.validate() {
2264            Err(ConfigValidationError::EmptyTableName(0)) => {},
2265            other => panic!("expected EmptyTableName(0), got {other:?}"),
2266        }
2267    }
2268
2269    /// Phase 90 gap-closure (GAP 3 / WR-02): a `[backend]` block with an
2270    /// empty / missing `base_url` is rejected at validate() time with
2271    /// [`ConfigValidationError::EmptyBackendBaseUrl`] — not a late opaque
2272    /// `DispatchError::Connector("invalid base URL")` at request time.
2273    #[cfg(feature = "http")]
2274    #[test]
2275    fn validate_rejects_empty_backend_base_url() {
2276        // base_url key present but empty.
2277        let toml = r#"
2278            [server]
2279            name = "demo"
2280            version = "0.1.0"
2281
2282            [backend]
2283            base_url = ""
2284        "#;
2285        let cfg = ServerConfig::from_toml(toml).expect("parse");
2286        match cfg.validate() {
2287            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
2288            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
2289        }
2290    }
2291
2292    /// A `[backend]` block whose `base_url` key is omitted entirely (defaults
2293    /// to `""` via `#[serde(default)]`) is rejected the same way.
2294    #[cfg(feature = "http")]
2295    #[test]
2296    fn validate_rejects_omitted_backend_base_url() {
2297        let toml = r#"
2298            [server]
2299            name = "demo"
2300            version = "0.1.0"
2301
2302            [backend]
2303        "#;
2304        let cfg = ServerConfig::from_toml(toml).expect("parse");
2305        match cfg.validate() {
2306            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
2307            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
2308        }
2309    }
2310
2311    /// A multi-placeholder composition (`${SCHEME}://${HOST}`) is a MALFORMED
2312    /// reference — the grammar resolves one whole-value `${VAR}`, it does not
2313    /// interpolate — so validate() refuses it at load time instead of letting
2314    /// every boot fail with an `UnresolvedBaseUrlRef` naming an empty variable.
2315    #[cfg(feature = "http")]
2316    #[test]
2317    fn validate_rejects_multi_placeholder_backend_base_url() {
2318        let toml = r#"
2319            [server]
2320            name = "demo"
2321            version = "0.1.0"
2322
2323            [backend]
2324            base_url = "${TFL_SCHEME}://${TFL_HOST}"
2325        "#;
2326        let cfg = ServerConfig::from_toml(toml).expect("parse");
2327        match cfg.validate() {
2328            Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
2329            other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
2330        }
2331    }
2332
2333    /// The empty `${}` form is the same class of defect and gets the same
2334    /// load-time refusal.
2335    #[cfg(feature = "http")]
2336    #[test]
2337    fn validate_rejects_empty_name_backend_base_url_ref() {
2338        let toml = r#"
2339            [server]
2340            name = "demo"
2341            version = "0.1.0"
2342
2343            [backend]
2344            base_url = "${}"
2345        "#;
2346        let cfg = ServerConfig::from_toml(toml).expect("parse");
2347        match cfg.validate() {
2348            Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
2349            other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
2350        }
2351    }
2352
2353    /// A well-formed single reference stays valid — the check refuses only
2354    /// malformed shapes, never the deferred-to-environment pattern itself.
2355    #[cfg(feature = "http")]
2356    #[test]
2357    fn validate_accepts_single_reference_backend_base_url() {
2358        let toml = r#"
2359            [server]
2360            name = "demo"
2361            version = "0.1.0"
2362
2363            [backend]
2364            base_url = "${TFL_BASE_URL}"
2365        "#;
2366        let cfg = ServerConfig::from_toml(toml).expect("parse");
2367        cfg.validate()
2368            .expect("a single ${VAR} backend.base_url reference must validate");
2369    }
2370
2371    /// The SAME malformed-reference rule applies to `[backend.auth]`
2372    /// credentials, and it applies at LOAD time. Without it the credential path
2373    /// resolved a malformed reference to the empty string and then OMITTED it:
2374    /// the server booted, every backend call went out unauthenticated, and
2375    /// nothing was logged. `${TFL-APP-KEY}` is the realistic shape — a dash is
2376    /// not a portably settable variable name, so the reference names nothing.
2377    #[cfg(feature = "http")]
2378    #[test]
2379    fn validate_rejects_malformed_backend_auth_credential_ref() {
2380        let toml = r#"
2381            [server]
2382            name = "demo"
2383            version = "0.1.0"
2384
2385            [backend]
2386            base_url = "https://api.example.com"
2387
2388            [backend.auth]
2389            type = "bearer"
2390            token = "${TFL-APP-KEY}"
2391        "#;
2392        let cfg = ServerConfig::from_toml(toml).expect("parse");
2393        match cfg.validate() {
2394            Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
2395                assert_eq!(field, "token");
2396            },
2397            other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
2398        }
2399    }
2400
2401    /// The api_key map path gets the same refusal, and the error names the
2402    /// offending entry so the operator knows WHICH parameter to fix.
2403    #[cfg(feature = "http")]
2404    #[test]
2405    fn validate_rejects_malformed_backend_auth_api_key_entry() {
2406        let toml = r#"
2407            [server]
2408            name = "demo"
2409            version = "0.1.0"
2410
2411            [backend]
2412            base_url = "https://api.example.com"
2413
2414            [backend.auth]
2415            type = "api_key"
2416            query_params = { app_key = "${TFL_SCHEME}://${TFL_HOST}" }
2417        "#;
2418        let cfg = ServerConfig::from_toml(toml).expect("parse");
2419        match cfg.validate() {
2420            Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
2421                assert_eq!(field, "query_params.app_key");
2422            },
2423            other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
2424        }
2425    }
2426
2427    /// The refusal is scoped to MALFORMED shapes only: a well-formed reference
2428    /// and a plain literal both still validate, so the deferred-to-environment
2429    /// pattern and committed dev configs are untouched.
2430    #[cfg(feature = "http")]
2431    #[test]
2432    fn validate_accepts_wellformed_and_literal_backend_auth_credentials() {
2433        let toml = r#"
2434            [server]
2435            name = "demo"
2436            version = "0.1.0"
2437
2438            [backend]
2439            base_url = "https://api.example.com"
2440
2441            [backend.auth]
2442            type = "basic"
2443            username = "svc-account"
2444            password = "${TFL_APP_KEY}"
2445        "#;
2446        let cfg = ServerConfig::from_toml(toml).expect("parse");
2447        cfg.validate()
2448            .expect("a literal username and a single ${VAR} password must validate");
2449    }
2450
2451    /// A `[backend]` block with a non-empty `base_url` validates OK.
2452    #[cfg(feature = "http")]
2453    #[test]
2454    fn validate_accepts_non_empty_backend_base_url() {
2455        let toml = r#"
2456            [server]
2457            name = "demo"
2458            version = "0.1.0"
2459
2460            [backend]
2461            base_url = "https://api.example.com"
2462        "#;
2463        let cfg = ServerConfig::from_toml(toml).expect("parse");
2464        cfg.validate()
2465            .expect("config with a non-empty backend.base_url must validate");
2466    }
2467
2468    /// A config with NO `[backend]` block (a pure-SQL config) is unaffected by
2469    /// the new check — `backend` is `None`, so the check never fires.
2470    #[cfg(feature = "http")]
2471    #[test]
2472    fn validate_accepts_absent_backend() {
2473        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
2474        assert!(cfg.backend.is_none());
2475        cfg.validate()
2476            .expect("a config without [backend] must validate (SQL configs unaffected)");
2477    }
2478
2479    /// The error Display names the offending field and is actionable.
2480    #[cfg(feature = "http")]
2481    #[test]
2482    fn empty_backend_base_url_error_names_the_field() {
2483        let msg = ConfigValidationError::EmptyBackendBaseUrl.to_string();
2484        assert!(
2485            msg.contains("[backend].base_url"),
2486            "error must name the field, got: {msg}"
2487        );
2488    }
2489
2490    #[test]
2491    fn database_url_optional_field_parses() {
2492        // Phase 84 CONN-04 / D-08: the additive `[database].url` field parses
2493        // under `#[serde(deny_unknown_fields)]` and carries the `env:VAR_NAME`
2494        // indirection string verbatim (resolution happens at the consumer layer).
2495        let toml = r#"
2496            [server]
2497            name = "x"
2498            version = "0.0.1"
2499
2500            [database]
2501            url = "env:DATABASE_URL"
2502        "#;
2503        let cfg = ServerConfig::from_toml(toml).expect("config with [database].url must parse");
2504        assert_eq!(cfg.database.url, Some("env:DATABASE_URL".to_string()));
2505    }
2506
2507    #[test]
2508    fn from_toml_strict_validated_rolls_both_errors() {
2509        // 1. Parse error path (unknown field).
2510        let bad_toml = r#"
2511            [server]
2512            name = "demo"
2513            version = "0.1.0"
2514            nonsense = "x"
2515        "#;
2516        let err = ServerConfig::from_toml_strict_validated(bad_toml)
2517            .expect_err("unknown field must surface");
2518        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
2519
2520        // 2. Validation error path (empty required value).
2521        let invalid_toml = r#"
2522            [server]
2523            name = ""
2524            version = "0.1.0"
2525        "#;
2526        let err = ServerConfig::from_toml_strict_validated(invalid_toml)
2527            .expect_err("empty name must surface");
2528        assert!(
2529            matches!(
2530                err,
2531                ToolkitError::Validation(ConfigValidationError::EmptyServerName)
2532            ),
2533            "got: {err:?}"
2534        );
2535    }
2536
2537    // -------------------------------------------------------------------------
2538    // ToolDecl two-kind detection — D-01 (shared, not http-gated)
2539    // -------------------------------------------------------------------------
2540
2541    #[test]
2542    fn test_tooldecl_single_call_parses() {
2543        let toml = r#"
2544            [server]
2545            name = "tube"
2546            version = "0.1.0"
2547
2548            [[tools]]
2549            name = "tube_status"
2550            path = "/Line/Mode/tube/Status"
2551            method = "GET"
2552        "#;
2553        let cfg = ServerConfig::from_toml(toml).expect("single-call tool must parse");
2554        let tool = &cfg.tools[0];
2555        assert_eq!(tool.path.as_deref(), Some("/Line/Mode/tube/Status"));
2556        assert_eq!(tool.method.as_deref(), Some("GET"));
2557        assert!(!tool.is_script_tool());
2558        cfg.validate()
2559            .expect("single-call tool is a valid single kind");
2560    }
2561
2562    #[test]
2563    fn test_tooldecl_script_parses() {
2564        let toml = r#"
2565            [server]
2566            name = "tube"
2567            version = "0.1.0"
2568
2569            [[tools]]
2570            name = "plan_journey"
2571            script = """
2572            const a = await api.get('/Journey/JourneyResults/' + args.from + '/to/' + args.to);
2573            return a;
2574            """
2575
2576            [[tools.parameters]]
2577            name = "from"
2578            type = "string"
2579            required = true
2580
2581            [[tools.parameters]]
2582            name = "to"
2583            type = "string"
2584            required = true
2585        "#;
2586        let cfg = ServerConfig::from_toml(toml).expect("script tool must parse");
2587        let tool = &cfg.tools[0];
2588        assert!(tool.script.is_some());
2589        assert!(tool.is_script_tool());
2590        assert_eq!(tool.parameters.len(), 2);
2591        cfg.validate().expect("script tool is a valid single kind");
2592    }
2593
2594    #[test]
2595    fn test_tooldecl_detection() {
2596        let script = ToolDecl {
2597            script: Some("return 1;".to_string()),
2598            ..Default::default()
2599        };
2600        assert!(script.is_script_tool());
2601
2602        let single = ToolDecl {
2603            path: Some("/x".to_string()),
2604            method: Some("GET".to_string()),
2605            ..Default::default()
2606        };
2607        assert!(!single.is_script_tool());
2608
2609        let sql = ToolDecl {
2610            sql: Some("SELECT 1".to_string()),
2611            ..Default::default()
2612        };
2613        assert!(!sql.is_script_tool());
2614    }
2615
2616    #[test]
2617    fn test_tooldecl_ambiguous_rejected() {
2618        // script + path/method is ambiguous (Codex MEDIUM): rejected, not
2619        // resolved by a silent "script wins".
2620        let toml = r#"
2621            [server]
2622            name = "tube"
2623            version = "0.1.0"
2624
2625            [[tools]]
2626            name = "confused"
2627            path = "/x"
2628            method = "GET"
2629            script = "return 1;"
2630        "#;
2631        let cfg = ServerConfig::from_toml(toml).expect("parse (ambiguity is a validate-time rule)");
2632        match cfg.validate() {
2633            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
2634            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
2635        }
2636    }
2637
2638    #[test]
2639    fn test_tooldecl_ambiguous_sql_plus_script_rejected() {
2640        let toml = r#"
2641            [server]
2642            name = "tube"
2643            version = "0.1.0"
2644
2645            [[tools]]
2646            name = "confused"
2647            sql = "SELECT 1"
2648            script = "return 1;"
2649        "#;
2650        let cfg = ServerConfig::from_toml(toml).expect("parse");
2651        match cfg.validate() {
2652            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
2653            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
2654        }
2655    }
2656
2657    #[test]
2658    fn test_tooldecl_sql_still_parses() {
2659        // REF-01 superset regression: an existing sql= tool is unaffected by the
2660        // additive path/method/base_url/script fields.
2661        let toml = r#"
2662            [server]
2663            name = "demo"
2664            version = "0.1.0"
2665
2666            [[tools]]
2667            name = "list_tables"
2668            sql = "SELECT name FROM sqlite_master"
2669        "#;
2670        let cfg = ServerConfig::from_toml(toml).expect("sql tool must still parse");
2671        let tool = &cfg.tools[0];
2672        assert_eq!(tool.sql.as_deref(), Some("SELECT name FROM sqlite_master"));
2673        assert!(tool.path.is_none());
2674        assert!(tool.method.is_none());
2675        assert!(tool.base_url.is_none());
2676        assert!(tool.script.is_none());
2677        assert!(!tool.is_script_tool());
2678        cfg.validate().expect("sql tool validates as a single kind");
2679    }
2680
2681    // -------------------------------------------------------------------------
2682    // [backend] / [backend.auth] / [backend.http] — D-06 (http feature)
2683    // -------------------------------------------------------------------------
2684
2685    #[cfg(feature = "http")]
2686    #[test]
2687    fn test_backend_section_parses() {
2688        // A full [backend] + [backend.auth] (api_key) + [backend.http] block
2689        // round-trips into ServerConfig with backend.is_some().
2690        let toml = r#"
2691            [server]
2692            name = "tube"
2693            version = "0.1.0"
2694
2695            [backend]
2696            base_url = "https://api.tfl.gov.uk"
2697
2698            [backend.auth]
2699            type = "api_key"
2700
2701            [backend.auth.query_params]
2702            app_key = "${TFL_APP_KEY}"
2703
2704            [backend.http]
2705            timeout_seconds = 10
2706            retries = 2
2707        "#;
2708        let cfg = ServerConfig::from_toml(toml).expect("[backend] config must parse");
2709        let backend = cfg.backend.expect("backend must be Some");
2710        assert_eq!(backend.base_url, "https://api.tfl.gov.uk");
2711        assert_eq!(backend.http.timeout_seconds, 10);
2712        assert_eq!(backend.http.retries, 2);
2713        assert!(
2714            matches!(backend.auth, AuthConfig::ApiKey { .. }),
2715            "auth must be api_key, got {:?}",
2716            backend.auth
2717        );
2718    }
2719
2720    #[cfg(feature = "http")]
2721    #[test]
2722    fn test_backend_auth_defaults_to_none() {
2723        // [backend] without a [backend.auth] sub-table defaults auth to None
2724        // and http to HttpConfig defaults (additive sub-tables).
2725        let toml = r#"
2726            [server]
2727            name = "tube"
2728            version = "0.1.0"
2729
2730            [backend]
2731            base_url = "https://api.example.com"
2732        "#;
2733        let cfg = ServerConfig::from_toml(toml).expect("backend w/o auth must parse");
2734        let backend = cfg.backend.expect("backend must be Some");
2735        assert!(matches!(backend.auth, AuthConfig::None));
2736        assert_eq!(backend.http, HttpConfig::default());
2737    }
2738
2739    #[cfg(feature = "http")]
2740    #[test]
2741    fn test_sql_config_unaffected() {
2742        // REF-01 superset / D-06 additive proof: a pure-SQL config with NO
2743        // [backend] still parses, and backend == None.
2744        let toml = r#"
2745            [server]
2746            name = "demo"
2747            version = "0.1.0"
2748
2749            [database]
2750            type = "sqlite"
2751            file_path = "/tmp/demo.db"
2752
2753            [[tools]]
2754            name = "list_tables"
2755            sql = "SELECT name FROM sqlite_master"
2756        "#;
2757        let cfg = ServerConfig::from_toml(toml).expect("SQL config must still parse");
2758        assert!(
2759            cfg.backend.is_none(),
2760            "SQL config must have backend == None"
2761        );
2762        assert_eq!(cfg.tools.len(), 1);
2763    }
2764
2765    #[cfg(feature = "http")]
2766    #[test]
2767    fn test_backend_unknown_field_rejected() {
2768        // T-90-02-01: deny_unknown_fields preserved — an unknown key under
2769        // [backend.http] is a hard parse error, never a silent default.
2770        let toml = r#"
2771            [server]
2772            name = "tube"
2773            version = "0.1.0"
2774
2775            [backend]
2776            base_url = "https://api.example.com"
2777
2778            [backend.http]
2779            foo = 1
2780        "#;
2781        let err =
2782            ServerConfig::from_toml(toml).expect_err("unknown [backend.http] key must be rejected");
2783        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
2784    }
2785
2786    // -------------------------------------------------------------------------
2787    // `[[config_slots]]` — PKG-03 slot declarations (Phase 120 Plan 04 Task 1)
2788    // -------------------------------------------------------------------------
2789
2790    /// The three-slot declaration block the london-tube proving fixture carries.
2791    const CONFIG_SLOTS_TOML: &str = r#"
2792        [server]
2793        name = "london-tube"
2794        version = "1.1.0"
2795
2796        [[config_slots]]
2797        key = "backend.base_url"
2798        kind = "endpoint"
2799        name = "TFL_BASE_URL"
2800        tested_value = "https://api.tfl.gov.uk"
2801
2802        [[config_slots]]
2803        key = "backend.auth.query_params.app_key"
2804        kind = "secret"
2805        name = "TFL_APP_KEY"
2806
2807        [[config_slots]]
2808        key = "backend.auth.type"
2809        kind = "auth_mode"
2810        name = "backend-auth-mode"
2811        tested_value = "api_key"
2812    "#;
2813
2814    /// Test 1: a `[[config_slots]]` block parses through the STRICT + validated
2815    /// entry point and exposes all three entries with their fields intact.
2816    #[test]
2817    fn config_slots_block_parses_through_strict_entry_point() {
2818        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
2819            .expect("[[config_slots]] must parse through the strict entry point");
2820        assert_eq!(cfg.config_slots.len(), 3, "three declared slots");
2821
2822        assert_eq!(cfg.config_slots[0].key, "backend.base_url");
2823        assert_eq!(cfg.config_slots[0].kind, ConfigSlotKind::Endpoint);
2824        assert_eq!(cfg.config_slots[0].name, "TFL_BASE_URL");
2825        assert_eq!(
2826            cfg.config_slots[0].tested_value.as_deref(),
2827            Some("https://api.tfl.gov.uk")
2828        );
2829
2830        assert_eq!(cfg.config_slots[1].kind, ConfigSlotKind::Secret);
2831        assert_eq!(cfg.config_slots[1].name, "TFL_APP_KEY");
2832        assert_eq!(cfg.config_slots[2].kind, ConfigSlotKind::AuthMode);
2833    }
2834
2835    /// A `[[config_slots]]` entry carrying `supplied_by` must BOOT.
2836    ///
2837    /// This is the load-bearing half of a two-crate change. `pmcp-package`
2838    /// refuses to pack a config whose fields this struct's
2839    /// `deny_unknown_fields` would reject, on the grounds that packing it would
2840    /// ship a server that cannot start. So if the packer learns `supplied_by`
2841    /// and this struct does not, every config using the field becomes
2842    /// unpackable; if this struct learns it and the packer does not, the packer
2843    /// rejects configs the server boots from happily. Both sides move together
2844    /// or neither does, and this test is the runtime half of that pin.
2845    #[test]
2846    fn a_config_slot_declaring_supplied_by_parses_through_the_strict_entry_point() {
2847        let toml = r#"
2848            [server]
2849            name = "tube"
2850            version = "0.1.0"
2851
2852            [[config_slots]]
2853            key = "backend.base_url"
2854            kind = "endpoint"
2855            name = "TFL_BASE_URL"
2856            tested_value = "https://api.tfl.gov.uk"
2857            supplied_by = "platform"
2858
2859            [[config_slots]]
2860            key = "backend.function_name"
2861            kind = "secret"
2862            name = "AWS_LAMBDA_FUNCTION_NAME"
2863            supplied_by = "runtime"
2864        "#;
2865        let cfg = ServerConfig::from_toml_strict_validated(toml)
2866            .expect("`supplied_by` must parse under deny_unknown_fields");
2867        assert_eq!(
2868            cfg.config_slots[0].supplied_by,
2869            ConfigSlotSuppliedBy::Platform
2870        );
2871        assert_eq!(
2872            cfg.config_slots[1].supplied_by,
2873            ConfigSlotSuppliedBy::Runtime
2874        );
2875    }
2876
2877    /// Omitting it means `environment`, so every config written before the
2878    /// field existed keeps its meaning rather than failing to parse.
2879    #[test]
2880    fn a_config_slot_without_supplied_by_defaults_to_environment() {
2881        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
2882            .expect("the pre-existing fixture must still parse");
2883        for slot in &cfg.config_slots {
2884            assert_eq!(slot.supplied_by, ConfigSlotSuppliedBy::Environment);
2885        }
2886    }
2887
2888    /// An unrecognized value is a parse ERROR, not a silent default — strict
2889    /// parse discipline (D-13). A defaulted typo here would tell an operator to
2890    /// supply a value the platform actually injects.
2891    #[test]
2892    fn an_unknown_supplied_by_value_is_a_parse_error() {
2893        let toml = r#"
2894            [server]
2895            name = "tube"
2896            version = "0.1.0"
2897
2898            [[config_slots]]
2899            key = "backend.base_url"
2900            kind = "endpoint"
2901            name = "TFL_BASE_URL"
2902            tested_value = "x"
2903            supplied_by = "platfrom"
2904        "#;
2905        ServerConfig::from_toml_strict_validated(toml)
2906            .expect_err("a misspelled supplied_by must not silently default");
2907    }
2908
2909    /// Test 2: the field is ADDITIVE — a config with no `[[config_slots]]` block
2910    /// parses unchanged and yields an empty vec (`#[serde(default)]`).
2911    #[test]
2912    fn config_without_config_slots_parses_with_empty_vec() {
2913        let cfg = ServerConfig::from_toml_strict_validated(MINIMAL)
2914            .expect("a config omitting [[config_slots]] still parses");
2915        assert!(
2916            cfg.config_slots.is_empty(),
2917            "absent block yields an empty vec, not a default entry"
2918        );
2919    }
2920
2921    /// Test 3: `deny_unknown_fields` still bites at the TOP level — a typo'd
2922    /// `[[config_slotz]]` is a hard parse error, never a silently-ignored block.
2923    #[test]
2924    fn top_level_config_slots_typo_is_still_rejected() {
2925        let toml = r#"
2926            [server]
2927            name = "demo"
2928            version = "0.1.0"
2929
2930            [[config_slotz]]
2931            key = "backend.base_url"
2932            kind = "endpoint"
2933            name = "TFL_BASE_URL"
2934        "#;
2935        let err = ServerConfig::from_toml(toml)
2936            .expect_err("a typo'd top-level array-of-tables must be rejected");
2937        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
2938    }
2939
2940    /// Test 4: the decl struct is itself `deny_unknown_fields` — a typo INSIDE
2941    /// the block (`nmae`) is rejected rather than silently dropped.
2942    #[test]
2943    fn config_slot_unknown_inner_key_is_rejected() {
2944        let toml = r#"
2945            [server]
2946            name = "demo"
2947            version = "0.1.0"
2948
2949            [[config_slots]]
2950            key = "backend.base_url"
2951            kind = "endpoint"
2952            nmae = "TFL_BASE_URL"
2953        "#;
2954        let err = ServerConfig::from_toml(toml)
2955            .expect_err("an unknown key inside [[config_slots]] must be rejected");
2956        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
2957    }
2958
2959    /// Test 5: `tested_value` is OPTIONAL — an identity-bearing slot structurally
2960    /// carries no value, so omitting it parses to `None`.
2961    #[test]
2962    fn config_slot_tested_value_is_optional() {
2963        let toml = r#"
2964            [server]
2965            name = "demo"
2966            version = "0.1.0"
2967
2968            [[config_slots]]
2969            key = "backend.auth.query_params.app_key"
2970            kind = "secret"
2971            name = "TFL_APP_KEY"
2972        "#;
2973        let cfg = ServerConfig::from_toml_strict_validated(toml)
2974            .expect("an entry without tested_value parses");
2975        assert_eq!(cfg.config_slots.len(), 1);
2976        assert!(
2977            cfg.config_slots[0].tested_value.is_none(),
2978            "omitted tested_value parses to None"
2979        );
2980    }
2981
2982    /// Test 6 (Codex MEDIUM — the invalid-kind hole): `kind` is a CLOSED
2983    /// vocabulary. A typo such as `endpont` — or an empty string — is a PARSE
2984    /// error naming the accepted set, not a declaration that parses cleanly and
2985    /// then fails to map to any package slot type two crates away.
2986    #[test]
2987    fn config_slot_invalid_kind_is_rejected_naming_the_accepted_set() {
2988        for bad in ["endpont", ""] {
2989            let toml = format!(
2990                r#"
2991                [server]
2992                name = "demo"
2993                version = "0.1.0"
2994
2995                [[config_slots]]
2996                key = "backend.base_url"
2997                kind = "{bad}"
2998                name = "TFL_BASE_URL"
2999                "#
3000            );
3001            let err = ServerConfig::from_toml(&toml)
3002                .expect_err("an unrecognized config-slot kind must be rejected at parse time");
3003            let rendered = err.to_string();
3004            for accepted in ["endpoint", "secret", "auth_mode"] {
3005                assert!(
3006                    rendered.contains(accepted),
3007                    "the error for kind = \"{bad}\" must name the accepted kind \
3008                     `{accepted}`: {rendered}"
3009                );
3010            }
3011        }
3012    }
3013
3014    /// Test 7: all three valid kinds parse, and the parsed value is a CLOSED
3015    /// enum — comparable as `ConfigSlotKind`, not as a free string. A fourth
3016    /// kind is therefore a deliberate addition here, never a silent
3017    /// pass-through to the package side.
3018    #[test]
3019    fn config_slot_all_three_kinds_parse_as_a_closed_enum() {
3020        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
3021            .expect("all three kinds parse");
3022        let kinds: Vec<ConfigSlotKind> = cfg.config_slots.iter().map(|s| s.kind).collect();
3023        assert_eq!(
3024            kinds,
3025            vec![
3026                ConfigSlotKind::Endpoint,
3027                ConfigSlotKind::Secret,
3028                ConfigSlotKind::AuthMode
3029            ],
3030            "kind is a closed enum, not a free string"
3031        );
3032    }
3033
3034    /// `validate()` rejects an entry whose `key` or `name` is empty/whitespace,
3035    /// carrying the offending entry INDEX (the `EmptyTableName(i)` error shape).
3036    #[test]
3037    fn config_slot_empty_key_or_name_fails_validation() {
3038        for field in ["key", "name"] {
3039            let (key, name) = if field == "key" {
3040                ("   ", "TFL_BASE_URL")
3041            } else {
3042                ("backend.base_url", "  ")
3043            };
3044            let toml = format!(
3045                r#"
3046                [server]
3047                name = "demo"
3048                version = "0.1.0"
3049
3050                [[config_slots]]
3051                key = "{key}"
3052                kind = "endpoint"
3053                name = "{name}"
3054                "#
3055            );
3056            let cfg = ServerConfig::from_toml(&toml).expect("parses; emptiness is semantic");
3057            let err = cfg
3058                .validate()
3059                .expect_err("an empty config-slot key/name must fail validation");
3060            assert!(
3061                matches!(err, ConfigValidationError::EmptyConfigSlotField(0)),
3062                "empty {field} must yield EmptyConfigSlotField(0), got: {err:?}"
3063            );
3064        }
3065    }
3066
3067    /// `validate()` refuses a `secret` declaration carrying a `tested_value` —
3068    /// identity-bearing slots structurally record no value, and this field is
3069    /// the one place a REAL credential could sit in a config that is served
3070    /// but never packed (pack-time gates only run on packaging).
3071    #[test]
3072    fn config_slot_secret_with_tested_value_fails_validation_without_echoing_it() {
3073        let toml = r#"
3074            [server]
3075            name = "demo"
3076            version = "0.1.0"
3077
3078            [[config_slots]]
3079            key = "backend.auth.query_params.app_key"
3080            kind = "secret"
3081            name = "TFL_APP_KEY"
3082            tested_value = "sentinel-real-credential"
3083        "#;
3084        let cfg = ServerConfig::from_toml(toml).expect("parses; the rule is semantic");
3085        let err = cfg
3086            .validate()
3087            .expect_err("a secret slot carrying a tested_value must fail validation");
3088        assert!(
3089            matches!(err, ConfigValidationError::SecretSlotCarriesTestedValue(0)),
3090            "got: {err:?}"
3091        );
3092        assert!(
3093            !err.to_string().contains("sentinel-real-credential"),
3094            "the error must not echo the value: {err}"
3095        );
3096    }
3097
3098    // -------------------------------------------------------------------------
3099    // Phase 128 D2 / SC-2 — the six new `ParamDecl` keys and the config-time
3100    // pattern-compile gate.
3101    // -------------------------------------------------------------------------
3102
3103    /// D2: all six new keys parse from TOML, including the
3104    /// `[tools.parameters.items]` sub-table.
3105    #[test]
3106    fn param_decl_parses_all_d2_keys() {
3107        let toml = r#"
3108            [server]
3109            name = "demo"
3110            version = "0.1.0"
3111
3112            [[tools]]
3113            name = "batch_lookup"
3114
3115            [[tools.parameters]]
3116            name = "codes"
3117            type = "array"
3118            required = true
3119            max_items = 25
3120            min_length = 2
3121            format = "uuid"
3122            pattern = "^[A-Z]{3}$"
3123            allow_slash = true
3124
3125            [tools.parameters.items]
3126            type = "string"
3127            max_length = 8
3128            pattern = "^[a-z]+$"
3129        "#;
3130        let cfg = ServerConfig::from_toml(toml).expect("parse");
3131        let p = &cfg.tools[0].parameters[0];
3132        assert_eq!(p.pattern.as_deref(), Some("^[A-Z]{3}$"));
3133        assert_eq!(p.min_length, Some(2));
3134        assert_eq!(p.format.as_deref(), Some("uuid"));
3135        assert_eq!(p.max_items, Some(25));
3136        assert!(p.allow_slash);
3137        let items = p.items.as_ref().expect("items sub-table");
3138        assert_eq!(items.item_type.as_deref(), Some("string"));
3139        assert_eq!(items.max_length, Some(8));
3140        assert_eq!(items.pattern.as_deref(), Some("^[a-z]+$"));
3141    }
3142
3143    /// D2: the six new keys survive a `Serialize` -> `Deserialize` round trip, so
3144    /// a config re-emitted by the toolkit does not silently drop a declared rule.
3145    #[test]
3146    fn param_decl_d2_keys_round_trip_through_toml() {
3147        let original = ParamDecl {
3148            name: "codes".to_string(),
3149            param_type: Some("array".to_string()),
3150            required: true,
3151            pattern: Some("^[A-Z]{3}$".to_string()),
3152            min_length: Some(2),
3153            format: Some("uuid".to_string()),
3154            max_items: Some(25),
3155            allow_slash: true,
3156            items: Some(ItemsDecl {
3157                item_type: Some("string".to_string()),
3158                max_length: Some(8),
3159                pattern: Some("^[a-z]+$".to_string()),
3160            }),
3161            ..Default::default()
3162        };
3163        let cfg = ServerConfig {
3164            server: ServerSection {
3165                name: "demo".to_string(),
3166                version: "0.1.0".to_string(),
3167                ..Default::default()
3168            },
3169            tools: vec![ToolDecl {
3170                name: "batch_lookup".to_string(),
3171                parameters: vec![original.clone()],
3172                ..Default::default()
3173            }],
3174            ..Default::default()
3175        };
3176        let text = toml::to_string(&cfg).expect("serialize");
3177        let parsed = ServerConfig::from_toml(&text).expect("re-parse");
3178        assert_eq!(parsed.tools[0].parameters[0], original);
3179    }
3180
3181    /// SC-2: a `pattern` that does not compile fails at CONFIG time, naming the
3182    /// offending parameter — not at call time, where it would take the whole
3183    /// tool's validator down.
3184    #[test]
3185    fn validate_rejects_uncompilable_param_pattern() {
3186        let toml = r#"
3187            [server]
3188            name = "demo"
3189            version = "0.1.0"
3190
3191            [[tools]]
3192            name = "lookup"
3193
3194            [[tools.parameters]]
3195            name = "region"
3196            type = "string"
3197            pattern = "^[A-Z"
3198        "#;
3199        let cfg = ServerConfig::from_toml(toml).expect("parse");
3200        match cfg.validate() {
3201            Err(ConfigValidationError::UncompilableParamSchema {
3202                ref tool,
3203                ref position,
3204                ref detail,
3205            }) => {
3206                assert_eq!(tool, "lookup");
3207                assert!(
3208                    position.contains("region"),
3209                    "position must name the offending parameter, got {position:?}"
3210                );
3211                assert!(
3212                    !detail.is_empty(),
3213                    "the author-facing detail must be present"
3214                );
3215            },
3216            other => panic!("expected UncompilableParamSchema, got {other:?}"),
3217        }
3218    }
3219
3220    /// SC-2 edge (adjacency): a pattern that COMPILES but matches nothing passes
3221    /// config validation. Config validation checks compilability, not
3222    /// satisfiability — `$^` will refuse every value at call time instead.
3223    #[test]
3224    fn validate_accepts_unsatisfiable_but_compilable_param_pattern() {
3225        let toml = r#"
3226            [server]
3227            name = "demo"
3228            version = "0.1.0"
3229
3230            [[tools]]
3231            name = "lookup"
3232
3233            [[tools.parameters]]
3234            name = "region"
3235            type = "string"
3236            pattern = "$^"
3237        "#;
3238        let cfg = ServerConfig::from_toml(toml).expect("parse");
3239        cfg.validate()
3240            .expect("an unsatisfiable pattern still compiles and must validate");
3241    }
3242
3243    /// SC-2 edge (empty): an empty `pattern` matches everything, so it buys no
3244    /// enforcement while reading like a rule. Refused as a likely author error.
3245    #[test]
3246    fn validate_rejects_empty_param_pattern() {
3247        let toml = r#"
3248            [server]
3249            name = "demo"
3250            version = "0.1.0"
3251
3252            [[tools]]
3253            name = "lookup"
3254
3255            [[tools.parameters]]
3256            name = "region"
3257            type = "string"
3258            pattern = ""
3259        "#;
3260        let cfg = ServerConfig::from_toml(toml).expect("parse");
3261        match cfg.validate() {
3262            Err(ConfigValidationError::EmptyParamPattern {
3263                ref tool,
3264                ref param,
3265            }) => {
3266                assert_eq!(tool, "lookup");
3267                assert_eq!(param, "region");
3268            },
3269            other => panic!("expected EmptyParamPattern, got {other:?}"),
3270        }
3271    }
3272
3273    /// D3 edge (precision): a bound whose magnitude exceeds 2^53 is refused,
3274    /// because `ParamDecl` stores bounds as `f64` and such a value cannot be
3275    /// represented exactly.
3276    #[test]
3277    fn validate_rejects_non_finite_param_bound() {
3278        let toml = r#"
3279            [server]
3280            name = "demo"
3281            version = "0.1.0"
3282
3283            [[tools]]
3284            name = "lookup"
3285
3286            [[tools.parameters]]
3287            name = "count"
3288            type = "integer"
3289            maximum = 1e300
3290        "#;
3291        let cfg = ServerConfig::from_toml(toml).expect("parse");
3292        match cfg.validate() {
3293            Err(ConfigValidationError::NonFiniteParamBound {
3294                ref tool,
3295                ref param,
3296            }) => {
3297                assert_eq!(tool, "lookup");
3298                assert_eq!(param, "count");
3299            },
3300            other => panic!("expected NonFiniteParamBound, got {other:?}"),
3301        }
3302    }
3303
3304    /// D3 edge (precision), the honest half: a bound AT the 2^53 boundary is
3305    /// accepted. The check cannot see that a larger TOML integer was already
3306    /// rounded into this value, and the rustdoc says so rather than claiming a
3307    /// guarantee it cannot deliver.
3308    #[test]
3309    fn validate_accepts_param_bound_at_the_representable_boundary() {
3310        let toml = r#"
3311            [server]
3312            name = "demo"
3313            version = "0.1.0"
3314
3315            [[tools]]
3316            name = "lookup"
3317
3318            [[tools.parameters]]
3319            name = "count"
3320            type = "integer"
3321            maximum = 9007199254740992
3322        "#;
3323        let cfg = ServerConfig::from_toml(toml).expect("parse");
3324        cfg.validate()
3325            .expect("a bound exactly at 2^53 is representable and must validate");
3326    }
3327
3328    // -------------------------------------------------------------------------
3329    // Phase 128 D3 / D-07 — `[server.validation]`, `param_position`, `lint()`
3330    // -------------------------------------------------------------------------
3331
3332    /// `[server.validation]` parses with all four keys.
3333    #[test]
3334    fn validation_section_parses_all_four_keys() {
3335        let toml = r#"
3336            [server]
3337            name = "demo"
3338            version = "0.1.0"
3339
3340            [server.validation]
3341            enforce_input_schema = false
3342            default_max_length = 64
3343            additional_properties = true
3344            strict = true
3345        "#;
3346        let cfg = ServerConfig::from_toml(toml).expect("parse");
3347        let v = &cfg.server.validation;
3348        assert!(!v.enforce_input_schema);
3349        assert_eq!(v.default_max_length, 64);
3350        assert!(v.additional_properties);
3351        assert!(v.strict);
3352    }
3353
3354    /// Each absent key yields its documented default, and an absent SECTION yields
3355    /// the same — enforcement is on unless an operator opts out.
3356    #[test]
3357    fn validation_section_absent_keys_yield_documented_defaults() {
3358        let partial = r#"
3359            [server]
3360            name = "demo"
3361            version = "0.1.0"
3362
3363            [server.validation]
3364            default_max_length = 10
3365        "#;
3366        let cfg = ServerConfig::from_toml(partial).expect("parse");
3367        let v = &cfg.server.validation;
3368        assert!(v.enforce_input_schema, "default is ON");
3369        assert_eq!(v.default_max_length, 10);
3370        assert!(!v.additional_properties, "default is a closed envelope");
3371        assert!(!v.strict, "default must not refuse to boot");
3372
3373        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
3374        assert_eq!(cfg.server.validation, ValidationSection::default());
3375        assert!(cfg.server.validation.enforce_input_schema);
3376        assert_eq!(cfg.server.validation.default_max_length, 256);
3377    }
3378
3379    /// An unknown key under `[server.validation]` is refused at PARSE time — the
3380    /// section carries `deny_unknown_fields`, so a typo'd opt-out cannot be
3381    /// silently ignored.
3382    #[test]
3383    fn validation_section_rejects_an_unknown_key() {
3384        let toml = r#"
3385            [server]
3386            name = "demo"
3387            version = "0.1.0"
3388
3389            [server.validation]
3390            enforce_input_schemas = false
3391        "#;
3392        let err = ServerConfig::from_toml(toml)
3393            .expect_err("a typo'd opt-out key must not be silently ignored");
3394        assert!(matches!(err, ToolkitError::Parse(_)), "got {err:?}");
3395    }
3396
3397    /// `ToolDecl::param_position` agrees with `tools.rs::build_operation`'s PATH
3398    /// split for every parameter of a single-call tool, and applies the
3399    /// method-aware query/body rule for the rest.
3400    #[test]
3401    fn param_position_agrees_with_build_operation_on_path_parameters() {
3402        let get_tool = ToolDecl {
3403            name: "line_status".to_string(),
3404            path: Some("/lines/{line_id}/status".to_string()),
3405            method: Some("GET".to_string()),
3406            parameters: vec![
3407                ParamDecl {
3408                    name: "line_id".to_string(),
3409                    ..Default::default()
3410                },
3411                ParamDecl {
3412                    name: "detail".to_string(),
3413                    ..Default::default()
3414                },
3415            ],
3416            ..Default::default()
3417        };
3418        // The PATH set is the shared helper both functions read.
3419        let path_names: Vec<&str> =
3420            path_placeholder_names(get_tool.path.as_deref().expect("path")).collect();
3421        assert_eq!(path_names, vec!["line_id"]);
3422        for p in &get_tool.parameters {
3423            let expected = if path_names.contains(&p.name.as_str()) {
3424                ParamPosition::Path
3425            } else {
3426                ParamPosition::Query
3427            };
3428            assert_eq!(
3429                get_tool.param_position(&p.name),
3430                expected,
3431                "position for {} must agree with the path split",
3432                p.name
3433            );
3434        }
3435
3436        // A mutating method's non-path parameter is BODY here AND
3437        // `ParameterLocation::Body` in `build_operation` — the two agree by
3438        // construction since CR-02/CR-03 (see
3439        // `param_position_agrees_with_the_built_parameter_location_for_every_method`
3440        // in `tools.rs::mod build_operation`, which asserts the pairing directly).
3441        let post_tool = ToolDecl {
3442            name: "add_comment".to_string(),
3443            path: Some("/issues/{id}/comments".to_string()),
3444            method: Some("POST".to_string()),
3445            ..Default::default()
3446        };
3447        assert_eq!(post_tool.param_position("id"), ParamPosition::Path);
3448        assert_eq!(post_tool.param_position("body_text"), ParamPosition::Body);
3449
3450        // A method that carries NO request body puts its non-path input in the URL,
3451        // `OPTIONS` included. Before CR-03 this returned `Body` — so the D3 cap was
3452        // withheld from a value `build_operation` sent in the query string.
3453        let options_tool = ToolDecl {
3454            name: "probe".to_string(),
3455            path: Some("/issues/{id}".to_string()),
3456            method: Some("OPTIONS".to_string()),
3457            ..Default::default()
3458        };
3459        assert_eq!(options_tool.param_position("id"), ParamPosition::Path);
3460        assert_eq!(options_tool.param_position("detail"), ParamPosition::Query);
3461
3462        // No method at all is still BODY: a SQL named bind or a script-tool
3463        // argument, neither of which has a URL to travel in.
3464        let sql_tool = ToolDecl {
3465            name: "q".to_string(),
3466            sql: Some("SELECT :id".to_string()),
3467            ..Default::default()
3468        };
3469        assert_eq!(sql_tool.param_position("id"), ParamPosition::Body);
3470    }
3471
3472    /// `{}` is not a placeholder, and a brace pair inside a larger segment is not
3473    /// one either — the helper matches whole `/`-delimited segments only.
3474    #[test]
3475    fn path_placeholder_names_matches_whole_segments_only() {
3476        let names: Vec<&str> = path_placeholder_names("/a/{}/b/{id}/c/pre{mid}post").collect();
3477        assert_eq!(names, vec!["id"]);
3478    }
3479
3480    /// SC-3 edge (empty): a config with zero tools and no active opt-out lints
3481    /// clean.
3482    #[test]
3483    fn lint_returns_empty_vec_for_a_config_with_zero_tools() {
3484        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
3485        assert_eq!(cfg.lint(), Vec::new());
3486    }
3487
3488    /// Build a config with one tool whose declarations are supplied by the caller.
3489    fn cfg_with_one_tool(tool: ToolDecl, validation: ValidationSection) -> ServerConfig {
3490        ServerConfig {
3491            server: ServerSection {
3492                name: "demo".to_string(),
3493                version: "0.1.0".to_string(),
3494                validation,
3495                ..Default::default()
3496            },
3497            tools: vec![tool],
3498            ..Default::default()
3499        }
3500    }
3501
3502    /// A SQL tool's string parameter with no `max_length` is BODY position and is
3503    /// surfaced by `lint()` — not refused (D-05 / D-07).
3504    #[test]
3505    fn lint_reports_one_uncapped_string_finding_per_body_parameter() {
3506        let cfg = cfg_with_one_tool(
3507            ToolDecl {
3508                name: "search_tracks".to_string(),
3509                sql: Some("SELECT 1".to_string()),
3510                parameters: vec![ParamDecl {
3511                    name: "q".to_string(),
3512                    param_type: Some("string".to_string()),
3513                    ..Default::default()
3514                }],
3515                ..Default::default()
3516            },
3517            ValidationSection::default(),
3518        );
3519        let findings = cfg.lint();
3520        assert_eq!(findings.len(), 1, "got {findings:?}");
3521        assert_eq!(findings[0].rule, UNCAPPED_STRING);
3522        assert_eq!(findings[0].tool.as_deref(), Some("search_tracks"));
3523        assert_eq!(findings[0].param.as_deref(), Some("q"));
3524        // The same config must still BOOT — a non-strict finding never refuses.
3525        cfg.validate()
3526            .expect("a lint finding must not fail validate");
3527    }
3528
3529    /// SC-3 ordering: findings follow `[[tools.parameters]]` declaration order, not
3530    /// alphabetical order.
3531    #[test]
3532    fn lint_returns_findings_in_declaration_order() {
3533        let cfg = cfg_with_one_tool(
3534            ToolDecl {
3535                name: "note".to_string(),
3536                sql: Some("SELECT 1".to_string()),
3537                parameters: vec![
3538                    ParamDecl {
3539                        name: "zebra".to_string(),
3540                        param_type: Some("string".to_string()),
3541                        ..Default::default()
3542                    },
3543                    ParamDecl {
3544                        name: "alpha".to_string(),
3545                        param_type: Some("string".to_string()),
3546                        ..Default::default()
3547                    },
3548                ],
3549                ..Default::default()
3550            },
3551            ValidationSection::default(),
3552        );
3553        let findings = cfg.lint();
3554        assert_eq!(findings.len(), 2, "got {findings:?}");
3555        assert_eq!(findings[0].param.as_deref(), Some("zebra"));
3556        assert_eq!(findings[1].param.as_deref(), Some("alpha"));
3557    }
3558
3559    /// D3 ordering (mixed): for a tool carrying BOTH an uncapped body string and an
3560    /// uncapped path string, only the body one is a finding — the path one is
3561    /// covered by the default cap — and the finding order follows declaration order.
3562    #[test]
3563    fn lint_skips_a_path_parameter_covered_by_the_default_cap() {
3564        let cfg = cfg_with_one_tool(
3565            ToolDecl {
3566                name: "add_comment".to_string(),
3567                path: Some("/issues/{id}/comments".to_string()),
3568                method: Some("POST".to_string()),
3569                parameters: vec![
3570                    ParamDecl {
3571                        name: "body_text".to_string(),
3572                        param_type: Some("string".to_string()),
3573                        ..Default::default()
3574                    },
3575                    ParamDecl {
3576                        name: "id".to_string(),
3577                        param_type: Some("string".to_string()),
3578                        ..Default::default()
3579                    },
3580                ],
3581                ..Default::default()
3582            },
3583            ValidationSection::default(),
3584        );
3585        let findings = cfg.lint();
3586        assert_eq!(findings.len(), 1, "got {findings:?}");
3587        assert_eq!(findings[0].param.as_deref(), Some("body_text"));
3588        assert_eq!(findings[0].rule, UNCAPPED_STRING);
3589    }
3590
3591    /// SC-7 shape mismatch: a path-position parameter declaring a `max_length`
3592    /// ABOVE the always-on placeholder floor is surfaced, because it publishes a
3593    /// limit the floor will not honour.
3594    #[cfg(feature = "input-validation")]
3595    #[test]
3596    fn lint_reports_a_declared_max_length_above_the_placeholder_floor() {
3597        let floor = pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH as u64;
3598        let cfg = cfg_with_one_tool(
3599            ToolDecl {
3600                name: "line_status".to_string(),
3601                path: Some("/lines/{line_id}/status".to_string()),
3602                method: Some("GET".to_string()),
3603                parameters: vec![ParamDecl {
3604                    name: "line_id".to_string(),
3605                    param_type: Some("string".to_string()),
3606                    max_length: Some(floor + 1),
3607                    ..Default::default()
3608                }],
3609                ..Default::default()
3610            },
3611            ValidationSection::default(),
3612        );
3613        let findings = cfg.lint();
3614        assert_eq!(findings.len(), 1, "got {findings:?}");
3615        assert_eq!(findings[0].rule, DECLARED_MAX_LENGTH_ABOVE_PLACEHOLDER_CAP);
3616        assert_eq!(findings[0].param.as_deref(), Some("line_id"));
3617        assert!(
3618            findings[0].detail.contains(&floor.to_string()),
3619            "the finding must name the effective limit: {}",
3620            findings[0].detail
3621        );
3622
3623        // Exactly AT the floor is fine — the adjacency case.
3624        let cfg = cfg_with_one_tool(
3625            ToolDecl {
3626                name: "line_status".to_string(),
3627                path: Some("/lines/{line_id}/status".to_string()),
3628                method: Some("GET".to_string()),
3629                parameters: vec![ParamDecl {
3630                    name: "line_id".to_string(),
3631                    param_type: Some("string".to_string()),
3632                    max_length: Some(floor),
3633                    ..Default::default()
3634                }],
3635                ..Default::default()
3636            },
3637            ValidationSection::default(),
3638        );
3639        assert_eq!(cfg.lint(), Vec::new());
3640    }
3641
3642    /// Every ACTIVE opt-out is reported, so a switched-off enforcement can never
3643    /// read as switched on.
3644    #[test]
3645    fn lint_reports_every_active_opt_out() {
3646        let cfg = cfg_with_one_tool(
3647            ToolDecl {
3648                name: "ping".to_string(),
3649                sql: Some("SELECT 1".to_string()),
3650                ..Default::default()
3651            },
3652            ValidationSection {
3653                enforce_input_schema: false,
3654                default_max_length: 0,
3655                additional_properties: true,
3656                strict: false,
3657            },
3658        );
3659        let rules: Vec<&str> = cfg.lint().iter().map(|w| w.rule).collect();
3660        assert_eq!(
3661            rules,
3662            vec![
3663                OPT_OUT_ENFORCE_INPUT_SCHEMA,
3664                OPT_OUT_DEFAULT_MAX_LENGTH_ZERO,
3665                OPT_OUT_ADDITIONAL_PROPERTIES,
3666            ]
3667        );
3668        // A server-level finding names no tool or parameter.
3669        for w in cfg.lint() {
3670            assert!(w.tool.is_none(), "{w:?}");
3671            assert!(w.param.is_none(), "{w:?}");
3672            assert!(!w.to_string().is_empty(), "Display must render");
3673        }
3674    }
3675
3676    /// D-07 strict mode: the SAME config that returns `Ok(())` with one lint
3677    /// finding under the default becomes a hard `validate()` failure under
3678    /// `strict = true`.
3679    #[test]
3680    fn validate_rejects_uncapped_string_param_in_strict_mode() {
3681        let toml_body = r#"
3682            [server]
3683            name = "demo"
3684            version = "0.1.0"
3685
3686            [[tools]]
3687            name = "search_tracks"
3688            sql = "SELECT 1"
3689
3690            [[tools.parameters]]
3691            name = "q"
3692            type = "string"
3693        "#;
3694        let lenient = ServerConfig::from_toml(toml_body).expect("parse");
3695        lenient
3696            .validate()
3697            .expect("non-strict must never refuse to boot over an uncapped body string");
3698        assert_eq!(lenient.lint().len(), 1);
3699
3700        let strict_toml = format!("{toml_body}\n[server.validation]\nstrict = true\n");
3701        let strict = ServerConfig::from_toml(&strict_toml).expect("parse");
3702        match strict.validate() {
3703            Err(ConfigValidationError::UncappedStringParam {
3704                ref tool,
3705                ref param,
3706            }) => {
3707                assert_eq!(tool, "search_tracks");
3708                assert_eq!(param, "q");
3709            },
3710            other => panic!("expected UncappedStringParam, got {other:?}"),
3711        }
3712    }
3713
3714    /// `validation_report()` carries the effective policy and a per-tool rule list
3715    /// for the once-at-startup log.
3716    #[test]
3717    fn validation_report_carries_the_effective_policy_and_per_tool_rules() {
3718        let cfg = cfg_with_one_tool(
3719            ToolDecl {
3720                name: "line_status".to_string(),
3721                path: Some("/lines/{line_id}/status".to_string()),
3722                method: Some("GET".to_string()),
3723                parameters: vec![ParamDecl {
3724                    name: "line_id".to_string(),
3725                    param_type: Some("string".to_string()),
3726                    required: true,
3727                    pattern: Some("^[0-9a-z-]+$".to_string()),
3728                    ..Default::default()
3729                }],
3730                ..Default::default()
3731            },
3732            ValidationSection::default(),
3733        );
3734        let report = cfg.validation_report();
3735        assert!(report.enforce_input_schema);
3736        assert_eq!(report.default_max_length, 256);
3737        assert!(report.opt_outs.is_empty(), "nothing is opted out");
3738        assert_eq!(report.tools.len(), 1);
3739        assert_eq!(report.tools[0].tool, "line_status");
3740        let rule = &report.tools[0].rules[0];
3741        assert!(rule.contains("line_id"), "{rule}");
3742        assert!(rule.contains("Path"), "{rule}");
3743        assert!(rule.contains("pattern"), "{rule}");
3744        assert!(rule.contains("maxLength=256 (default)"), "{rule}");
3745    }
3746
3747    // -- Phase 128 D4(b) step 3b: the curated template parser's limit, enforced at
3748    //    CONFIG time rather than failing obscurely at call time. ---------------
3749
3750    /// A single-call `GET` tool on `path`, no parameters.
3751    fn single_call_on(path: &str) -> ServerConfig {
3752        cfg_with_one_tool(
3753            ToolDecl {
3754                name: "t".to_string(),
3755                description: Some("t".to_string()),
3756                path: Some(path.to_string()),
3757                method: Some("GET".to_string()),
3758                ..Default::default()
3759            },
3760            ValidationSection::default(),
3761        )
3762    }
3763
3764    fn assert_malformed_segment(path: &str) {
3765        let err = single_call_on(path)
3766            .validate()
3767            .expect_err("an unsupported path-template segment must be refused at config time");
3768        match err {
3769            ConfigValidationError::MalformedPathTemplateSegment { tool, segment } => {
3770                assert_eq!(tool, "t");
3771                assert!(!segment.is_empty(), "the finding must name the segment");
3772            },
3773            other => panic!("expected MalformedPathTemplateSegment, got {other:?}"),
3774        }
3775    }
3776
3777    /// `/search/{a}{b}` parses to the SINGLE name `a}{b`, which no `ParamDecl` can
3778    /// match — refused at config time instead of sending literal braces upstream.
3779    #[test]
3780    fn validate_rejects_a_path_template_segment_with_two_brace_pairs() {
3781        assert_malformed_segment("/search/{a}{b}");
3782    }
3783
3784    /// `/prefix-{id}` is not recognized as carrying a placeholder at all.
3785    #[test]
3786    fn validate_rejects_a_path_template_segment_with_text_adjacent_to_a_brace_pair() {
3787        assert_malformed_segment("/prefix-{id}");
3788    }
3789
3790    /// `{}` is a brace pair with no name — not a placeholder.
3791    #[test]
3792    fn validate_rejects_an_empty_path_template_placeholder() {
3793        assert_malformed_segment("/a/{}/b");
3794    }
3795
3796    /// An unbalanced brace is the same author error seen from the other side.
3797    #[test]
3798    fn validate_rejects_an_unbalanced_path_template_brace() {
3799        assert_malformed_segment("/a/{id");
3800    }
3801
3802    /// ACCEPT control: whole-segment placeholders are the supported shape. Without
3803    /// this row the four refusals above are satisfiable by refusing every template.
3804    #[test]
3805    fn validate_accepts_whole_segment_path_template_placeholders() {
3806        single_call_on("/content/{version}/CUI/{cui}")
3807            .validate()
3808            .expect("whole-segment placeholders are the supported shape");
3809    }
3810
3811    /// ACCEPT control, and the CURATED half of the inherited `?` narrowing: an
3812    /// author-written query string in a `[[tools]]` `path` is configuration, not
3813    /// caller data, and must keep working.
3814    #[test]
3815    fn validate_accepts_a_path_template_carrying_an_author_written_query_string() {
3816        single_call_on("/content/{version}/CUI?string=x")
3817            .validate()
3818            .expect("an author-written query string in a curated path must be accepted");
3819    }
3820
3821    /// A SQL tool has no `path`, so the template rule cannot reach it.
3822    #[test]
3823    fn validate_ignores_the_template_rule_for_a_tool_with_no_path() {
3824        cfg_with_one_tool(
3825            ToolDecl {
3826                name: "t".to_string(),
3827                sql: Some("SELECT 1".to_string()),
3828                ..Default::default()
3829            },
3830            ValidationSection::default(),
3831        )
3832        .validate()
3833        .expect("a SQL tool carries no path template");
3834    }
3835
3836    proptest! {
3837        /// TEST-02: any valid `ServerConfig` round-trips through TOML.
3838        ///
3839        /// Builds a `ServerConfig` from an arbitrary (but valid) `(name, version)`
3840        /// pair, serializes it, parses it back, and asserts equality on the
3841        /// load-bearing scalars.
3842        #[test]
3843        fn server_config_minimal_round_trips(
3844            name in "[a-zA-Z0-9_-]{1,32}",
3845            version in "[0-9]+\\.[0-9]+\\.[0-9]+",
3846        ) {
3847            let cfg = ServerConfig {
3848                server: ServerSection {
3849                    name: name.clone(),
3850                    version: version.clone(),
3851                    ..Default::default()
3852                },
3853                ..Default::default()
3854            };
3855            let s = toml::to_string(&cfg).unwrap();
3856            let parsed = ServerConfig::from_toml(&s).unwrap();
3857            prop_assert_eq!(parsed.server.name, name);
3858            prop_assert_eq!(parsed.server.version, version);
3859        }
3860    }
3861}
3862
3863/// `ServerConfig::lint_against_spec` — the CONFIG-time half of the
3864/// template-spelling-drift guard (Phase 128, D4(b) / T-128-36a).
3865///
3866/// Four rows, and the shape matters: one row per DRIFT that must be reported, plus
3867/// three accept rows for the shapes that must NOT be, because a finding-producing
3868/// lint with no accept rows is indistinguishable from one that fires on everything.
3869#[cfg(all(test, feature = "http"))]
3870mod lint_against_spec_tests {
3871    use super::{ServerConfig, ToolDecl, CONFIGURED_TEMPLATE_NOT_IN_SPEC};
3872    use crate::http::OpenApiSchema;
3873
3874    const SPEC: &str = r#"{
3875      "openapi": "3.0.0",
3876      "info": { "title": "t", "version": "1" },
3877      "paths": {
3878        "/content/{version}/CUI": {
3879          "get": {
3880            "operationId": "getCui",
3881            "parameters": [
3882              { "name": "version", "in": "path", "required": true,
3883                "schema": { "type": "string", "pattern": "^[a-z]+$" } }
3884            ],
3885            "responses": { "200": { "description": "ok" } }
3886          }
3887        }
3888      }
3889    }"#;
3890
3891    fn spec() -> OpenApiSchema {
3892        OpenApiSchema::parse(SPEC).expect("the fixture spec parses")
3893    }
3894
3895    fn cfg_with(tools: Vec<ToolDecl>) -> ServerConfig {
3896        ServerConfig {
3897            server: super::ServerSection {
3898                name: "t".to_string(),
3899                version: "0.1.0".to_string(),
3900                ..Default::default()
3901            },
3902            tools,
3903            ..Default::default()
3904        }
3905    }
3906
3907    fn http_tool(name: &str, method: &str, path: &str) -> ToolDecl {
3908        ToolDecl {
3909            name: name.to_string(),
3910            method: Some(method.to_string()),
3911            path: Some(path.to_string()),
3912            ..Default::default()
3913        }
3914    }
3915
3916    /// The drift the guard exists for: a placeholder spelled differently from the
3917    /// spec's own reaches the same endpoint with the declaration dropped.
3918    #[test]
3919    fn lint_against_spec_reports_a_template_the_spec_does_not_declare() {
3920        let cfg = cfg_with(vec![http_tool("get_cui", "GET", "/content/{alias}/CUI")]);
3921        let findings = cfg.lint_against_spec(&spec());
3922        assert_eq!(findings.len(), 1, "{findings:?}");
3923        assert_eq!(findings[0].rule, CONFIGURED_TEMPLATE_NOT_IN_SPEC);
3924        assert_eq!(findings[0].tool.as_deref(), Some("get_cui"));
3925        assert!(
3926            findings[0].detail.contains("floor"),
3927            "the finding must say what a miss RETAINS, not only what it loses: {}",
3928            findings[0].detail
3929        );
3930    }
3931
3932    /// A method the spec does not declare on a path it does is the same class of
3933    /// drift, because operations are indexed by `(path, METHOD)`.
3934    #[test]
3935    fn lint_against_spec_reports_a_method_the_spec_does_not_declare() {
3936        let cfg = cfg_with(vec![http_tool(
3937            "del_cui",
3938            "DELETE",
3939            "/content/{version}/CUI",
3940        )]);
3941        let rules: Vec<&str> = cfg
3942            .lint_against_spec(&spec())
3943            .iter()
3944            .map(|w| w.rule)
3945            .collect();
3946        assert_eq!(rules, vec![CONFIGURED_TEMPLATE_NOT_IN_SPEC]);
3947    }
3948
3949    /// ACCEPT — an exact match, in either method case, is no finding.
3950    #[test]
3951    fn lint_against_spec_accepts_an_exactly_declared_template() {
3952        let cfg = cfg_with(vec![
3953            http_tool("a", "GET", "/content/{version}/CUI"),
3954            http_tool("b", "get", "/content/{version}/CUI"),
3955        ]);
3956        assert_eq!(cfg.lint_against_spec(&spec()), Vec::new());
3957    }
3958
3959    /// ACCEPT — an author-written query string on the configured `path` is legal
3960    /// curated authoring (plan 06's `?` narrowing) and an OpenAPI template never
3961    /// carries one, so it is stripped before the lookup rather than reported.
3962    #[test]
3963    fn lint_against_spec_accepts_an_author_written_query_string() {
3964        let cfg = cfg_with(vec![http_tool(
3965            "a",
3966            "GET",
3967            "/content/{version}/CUI?string=x",
3968        )]);
3969        assert_eq!(cfg.lint_against_spec(&spec()), Vec::new());
3970    }
3971
3972    /// ACCEPT — a tool that addresses no single spec operation is skipped, not
3973    /// reported. A SQL tool has no `(method, path)` to look up.
3974    #[test]
3975    fn lint_against_spec_skips_a_tool_with_no_method_path_pair() {
3976        let cfg = cfg_with(vec![ToolDecl {
3977            name: "q".to_string(),
3978            sql: Some("SELECT 1".to_string()),
3979            ..Default::default()
3980        }]);
3981        assert_eq!(cfg.lint_against_spec(&spec()), Vec::new());
3982    }
3983}