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