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}