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, 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    ///
242    /// # Errors
243    ///
244    /// Returns a [`ConfigValidationError`] variant identifying the
245    /// first rule violated. Iteration order matches struct field order.
246    pub fn validate(&self) -> std::result::Result<(), ConfigValidationError> {
247        if self.server.name.trim().is_empty() {
248            return Err(ConfigValidationError::EmptyServerName);
249        }
250        if self.server.version.trim().is_empty() {
251            return Err(ConfigValidationError::EmptyServerVersion);
252        }
253        for (i, tool) in self.tools.iter().enumerate() {
254            if tool.name.trim().is_empty() {
255                return Err(ConfigValidationError::EmptyToolName(i));
256            }
257            // D-01 / T-90-02-04: a tool is EITHER sql, single-call (path/method),
258            // OR script — never a mixture. Reject ambiguity instead of letting a
259            // silent "script wins" precedence hide a config mistake.
260            if tool.declared_kind_count() > 1 {
261                return Err(ConfigValidationError::AmbiguousToolKind(i));
262            }
263        }
264        for (i, table) in self.database.tables.iter().enumerate() {
265            if table.name.trim().is_empty() {
266                return Err(ConfigValidationError::EmptyTableName(i));
267            }
268        }
269        // PKG-03 (Phase 120 Plan 04): a declared slot must actually name a
270        // config path AND a variable. An empty `key`/`name` claims coverage the
271        // declaration cannot deliver. Deliberately NOT a completeness
272        // heuristic — a "this literal looks secret, so a slot is missing" check
273        // would flag the london-tube fixture's guarded dev `token_secret`, and
274        // a check that cries wolf is worse than none.
275        for (i, slot) in self.config_slots.iter().enumerate() {
276            if slot.key.trim().is_empty() || slot.name.trim().is_empty() {
277                return Err(ConfigValidationError::EmptyConfigSlotField(i));
278            }
279            // Identity-bearing slots structurally carry no value (the whole
280            // "secrets never travel" premise) — a `tested_value` on a `secret`
281            // declaration is the one field where a REAL credential could sit in
282            // a config that is served but never packed, so the doc-comment rule
283            // is enforced here rather than trusted.
284            if slot.kind == ConfigSlotKind::Secret && slot.tested_value.is_some() {
285                return Err(ConfigValidationError::SecretSlotCarriesTestedValue(i));
286            }
287        }
288        // Phase 90 gap-closure (GAP 3 / WR-02): when a `[backend]` block is
289        // declared, its `base_url` must be non-empty. Catch a typo'd / omitted
290        // URL here (the field is `#[serde(default)]` -> `""`) rather than
291        // letting it surface late as an opaque DispatchError at request time.
292        // Gated on `http` because the `backend` field itself is http-only; the
293        // block simply vanishes in a no-http build (SQL configs unaffected).
294        #[cfg(feature = "http")]
295        if let Some(backend) = &self.backend {
296            if backend.base_url.trim().is_empty() {
297                return Err(ConfigValidationError::EmptyBackendBaseUrl);
298            }
299            // Phase 120 follow-up: a reference-shaped base_url must name
300            // exactly ONE variable. The grammar maps every malformed brace
301            // form — the empty `${}` and multi-placeholder compositions like
302            // `${SCHEME}://${HOST}` — to the empty name; catching that here
303            // turns a boot-time `UnresolvedBaseUrlRef` with an empty variable
304            // name into a load-time error naming the actual mistake.
305            if crate::env_ref::parse_env_ref(&backend.base_url) == Some("") {
306                return Err(ConfigValidationError::MalformedBackendBaseUrlRef);
307            }
308            // The same rule for `[backend.auth]` credentials. It is NOT the
309            // same consequence: an unresolvable base_url breaks every request
310            // loudly, while an unresolvable CREDENTIAL was silently omitted
311            // (`expand_api_key_map` drops the entry; the scalar variants
312            // collapse to `NoAuth`), so the server booted and sent every
313            // backend request unauthenticated. Catching it here is what makes
314            // that failure visible at all.
315            if let Some(field) = backend.auth.malformed_env_ref_field() {
316                return Err(ConfigValidationError::MalformedBackendAuthRef(field));
317            }
318        }
319        Ok(())
320    }
321}
322
323// -----------------------------------------------------------------------------
324// [server]
325// -----------------------------------------------------------------------------
326
327/// `[server]` section — identity and version metadata.
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
329#[serde(deny_unknown_fields)]
330pub struct ServerSection {
331    /// Stable server identifier (e.g. `"open-images"`). Optional in the TOML;
332    /// callers that need it should fall back to deriving from `name`.
333    #[serde(default)]
334    pub id: Option<String>,
335    /// Human-readable server name (required for production via [`ServerConfig::validate`]).
336    #[serde(default)]
337    pub name: String,
338    /// Short server description.
339    #[serde(default)]
340    pub description: Option<String>,
341    /// Server flavour (e.g. `"sql-api"`). Free-form for now; future plans may tighten.
342    #[serde(default, rename = "type")]
343    pub server_type: Option<String>,
344    /// Semver version string (required for production via [`ServerConfig::validate`]).
345    #[serde(default)]
346    pub version: String,
347    /// Whether this server is the **reference** server that provisions shared
348    /// infrastructure (the `[shared_policy_store]` for all sibling SQL servers).
349    /// Additive per the REF-01 superset invariant (Plan 85-01); the SQLite
350    /// Chinook reference config sets `is_reference = true`.
351    #[serde(default)]
352    pub is_reference: bool,
353}
354
355// -----------------------------------------------------------------------------
356// [metadata]
357// -----------------------------------------------------------------------------
358
359/// `[metadata]` section — admin-facing display defaults (visible in the
360/// pmcp.run UI before an operator customises them).
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
362#[serde(deny_unknown_fields)]
363pub struct MetadataSection {
364    /// Long-form display name shown in the UI.
365    #[serde(default)]
366    pub display_name: Option<String>,
367    /// One-line summary for list views.
368    #[serde(default)]
369    pub short_description: Option<String>,
370    /// Multi-line description for detail pages.
371    #[serde(default)]
372    pub description: Option<String>,
373    /// Tag list for filtering / discovery.
374    #[serde(default)]
375    pub tags: Vec<String>,
376    /// Server author (organisation or individual).
377    #[serde(default)]
378    pub author: Option<String>,
379    /// Visibility flag (e.g. `"public"`, `"private"`).
380    #[serde(default)]
381    pub visibility: Option<String>,
382}
383
384// -----------------------------------------------------------------------------
385// [database]
386// -----------------------------------------------------------------------------
387
388/// `[database]` section — backend identification and table catalogue.
389///
390/// Includes Athena-specific keys (`output_location`, `workgroup`) as optional
391/// fields per the REF-01 superset invariant — non-Athena backends omit them.
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
393#[serde(deny_unknown_fields)]
394pub struct DatabaseSection {
395    /// Backend type (`"athena"`, `"postgres"`, `"mysql"`, `"sqlite"`, …).
396    #[serde(default, rename = "type")]
397    pub backend_type: Option<String>,
398    /// Database / schema name.
399    #[serde(default)]
400    pub database: Option<String>,
401    /// Athena S3 output location for query results.
402    #[serde(default)]
403    pub output_location: Option<String>,
404    /// Athena workgroup name.
405    #[serde(default)]
406    pub workgroup: Option<String>,
407    /// Per-query timeout in milliseconds.
408    #[serde(default)]
409    pub query_timeout_ms: Option<u64>,
410    /// `[[database.tables]]` — declared table catalogue for schema enrichment.
411    #[serde(default)]
412    pub tables: Vec<DatabaseTableDecl>,
413    /// Connection URL for Postgres / MySQL backends. Supports `env:VAR_NAME`
414    /// indirection at the consumer-resolution layer (the toolkit parses the
415    /// string as-is and leaves resolution to the per-backend connector or
416    /// the secret-resolution machinery from P83 R6/R9). Optional/unused for
417    /// Athena (uses `region` + `workgroup` + `output_location`) and SQLite
418    /// (uses `database` for the file path or `:memory:` literal).
419    #[serde(default)]
420    pub url: Option<String>,
421    /// Filesystem path to a SQLite database file (e.g.
422    /// `"/var/task/assets/chinook.db"` for a Lambda-bundled asset). Additive per
423    /// the REF-01 superset invariant (Plan 85-01). Distinct from `database`
424    /// (which carries the `:memory:` literal or a schema name) and `url` (used
425    /// by Postgres / MySQL). Stored verbatim; the SQLite connector resolves it.
426    #[serde(default)]
427    pub file_path: Option<String>,
428    /// `[database.pool]` — connection-pool tuning (optional).
429    #[serde(default)]
430    pub pool: Option<DatabasePoolSection>,
431}
432
433/// Single `[[database.tables]]` entry.
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
435#[serde(deny_unknown_fields)]
436pub struct DatabaseTableDecl {
437    /// Table or view name (required for production via [`ServerConfig::validate`]).
438    #[serde(default)]
439    pub name: String,
440    /// Human-readable table description for schema enrichment.
441    #[serde(default)]
442    pub description: Option<String>,
443}
444
445/// `[database.pool]` connection-pool tuning.
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
447#[serde(deny_unknown_fields)]
448pub struct DatabasePoolSection {
449    /// Maximum concurrent connections.
450    #[serde(default)]
451    pub max_connections: Option<u32>,
452    /// Connection-acquisition timeout, in seconds.
453    #[serde(default)]
454    pub connection_timeout_seconds: Option<u64>,
455}
456
457// -----------------------------------------------------------------------------
458// [backend] (http feature)
459// -----------------------------------------------------------------------------
460
461/// Re-export of the outgoing-HTTP authentication config (owned by
462/// [`crate::http::auth`], Plan 90-01). Callers may also reach it via the
463/// `crate::http` module path; this re-export keeps `[backend.auth]` named
464/// alongside the `ServerConfig` types it deserializes into.
465#[cfg(feature = "http")]
466pub use crate::http::auth::AuthConfig;
467
468/// Re-export of the HTTP client tuning config (owned by [`crate::http::client`],
469/// Plan 90-01) used by `[backend.http]`.
470#[cfg(feature = "http")]
471pub use crate::http::client::HttpConfig;
472
473/// `[backend]` section — the OpenAPI/REST HTTP backend declaration (D-06).
474///
475/// This is the HTTP analog of [`DatabaseSection`]: it identifies the upstream
476/// REST API the synthesized tools call. `base_url` is the API root; the optional
477/// `[backend.auth]` sub-table selects an [`AuthConfig`] variant (`type = "..."`)
478/// and `[backend.http]` carries [`HttpConfig`] tuning (timeout / retries / …).
479///
480/// Gated behind the `http` feature — the whole section (and the
481/// [`ServerConfig::backend`] field) is absent in a no-http build so there is no
482/// dead stub type. `AuthConfig` and `HttpConfig` are DEFINED in
483/// [`crate::http`] (Plan 90-01) and re-exported here, not redefined (H3).
484///
485/// Strict-parse discipline (D-13) is preserved: `#[serde(deny_unknown_fields)]`
486/// rejects a typo'd key under `[backend]` or `[backend.http]`.
487///
488/// Secrets posture (T-90-02-02): inline token fields under `[backend.auth]`
489/// hold operator references (`${ENV}` / `env:VAR`) resolved upstream by the
490/// Phase 83 secrets machinery — config parsing stores the string verbatim and
491/// never the resolved value.
492#[cfg(feature = "http")]
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
494#[serde(deny_unknown_fields)]
495pub struct BackendSection {
496    /// REST API root URL (e.g. `"https://api.tfl.gov.uk"`). Single-call tools
497    /// concatenate their `path` onto this (an empty per-tool `base_url`
498    /// inherits this value).
499    #[serde(default)]
500    pub base_url: String,
501    /// `[backend.auth]` — outgoing authentication ([`AuthConfig`], six modes).
502    /// Defaults to [`AuthConfig::None`] when the sub-table is omitted.
503    #[serde(default)]
504    pub auth: AuthConfig,
505    /// `[backend.http]` — client tuning ([`HttpConfig`]: timeout / retries /
506    /// backoff / user-agent / default headers). Defaults to [`HttpConfig`]'s
507    /// defaults when the sub-table is omitted.
508    #[serde(default)]
509    pub http: HttpConfig,
510}
511
512#[cfg(feature = "http")]
513impl BackendSection {
514    /// Resolve [`Self::base_url`], expanding a `${VAR}` / `env:VAR` reference
515    /// from the process environment. Callers MUST use this rather than reading
516    /// `base_url` directly — the raw field may hold an unresolved placeholder.
517    ///
518    /// A Shape A server's endpoint is frequently a slot the target environment
519    /// fills, so the config records `base_url = "${TFL_BASE_URL}"` and the
520    /// package digest stays environment-independent. Without expansion that
521    /// literal `${...}` parses, VALIDATES (it is non-empty, so the emptiness
522    /// rule passes) and is then sent as the request URL.
523    ///
524    /// Resolution rules — the grammar is [`crate::env_ref::parse_env_ref`], the
525    /// single toolkit-wide chokepoint:
526    /// - a plain literal (no `${...}` / `env:` prefix) is returned VERBATIM;
527    /// - `${VAR}` / `env:VAR` reads `VAR` from the process environment;
528    /// - a MALFORMED reference — the empty `${}`, or a multi-placeholder
529    ///   composition like `${A}://${B}` (a brace reference names exactly ONE
530    ///   variable) — is an error;
531    /// - an UNSET variable, or one set to an empty / whitespace-only value, is
532    ///   an error.
533    ///
534    /// # Deliberate divergence from credential resolution
535    ///
536    /// A credential resolves an unset reference to the empty string so an
537    /// optional credential is OMITTED (see `crate::http::auth`). An endpoint
538    /// does NOT get that treatment: an empty credential yields a degraded
539    /// request, but an empty endpoint yields a broken one, and
540    /// [`ServerConfig::validate`] only checks emptiness at parse time — an
541    /// empty resolution would sail through and then break every request. This
542    /// uses the error-on-unset semantics of `code_mode`'s `token_secret`
543    /// resolution instead.
544    ///
545    /// # Errors
546    ///
547    /// Returns [`ToolkitError::UnresolvedBaseUrlRef`] when the reference cannot
548    /// be resolved. Per T-120-17 the error names the FIELD and the
549    /// environment-variable NAME only — never a resolved URL or credential.
550    ///
551    /// # Examples
552    ///
553    /// ```
554    /// use pmcp_server_toolkit::config::ServerConfig;
555    ///
556    /// let cfg = ServerConfig::from_toml_strict_validated(
557    ///     "[server]\nname = \"demo\"\nversion = \"0.1.0\"\n\
558    ///      [backend]\nbase_url = \"https://api.example.com\"\n",
559    /// )
560    /// .expect("valid config");
561    /// let backend = cfg.backend.as_ref().expect("[backend] present");
562    /// // A plain literal is used verbatim.
563    /// assert_eq!(backend.resolved_base_url().unwrap(), "https://api.example.com");
564    /// ```
565    pub fn resolved_base_url(&self) -> std::result::Result<String, ToolkitError> {
566        match crate::env_ref::parse_env_ref(&self.base_url) {
567            // Plain literal — used verbatim (every existing [backend] config
568            // and the four SQL reference configs land here, unchanged).
569            None => Ok(self.base_url.clone()),
570            // Malformed `${}` — a reference to an empty name. A credential
571            // treats this as "omit"; an endpoint cannot be omitted.
572            Some("") => Err(ToolkitError::UnresolvedBaseUrlRef { var: String::new() }),
573            Some(name) => match std::env::var(name) {
574                Ok(value) if !value.trim().is_empty() => Ok(value),
575                // Unset, or set-but-empty/whitespace — the same error either
576                // way. The VALUE is never carried into the error.
577                _ => Err(ToolkitError::UnresolvedBaseUrlRef {
578                    var: name.to_string(),
579                }),
580            },
581        }
582    }
583}
584
585// -----------------------------------------------------------------------------
586// [code_mode]
587// -----------------------------------------------------------------------------
588
589/// `[code_mode]` section — code-mode policy + complexity limits.
590///
591/// The toolkit uses **unprefixed** field names (REF-01 invariant); the mapping
592/// to `pmcp_code_mode::CodeModeConfig`'s prefixed names (`sql_allow_writes`,
593/// etc.) is handled by Plan 06's executor wiring.
594#[allow(clippy::struct_excessive_bools)]
595// 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.
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
597#[serde(deny_unknown_fields)]
598pub struct CodeModeSection {
599    /// Master enable flag for code-mode.
600    #[serde(default)]
601    pub enabled: bool,
602    /// Server identifier used by AVP / Cedar policy resolution.
603    #[serde(default)]
604    pub server_id: Option<String>,
605    /// Whether INSERT / UPDATE / MERGE statements are allowed.
606    #[serde(default)]
607    pub allow_writes: bool,
608    /// Whether DELETE statements are allowed.
609    #[serde(default)]
610    pub allow_deletes: bool,
611    /// Whether DDL (CREATE / ALTER / DROP) is allowed.
612    #[serde(default)]
613    pub allow_ddl: bool,
614    /// Whether `SELECT` queries must declare a `LIMIT`.
615    #[serde(default)]
616    pub require_limit: bool,
617    /// Maximum allowed `LIMIT` value.
618    #[serde(default)]
619    pub max_limit: Option<u64>,
620    /// Table names blocked from any query (denylist).
621    #[serde(default)]
622    pub blocked_tables: Vec<String>,
623    /// `table.column` strings stripped from query output.
624    #[serde(default)]
625    pub sensitive_columns: Vec<String>,
626    /// Risk levels eligible for auto-approval (e.g. `["low"]`).
627    #[serde(default)]
628    pub auto_approve_levels: Vec<String>,
629    /// Token TTL, in seconds, for HMAC-signed approval tokens.
630    #[serde(default)]
631    pub token_ttl_seconds: Option<u64>,
632    /// Secret reference (e.g. `"${CODE_MODE_SECRET}"`) for HMAC signing — resolved
633    /// at runtime by `SecretsProvider`. NEVER a raw secret value (review R6 +
634    /// T-83-04-04 in the plan threat model).
635    #[serde(default)]
636    pub token_secret: Option<String>,
637    /// Per Phase 83 review R9: inline `token_secret = "raw-string"` is REJECTED
638    /// by default to prevent secrets from being committed to source-controlled
639    /// configs. Set this flag to `true` ONLY in dev/test configs where the
640    /// operator explicitly accepts the risk. NEVER set this in a committed
641    /// production config — production must use the `env:VAR_NAME` syntax that
642    /// resolves at runtime through `SecretsProvider`.
643    #[serde(default)]
644    pub allow_inline_token_secret_for_dev: bool,
645    /// `[code_mode.limits]` — query-complexity caps.
646    #[serde(default)]
647    pub limits: Option<CodeModeLimits>,
648}
649
650/// `[code_mode.limits]` — query-complexity caps.
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
652#[serde(deny_unknown_fields)]
653pub struct CodeModeLimits {
654    /// Maximum number of distinct tables referenced in a single query.
655    #[serde(default)]
656    pub max_tables_per_query: Option<u32>,
657    /// Maximum JOIN nesting depth.
658    #[serde(default)]
659    pub max_join_depth: Option<u32>,
660    /// Maximum subquery nesting depth.
661    #[serde(default)]
662    pub max_subquery_depth: Option<u32>,
663}
664
665// -----------------------------------------------------------------------------
666// [shared_policy_store]
667// -----------------------------------------------------------------------------
668
669/// `[shared_policy_store]` section — AVP/Cedar shared-policy-store declaration.
670///
671/// Emitted only by the **reference** SQL server (`[server] is_reference = true`),
672/// which provisions a single shared policy store + a set of Cedar templates that
673/// all sibling SQL servers attach to (rather than each minting its own store).
674///
675/// Additive per the REF-01 superset invariant (Plan 85-01). The toolkit parses
676/// this verbatim — SSM export and store provisioning are deployment-time
677/// concerns handled outside config parsing (D-02 parse-only + lazy startup).
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
679#[serde(deny_unknown_fields)]
680pub struct SharedPolicyStoreSection {
681    /// Whether this server creates the shared policy store for all SQL servers.
682    #[serde(default)]
683    pub creates_shared_store: bool,
684    /// Whether the created store's identifier is exported to SSM Parameter Store.
685    #[serde(default)]
686    pub export_to_ssm: bool,
687    /// SSM Parameter Store path the store identifier is exported to (when
688    /// `export_to_ssm = true`).
689    #[serde(default)]
690    pub ssm_path: Option<String>,
691    /// Cedar policy-template names included in the shared store (e.g.
692    /// `"PermitAllSelects"`, `"ForbidAllDeletes"`).
693    #[serde(default)]
694    pub templates: Vec<String>,
695}
696
697// -----------------------------------------------------------------------------
698// [[config_slots]]
699// -----------------------------------------------------------------------------
700
701/// The kind of a declared `[[config_slots]]` entry — a CLOSED vocabulary.
702///
703/// Deliberately an enum rather than a free `String`. A free string lets a typo
704/// (`kind = "endpont"`) parse cleanly, survive
705/// [`ServerConfig::validate`], and fail only at package time when it maps to no
706/// slot type — the failure surfacing two crates away from its cause. As a closed
707/// enum, an unrecognized discriminator is a serde parse error naming the
708/// accepted set, and a fourth kind becomes a deliberate addition here rather
709/// than a silent pass-through.
710///
711/// # Why this type is toolkit-LOCAL
712///
713/// The three `snake_case` discriminators (`endpoint`, `secret`, `auth_mode`)
714/// are deliberately the same strings the `pmcp-package` slot-type discriminator
715/// uses for the corresponding variants, so a packaging tool can compare a
716/// declaration against a package slot **without either crate depending on the
717/// other**. The toolkit must NOT depend on `pmcp-package`: that crate is the
718/// workspace-excluded leaf, and a toolkit dependency on it inverts the layering.
719/// The agreement is enforced by the package side re-parsing the SAME config
720/// bytes, not by a shared type.
721#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
722#[serde(rename_all = "snake_case")]
723pub enum ConfigSlotKind {
724    /// A network endpoint the target environment must supply (e.g. the backend
725    /// API root). Behaviour-relevant: its `tested_value` records the endpoint
726    /// the package was tested against.
727    #[default]
728    Endpoint,
729    /// A named secret the target environment must supply (e.g. an API key).
730    /// Identity-bearing: it structurally carries no `tested_value`.
731    Secret,
732    /// The backend authentication MODE. Structural rather than value-bearing:
733    /// the auth-mode key is a serde tag, so no `${VAR}` placeholder form of it
734    /// can deserialize — the baked literal IS the default and deviation
735    /// surfaces through slot classification, not through a placeholder.
736    AuthMode,
737}
738
739/// Single `[[config_slots]]` entry — a config value the TARGET environment must
740/// fill for this server to run.
741///
742/// A Shape A server's whole identity is its config, so "what must the operator
743/// supply?" has to be declarable IN that config rather than discovered by
744/// grepping for `${...}`. This block is that declaration: it names the config
745/// path, the kind of thing it is, and the value exercised when the server was
746/// tested.
747///
748/// Additive per the REF-01 superset invariant — a config omitting the block
749/// parses to an empty [`ServerConfig::config_slots`]. Strict-parse discipline
750/// (D-13) applies: `#[serde(deny_unknown_fields)]` rejects a typo'd inner key.
751///
752/// # Example
753///
754/// ```toml
755/// [[config_slots]]
756/// key = "backend.base_url"
757/// kind = "endpoint"
758/// name = "TFL_BASE_URL"
759/// tested_value = "https://api.tfl.gov.uk"
760/// ```
761/// Who fills a config slot's value.
762///
763/// Mirrors `pmcp-package`'s `SuppliedBy` **by TOML value name, never by shared
764/// type** — the same arrangement as [`ConfigSlotKind`], and for the same
765/// reason: the toolkit must NOT depend on `pmcp-package` (the
766/// workspace-excluded leaf), and a dependency the other way inverts the
767/// layering. The agreement is enforced by the package side re-parsing these
768/// same config bytes, not by a shared definition.
769///
770/// Defaults to [`Environment`](Self::Environment), so every config written
771/// before this field existed keeps its exact meaning: the operator supplies it.
772#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
773#[serde(rename_all = "snake_case")]
774pub enum ConfigSlotSuppliedBy {
775    /// The operator supplies it in the target environment. The default, and the
776    /// only class a package enumerates as REQUIRED of an operator.
777    #[default]
778    Environment,
779    /// The hosting platform injects it at deploy time.
780    Platform,
781    /// The execution environment injects it (e.g. `AWS_LAMBDA_FUNCTION_NAME`);
782    /// neither the operator nor the platform supplies it.
783    Runtime,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
787#[serde(deny_unknown_fields)]
788pub struct ConfigSlotDecl {
789    /// The dotted TOML path this slot fills, e.g. `backend.base_url`,
790    /// `backend.auth.query_params.app_key`, `backend.auth.type`.
791    #[serde(default)]
792    pub key: String,
793    /// The slot kind ([`ConfigSlotKind`] — a closed vocabulary). REQUIRED: an
794    /// entry omitting `kind` is a parse error, because a defaulted kind would
795    /// silently mis-classify the slot.
796    pub kind: ConfigSlotKind,
797    /// The slot's declared name — for a `secret`, the environment-variable
798    /// name; for an `endpoint`, the variable the `${VAR}` placeholder reads.
799    #[serde(default)]
800    pub name: String,
801    /// The value exercised when the server was tested. `None` for
802    /// identity-bearing slots (a secret), which structurally carry no value —
803    /// ENFORCED by [`ServerConfig::validate`], not just stated: a `secret`
804    /// entry carrying a `tested_value` is refused, because that field is the
805    /// one place a real credential could sit in a config that is served but
806    /// never packed.
807    #[serde(default)]
808    pub tested_value: Option<String>,
809    /// Who fills this slot — see [`ConfigSlotSuppliedBy`]. Defaults to
810    /// `environment` (the operator supplies it), so a config written before this
811    /// field existed is unchanged in meaning.
812    ///
813    /// This field is why the toolkit had to move in the same change as the
814    /// packer: `deny_unknown_fields` above means a config carrying
815    /// `supplied_by` would FAIL TO BOOT if only the package side learned it,
816    /// and `pmcp-package` refuses to pack a config it knows the server cannot
817    /// parse. Both sides accept it, or neither does.
818    #[serde(default)]
819    pub supplied_by: ConfigSlotSuppliedBy,
820}
821
822// -----------------------------------------------------------------------------
823// [[tools]]
824// -----------------------------------------------------------------------------
825
826/// Single `[[tools]]` entry — a declaratively-defined tool surface.
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
828#[serde(deny_unknown_fields)]
829pub struct ToolDecl {
830    /// Tool name (required for production via [`ServerConfig::validate`]).
831    #[serde(default)]
832    pub name: String,
833    /// Human-readable tool description.
834    #[serde(default)]
835    pub description: Option<String>,
836    /// SQL template (uses `:param` placeholders bound by [`ParamDecl`]).
837    #[serde(default)]
838    pub sql: Option<String>,
839    /// HTTP request path for a **single-call** OpenAPI/REST tool (D-01), e.g.
840    /// `"/Line/Mode/tube/Status"`. Concatenated onto the backend `base_url`
841    /// (or this tool's [`Self::base_url`] override). Additive per REF-01 — `None`
842    /// for SQL / script tools.
843    #[serde(default)]
844    pub path: Option<String>,
845    /// HTTP method for a single-call tool (`"GET"`, `"POST"`, …). Pairs with
846    /// [`Self::path`] (D-01). Additive; `None` for SQL / script tools.
847    #[serde(default)]
848    pub method: Option<String>,
849    /// Per-tool backend base-URL override. When absent a single-call tool
850    /// inherits `[backend].base_url`. Additive; `None` for SQL / script tools.
851    #[serde(default)]
852    pub base_url: Option<String>,
853    /// JavaScript body for a **script** tool (D-01) — a code-mode snippet that
854    /// orchestrates multiple backend calls and binds `[[tools.parameters]]` to
855    /// `args`. When set, this entry is a script tool ([`Self::is_script_tool`]).
856    /// Additive; `None` for SQL / single-call tools.
857    #[serde(default)]
858    pub script: Option<String>,
859    /// Optional UI-resource URI for `structuredContent` widgets.
860    #[serde(default)]
861    pub ui_resource_uri: Option<String>,
862    /// `[[tools.parameters]]` — declared input parameters.
863    #[serde(default)]
864    pub parameters: Vec<ParamDecl>,
865    /// `[tools.annotations]` — MCP `toolAnnotations`.
866    #[serde(default)]
867    pub annotations: Option<AnnotationsDecl>,
868}
869
870impl ToolDecl {
871    /// Whether this `[[tools]]` entry is a **script** tool (D-01 detection rule).
872    ///
873    /// The detection rule is: `script.is_some()` ⇒ script tool; otherwise a
874    /// `path` + `method` pair ⇒ single-call HTTP tool; otherwise (a `sql`
875    /// field) ⇒ SQL tool. Plan 03/05 synthesizers branch on this method so the
876    /// rule lives in exactly one place. Mutual-exclusivity is enforced at
877    /// [`ServerConfig::validate`] (an entry mixing kinds is rejected, not
878    /// silently resolved by precedence).
879    ///
880    /// # Examples
881    ///
882    /// ```
883    /// use pmcp_server_toolkit::config::ToolDecl;
884    ///
885    /// let script = ToolDecl { script: Some("await api.get('/x')".into()), ..Default::default() };
886    /// assert!(script.is_script_tool());
887    ///
888    /// let single = ToolDecl {
889    ///     path: Some("/Line/Mode/tube/Status".into()),
890    ///     method: Some("GET".into()),
891    ///     ..Default::default()
892    /// };
893    /// assert!(!single.is_script_tool());
894    /// ```
895    #[must_use]
896    pub fn is_script_tool(&self) -> bool {
897        self.script.is_some()
898    }
899
900    /// Number of distinct mutually-exclusive tool kinds declared on this entry.
901    ///
902    /// Used by [`ServerConfig::validate`] to reject an ambiguous `[[tools]]`
903    /// entry (D-01 / T-90-02-04). A well-formed entry declares exactly one kind
904    /// (count `1`); count `> 1` is ambiguous; count `0` is a kind-less stub
905    /// (left to other validation rules).
906    fn declared_kind_count(&self) -> usize {
907        let is_sql = self.sql.is_some();
908        let is_single_call = self.path.is_some() || self.method.is_some();
909        let is_script = self.script.is_some();
910        usize::from(is_sql) + usize::from(is_single_call) + usize::from(is_script)
911    }
912}
913
914/// Single `[[tools.parameters]]` entry.
915///
916/// The `default` and `enum` fields use [`toml::Value`] because they are
917/// heterogeneous in the reference configs (a `default` may be an integer,
918/// a string, or a boolean depending on the parameter type).
919#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
920#[serde(deny_unknown_fields)]
921pub struct ParamDecl {
922    /// Parameter name (the `:param` token used in the tool's `sql`).
923    #[serde(default)]
924    pub name: String,
925    /// JSON-schema type (`"string"`, `"integer"`, `"number"`, `"boolean"`).
926    #[serde(default, rename = "type")]
927    pub param_type: Option<String>,
928    /// Human-readable parameter description.
929    #[serde(default)]
930    pub description: Option<String>,
931    /// Whether the parameter is required.
932    #[serde(default)]
933    pub required: bool,
934    /// Optional default value (any TOML type).
935    #[serde(default)]
936    pub default: Option<toml::Value>,
937    /// Maximum string length (string parameters only).
938    #[serde(default)]
939    pub max_length: Option<u64>,
940    /// Inclusive minimum (integer / number parameters only).
941    #[serde(default)]
942    pub minimum: Option<f64>,
943    /// Inclusive maximum (integer / number parameters only).
944    #[serde(default)]
945    pub maximum: Option<f64>,
946    /// Closed set of allowed values (any TOML scalar).
947    #[serde(default, rename = "enum")]
948    pub enum_values: Option<Vec<toml::Value>>,
949}
950
951/// `[tools.annotations]` — MCP `toolAnnotations` hints.
952#[allow(clippy::struct_excessive_bools)] // Why: REF-01 superset — mirrors the MCP `toolAnnotations` flag set 1:1.
953#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
954#[serde(deny_unknown_fields)]
955pub struct AnnotationsDecl {
956    /// Whether the tool only reads (never mutates) state.
957    #[serde(default)]
958    pub read_only_hint: bool,
959    /// Whether the tool may destroy data.
960    #[serde(default)]
961    pub destructive_hint: bool,
962    /// Whether repeated calls with the same args produce the same result.
963    #[serde(default)]
964    pub idempotent_hint: bool,
965    /// Whether the tool interacts with an open-world (external) service.
966    #[serde(default)]
967    pub open_world_hint: bool,
968    /// Cost hint (`"low"`, `"medium"`, `"high"`).
969    #[serde(default)]
970    pub cost_hint: Option<String>,
971}
972
973// -----------------------------------------------------------------------------
974// [[prompts]]
975// -----------------------------------------------------------------------------
976
977/// Single `[[prompts]]` entry.
978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
979#[serde(deny_unknown_fields)]
980pub struct PromptDecl {
981    /// Prompt name (the identifier MCP clients call by).
982    #[serde(default)]
983    pub name: String,
984    /// Human-readable prompt description.
985    #[serde(default)]
986    pub description: Option<String>,
987    /// Resource URIs to include in the prompt's assembled body.
988    #[serde(default)]
989    pub include_resources: Vec<String>,
990    /// Declared prompt arguments (MCP `PromptArgument`).
991    #[serde(default)]
992    pub arguments: Vec<PromptArgumentDecl>,
993}
994
995/// Single argument under `[[prompts.arguments]]`.
996#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
997#[serde(deny_unknown_fields)]
998pub struct PromptArgumentDecl {
999    /// Argument name.
1000    #[serde(default)]
1001    pub name: String,
1002    /// Human-readable description.
1003    #[serde(default)]
1004    pub description: Option<String>,
1005    /// Whether the argument is required.
1006    #[serde(default)]
1007    pub required: bool,
1008}
1009
1010// -----------------------------------------------------------------------------
1011// [[resources]]
1012// -----------------------------------------------------------------------------
1013
1014/// Single `[[resources]]` entry — a statically-shipped resource.
1015#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1016#[serde(deny_unknown_fields)]
1017pub struct ResourceDecl {
1018    /// Resource URI (e.g. `"docs://open-images/schema"`).
1019    #[serde(default)]
1020    pub uri: String,
1021    /// Human-readable resource name.
1022    #[serde(default)]
1023    pub name: Option<String>,
1024    /// Resource description.
1025    #[serde(default)]
1026    pub description: Option<String>,
1027    /// MIME type (e.g. `"text/markdown"`).
1028    #[serde(default)]
1029    pub mime_type: Option<String>,
1030    /// Inline resource content (or `"loaded from path.md"` placeholder string —
1031    /// the toolkit treats the value verbatim; resolution to filesystem reads
1032    /// is the caller's responsibility).
1033    #[serde(default)]
1034    pub content: Option<String>,
1035}
1036
1037// -----------------------------------------------------------------------------
1038// Tests
1039// -----------------------------------------------------------------------------
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use proptest::prelude::*;
1045
1046    const MINIMAL: &str = r#"
1047        [server]
1048        name = "demo"
1049        version = "0.1.0"
1050    "#;
1051
1052    #[test]
1053    fn parse_minimal_config_succeeds() {
1054        let cfg = ServerConfig::from_toml(MINIMAL).expect("minimal must parse");
1055        assert_eq!(cfg.server.name, "demo");
1056        assert_eq!(cfg.server.version, "0.1.0");
1057        assert!(cfg.tools.is_empty());
1058        assert!(cfg.code_mode.is_none());
1059    }
1060
1061    #[test]
1062    fn parse_unknown_field_fails() {
1063        let toml = r#"
1064            [server]
1065            name = "demo"
1066            version = "0.1.0"
1067            unknown_field = "x"
1068        "#;
1069        let err = ServerConfig::from_toml(toml).expect_err("unknown field must fail");
1070        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1071    }
1072
1073    #[test]
1074    fn parse_typo_in_code_mode_key_fails() {
1075        // T-83-04-02: defence-in-depth against silent policy widening.
1076        let toml = r#"
1077            [server]
1078            name = "demo"
1079            version = "0.1.0"
1080            [code_mode]
1081            enabled = true
1082            auto_aprove_levels = ["low"]
1083        "#;
1084        let err = ServerConfig::from_toml(toml).expect_err("typo'd code_mode key must be rejected");
1085        assert!(matches!(err, ToolkitError::Parse(_)));
1086    }
1087
1088    #[test]
1089    fn code_mode_section_optional() {
1090        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
1091        assert!(cfg.code_mode.is_none());
1092    }
1093
1094    #[test]
1095    fn validate_accepts_valid_config() {
1096        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
1097        cfg.validate().expect("minimal config must validate");
1098    }
1099
1100    #[test]
1101    fn validate_rejects_empty_server_name() {
1102        let toml = r#"
1103            [server]
1104            name = ""
1105            version = "0.1.0"
1106        "#;
1107        let cfg = ServerConfig::from_toml(toml).expect("parse");
1108        match cfg.validate() {
1109            Err(ConfigValidationError::EmptyServerName) => {},
1110            other => panic!("expected EmptyServerName, got {other:?}"),
1111        }
1112    }
1113
1114    #[test]
1115    fn validate_rejects_empty_server_version() {
1116        let toml = r#"
1117            [server]
1118            name = "demo"
1119            version = ""
1120        "#;
1121        let cfg = ServerConfig::from_toml(toml).expect("parse");
1122        match cfg.validate() {
1123            Err(ConfigValidationError::EmptyServerVersion) => {},
1124            other => panic!("expected EmptyServerVersion, got {other:?}"),
1125        }
1126    }
1127
1128    #[test]
1129    fn validate_rejects_empty_tool_name() {
1130        let toml = r#"
1131            [server]
1132            name = "demo"
1133            version = "0.1.0"
1134
1135            [[tools]]
1136            name = "ok"
1137            description = "first"
1138
1139            [[tools]]
1140            name = ""
1141            description = "second-is-empty"
1142        "#;
1143        let cfg = ServerConfig::from_toml(toml).expect("parse");
1144        match cfg.validate() {
1145            Err(ConfigValidationError::EmptyToolName(1)) => {},
1146            other => panic!("expected EmptyToolName(1), got {other:?}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn validate_rejects_empty_table_name() {
1152        let toml = r#"
1153            [server]
1154            name = "demo"
1155            version = "0.1.0"
1156
1157            [[database.tables]]
1158            name = ""
1159            description = "missing-name"
1160        "#;
1161        let cfg = ServerConfig::from_toml(toml).expect("parse");
1162        match cfg.validate() {
1163            Err(ConfigValidationError::EmptyTableName(0)) => {},
1164            other => panic!("expected EmptyTableName(0), got {other:?}"),
1165        }
1166    }
1167
1168    /// Phase 90 gap-closure (GAP 3 / WR-02): a `[backend]` block with an
1169    /// empty / missing `base_url` is rejected at validate() time with
1170    /// [`ConfigValidationError::EmptyBackendBaseUrl`] — not a late opaque
1171    /// `DispatchError::Connector("invalid base URL")` at request time.
1172    #[cfg(feature = "http")]
1173    #[test]
1174    fn validate_rejects_empty_backend_base_url() {
1175        // base_url key present but empty.
1176        let toml = r#"
1177            [server]
1178            name = "demo"
1179            version = "0.1.0"
1180
1181            [backend]
1182            base_url = ""
1183        "#;
1184        let cfg = ServerConfig::from_toml(toml).expect("parse");
1185        match cfg.validate() {
1186            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
1187            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
1188        }
1189    }
1190
1191    /// A `[backend]` block whose `base_url` key is omitted entirely (defaults
1192    /// to `""` via `#[serde(default)]`) is rejected the same way.
1193    #[cfg(feature = "http")]
1194    #[test]
1195    fn validate_rejects_omitted_backend_base_url() {
1196        let toml = r#"
1197            [server]
1198            name = "demo"
1199            version = "0.1.0"
1200
1201            [backend]
1202        "#;
1203        let cfg = ServerConfig::from_toml(toml).expect("parse");
1204        match cfg.validate() {
1205            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
1206            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
1207        }
1208    }
1209
1210    /// A multi-placeholder composition (`${SCHEME}://${HOST}`) is a MALFORMED
1211    /// reference — the grammar resolves one whole-value `${VAR}`, it does not
1212    /// interpolate — so validate() refuses it at load time instead of letting
1213    /// every boot fail with an `UnresolvedBaseUrlRef` naming an empty variable.
1214    #[cfg(feature = "http")]
1215    #[test]
1216    fn validate_rejects_multi_placeholder_backend_base_url() {
1217        let toml = r#"
1218            [server]
1219            name = "demo"
1220            version = "0.1.0"
1221
1222            [backend]
1223            base_url = "${TFL_SCHEME}://${TFL_HOST}"
1224        "#;
1225        let cfg = ServerConfig::from_toml(toml).expect("parse");
1226        match cfg.validate() {
1227            Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
1228            other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
1229        }
1230    }
1231
1232    /// The empty `${}` form is the same class of defect and gets the same
1233    /// load-time refusal.
1234    #[cfg(feature = "http")]
1235    #[test]
1236    fn validate_rejects_empty_name_backend_base_url_ref() {
1237        let toml = r#"
1238            [server]
1239            name = "demo"
1240            version = "0.1.0"
1241
1242            [backend]
1243            base_url = "${}"
1244        "#;
1245        let cfg = ServerConfig::from_toml(toml).expect("parse");
1246        match cfg.validate() {
1247            Err(ConfigValidationError::MalformedBackendBaseUrlRef) => {},
1248            other => panic!("expected MalformedBackendBaseUrlRef, got {other:?}"),
1249        }
1250    }
1251
1252    /// A well-formed single reference stays valid — the check refuses only
1253    /// malformed shapes, never the deferred-to-environment pattern itself.
1254    #[cfg(feature = "http")]
1255    #[test]
1256    fn validate_accepts_single_reference_backend_base_url() {
1257        let toml = r#"
1258            [server]
1259            name = "demo"
1260            version = "0.1.0"
1261
1262            [backend]
1263            base_url = "${TFL_BASE_URL}"
1264        "#;
1265        let cfg = ServerConfig::from_toml(toml).expect("parse");
1266        cfg.validate()
1267            .expect("a single ${VAR} backend.base_url reference must validate");
1268    }
1269
1270    /// The SAME malformed-reference rule applies to `[backend.auth]`
1271    /// credentials, and it applies at LOAD time. Without it the credential path
1272    /// resolved a malformed reference to the empty string and then OMITTED it:
1273    /// the server booted, every backend call went out unauthenticated, and
1274    /// nothing was logged. `${TFL-APP-KEY}` is the realistic shape — a dash is
1275    /// not a portably settable variable name, so the reference names nothing.
1276    #[cfg(feature = "http")]
1277    #[test]
1278    fn validate_rejects_malformed_backend_auth_credential_ref() {
1279        let toml = r#"
1280            [server]
1281            name = "demo"
1282            version = "0.1.0"
1283
1284            [backend]
1285            base_url = "https://api.example.com"
1286
1287            [backend.auth]
1288            type = "bearer"
1289            token = "${TFL-APP-KEY}"
1290        "#;
1291        let cfg = ServerConfig::from_toml(toml).expect("parse");
1292        match cfg.validate() {
1293            Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
1294                assert_eq!(field, "token");
1295            },
1296            other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
1297        }
1298    }
1299
1300    /// The api_key map path gets the same refusal, and the error names the
1301    /// offending entry so the operator knows WHICH parameter to fix.
1302    #[cfg(feature = "http")]
1303    #[test]
1304    fn validate_rejects_malformed_backend_auth_api_key_entry() {
1305        let toml = r#"
1306            [server]
1307            name = "demo"
1308            version = "0.1.0"
1309
1310            [backend]
1311            base_url = "https://api.example.com"
1312
1313            [backend.auth]
1314            type = "api_key"
1315            query_params = { app_key = "${TFL_SCHEME}://${TFL_HOST}" }
1316        "#;
1317        let cfg = ServerConfig::from_toml(toml).expect("parse");
1318        match cfg.validate() {
1319            Err(ConfigValidationError::MalformedBackendAuthRef(field)) => {
1320                assert_eq!(field, "query_params.app_key");
1321            },
1322            other => panic!("expected MalformedBackendAuthRef, got {other:?}"),
1323        }
1324    }
1325
1326    /// The refusal is scoped to MALFORMED shapes only: a well-formed reference
1327    /// and a plain literal both still validate, so the deferred-to-environment
1328    /// pattern and committed dev configs are untouched.
1329    #[cfg(feature = "http")]
1330    #[test]
1331    fn validate_accepts_wellformed_and_literal_backend_auth_credentials() {
1332        let toml = r#"
1333            [server]
1334            name = "demo"
1335            version = "0.1.0"
1336
1337            [backend]
1338            base_url = "https://api.example.com"
1339
1340            [backend.auth]
1341            type = "basic"
1342            username = "svc-account"
1343            password = "${TFL_APP_KEY}"
1344        "#;
1345        let cfg = ServerConfig::from_toml(toml).expect("parse");
1346        cfg.validate()
1347            .expect("a literal username and a single ${VAR} password must validate");
1348    }
1349
1350    /// A `[backend]` block with a non-empty `base_url` validates OK.
1351    #[cfg(feature = "http")]
1352    #[test]
1353    fn validate_accepts_non_empty_backend_base_url() {
1354        let toml = r#"
1355            [server]
1356            name = "demo"
1357            version = "0.1.0"
1358
1359            [backend]
1360            base_url = "https://api.example.com"
1361        "#;
1362        let cfg = ServerConfig::from_toml(toml).expect("parse");
1363        cfg.validate()
1364            .expect("config with a non-empty backend.base_url must validate");
1365    }
1366
1367    /// A config with NO `[backend]` block (a pure-SQL config) is unaffected by
1368    /// the new check — `backend` is `None`, so the check never fires.
1369    #[cfg(feature = "http")]
1370    #[test]
1371    fn validate_accepts_absent_backend() {
1372        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
1373        assert!(cfg.backend.is_none());
1374        cfg.validate()
1375            .expect("a config without [backend] must validate (SQL configs unaffected)");
1376    }
1377
1378    /// The error Display names the offending field and is actionable.
1379    #[cfg(feature = "http")]
1380    #[test]
1381    fn empty_backend_base_url_error_names_the_field() {
1382        let msg = ConfigValidationError::EmptyBackendBaseUrl.to_string();
1383        assert!(
1384            msg.contains("[backend].base_url"),
1385            "error must name the field, got: {msg}"
1386        );
1387    }
1388
1389    #[test]
1390    fn database_url_optional_field_parses() {
1391        // Phase 84 CONN-04 / D-08: the additive `[database].url` field parses
1392        // under `#[serde(deny_unknown_fields)]` and carries the `env:VAR_NAME`
1393        // indirection string verbatim (resolution happens at the consumer layer).
1394        let toml = r#"
1395            [server]
1396            name = "x"
1397            version = "0.0.1"
1398
1399            [database]
1400            url = "env:DATABASE_URL"
1401        "#;
1402        let cfg = ServerConfig::from_toml(toml).expect("config with [database].url must parse");
1403        assert_eq!(cfg.database.url, Some("env:DATABASE_URL".to_string()));
1404    }
1405
1406    #[test]
1407    fn from_toml_strict_validated_rolls_both_errors() {
1408        // 1. Parse error path (unknown field).
1409        let bad_toml = r#"
1410            [server]
1411            name = "demo"
1412            version = "0.1.0"
1413            nonsense = "x"
1414        "#;
1415        let err = ServerConfig::from_toml_strict_validated(bad_toml)
1416            .expect_err("unknown field must surface");
1417        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1418
1419        // 2. Validation error path (empty required value).
1420        let invalid_toml = r#"
1421            [server]
1422            name = ""
1423            version = "0.1.0"
1424        "#;
1425        let err = ServerConfig::from_toml_strict_validated(invalid_toml)
1426            .expect_err("empty name must surface");
1427        assert!(
1428            matches!(
1429                err,
1430                ToolkitError::Validation(ConfigValidationError::EmptyServerName)
1431            ),
1432            "got: {err:?}"
1433        );
1434    }
1435
1436    // -------------------------------------------------------------------------
1437    // ToolDecl two-kind detection — D-01 (shared, not http-gated)
1438    // -------------------------------------------------------------------------
1439
1440    #[test]
1441    fn test_tooldecl_single_call_parses() {
1442        let toml = r#"
1443            [server]
1444            name = "tube"
1445            version = "0.1.0"
1446
1447            [[tools]]
1448            name = "tube_status"
1449            path = "/Line/Mode/tube/Status"
1450            method = "GET"
1451        "#;
1452        let cfg = ServerConfig::from_toml(toml).expect("single-call tool must parse");
1453        let tool = &cfg.tools[0];
1454        assert_eq!(tool.path.as_deref(), Some("/Line/Mode/tube/Status"));
1455        assert_eq!(tool.method.as_deref(), Some("GET"));
1456        assert!(!tool.is_script_tool());
1457        cfg.validate()
1458            .expect("single-call tool is a valid single kind");
1459    }
1460
1461    #[test]
1462    fn test_tooldecl_script_parses() {
1463        let toml = r#"
1464            [server]
1465            name = "tube"
1466            version = "0.1.0"
1467
1468            [[tools]]
1469            name = "plan_journey"
1470            script = """
1471            const a = await api.get('/Journey/JourneyResults/' + args.from + '/to/' + args.to);
1472            return a;
1473            """
1474
1475            [[tools.parameters]]
1476            name = "from"
1477            type = "string"
1478            required = true
1479
1480            [[tools.parameters]]
1481            name = "to"
1482            type = "string"
1483            required = true
1484        "#;
1485        let cfg = ServerConfig::from_toml(toml).expect("script tool must parse");
1486        let tool = &cfg.tools[0];
1487        assert!(tool.script.is_some());
1488        assert!(tool.is_script_tool());
1489        assert_eq!(tool.parameters.len(), 2);
1490        cfg.validate().expect("script tool is a valid single kind");
1491    }
1492
1493    #[test]
1494    fn test_tooldecl_detection() {
1495        let script = ToolDecl {
1496            script: Some("return 1;".to_string()),
1497            ..Default::default()
1498        };
1499        assert!(script.is_script_tool());
1500
1501        let single = ToolDecl {
1502            path: Some("/x".to_string()),
1503            method: Some("GET".to_string()),
1504            ..Default::default()
1505        };
1506        assert!(!single.is_script_tool());
1507
1508        let sql = ToolDecl {
1509            sql: Some("SELECT 1".to_string()),
1510            ..Default::default()
1511        };
1512        assert!(!sql.is_script_tool());
1513    }
1514
1515    #[test]
1516    fn test_tooldecl_ambiguous_rejected() {
1517        // script + path/method is ambiguous (Codex MEDIUM): rejected, not
1518        // resolved by a silent "script wins".
1519        let toml = r#"
1520            [server]
1521            name = "tube"
1522            version = "0.1.0"
1523
1524            [[tools]]
1525            name = "confused"
1526            path = "/x"
1527            method = "GET"
1528            script = "return 1;"
1529        "#;
1530        let cfg = ServerConfig::from_toml(toml).expect("parse (ambiguity is a validate-time rule)");
1531        match cfg.validate() {
1532            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
1533            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
1534        }
1535    }
1536
1537    #[test]
1538    fn test_tooldecl_ambiguous_sql_plus_script_rejected() {
1539        let toml = r#"
1540            [server]
1541            name = "tube"
1542            version = "0.1.0"
1543
1544            [[tools]]
1545            name = "confused"
1546            sql = "SELECT 1"
1547            script = "return 1;"
1548        "#;
1549        let cfg = ServerConfig::from_toml(toml).expect("parse");
1550        match cfg.validate() {
1551            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
1552            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
1553        }
1554    }
1555
1556    #[test]
1557    fn test_tooldecl_sql_still_parses() {
1558        // REF-01 superset regression: an existing sql= tool is unaffected by the
1559        // additive path/method/base_url/script fields.
1560        let toml = r#"
1561            [server]
1562            name = "demo"
1563            version = "0.1.0"
1564
1565            [[tools]]
1566            name = "list_tables"
1567            sql = "SELECT name FROM sqlite_master"
1568        "#;
1569        let cfg = ServerConfig::from_toml(toml).expect("sql tool must still parse");
1570        let tool = &cfg.tools[0];
1571        assert_eq!(tool.sql.as_deref(), Some("SELECT name FROM sqlite_master"));
1572        assert!(tool.path.is_none());
1573        assert!(tool.method.is_none());
1574        assert!(tool.base_url.is_none());
1575        assert!(tool.script.is_none());
1576        assert!(!tool.is_script_tool());
1577        cfg.validate().expect("sql tool validates as a single kind");
1578    }
1579
1580    // -------------------------------------------------------------------------
1581    // [backend] / [backend.auth] / [backend.http] — D-06 (http feature)
1582    // -------------------------------------------------------------------------
1583
1584    #[cfg(feature = "http")]
1585    #[test]
1586    fn test_backend_section_parses() {
1587        // A full [backend] + [backend.auth] (api_key) + [backend.http] block
1588        // round-trips into ServerConfig with backend.is_some().
1589        let toml = r#"
1590            [server]
1591            name = "tube"
1592            version = "0.1.0"
1593
1594            [backend]
1595            base_url = "https://api.tfl.gov.uk"
1596
1597            [backend.auth]
1598            type = "api_key"
1599
1600            [backend.auth.query_params]
1601            app_key = "${TFL_APP_KEY}"
1602
1603            [backend.http]
1604            timeout_seconds = 10
1605            retries = 2
1606        "#;
1607        let cfg = ServerConfig::from_toml(toml).expect("[backend] config must parse");
1608        let backend = cfg.backend.expect("backend must be Some");
1609        assert_eq!(backend.base_url, "https://api.tfl.gov.uk");
1610        assert_eq!(backend.http.timeout_seconds, 10);
1611        assert_eq!(backend.http.retries, 2);
1612        assert!(
1613            matches!(backend.auth, AuthConfig::ApiKey { .. }),
1614            "auth must be api_key, got {:?}",
1615            backend.auth
1616        );
1617    }
1618
1619    #[cfg(feature = "http")]
1620    #[test]
1621    fn test_backend_auth_defaults_to_none() {
1622        // [backend] without a [backend.auth] sub-table defaults auth to None
1623        // and http to HttpConfig defaults (additive sub-tables).
1624        let toml = r#"
1625            [server]
1626            name = "tube"
1627            version = "0.1.0"
1628
1629            [backend]
1630            base_url = "https://api.example.com"
1631        "#;
1632        let cfg = ServerConfig::from_toml(toml).expect("backend w/o auth must parse");
1633        let backend = cfg.backend.expect("backend must be Some");
1634        assert!(matches!(backend.auth, AuthConfig::None));
1635        assert_eq!(backend.http, HttpConfig::default());
1636    }
1637
1638    #[cfg(feature = "http")]
1639    #[test]
1640    fn test_sql_config_unaffected() {
1641        // REF-01 superset / D-06 additive proof: a pure-SQL config with NO
1642        // [backend] still parses, and backend == None.
1643        let toml = r#"
1644            [server]
1645            name = "demo"
1646            version = "0.1.0"
1647
1648            [database]
1649            type = "sqlite"
1650            file_path = "/tmp/demo.db"
1651
1652            [[tools]]
1653            name = "list_tables"
1654            sql = "SELECT name FROM sqlite_master"
1655        "#;
1656        let cfg = ServerConfig::from_toml(toml).expect("SQL config must still parse");
1657        assert!(
1658            cfg.backend.is_none(),
1659            "SQL config must have backend == None"
1660        );
1661        assert_eq!(cfg.tools.len(), 1);
1662    }
1663
1664    #[cfg(feature = "http")]
1665    #[test]
1666    fn test_backend_unknown_field_rejected() {
1667        // T-90-02-01: deny_unknown_fields preserved — an unknown key under
1668        // [backend.http] is a hard parse error, never a silent default.
1669        let toml = r#"
1670            [server]
1671            name = "tube"
1672            version = "0.1.0"
1673
1674            [backend]
1675            base_url = "https://api.example.com"
1676
1677            [backend.http]
1678            foo = 1
1679        "#;
1680        let err =
1681            ServerConfig::from_toml(toml).expect_err("unknown [backend.http] key must be rejected");
1682        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1683    }
1684
1685    // -------------------------------------------------------------------------
1686    // `[[config_slots]]` — PKG-03 slot declarations (Phase 120 Plan 04 Task 1)
1687    // -------------------------------------------------------------------------
1688
1689    /// The three-slot declaration block the london-tube proving fixture carries.
1690    const CONFIG_SLOTS_TOML: &str = r#"
1691        [server]
1692        name = "london-tube"
1693        version = "1.1.0"
1694
1695        [[config_slots]]
1696        key = "backend.base_url"
1697        kind = "endpoint"
1698        name = "TFL_BASE_URL"
1699        tested_value = "https://api.tfl.gov.uk"
1700
1701        [[config_slots]]
1702        key = "backend.auth.query_params.app_key"
1703        kind = "secret"
1704        name = "TFL_APP_KEY"
1705
1706        [[config_slots]]
1707        key = "backend.auth.type"
1708        kind = "auth_mode"
1709        name = "backend-auth-mode"
1710        tested_value = "api_key"
1711    "#;
1712
1713    /// Test 1: a `[[config_slots]]` block parses through the STRICT + validated
1714    /// entry point and exposes all three entries with their fields intact.
1715    #[test]
1716    fn config_slots_block_parses_through_strict_entry_point() {
1717        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
1718            .expect("[[config_slots]] must parse through the strict entry point");
1719        assert_eq!(cfg.config_slots.len(), 3, "three declared slots");
1720
1721        assert_eq!(cfg.config_slots[0].key, "backend.base_url");
1722        assert_eq!(cfg.config_slots[0].kind, ConfigSlotKind::Endpoint);
1723        assert_eq!(cfg.config_slots[0].name, "TFL_BASE_URL");
1724        assert_eq!(
1725            cfg.config_slots[0].tested_value.as_deref(),
1726            Some("https://api.tfl.gov.uk")
1727        );
1728
1729        assert_eq!(cfg.config_slots[1].kind, ConfigSlotKind::Secret);
1730        assert_eq!(cfg.config_slots[1].name, "TFL_APP_KEY");
1731        assert_eq!(cfg.config_slots[2].kind, ConfigSlotKind::AuthMode);
1732    }
1733
1734    /// A `[[config_slots]]` entry carrying `supplied_by` must BOOT.
1735    ///
1736    /// This is the load-bearing half of a two-crate change. `pmcp-package`
1737    /// refuses to pack a config whose fields this struct's
1738    /// `deny_unknown_fields` would reject, on the grounds that packing it would
1739    /// ship a server that cannot start. So if the packer learns `supplied_by`
1740    /// and this struct does not, every config using the field becomes
1741    /// unpackable; if this struct learns it and the packer does not, the packer
1742    /// rejects configs the server boots from happily. Both sides move together
1743    /// or neither does, and this test is the runtime half of that pin.
1744    #[test]
1745    fn a_config_slot_declaring_supplied_by_parses_through_the_strict_entry_point() {
1746        let toml = r#"
1747            [server]
1748            name = "tube"
1749            version = "0.1.0"
1750
1751            [[config_slots]]
1752            key = "backend.base_url"
1753            kind = "endpoint"
1754            name = "TFL_BASE_URL"
1755            tested_value = "https://api.tfl.gov.uk"
1756            supplied_by = "platform"
1757
1758            [[config_slots]]
1759            key = "backend.function_name"
1760            kind = "secret"
1761            name = "AWS_LAMBDA_FUNCTION_NAME"
1762            supplied_by = "runtime"
1763        "#;
1764        let cfg = ServerConfig::from_toml_strict_validated(toml)
1765            .expect("`supplied_by` must parse under deny_unknown_fields");
1766        assert_eq!(
1767            cfg.config_slots[0].supplied_by,
1768            ConfigSlotSuppliedBy::Platform
1769        );
1770        assert_eq!(
1771            cfg.config_slots[1].supplied_by,
1772            ConfigSlotSuppliedBy::Runtime
1773        );
1774    }
1775
1776    /// Omitting it means `environment`, so every config written before the
1777    /// field existed keeps its meaning rather than failing to parse.
1778    #[test]
1779    fn a_config_slot_without_supplied_by_defaults_to_environment() {
1780        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
1781            .expect("the pre-existing fixture must still parse");
1782        for slot in &cfg.config_slots {
1783            assert_eq!(slot.supplied_by, ConfigSlotSuppliedBy::Environment);
1784        }
1785    }
1786
1787    /// An unrecognized value is a parse ERROR, not a silent default — strict
1788    /// parse discipline (D-13). A defaulted typo here would tell an operator to
1789    /// supply a value the platform actually injects.
1790    #[test]
1791    fn an_unknown_supplied_by_value_is_a_parse_error() {
1792        let toml = r#"
1793            [server]
1794            name = "tube"
1795            version = "0.1.0"
1796
1797            [[config_slots]]
1798            key = "backend.base_url"
1799            kind = "endpoint"
1800            name = "TFL_BASE_URL"
1801            tested_value = "x"
1802            supplied_by = "platfrom"
1803        "#;
1804        ServerConfig::from_toml_strict_validated(toml)
1805            .expect_err("a misspelled supplied_by must not silently default");
1806    }
1807
1808    /// Test 2: the field is ADDITIVE — a config with no `[[config_slots]]` block
1809    /// parses unchanged and yields an empty vec (`#[serde(default)]`).
1810    #[test]
1811    fn config_without_config_slots_parses_with_empty_vec() {
1812        let cfg = ServerConfig::from_toml_strict_validated(MINIMAL)
1813            .expect("a config omitting [[config_slots]] still parses");
1814        assert!(
1815            cfg.config_slots.is_empty(),
1816            "absent block yields an empty vec, not a default entry"
1817        );
1818    }
1819
1820    /// Test 3: `deny_unknown_fields` still bites at the TOP level — a typo'd
1821    /// `[[config_slotz]]` is a hard parse error, never a silently-ignored block.
1822    #[test]
1823    fn top_level_config_slots_typo_is_still_rejected() {
1824        let toml = r#"
1825            [server]
1826            name = "demo"
1827            version = "0.1.0"
1828
1829            [[config_slotz]]
1830            key = "backend.base_url"
1831            kind = "endpoint"
1832            name = "TFL_BASE_URL"
1833        "#;
1834        let err = ServerConfig::from_toml(toml)
1835            .expect_err("a typo'd top-level array-of-tables must be rejected");
1836        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1837    }
1838
1839    /// Test 4: the decl struct is itself `deny_unknown_fields` — a typo INSIDE
1840    /// the block (`nmae`) is rejected rather than silently dropped.
1841    #[test]
1842    fn config_slot_unknown_inner_key_is_rejected() {
1843        let toml = r#"
1844            [server]
1845            name = "demo"
1846            version = "0.1.0"
1847
1848            [[config_slots]]
1849            key = "backend.base_url"
1850            kind = "endpoint"
1851            nmae = "TFL_BASE_URL"
1852        "#;
1853        let err = ServerConfig::from_toml(toml)
1854            .expect_err("an unknown key inside [[config_slots]] must be rejected");
1855        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1856    }
1857
1858    /// Test 5: `tested_value` is OPTIONAL — an identity-bearing slot structurally
1859    /// carries no value, so omitting it parses to `None`.
1860    #[test]
1861    fn config_slot_tested_value_is_optional() {
1862        let toml = r#"
1863            [server]
1864            name = "demo"
1865            version = "0.1.0"
1866
1867            [[config_slots]]
1868            key = "backend.auth.query_params.app_key"
1869            kind = "secret"
1870            name = "TFL_APP_KEY"
1871        "#;
1872        let cfg = ServerConfig::from_toml_strict_validated(toml)
1873            .expect("an entry without tested_value parses");
1874        assert_eq!(cfg.config_slots.len(), 1);
1875        assert!(
1876            cfg.config_slots[0].tested_value.is_none(),
1877            "omitted tested_value parses to None"
1878        );
1879    }
1880
1881    /// Test 6 (Codex MEDIUM — the invalid-kind hole): `kind` is a CLOSED
1882    /// vocabulary. A typo such as `endpont` — or an empty string — is a PARSE
1883    /// error naming the accepted set, not a declaration that parses cleanly and
1884    /// then fails to map to any package slot type two crates away.
1885    #[test]
1886    fn config_slot_invalid_kind_is_rejected_naming_the_accepted_set() {
1887        for bad in ["endpont", ""] {
1888            let toml = format!(
1889                r#"
1890                [server]
1891                name = "demo"
1892                version = "0.1.0"
1893
1894                [[config_slots]]
1895                key = "backend.base_url"
1896                kind = "{bad}"
1897                name = "TFL_BASE_URL"
1898                "#
1899            );
1900            let err = ServerConfig::from_toml(&toml)
1901                .expect_err("an unrecognized config-slot kind must be rejected at parse time");
1902            let rendered = err.to_string();
1903            for accepted in ["endpoint", "secret", "auth_mode"] {
1904                assert!(
1905                    rendered.contains(accepted),
1906                    "the error for kind = \"{bad}\" must name the accepted kind \
1907                     `{accepted}`: {rendered}"
1908                );
1909            }
1910        }
1911    }
1912
1913    /// Test 7: all three valid kinds parse, and the parsed value is a CLOSED
1914    /// enum — comparable as `ConfigSlotKind`, not as a free string. A fourth
1915    /// kind is therefore a deliberate addition here, never a silent
1916    /// pass-through to the package side.
1917    #[test]
1918    fn config_slot_all_three_kinds_parse_as_a_closed_enum() {
1919        let cfg = ServerConfig::from_toml_strict_validated(CONFIG_SLOTS_TOML)
1920            .expect("all three kinds parse");
1921        let kinds: Vec<ConfigSlotKind> = cfg.config_slots.iter().map(|s| s.kind).collect();
1922        assert_eq!(
1923            kinds,
1924            vec![
1925                ConfigSlotKind::Endpoint,
1926                ConfigSlotKind::Secret,
1927                ConfigSlotKind::AuthMode
1928            ],
1929            "kind is a closed enum, not a free string"
1930        );
1931    }
1932
1933    /// `validate()` rejects an entry whose `key` or `name` is empty/whitespace,
1934    /// carrying the offending entry INDEX (the `EmptyTableName(i)` error shape).
1935    #[test]
1936    fn config_slot_empty_key_or_name_fails_validation() {
1937        for field in ["key", "name"] {
1938            let (key, name) = if field == "key" {
1939                ("   ", "TFL_BASE_URL")
1940            } else {
1941                ("backend.base_url", "  ")
1942            };
1943            let toml = format!(
1944                r#"
1945                [server]
1946                name = "demo"
1947                version = "0.1.0"
1948
1949                [[config_slots]]
1950                key = "{key}"
1951                kind = "endpoint"
1952                name = "{name}"
1953                "#
1954            );
1955            let cfg = ServerConfig::from_toml(&toml).expect("parses; emptiness is semantic");
1956            let err = cfg
1957                .validate()
1958                .expect_err("an empty config-slot key/name must fail validation");
1959            assert!(
1960                matches!(err, ConfigValidationError::EmptyConfigSlotField(0)),
1961                "empty {field} must yield EmptyConfigSlotField(0), got: {err:?}"
1962            );
1963        }
1964    }
1965
1966    /// `validate()` refuses a `secret` declaration carrying a `tested_value` —
1967    /// identity-bearing slots structurally record no value, and this field is
1968    /// the one place a REAL credential could sit in a config that is served
1969    /// but never packed (pack-time gates only run on packaging).
1970    #[test]
1971    fn config_slot_secret_with_tested_value_fails_validation_without_echoing_it() {
1972        let toml = r#"
1973            [server]
1974            name = "demo"
1975            version = "0.1.0"
1976
1977            [[config_slots]]
1978            key = "backend.auth.query_params.app_key"
1979            kind = "secret"
1980            name = "TFL_APP_KEY"
1981            tested_value = "sentinel-real-credential"
1982        "#;
1983        let cfg = ServerConfig::from_toml(toml).expect("parses; the rule is semantic");
1984        let err = cfg
1985            .validate()
1986            .expect_err("a secret slot carrying a tested_value must fail validation");
1987        assert!(
1988            matches!(err, ConfigValidationError::SecretSlotCarriesTestedValue(0)),
1989            "got: {err:?}"
1990        );
1991        assert!(
1992            !err.to_string().contains("sentinel-real-credential"),
1993            "the error must not echo the value: {err}"
1994        );
1995    }
1996
1997    proptest! {
1998        /// TEST-02: any valid `ServerConfig` round-trips through TOML.
1999        ///
2000        /// Builds a `ServerConfig` from an arbitrary (but valid) `(name, version)`
2001        /// pair, serializes it, parses it back, and asserts equality on the
2002        /// load-bearing scalars.
2003        #[test]
2004        fn server_config_minimal_round_trips(
2005            name in "[a-zA-Z0-9_-]{1,32}",
2006            version in "[0-9]+\\.[0-9]+\\.[0-9]+",
2007        ) {
2008            let cfg = ServerConfig {
2009                server: ServerSection {
2010                    name: name.clone(),
2011                    version: version.clone(),
2012                    ..Default::default()
2013                },
2014                ..Default::default()
2015            };
2016            let s = toml::to_string(&cfg).unwrap();
2017            let parsed = ServerConfig::from_toml(&s).unwrap();
2018            prop_assert_eq!(parsed.server.name, name);
2019            prop_assert_eq!(parsed.server.version, version);
2020        }
2021    }
2022}