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