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    /// `[[prompts]]` — declarative prompt surface.
136    #[serde(default)]
137    pub prompts: Vec<PromptDecl>,
138
139    /// `[[resources]]` — declarative resource surface.
140    #[serde(default)]
141    pub resources: Vec<ResourceDecl>,
142
143    /// `[shared_policy_store]` (optional) — AVP/Cedar shared-policy-store
144    /// declaration emitted by the reference SQL server (`is_reference = true`),
145    /// which provisions the policy store all sibling SQL servers attach to.
146    /// Additive per the REF-01 superset invariant (Plan 85-01); parsed
147    /// verbatim — the toolkit does not provision SSM at parse time.
148    #[serde(default)]
149    pub shared_policy_store: Option<SharedPolicyStoreSection>,
150}
151
152impl ServerConfig {
153    /// Parse `ServerConfig` from a TOML config string.
154    ///
155    /// Performs **strict parsing** (`#[serde(deny_unknown_fields)]` on every
156    /// section, per D-13). Does **not** run semantic validation — callers
157    /// wanting required-field guarantees should use
158    /// [`Self::from_toml_strict_validated`] instead.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`ToolkitError::Parse`] on syntax error or unknown field. A
163    /// mis-spelled key (e.g. `auto_aprove_levels` for `auto_approve_levels`)
164    /// produces a parse error here, not a silent default.
165    ///
166    /// # Example
167    ///
168    /// ```
169    /// use pmcp_server_toolkit::config::ServerConfig;
170    ///
171    /// let toml = r#"
172    ///     [server]
173    ///     id = "demo"
174    ///     name = "Demo"
175    ///     version = "0.1.0"
176    /// "#;
177    /// let cfg = ServerConfig::from_toml(toml).expect("parse");
178    /// assert_eq!(cfg.server.name, "Demo");
179    /// ```
180    pub fn from_toml(toml_str: &str) -> Result<Self> {
181        toml::from_str(toml_str).map_err(ToolkitError::Parse)
182    }
183
184    /// Parse + validate. Per Phase 83 review R8 — guards against the
185    /// missing-required-value trap that the `Default` impls on sub-sections
186    /// would otherwise hide behind silent empty strings (e.g. a typo'd
187    /// `[serever]` header makes `server.name` default to `""`).
188    ///
189    /// # Errors
190    ///
191    /// Returns [`ToolkitError::Parse`] on TOML syntax / unknown-field errors,
192    /// or [`ToolkitError::Validation`] (wrapping
193    /// [`ConfigValidationError`]) on missing required values
194    /// (empty `server.name`, empty `server.version`, empty tool name, empty
195    /// table name).
196    ///
197    /// # Example
198    ///
199    /// ```
200    /// use pmcp_server_toolkit::config::ServerConfig;
201    /// let toml = r#"
202    ///     [server]
203    ///     name = "demo"
204    ///     version = "0.1.0"
205    /// "#;
206    /// let cfg = ServerConfig::from_toml_strict_validated(toml).expect("valid");
207    /// # let _ = cfg;
208    /// ```
209    pub fn from_toml_strict_validated(toml_str: &str) -> Result<Self> {
210        let cfg = Self::from_toml(toml_str)?;
211        cfg.validate()?;
212        Ok(cfg)
213    }
214
215    /// Validate required-field semantics that `#[serde(default)]` would
216    /// otherwise mask. Per Phase 83 review R8.
217    ///
218    /// Rules checked, in order:
219    /// 1. `server.name` is non-empty (trimmed).
220    /// 2. `server.version` is non-empty (trimmed).
221    /// 3. Every `[[tools]]` entry has a non-empty `name`.
222    /// 4. No `[[tools]]` entry mixes tool kinds (`sql` / `path`+`method` /
223    ///    `script`) — D-01 / T-90-02-04.
224    /// 5. Every `[[database.tables]]` entry has a non-empty `name`.
225    /// 6. When a `[backend]` block is present (`http` feature), its `base_url`
226    ///    is non-empty (trimmed) — GAP 3 / WR-02. Absent on no-http builds.
227    ///
228    /// # Errors
229    ///
230    /// Returns a [`ConfigValidationError`] variant identifying the
231    /// first rule violated. Iteration order matches struct field order.
232    pub fn validate(&self) -> std::result::Result<(), ConfigValidationError> {
233        if self.server.name.trim().is_empty() {
234            return Err(ConfigValidationError::EmptyServerName);
235        }
236        if self.server.version.trim().is_empty() {
237            return Err(ConfigValidationError::EmptyServerVersion);
238        }
239        for (i, tool) in self.tools.iter().enumerate() {
240            if tool.name.trim().is_empty() {
241                return Err(ConfigValidationError::EmptyToolName(i));
242            }
243            // D-01 / T-90-02-04: a tool is EITHER sql, single-call (path/method),
244            // OR script — never a mixture. Reject ambiguity instead of letting a
245            // silent "script wins" precedence hide a config mistake.
246            if tool.declared_kind_count() > 1 {
247                return Err(ConfigValidationError::AmbiguousToolKind(i));
248            }
249        }
250        for (i, table) in self.database.tables.iter().enumerate() {
251            if table.name.trim().is_empty() {
252                return Err(ConfigValidationError::EmptyTableName(i));
253            }
254        }
255        // Phase 90 gap-closure (GAP 3 / WR-02): when a `[backend]` block is
256        // declared, its `base_url` must be non-empty. Catch a typo'd / omitted
257        // URL here (the field is `#[serde(default)]` -> `""`) rather than
258        // letting it surface late as an opaque DispatchError at request time.
259        // Gated on `http` because the `backend` field itself is http-only; the
260        // block simply vanishes in a no-http build (SQL configs unaffected).
261        #[cfg(feature = "http")]
262        if let Some(backend) = &self.backend {
263            if backend.base_url.trim().is_empty() {
264                return Err(ConfigValidationError::EmptyBackendBaseUrl);
265            }
266        }
267        Ok(())
268    }
269}
270
271// -----------------------------------------------------------------------------
272// [server]
273// -----------------------------------------------------------------------------
274
275/// `[server]` section — identity and version metadata.
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
277#[serde(deny_unknown_fields)]
278pub struct ServerSection {
279    /// Stable server identifier (e.g. `"open-images"`). Optional in the TOML;
280    /// callers that need it should fall back to deriving from `name`.
281    #[serde(default)]
282    pub id: Option<String>,
283    /// Human-readable server name (required for production via [`ServerConfig::validate`]).
284    #[serde(default)]
285    pub name: String,
286    /// Short server description.
287    #[serde(default)]
288    pub description: Option<String>,
289    /// Server flavour (e.g. `"sql-api"`). Free-form for now; future plans may tighten.
290    #[serde(default, rename = "type")]
291    pub server_type: Option<String>,
292    /// Semver version string (required for production via [`ServerConfig::validate`]).
293    #[serde(default)]
294    pub version: String,
295    /// Whether this server is the **reference** server that provisions shared
296    /// infrastructure (the `[shared_policy_store]` for all sibling SQL servers).
297    /// Additive per the REF-01 superset invariant (Plan 85-01); the SQLite
298    /// Chinook reference config sets `is_reference = true`.
299    #[serde(default)]
300    pub is_reference: bool,
301}
302
303// -----------------------------------------------------------------------------
304// [metadata]
305// -----------------------------------------------------------------------------
306
307/// `[metadata]` section — admin-facing display defaults (visible in the
308/// pmcp.run UI before an operator customises them).
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
310#[serde(deny_unknown_fields)]
311pub struct MetadataSection {
312    /// Long-form display name shown in the UI.
313    #[serde(default)]
314    pub display_name: Option<String>,
315    /// One-line summary for list views.
316    #[serde(default)]
317    pub short_description: Option<String>,
318    /// Multi-line description for detail pages.
319    #[serde(default)]
320    pub description: Option<String>,
321    /// Tag list for filtering / discovery.
322    #[serde(default)]
323    pub tags: Vec<String>,
324    /// Server author (organisation or individual).
325    #[serde(default)]
326    pub author: Option<String>,
327    /// Visibility flag (e.g. `"public"`, `"private"`).
328    #[serde(default)]
329    pub visibility: Option<String>,
330}
331
332// -----------------------------------------------------------------------------
333// [database]
334// -----------------------------------------------------------------------------
335
336/// `[database]` section — backend identification and table catalogue.
337///
338/// Includes Athena-specific keys (`output_location`, `workgroup`) as optional
339/// fields per the REF-01 superset invariant — non-Athena backends omit them.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
341#[serde(deny_unknown_fields)]
342pub struct DatabaseSection {
343    /// Backend type (`"athena"`, `"postgres"`, `"mysql"`, `"sqlite"`, …).
344    #[serde(default, rename = "type")]
345    pub backend_type: Option<String>,
346    /// Database / schema name.
347    #[serde(default)]
348    pub database: Option<String>,
349    /// Athena S3 output location for query results.
350    #[serde(default)]
351    pub output_location: Option<String>,
352    /// Athena workgroup name.
353    #[serde(default)]
354    pub workgroup: Option<String>,
355    /// Per-query timeout in milliseconds.
356    #[serde(default)]
357    pub query_timeout_ms: Option<u64>,
358    /// `[[database.tables]]` — declared table catalogue for schema enrichment.
359    #[serde(default)]
360    pub tables: Vec<DatabaseTableDecl>,
361    /// Connection URL for Postgres / MySQL backends. Supports `env:VAR_NAME`
362    /// indirection at the consumer-resolution layer (the toolkit parses the
363    /// string as-is and leaves resolution to the per-backend connector or
364    /// the secret-resolution machinery from P83 R6/R9). Optional/unused for
365    /// Athena (uses `region` + `workgroup` + `output_location`) and SQLite
366    /// (uses `database` for the file path or `:memory:` literal).
367    #[serde(default)]
368    pub url: Option<String>,
369    /// Filesystem path to a SQLite database file (e.g.
370    /// `"/var/task/assets/chinook.db"` for a Lambda-bundled asset). Additive per
371    /// the REF-01 superset invariant (Plan 85-01). Distinct from `database`
372    /// (which carries the `:memory:` literal or a schema name) and `url` (used
373    /// by Postgres / MySQL). Stored verbatim; the SQLite connector resolves it.
374    #[serde(default)]
375    pub file_path: Option<String>,
376    /// `[database.pool]` — connection-pool tuning (optional).
377    #[serde(default)]
378    pub pool: Option<DatabasePoolSection>,
379}
380
381/// Single `[[database.tables]]` entry.
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
383#[serde(deny_unknown_fields)]
384pub struct DatabaseTableDecl {
385    /// Table or view name (required for production via [`ServerConfig::validate`]).
386    #[serde(default)]
387    pub name: String,
388    /// Human-readable table description for schema enrichment.
389    #[serde(default)]
390    pub description: Option<String>,
391}
392
393/// `[database.pool]` connection-pool tuning.
394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
395#[serde(deny_unknown_fields)]
396pub struct DatabasePoolSection {
397    /// Maximum concurrent connections.
398    #[serde(default)]
399    pub max_connections: Option<u32>,
400    /// Connection-acquisition timeout, in seconds.
401    #[serde(default)]
402    pub connection_timeout_seconds: Option<u64>,
403}
404
405// -----------------------------------------------------------------------------
406// [backend] (http feature)
407// -----------------------------------------------------------------------------
408
409/// Re-export of the outgoing-HTTP authentication config (owned by
410/// [`crate::http::auth`], Plan 90-01). Callers may also reach it via the
411/// `crate::http` module path; this re-export keeps `[backend.auth]` named
412/// alongside the `ServerConfig` types it deserializes into.
413#[cfg(feature = "http")]
414pub use crate::http::auth::AuthConfig;
415
416/// Re-export of the HTTP client tuning config (owned by [`crate::http::client`],
417/// Plan 90-01) used by `[backend.http]`.
418#[cfg(feature = "http")]
419pub use crate::http::client::HttpConfig;
420
421/// `[backend]` section — the OpenAPI/REST HTTP backend declaration (D-06).
422///
423/// This is the HTTP analog of [`DatabaseSection`]: it identifies the upstream
424/// REST API the synthesized tools call. `base_url` is the API root; the optional
425/// `[backend.auth]` sub-table selects an [`AuthConfig`] variant (`type = "..."`)
426/// and `[backend.http]` carries [`HttpConfig`] tuning (timeout / retries / …).
427///
428/// Gated behind the `http` feature — the whole section (and the
429/// [`ServerConfig::backend`] field) is absent in a no-http build so there is no
430/// dead stub type. `AuthConfig` and `HttpConfig` are DEFINED in
431/// [`crate::http`] (Plan 90-01) and re-exported here, not redefined (H3).
432///
433/// Strict-parse discipline (D-13) is preserved: `#[serde(deny_unknown_fields)]`
434/// rejects a typo'd key under `[backend]` or `[backend.http]`.
435///
436/// Secrets posture (T-90-02-02): inline token fields under `[backend.auth]`
437/// hold operator references (`${ENV}` / `env:VAR`) resolved upstream by the
438/// Phase 83 secrets machinery — config parsing stores the string verbatim and
439/// never the resolved value.
440#[cfg(feature = "http")]
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
442#[serde(deny_unknown_fields)]
443pub struct BackendSection {
444    /// REST API root URL (e.g. `"https://api.tfl.gov.uk"`). Single-call tools
445    /// concatenate their `path` onto this (an empty per-tool `base_url`
446    /// inherits this value).
447    #[serde(default)]
448    pub base_url: String,
449    /// `[backend.auth]` — outgoing authentication ([`AuthConfig`], six modes).
450    /// Defaults to [`AuthConfig::None`] when the sub-table is omitted.
451    #[serde(default)]
452    pub auth: AuthConfig,
453    /// `[backend.http]` — client tuning ([`HttpConfig`]: timeout / retries /
454    /// backoff / user-agent / default headers). Defaults to [`HttpConfig`]'s
455    /// defaults when the sub-table is omitted.
456    #[serde(default)]
457    pub http: HttpConfig,
458}
459
460// -----------------------------------------------------------------------------
461// [code_mode]
462// -----------------------------------------------------------------------------
463
464/// `[code_mode]` section — code-mode policy + complexity limits.
465///
466/// The toolkit uses **unprefixed** field names (REF-01 invariant); the mapping
467/// to `pmcp_code_mode::CodeModeConfig`'s prefixed names (`sql_allow_writes`,
468/// etc.) is handled by Plan 06's executor wiring.
469#[allow(clippy::struct_excessive_bools)]
470// 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.
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
472#[serde(deny_unknown_fields)]
473pub struct CodeModeSection {
474    /// Master enable flag for code-mode.
475    #[serde(default)]
476    pub enabled: bool,
477    /// Server identifier used by AVP / Cedar policy resolution.
478    #[serde(default)]
479    pub server_id: Option<String>,
480    /// Whether INSERT / UPDATE / MERGE statements are allowed.
481    #[serde(default)]
482    pub allow_writes: bool,
483    /// Whether DELETE statements are allowed.
484    #[serde(default)]
485    pub allow_deletes: bool,
486    /// Whether DDL (CREATE / ALTER / DROP) is allowed.
487    #[serde(default)]
488    pub allow_ddl: bool,
489    /// Whether `SELECT` queries must declare a `LIMIT`.
490    #[serde(default)]
491    pub require_limit: bool,
492    /// Maximum allowed `LIMIT` value.
493    #[serde(default)]
494    pub max_limit: Option<u64>,
495    /// Table names blocked from any query (denylist).
496    #[serde(default)]
497    pub blocked_tables: Vec<String>,
498    /// `table.column` strings stripped from query output.
499    #[serde(default)]
500    pub sensitive_columns: Vec<String>,
501    /// Risk levels eligible for auto-approval (e.g. `["low"]`).
502    #[serde(default)]
503    pub auto_approve_levels: Vec<String>,
504    /// Token TTL, in seconds, for HMAC-signed approval tokens.
505    #[serde(default)]
506    pub token_ttl_seconds: Option<u64>,
507    /// Secret reference (e.g. `"${CODE_MODE_SECRET}"`) for HMAC signing — resolved
508    /// at runtime by `SecretsProvider`. NEVER a raw secret value (review R6 +
509    /// T-83-04-04 in the plan threat model).
510    #[serde(default)]
511    pub token_secret: Option<String>,
512    /// Per Phase 83 review R9: inline `token_secret = "raw-string"` is REJECTED
513    /// by default to prevent secrets from being committed to source-controlled
514    /// configs. Set this flag to `true` ONLY in dev/test configs where the
515    /// operator explicitly accepts the risk. NEVER set this in a committed
516    /// production config — production must use the `env:VAR_NAME` syntax that
517    /// resolves at runtime through `SecretsProvider`.
518    #[serde(default)]
519    pub allow_inline_token_secret_for_dev: bool,
520    /// `[code_mode.limits]` — query-complexity caps.
521    #[serde(default)]
522    pub limits: Option<CodeModeLimits>,
523}
524
525/// `[code_mode.limits]` — query-complexity caps.
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
527#[serde(deny_unknown_fields)]
528pub struct CodeModeLimits {
529    /// Maximum number of distinct tables referenced in a single query.
530    #[serde(default)]
531    pub max_tables_per_query: Option<u32>,
532    /// Maximum JOIN nesting depth.
533    #[serde(default)]
534    pub max_join_depth: Option<u32>,
535    /// Maximum subquery nesting depth.
536    #[serde(default)]
537    pub max_subquery_depth: Option<u32>,
538}
539
540// -----------------------------------------------------------------------------
541// [shared_policy_store]
542// -----------------------------------------------------------------------------
543
544/// `[shared_policy_store]` section — AVP/Cedar shared-policy-store declaration.
545///
546/// Emitted only by the **reference** SQL server (`[server] is_reference = true`),
547/// which provisions a single shared policy store + a set of Cedar templates that
548/// all sibling SQL servers attach to (rather than each minting its own store).
549///
550/// Additive per the REF-01 superset invariant (Plan 85-01). The toolkit parses
551/// this verbatim — SSM export and store provisioning are deployment-time
552/// concerns handled outside config parsing (D-02 parse-only + lazy startup).
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
554#[serde(deny_unknown_fields)]
555pub struct SharedPolicyStoreSection {
556    /// Whether this server creates the shared policy store for all SQL servers.
557    #[serde(default)]
558    pub creates_shared_store: bool,
559    /// Whether the created store's identifier is exported to SSM Parameter Store.
560    #[serde(default)]
561    pub export_to_ssm: bool,
562    /// SSM Parameter Store path the store identifier is exported to (when
563    /// `export_to_ssm = true`).
564    #[serde(default)]
565    pub ssm_path: Option<String>,
566    /// Cedar policy-template names included in the shared store (e.g.
567    /// `"PermitAllSelects"`, `"ForbidAllDeletes"`).
568    #[serde(default)]
569    pub templates: Vec<String>,
570}
571
572// -----------------------------------------------------------------------------
573// [[tools]]
574// -----------------------------------------------------------------------------
575
576/// Single `[[tools]]` entry — a declaratively-defined tool surface.
577#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
578#[serde(deny_unknown_fields)]
579pub struct ToolDecl {
580    /// Tool name (required for production via [`ServerConfig::validate`]).
581    #[serde(default)]
582    pub name: String,
583    /// Human-readable tool description.
584    #[serde(default)]
585    pub description: Option<String>,
586    /// SQL template (uses `:param` placeholders bound by [`ParamDecl`]).
587    #[serde(default)]
588    pub sql: Option<String>,
589    /// HTTP request path for a **single-call** OpenAPI/REST tool (D-01), e.g.
590    /// `"/Line/Mode/tube/Status"`. Concatenated onto the backend `base_url`
591    /// (or this tool's [`Self::base_url`] override). Additive per REF-01 — `None`
592    /// for SQL / script tools.
593    #[serde(default)]
594    pub path: Option<String>,
595    /// HTTP method for a single-call tool (`"GET"`, `"POST"`, …). Pairs with
596    /// [`Self::path`] (D-01). Additive; `None` for SQL / script tools.
597    #[serde(default)]
598    pub method: Option<String>,
599    /// Per-tool backend base-URL override. When absent a single-call tool
600    /// inherits `[backend].base_url`. Additive; `None` for SQL / script tools.
601    #[serde(default)]
602    pub base_url: Option<String>,
603    /// JavaScript body for a **script** tool (D-01) — a code-mode snippet that
604    /// orchestrates multiple backend calls and binds `[[tools.parameters]]` to
605    /// `args`. When set, this entry is a script tool ([`Self::is_script_tool`]).
606    /// Additive; `None` for SQL / single-call tools.
607    #[serde(default)]
608    pub script: Option<String>,
609    /// Optional UI-resource URI for `structuredContent` widgets.
610    #[serde(default)]
611    pub ui_resource_uri: Option<String>,
612    /// `[[tools.parameters]]` — declared input parameters.
613    #[serde(default)]
614    pub parameters: Vec<ParamDecl>,
615    /// `[tools.annotations]` — MCP `toolAnnotations`.
616    #[serde(default)]
617    pub annotations: Option<AnnotationsDecl>,
618}
619
620impl ToolDecl {
621    /// Whether this `[[tools]]` entry is a **script** tool (D-01 detection rule).
622    ///
623    /// The detection rule is: `script.is_some()` ⇒ script tool; otherwise a
624    /// `path` + `method` pair ⇒ single-call HTTP tool; otherwise (a `sql`
625    /// field) ⇒ SQL tool. Plan 03/05 synthesizers branch on this method so the
626    /// rule lives in exactly one place. Mutual-exclusivity is enforced at
627    /// [`ServerConfig::validate`] (an entry mixing kinds is rejected, not
628    /// silently resolved by precedence).
629    ///
630    /// # Examples
631    ///
632    /// ```
633    /// use pmcp_server_toolkit::config::ToolDecl;
634    ///
635    /// let script = ToolDecl { script: Some("await api.get('/x')".into()), ..Default::default() };
636    /// assert!(script.is_script_tool());
637    ///
638    /// let single = ToolDecl {
639    ///     path: Some("/Line/Mode/tube/Status".into()),
640    ///     method: Some("GET".into()),
641    ///     ..Default::default()
642    /// };
643    /// assert!(!single.is_script_tool());
644    /// ```
645    #[must_use]
646    pub fn is_script_tool(&self) -> bool {
647        self.script.is_some()
648    }
649
650    /// Number of distinct mutually-exclusive tool kinds declared on this entry.
651    ///
652    /// Used by [`ServerConfig::validate`] to reject an ambiguous `[[tools]]`
653    /// entry (D-01 / T-90-02-04). A well-formed entry declares exactly one kind
654    /// (count `1`); count `> 1` is ambiguous; count `0` is a kind-less stub
655    /// (left to other validation rules).
656    fn declared_kind_count(&self) -> usize {
657        let is_sql = self.sql.is_some();
658        let is_single_call = self.path.is_some() || self.method.is_some();
659        let is_script = self.script.is_some();
660        usize::from(is_sql) + usize::from(is_single_call) + usize::from(is_script)
661    }
662}
663
664/// Single `[[tools.parameters]]` entry.
665///
666/// The `default` and `enum` fields use [`toml::Value`] because they are
667/// heterogeneous in the reference configs (a `default` may be an integer,
668/// a string, or a boolean depending on the parameter type).
669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
670#[serde(deny_unknown_fields)]
671pub struct ParamDecl {
672    /// Parameter name (the `:param` token used in the tool's `sql`).
673    #[serde(default)]
674    pub name: String,
675    /// JSON-schema type (`"string"`, `"integer"`, `"number"`, `"boolean"`).
676    #[serde(default, rename = "type")]
677    pub param_type: Option<String>,
678    /// Human-readable parameter description.
679    #[serde(default)]
680    pub description: Option<String>,
681    /// Whether the parameter is required.
682    #[serde(default)]
683    pub required: bool,
684    /// Optional default value (any TOML type).
685    #[serde(default)]
686    pub default: Option<toml::Value>,
687    /// Maximum string length (string parameters only).
688    #[serde(default)]
689    pub max_length: Option<u64>,
690    /// Inclusive minimum (integer / number parameters only).
691    #[serde(default)]
692    pub minimum: Option<f64>,
693    /// Inclusive maximum (integer / number parameters only).
694    #[serde(default)]
695    pub maximum: Option<f64>,
696    /// Closed set of allowed values (any TOML scalar).
697    #[serde(default, rename = "enum")]
698    pub enum_values: Option<Vec<toml::Value>>,
699}
700
701/// `[tools.annotations]` — MCP `toolAnnotations` hints.
702#[allow(clippy::struct_excessive_bools)] // Why: REF-01 superset — mirrors the MCP `toolAnnotations` flag set 1:1.
703#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
704#[serde(deny_unknown_fields)]
705pub struct AnnotationsDecl {
706    /// Whether the tool only reads (never mutates) state.
707    #[serde(default)]
708    pub read_only_hint: bool,
709    /// Whether the tool may destroy data.
710    #[serde(default)]
711    pub destructive_hint: bool,
712    /// Whether repeated calls with the same args produce the same result.
713    #[serde(default)]
714    pub idempotent_hint: bool,
715    /// Whether the tool interacts with an open-world (external) service.
716    #[serde(default)]
717    pub open_world_hint: bool,
718    /// Cost hint (`"low"`, `"medium"`, `"high"`).
719    #[serde(default)]
720    pub cost_hint: Option<String>,
721}
722
723// -----------------------------------------------------------------------------
724// [[prompts]]
725// -----------------------------------------------------------------------------
726
727/// Single `[[prompts]]` entry.
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
729#[serde(deny_unknown_fields)]
730pub struct PromptDecl {
731    /// Prompt name (the identifier MCP clients call by).
732    #[serde(default)]
733    pub name: String,
734    /// Human-readable prompt description.
735    #[serde(default)]
736    pub description: Option<String>,
737    /// Resource URIs to include in the prompt's assembled body.
738    #[serde(default)]
739    pub include_resources: Vec<String>,
740    /// Declared prompt arguments (MCP `PromptArgument`).
741    #[serde(default)]
742    pub arguments: Vec<PromptArgumentDecl>,
743}
744
745/// Single argument under `[[prompts.arguments]]`.
746#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
747#[serde(deny_unknown_fields)]
748pub struct PromptArgumentDecl {
749    /// Argument name.
750    #[serde(default)]
751    pub name: String,
752    /// Human-readable description.
753    #[serde(default)]
754    pub description: Option<String>,
755    /// Whether the argument is required.
756    #[serde(default)]
757    pub required: bool,
758}
759
760// -----------------------------------------------------------------------------
761// [[resources]]
762// -----------------------------------------------------------------------------
763
764/// Single `[[resources]]` entry — a statically-shipped resource.
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
766#[serde(deny_unknown_fields)]
767pub struct ResourceDecl {
768    /// Resource URI (e.g. `"docs://open-images/schema"`).
769    #[serde(default)]
770    pub uri: String,
771    /// Human-readable resource name.
772    #[serde(default)]
773    pub name: Option<String>,
774    /// Resource description.
775    #[serde(default)]
776    pub description: Option<String>,
777    /// MIME type (e.g. `"text/markdown"`).
778    #[serde(default)]
779    pub mime_type: Option<String>,
780    /// Inline resource content (or `"loaded from path.md"` placeholder string —
781    /// the toolkit treats the value verbatim; resolution to filesystem reads
782    /// is the caller's responsibility).
783    #[serde(default)]
784    pub content: Option<String>,
785}
786
787// -----------------------------------------------------------------------------
788// Tests
789// -----------------------------------------------------------------------------
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use proptest::prelude::*;
795
796    const MINIMAL: &str = r#"
797        [server]
798        name = "demo"
799        version = "0.1.0"
800    "#;
801
802    #[test]
803    fn parse_minimal_config_succeeds() {
804        let cfg = ServerConfig::from_toml(MINIMAL).expect("minimal must parse");
805        assert_eq!(cfg.server.name, "demo");
806        assert_eq!(cfg.server.version, "0.1.0");
807        assert!(cfg.tools.is_empty());
808        assert!(cfg.code_mode.is_none());
809    }
810
811    #[test]
812    fn parse_unknown_field_fails() {
813        let toml = r#"
814            [server]
815            name = "demo"
816            version = "0.1.0"
817            unknown_field = "x"
818        "#;
819        let err = ServerConfig::from_toml(toml).expect_err("unknown field must fail");
820        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
821    }
822
823    #[test]
824    fn parse_typo_in_code_mode_key_fails() {
825        // T-83-04-02: defence-in-depth against silent policy widening.
826        let toml = r#"
827            [server]
828            name = "demo"
829            version = "0.1.0"
830            [code_mode]
831            enabled = true
832            auto_aprove_levels = ["low"]
833        "#;
834        let err = ServerConfig::from_toml(toml).expect_err("typo'd code_mode key must be rejected");
835        assert!(matches!(err, ToolkitError::Parse(_)));
836    }
837
838    #[test]
839    fn code_mode_section_optional() {
840        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
841        assert!(cfg.code_mode.is_none());
842    }
843
844    #[test]
845    fn validate_accepts_valid_config() {
846        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
847        cfg.validate().expect("minimal config must validate");
848    }
849
850    #[test]
851    fn validate_rejects_empty_server_name() {
852        let toml = r#"
853            [server]
854            name = ""
855            version = "0.1.0"
856        "#;
857        let cfg = ServerConfig::from_toml(toml).expect("parse");
858        match cfg.validate() {
859            Err(ConfigValidationError::EmptyServerName) => {},
860            other => panic!("expected EmptyServerName, got {other:?}"),
861        }
862    }
863
864    #[test]
865    fn validate_rejects_empty_server_version() {
866        let toml = r#"
867            [server]
868            name = "demo"
869            version = ""
870        "#;
871        let cfg = ServerConfig::from_toml(toml).expect("parse");
872        match cfg.validate() {
873            Err(ConfigValidationError::EmptyServerVersion) => {},
874            other => panic!("expected EmptyServerVersion, got {other:?}"),
875        }
876    }
877
878    #[test]
879    fn validate_rejects_empty_tool_name() {
880        let toml = r#"
881            [server]
882            name = "demo"
883            version = "0.1.0"
884
885            [[tools]]
886            name = "ok"
887            description = "first"
888
889            [[tools]]
890            name = ""
891            description = "second-is-empty"
892        "#;
893        let cfg = ServerConfig::from_toml(toml).expect("parse");
894        match cfg.validate() {
895            Err(ConfigValidationError::EmptyToolName(1)) => {},
896            other => panic!("expected EmptyToolName(1), got {other:?}"),
897        }
898    }
899
900    #[test]
901    fn validate_rejects_empty_table_name() {
902        let toml = r#"
903            [server]
904            name = "demo"
905            version = "0.1.0"
906
907            [[database.tables]]
908            name = ""
909            description = "missing-name"
910        "#;
911        let cfg = ServerConfig::from_toml(toml).expect("parse");
912        match cfg.validate() {
913            Err(ConfigValidationError::EmptyTableName(0)) => {},
914            other => panic!("expected EmptyTableName(0), got {other:?}"),
915        }
916    }
917
918    /// Phase 90 gap-closure (GAP 3 / WR-02): a `[backend]` block with an
919    /// empty / missing `base_url` is rejected at validate() time with
920    /// [`ConfigValidationError::EmptyBackendBaseUrl`] — not a late opaque
921    /// `DispatchError::Connector("invalid base URL")` at request time.
922    #[cfg(feature = "http")]
923    #[test]
924    fn validate_rejects_empty_backend_base_url() {
925        // base_url key present but empty.
926        let toml = r#"
927            [server]
928            name = "demo"
929            version = "0.1.0"
930
931            [backend]
932            base_url = ""
933        "#;
934        let cfg = ServerConfig::from_toml(toml).expect("parse");
935        match cfg.validate() {
936            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
937            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
938        }
939    }
940
941    /// A `[backend]` block whose `base_url` key is omitted entirely (defaults
942    /// to `""` via `#[serde(default)]`) is rejected the same way.
943    #[cfg(feature = "http")]
944    #[test]
945    fn validate_rejects_omitted_backend_base_url() {
946        let toml = r#"
947            [server]
948            name = "demo"
949            version = "0.1.0"
950
951            [backend]
952        "#;
953        let cfg = ServerConfig::from_toml(toml).expect("parse");
954        match cfg.validate() {
955            Err(ConfigValidationError::EmptyBackendBaseUrl) => {},
956            other => panic!("expected EmptyBackendBaseUrl, got {other:?}"),
957        }
958    }
959
960    /// A `[backend]` block with a non-empty `base_url` validates OK.
961    #[cfg(feature = "http")]
962    #[test]
963    fn validate_accepts_non_empty_backend_base_url() {
964        let toml = r#"
965            [server]
966            name = "demo"
967            version = "0.1.0"
968
969            [backend]
970            base_url = "https://api.example.com"
971        "#;
972        let cfg = ServerConfig::from_toml(toml).expect("parse");
973        cfg.validate()
974            .expect("config with a non-empty backend.base_url must validate");
975    }
976
977    /// A config with NO `[backend]` block (a pure-SQL config) is unaffected by
978    /// the new check — `backend` is `None`, so the check never fires.
979    #[cfg(feature = "http")]
980    #[test]
981    fn validate_accepts_absent_backend() {
982        let cfg = ServerConfig::from_toml(MINIMAL).expect("parse");
983        assert!(cfg.backend.is_none());
984        cfg.validate()
985            .expect("a config without [backend] must validate (SQL configs unaffected)");
986    }
987
988    /// The error Display names the offending field and is actionable.
989    #[cfg(feature = "http")]
990    #[test]
991    fn empty_backend_base_url_error_names_the_field() {
992        let msg = ConfigValidationError::EmptyBackendBaseUrl.to_string();
993        assert!(
994            msg.contains("[backend].base_url"),
995            "error must name the field, got: {msg}"
996        );
997    }
998
999    #[test]
1000    fn database_url_optional_field_parses() {
1001        // Phase 84 CONN-04 / D-08: the additive `[database].url` field parses
1002        // under `#[serde(deny_unknown_fields)]` and carries the `env:VAR_NAME`
1003        // indirection string verbatim (resolution happens at the consumer layer).
1004        let toml = r#"
1005            [server]
1006            name = "x"
1007            version = "0.0.1"
1008
1009            [database]
1010            url = "env:DATABASE_URL"
1011        "#;
1012        let cfg = ServerConfig::from_toml(toml).expect("config with [database].url must parse");
1013        assert_eq!(cfg.database.url, Some("env:DATABASE_URL".to_string()));
1014    }
1015
1016    #[test]
1017    fn from_toml_strict_validated_rolls_both_errors() {
1018        // 1. Parse error path (unknown field).
1019        let bad_toml = r#"
1020            [server]
1021            name = "demo"
1022            version = "0.1.0"
1023            nonsense = "x"
1024        "#;
1025        let err = ServerConfig::from_toml_strict_validated(bad_toml)
1026            .expect_err("unknown field must surface");
1027        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1028
1029        // 2. Validation error path (empty required value).
1030        let invalid_toml = r#"
1031            [server]
1032            name = ""
1033            version = "0.1.0"
1034        "#;
1035        let err = ServerConfig::from_toml_strict_validated(invalid_toml)
1036            .expect_err("empty name must surface");
1037        assert!(
1038            matches!(
1039                err,
1040                ToolkitError::Validation(ConfigValidationError::EmptyServerName)
1041            ),
1042            "got: {err:?}"
1043        );
1044    }
1045
1046    // -------------------------------------------------------------------------
1047    // ToolDecl two-kind detection — D-01 (shared, not http-gated)
1048    // -------------------------------------------------------------------------
1049
1050    #[test]
1051    fn test_tooldecl_single_call_parses() {
1052        let toml = r#"
1053            [server]
1054            name = "tube"
1055            version = "0.1.0"
1056
1057            [[tools]]
1058            name = "tube_status"
1059            path = "/Line/Mode/tube/Status"
1060            method = "GET"
1061        "#;
1062        let cfg = ServerConfig::from_toml(toml).expect("single-call tool must parse");
1063        let tool = &cfg.tools[0];
1064        assert_eq!(tool.path.as_deref(), Some("/Line/Mode/tube/Status"));
1065        assert_eq!(tool.method.as_deref(), Some("GET"));
1066        assert!(!tool.is_script_tool());
1067        cfg.validate()
1068            .expect("single-call tool is a valid single kind");
1069    }
1070
1071    #[test]
1072    fn test_tooldecl_script_parses() {
1073        let toml = r#"
1074            [server]
1075            name = "tube"
1076            version = "0.1.0"
1077
1078            [[tools]]
1079            name = "plan_journey"
1080            script = """
1081            const a = await api.get('/Journey/JourneyResults/' + args.from + '/to/' + args.to);
1082            return a;
1083            """
1084
1085            [[tools.parameters]]
1086            name = "from"
1087            type = "string"
1088            required = true
1089
1090            [[tools.parameters]]
1091            name = "to"
1092            type = "string"
1093            required = true
1094        "#;
1095        let cfg = ServerConfig::from_toml(toml).expect("script tool must parse");
1096        let tool = &cfg.tools[0];
1097        assert!(tool.script.is_some());
1098        assert!(tool.is_script_tool());
1099        assert_eq!(tool.parameters.len(), 2);
1100        cfg.validate().expect("script tool is a valid single kind");
1101    }
1102
1103    #[test]
1104    fn test_tooldecl_detection() {
1105        let script = ToolDecl {
1106            script: Some("return 1;".to_string()),
1107            ..Default::default()
1108        };
1109        assert!(script.is_script_tool());
1110
1111        let single = ToolDecl {
1112            path: Some("/x".to_string()),
1113            method: Some("GET".to_string()),
1114            ..Default::default()
1115        };
1116        assert!(!single.is_script_tool());
1117
1118        let sql = ToolDecl {
1119            sql: Some("SELECT 1".to_string()),
1120            ..Default::default()
1121        };
1122        assert!(!sql.is_script_tool());
1123    }
1124
1125    #[test]
1126    fn test_tooldecl_ambiguous_rejected() {
1127        // script + path/method is ambiguous (Codex MEDIUM): rejected, not
1128        // resolved by a silent "script wins".
1129        let toml = r#"
1130            [server]
1131            name = "tube"
1132            version = "0.1.0"
1133
1134            [[tools]]
1135            name = "confused"
1136            path = "/x"
1137            method = "GET"
1138            script = "return 1;"
1139        "#;
1140        let cfg = ServerConfig::from_toml(toml).expect("parse (ambiguity is a validate-time rule)");
1141        match cfg.validate() {
1142            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
1143            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
1144        }
1145    }
1146
1147    #[test]
1148    fn test_tooldecl_ambiguous_sql_plus_script_rejected() {
1149        let toml = r#"
1150            [server]
1151            name = "tube"
1152            version = "0.1.0"
1153
1154            [[tools]]
1155            name = "confused"
1156            sql = "SELECT 1"
1157            script = "return 1;"
1158        "#;
1159        let cfg = ServerConfig::from_toml(toml).expect("parse");
1160        match cfg.validate() {
1161            Err(ConfigValidationError::AmbiguousToolKind(0)) => {},
1162            other => panic!("expected AmbiguousToolKind(0), got {other:?}"),
1163        }
1164    }
1165
1166    #[test]
1167    fn test_tooldecl_sql_still_parses() {
1168        // REF-01 superset regression: an existing sql= tool is unaffected by the
1169        // additive path/method/base_url/script fields.
1170        let toml = r#"
1171            [server]
1172            name = "demo"
1173            version = "0.1.0"
1174
1175            [[tools]]
1176            name = "list_tables"
1177            sql = "SELECT name FROM sqlite_master"
1178        "#;
1179        let cfg = ServerConfig::from_toml(toml).expect("sql tool must still parse");
1180        let tool = &cfg.tools[0];
1181        assert_eq!(tool.sql.as_deref(), Some("SELECT name FROM sqlite_master"));
1182        assert!(tool.path.is_none());
1183        assert!(tool.method.is_none());
1184        assert!(tool.base_url.is_none());
1185        assert!(tool.script.is_none());
1186        assert!(!tool.is_script_tool());
1187        cfg.validate().expect("sql tool validates as a single kind");
1188    }
1189
1190    // -------------------------------------------------------------------------
1191    // [backend] / [backend.auth] / [backend.http] — D-06 (http feature)
1192    // -------------------------------------------------------------------------
1193
1194    #[cfg(feature = "http")]
1195    #[test]
1196    fn test_backend_section_parses() {
1197        // A full [backend] + [backend.auth] (api_key) + [backend.http] block
1198        // round-trips into ServerConfig with backend.is_some().
1199        let toml = r#"
1200            [server]
1201            name = "tube"
1202            version = "0.1.0"
1203
1204            [backend]
1205            base_url = "https://api.tfl.gov.uk"
1206
1207            [backend.auth]
1208            type = "api_key"
1209
1210            [backend.auth.query_params]
1211            app_key = "${TFL_APP_KEY}"
1212
1213            [backend.http]
1214            timeout_seconds = 10
1215            retries = 2
1216        "#;
1217        let cfg = ServerConfig::from_toml(toml).expect("[backend] config must parse");
1218        let backend = cfg.backend.expect("backend must be Some");
1219        assert_eq!(backend.base_url, "https://api.tfl.gov.uk");
1220        assert_eq!(backend.http.timeout_seconds, 10);
1221        assert_eq!(backend.http.retries, 2);
1222        assert!(
1223            matches!(backend.auth, AuthConfig::ApiKey { .. }),
1224            "auth must be api_key, got {:?}",
1225            backend.auth
1226        );
1227    }
1228
1229    #[cfg(feature = "http")]
1230    #[test]
1231    fn test_backend_auth_defaults_to_none() {
1232        // [backend] without a [backend.auth] sub-table defaults auth to None
1233        // and http to HttpConfig defaults (additive sub-tables).
1234        let toml = r#"
1235            [server]
1236            name = "tube"
1237            version = "0.1.0"
1238
1239            [backend]
1240            base_url = "https://api.example.com"
1241        "#;
1242        let cfg = ServerConfig::from_toml(toml).expect("backend w/o auth must parse");
1243        let backend = cfg.backend.expect("backend must be Some");
1244        assert!(matches!(backend.auth, AuthConfig::None));
1245        assert_eq!(backend.http, HttpConfig::default());
1246    }
1247
1248    #[cfg(feature = "http")]
1249    #[test]
1250    fn test_sql_config_unaffected() {
1251        // REF-01 superset / D-06 additive proof: a pure-SQL config with NO
1252        // [backend] still parses, and backend == None.
1253        let toml = r#"
1254            [server]
1255            name = "demo"
1256            version = "0.1.0"
1257
1258            [database]
1259            type = "sqlite"
1260            file_path = "/tmp/demo.db"
1261
1262            [[tools]]
1263            name = "list_tables"
1264            sql = "SELECT name FROM sqlite_master"
1265        "#;
1266        let cfg = ServerConfig::from_toml(toml).expect("SQL config must still parse");
1267        assert!(
1268            cfg.backend.is_none(),
1269            "SQL config must have backend == None"
1270        );
1271        assert_eq!(cfg.tools.len(), 1);
1272    }
1273
1274    #[cfg(feature = "http")]
1275    #[test]
1276    fn test_backend_unknown_field_rejected() {
1277        // T-90-02-01: deny_unknown_fields preserved — an unknown key under
1278        // [backend.http] is a hard parse error, never a silent default.
1279        let toml = r#"
1280            [server]
1281            name = "tube"
1282            version = "0.1.0"
1283
1284            [backend]
1285            base_url = "https://api.example.com"
1286
1287            [backend.http]
1288            foo = 1
1289        "#;
1290        let err =
1291            ServerConfig::from_toml(toml).expect_err("unknown [backend.http] key must be rejected");
1292        assert!(matches!(err, ToolkitError::Parse(_)), "got: {err:?}");
1293    }
1294
1295    proptest! {
1296        /// TEST-02: any valid `ServerConfig` round-trips through TOML.
1297        ///
1298        /// Builds a `ServerConfig` from an arbitrary (but valid) `(name, version)`
1299        /// pair, serializes it, parses it back, and asserts equality on the
1300        /// load-bearing scalars.
1301        #[test]
1302        fn server_config_minimal_round_trips(
1303            name in "[a-zA-Z0-9_-]{1,32}",
1304            version in "[0-9]+\\.[0-9]+\\.[0-9]+",
1305        ) {
1306            let cfg = ServerConfig {
1307                server: ServerSection {
1308                    name: name.clone(),
1309                    version: version.clone(),
1310                    ..Default::default()
1311                },
1312                ..Default::default()
1313            };
1314            let s = toml::to_string(&cfg).unwrap();
1315            let parsed = ServerConfig::from_toml(&s).unwrap();
1316            prop_assert_eq!(parsed.server.name, name);
1317            prop_assert_eq!(parsed.server.version, version);
1318        }
1319    }
1320}