Skip to main content

pmcp_server_toolkit/
tools.rs

1// Net-new code for Phase 83 TKIT-07.
2// Lands the `[[tools]]`-config-driven synthesizer that turns config rows into
3// `ToolInfo` + `Arc<dyn ToolHandler>` pairs.
4
5//! `[[tools]]` → `ToolInfo` + `Arc<dyn ToolHandler>` synthesizer.
6//!
7//! Net-new code for Phase 83 TKIT-07. Turns curated `[[tools]]` config entries
8//! into complete pmcp [`ToolInfo`] + [`Arc<dyn ToolHandler>`] pairs with zero
9//! per-tool Rust handlers.
10//!
11//! # Invariants enforced
12//!
13//! - **JSON Schema object envelope, CHECKED HERE.** Every synthesized
14//!   [`ToolInfo`] carries an `input_schema` with `"type": "object"`, an explicit
15//!   `properties` map, a `required` array, and `"additionalProperties": false`
16//!   (`true` only when `[server.validation] additional_properties` opts out).
17//!   Unknown argument keys are refused by
18//!   `pmcp::server::schema_validation::validate_input`, called from the
19//!   `ValidatingToolHandler` decorator that `enforce_input_schema` wraps
20//!   around every handler this module pushes — **in the toolkit, before the
21//!   backend call**. Threat T-83-05-02.
22//!
23//!   Both halves matter, and stating only the first is what made this comment a
24//!   defect for two phases. Core `pmcp`'s `tools/call` dispatch does **not**
25//!   validate request arguments against a tool's declared `inputSchema`; that
26//!   wiring is deliberately deferred (Phase 128 D-01), so a comment locating the
27//!   enforcement "upstream" described a mitigation that did not exist. It exists
28//!   now, and it exists *here*.
29//!
30//!   Condition: the `input-validation` feature must be on. It is on via this
31//!   crate's `default` feature set and by that route ALONE — measured: `http =
32//!   ["dep:reqwest", "dep:url", "dep:openapiv3", "dep:serde_yaml", "dep:base64",
33//!   "dep:regex", "dep:tokio", "pmcp/streamable-http"]` and `openapi-code-mode =
34//!   ["http", "code-mode", "pmcp-code-mode/js-runtime"]`, neither of which names
35//!   it. Those two umbrellas do NOT forward it, so a
36//!   `--no-default-features --features http` build — exactly the shape a
37//!   consumer reaches for — turns it off, and then
38//!   `enforce_input_schema` is the identity function and says so in a
39//!   `tracing::warn!`. Second condition: `[server.validation]
40//!   enforce_input_schema` must not be `false`.
41//!
42//!   Backed by `tests/input_validation_acceptance.rs`'s
43//!   `input_validation_refuses_undeclared_argument_without_contacting_upstream`
44//!   (row 10) and `input_validation_refuses_absent_arguments_when_required_declared`
45//!   (row 9), each of which fails if the `validate_input` call is removed, plus
46//!   `input_validation_accepts_compliant_call_with_one_upstream_request` (row 11)
47//!   as the passing control that catches an over-refusing decorator.
48//! - **`handler.metadata()` returns `Some(ToolInfo)`.** Phase 82's `tool_arc`
49//!   consumes `handler.metadata()` at registration; returning `None` would
50//!   silently degrade the schema enforcement to "anything goes" (RESEARCH
51//!   §Risks #2 — threat T-83-05-01).
52//! - **Constructors, never struct-literals.** Both [`ToolInfo`] and
53//!   [`ToolAnnotations`] are `#[non_exhaustive]` (PATTERNS §Pattern C). The
54//!   synthesizer uses [`ToolInfo::with_annotations`] / [`ToolInfo::new`] and
55//!   the [`ToolAnnotations::new()`]-then-`.with_*` fluent builder.
56//! - **Cognitive complexity ≤25 per function.** Decomposed into
57//!   [`build_input_schema`], [`build_param_property`], and [`build_annotations`]
58//!   per Phase 75 D-03 + PATTERNS §Pattern G. No `#[allow]` annotations.
59
60use std::sync::Arc;
61
62use async_trait::async_trait;
63use pmcp::server::ToolHandler;
64use pmcp::types::{ToolAnnotations, ToolInfo};
65use pmcp::RequestHandlerExtra;
66use serde_json::{json, Map, Value};
67
68use crate::config::{AnnotationsDecl, ParamDecl, ServerConfig, ToolDecl, ValidationSection};
69use crate::error::Result;
70use crate::policy::ToolkitHooks;
71// Every use of the trait object itself lives inside a `#[cfg(feature =
72// "input-validation")]` item (`ValidatingToolHandler`'s field and `wrap`'s
73// parameter), so an ungated import is an `unused_imports` warning — and a hard
74// error — in the `--no-default-features --features http` build this module's own
75// header names as the shape a consumer reaches for.
76#[cfg(feature = "input-validation")]
77use crate::policy::ArgumentValidator;
78use crate::sql::SqlConnector;
79
80#[cfg(feature = "http")]
81use crate::error::ToolkitError;
82#[cfg(feature = "http")]
83use crate::http::{HttpConnector, Operation, Parameter, ParameterLocation};
84
85#[cfg(feature = "openapi-code-mode")]
86use crate::code_mode::HttpCodeExecutor;
87#[cfg(feature = "openapi-code-mode")]
88use pmcp_code_mode::ExecutionConfig;
89
90/// Type alias for one synthesized tool tuple: `(name, ToolInfo, Arc<dyn ToolHandler>)`.
91///
92/// Exists so [`synthesize_from_config`]'s return type does not trip
93/// `clippy::type_complexity` while preserving the exact `(name, ToolInfo, Arc)`
94/// shape consumers register with `pmcp::ServerBuilder::tool_arc` (PATTERNS §9).
95pub type SynthesizedTool = (String, ToolInfo, Arc<dyn ToolHandler>);
96
97/// Synthesize one `ToolInfo` + handler per `[[tools]]` config entry.
98///
99/// Each returned tuple is `(name, ToolInfo, Arc<dyn ToolHandler>)` and is
100/// ready to feed into `pmcp::ServerBuilder::tool_arc(name, handler)`. The
101/// `ToolInfo` carries the full input schema (synthesized from
102/// `[[tools.parameters]]`) and `ToolAnnotations` (from `[tools.annotations]`)
103/// so the builder's metadata cache will never fall back to the empty schema.
104///
105/// # Errors
106///
107/// Returns [`crate::ToolkitError::Synth`] if a tool declaration is internally
108/// inconsistent. The Plan 05 GREEN body never produces this error path —
109/// synthesis is total over the parsed [`ServerConfig`] surface — but the
110/// `Result` return is kept for forward compatibility with Plan 06 (code-mode
111/// wiring) and Phase 84 (SQL backend resolution).
112///
113/// # Example
114///
115/// ```
116/// use pmcp_server_toolkit::config::ServerConfig;
117/// use pmcp_server_toolkit::tools::synthesize_from_config;
118///
119/// let cfg = ServerConfig::default();
120/// let synthesized = synthesize_from_config(&cfg).unwrap();
121/// assert_eq!(synthesized.len(), 0);
122/// ```
123pub fn synthesize_from_config(config: &ServerConfig) -> Result<Vec<SynthesizedTool>> {
124    synthesize_inner(config, None, &ToolkitHooks::default())
125}
126
127/// [`synthesize_from_config`] with registered E2 hooks (Phase 128).
128///
129/// PUBLIC, not `pub(crate)`: `crates/pmcp-openapi-server` is a DIFFERENT crate and
130/// calls the free synthesizers directly rather than going through
131/// [`crate::ServerBuilderExt`], so a crate-private variant would leave the shipped
132/// OpenAPI binary unable to pass hooks at all — a registration surface that exists
133/// and cannot be reached from the deployment that most needs it.
134///
135/// # Errors
136///
137/// As [`synthesize_from_config`].
138pub fn synthesize_from_config_and_hooks(
139    config: &ServerConfig,
140    hooks: &ToolkitHooks,
141) -> Result<Vec<SynthesizedTool>> {
142    synthesize_inner(config, None, hooks)
143}
144
145/// Synthesize tools that execute against a wired [`SqlConnector`] (Phase 84
146/// CONN-01 / D-06). ADDITIVE variant alongside [`synthesize_from_config`] — the
147/// existing API is unchanged and all P83 callers compile without modification.
148///
149/// Each synthesized [`SynthesizedToolHandler`] holds the shared `connector`, so
150/// its `handle()` body calls [`SqlConnector::execute`] with the tool's declared
151/// `sql` + the named parameters extracted from the validated args. When a tool
152/// declares `ui_resource_uri`, the synthesized [`ToolInfo`] also carries widget
153/// metadata so pmcp core's `with_widget_enrichment` populates `structuredContent`
154/// (D-06) — that flip lives in the shared [`synthesize_inner`] helper and so
155/// fires for both entry points.
156///
157/// # Errors
158///
159/// Returns [`crate::ToolkitError::Synth`] if a tool declaration is internally
160/// inconsistent. Synthesis is total over the parsed [`ServerConfig`] surface —
161/// the connector is threaded into each handler for runtime use, not consulted at
162/// synthesis time.
163///
164/// # Example
165///
166/// ```no_run
167/// use std::sync::Arc;
168/// use pmcp_server_toolkit::config::ServerConfig;
169/// use pmcp_server_toolkit::sql::SqlConnector;
170/// use pmcp_server_toolkit::tools::synthesize_from_config_with_connector;
171///
172/// fn build(connector: Arc<dyn SqlConnector>) {
173///     let cfg = ServerConfig::default();
174///     let tools = synthesize_from_config_with_connector(&cfg, connector).unwrap();
175///     assert_eq!(tools.len(), 0);
176/// }
177/// ```
178pub fn synthesize_from_config_with_connector(
179    config: &ServerConfig,
180    connector: Arc<dyn SqlConnector>,
181) -> Result<Vec<SynthesizedTool>> {
182    synthesize_inner(config, Some(connector), &ToolkitHooks::default())
183}
184
185/// [`synthesize_from_config_with_connector`] with registered E2 hooks (Phase 128).
186///
187/// PUBLIC for the reason given on [`synthesize_from_config_and_hooks`].
188///
189/// # Errors
190///
191/// As [`synthesize_from_config_with_connector`].
192pub fn synthesize_from_config_with_connector_and_hooks(
193    config: &ServerConfig,
194    connector: Arc<dyn SqlConnector>,
195    hooks: &ToolkitHooks,
196) -> Result<Vec<SynthesizedTool>> {
197    synthesize_inner(config, Some(connector), hooks)
198}
199
200/// Shared synthesizer body for both [`synthesize_from_config`] (no connector)
201/// and [`synthesize_from_config_with_connector`] (connector wired).
202///
203/// Keeps the two public entry points one-liners so the widget_meta flip (D-06)
204/// and the handler construction logic are not duplicated. Decomposed per
205/// PATTERNS §Pattern G — the per-tool body delegates to [`build_input_schema`],
206/// [`build_annotations`], and [`apply_widget_meta`] to stay under cog 25.
207fn synthesize_inner(
208    config: &ServerConfig,
209    connector: Option<Arc<dyn SqlConnector>>,
210    hooks: &ToolkitHooks,
211) -> Result<Vec<SynthesizedTool>> {
212    let validation = &config.server.validation;
213    let mut out = Vec::with_capacity(config.tools.len());
214    for decl in &config.tools {
215        let info = build_tool_info(decl, validation);
216        let handler: Arc<dyn ToolHandler> = Arc::new(SynthesizedToolHandler {
217            info: info.clone(),
218            decl: decl.clone(),
219            connector: connector.clone(),
220        });
221        // Push site 1 of 3 (SQL handler) — D1 enforcement + E2 validator.
222        let handler = enforce_input_schema(handler, &info, decl, validation, hooks);
223        out.push((decl.name.clone(), info, handler));
224    }
225    Ok(out)
226}
227
228/// Enforce a synthesized tool's declared `inputSchema` before its inner handler
229/// runs (Phase 128, D1 / T-128-01).
230///
231/// # Enforcing function
232///
233/// `pmcp::server::schema_validation::validate_input`, called from BOTH
234/// [`ValidatingToolHandler::handle`] and [`ValidatingToolHandler::handle_output`].
235/// Backed by `tests/input_validation_acceptance.rs` acceptance rows 8–11, which
236/// fail if either call is removed.
237///
238/// Wrapping happens at every handler push site in this module, so all four public
239/// entry points inherit the enforcement with none forgotten.
240///
241/// With the `input-validation` feature OFF this is the identity function, and the
242/// opt-out is logged once per synthesized tool at synthesis time: a validation rule
243/// that is off must never read as on.
244///
245/// # `[server.validation] enforce_input_schema = false`
246///
247/// That flag is an operator opt-out from the SCHEMA CHECK, not from the decorator.
248/// `ValidatingToolHandler` carries an `enforce_schema: bool` and skips only the
249/// `validate_input` call, so a future explicitly-registered argument validator
250/// living in the same decorator keeps running. Turning off one enforcement must
251/// never silently turn off another — and losing an explicit custom validator
252/// because schema enforcement was disabled is the worst form of that class, since
253/// the operator turned off A and lost B without being told.
254///
255/// The decorator is constructed when schema enforcement is ON **or** a validator is
256/// registered for this tool. The registry is [`ToolkitHooks`], consulted below via
257/// [`ToolkitHooks::argument_validator_for`], so the decorator is skipped entirely
258/// only when `enforce_input_schema = false` AND no validator is registered for this
259/// tool name — which preserves the "no added allocation for a server that uses
260/// neither" property.
261///
262/// Until Phase 128 plan 09 this paragraph read "No validator registry exists yet
263/// (it lands with E2), so `has_registered_validator` is `false` today". That
264/// sentence outlived the registry by one plan and is kept named here rather than
265/// silently deleted: it is the documented-but-absent defect this phase exists to
266/// close, INVERTED — a comment in the very function that closes the gap still
267/// describing the machinery as absent. The binding it named no longer exists.
268///
269/// Backed by `tools::argument_validator_seam::a_registered_validator_still_runs_with_enforce_input_schema_false`,
270/// which fails if the `registered_validator.is_none()` conjunct is dropped from the
271/// early return, and by
272/// `tools::argument_validator_seam::a_registered_validator_refuses_a_combination_the_schema_permits`,
273/// which fails if the lookup is removed.
274#[cfg(feature = "input-validation")]
275fn enforce_input_schema(
276    handler: Arc<dyn ToolHandler>,
277    info: &ToolInfo,
278    decl: &ToolDecl,
279    validation: &ValidationSection,
280    hooks: &ToolkitHooks,
281) -> Arc<dyn ToolHandler> {
282    // Phase 128 E2 — the registry lookup that replaced plan 03's
283    // `let has_registered_validator = false;` placeholder.
284    let registered_validator = hooks.argument_validator_for(&decl.name);
285    if !validation.enforce_input_schema && registered_validator.is_none() {
286        tracing::warn!(
287            tool = %decl.name,
288            "[server.validation] enforce_input_schema = false: this tool's arguments are \
289             NOT checked against its declared inputSchema before the backend call"
290        );
291        return handler;
292    }
293    ValidatingToolHandler::wrap(
294        handler,
295        info,
296        decl,
297        validation.enforce_input_schema,
298        registered_validator,
299    )
300}
301
302/// The `input-validation`-off half of [`enforce_input_schema`]: the identity
303/// function, with the absence logged once per synthesized tool.
304///
305/// A `#[cfg]` SIBLING rather than one function with two inner blocks, because the
306/// off arm reads none of the schema inputs and the single-function form needed an
307/// `#[allow(unused_variables)]` — which this module's own header rules out ("No
308/// `#[allow]` annotations"). This is the same sibling-pair idiom
309/// `check_tool_input_schema_compiles`, `warn_if_pattern_checking_unavailable`,
310/// `spec_placeholder_rules` and `lint_declared_cap_above_placeholder_floor`
311/// already use in this crate for exactly this situation.
312#[cfg(not(feature = "input-validation"))]
313fn enforce_input_schema(
314    handler: Arc<dyn ToolHandler>,
315    _info: &ToolInfo,
316    decl: &ToolDecl,
317    _validation: &ValidationSection,
318    hooks: &ToolkitHooks,
319) -> Arc<dyn ToolHandler> {
320    tracing::warn!(
321        tool = %decl.name,
322        "the `input-validation` feature is OFF: this tool's arguments are NOT checked \
323         against its declared inputSchema before the backend call"
324    );
325    // `ToolkitHooks::argument_validator_for` is NOT feature-gated, so
326    // `with_argument_validator` compiles and appears to succeed in this build —
327    // while `ValidatingToolHandler`, the only thing that can RUN a registered
328    // validator, is gated away. Naming the drop is the difference between an
329    // enforcement that is off and one that silently reads as on; the schema warning
330    // above does not cover it, because a registered validator is a SEPARATE switch
331    // (see `ValidatingToolHandler::enforce_schema`).
332    if hooks.argument_validator_for(&decl.name).is_some() {
333        tracing::warn!(
334            tool = %decl.name,
335            "an E2 ArgumentValidator IS registered for this tool and is DISCARDED: \
336             running it requires the `input-validation` feature"
337        );
338    }
339    handler
340}
341
342/// Decorator that refuses a `tools/call` whose arguments violate the tool's
343/// declared `inputSchema`, WITHOUT invoking the inner handler.
344///
345/// Crate-private by design: the validator and the value-free refusal renderer both
346/// live in core `pmcp` (D-01), so this type holds no schema logic of its own.
347#[cfg(feature = "input-validation")]
348struct ValidatingToolHandler {
349    inner: Arc<dyn ToolHandler>,
350    /// The synthesized `ToolInfo`'s `input_schema`, verbatim.
351    input_schema: Value,
352    /// `input_schema.to_string()`, computed ONCE at synthesis so the hot
353    /// `tools/call` path never re-serializes the whole schema to hit the core
354    /// validator cache.
355    schema_key: String,
356    /// Declared parameter names in declaration order — the ONLY names a refusal
357    /// message may echo (SC-7).
358    declared: Vec<String>,
359    /// Whether the SCHEMA CHECK runs, from `[server.validation]
360    /// enforce_input_schema`.
361    ///
362    /// A SEPARATE switch from the decorator's existence on purpose: when the E2
363    /// argument-validator registry lands in the same decorator, an operator who
364    /// turns schema enforcement off must not silently lose an
365    /// explicitly-registered validator too.
366    enforce_schema: bool,
367    /// The E2 [`ArgumentValidator`] registered for this tool, if any (Phase 128).
368    ///
369    /// Looked up by tool NAME once at synthesis, so the `tools/call` path does no
370    /// map lookup. `None` is the overwhelmingly common case and costs one
371    /// `Option` discriminant test per call.
372    validator: Option<Arc<dyn ArgumentValidator>>,
373}
374
375#[cfg(feature = "input-validation")]
376impl ValidatingToolHandler {
377    /// Wrap `handler`, taking the schema from the already-built `info` and the
378    /// declared parameter names from `decl`.
379    fn wrap(
380        inner: Arc<dyn ToolHandler>,
381        info: &ToolInfo,
382        decl: &ToolDecl,
383        enforce_schema: bool,
384        validator: Option<Arc<dyn ArgumentValidator>>,
385    ) -> Arc<dyn ToolHandler> {
386        let input_schema = info.input_schema.clone();
387        let schema_key = input_schema.to_string();
388        Arc::new(Self {
389            inner,
390            input_schema,
391            schema_key,
392            declared: decl.parameters.iter().map(|p| p.name.clone()).collect(),
393            enforce_schema,
394            validator,
395        })
396    }
397
398    /// Validate `args`, mapping any violation to a value-free
399    /// `pmcp::Error::Validation`. Kept a separate helper so both trait entry
400    /// points stay one-liners and well under the cog-25 gate.
401    ///
402    /// Returns `Ok(())` without consulting the schema when `enforce_schema` is
403    /// `false` — skipping the CHECK, not the decorator, so anything else this
404    /// decorator does still happens.
405    fn check(&self, args: &Value) -> pmcp::Result<()> {
406        self.check_schema(args)?;
407        self.check_registered_validator(args)
408    }
409
410    /// The D1 declared-schema check.
411    ///
412    /// Returns `Ok(())` without consulting the schema when `enforce_schema` is
413    /// `false` — skipping the CHECK, not the decorator, so the E2 validator below
414    /// still runs.
415    fn check_schema(&self, args: &Value) -> pmcp::Result<()> {
416        use pmcp::server::schema_validation::{render_refusal, validate_input};
417
418        if !self.enforce_schema {
419            return Ok(());
420        }
421        validate_input(&self.input_schema, Some(args), Some(&self.schema_key)).map_err(
422            |violations| {
423                let declared: Vec<&str> = self.declared.iter().map(String::as_str).collect();
424                pmcp::Error::Validation(render_refusal(&violations, &declared))
425            },
426        )
427    }
428
429    /// The E2 registered-validator check (Phase 128).
430    ///
431    /// Called by [`Self::check`] STRICTLY AFTER [`Self::check_schema`] returns
432    /// `Ok`, which is the E2 ordering contract: a custom rule never sees arguments
433    /// the declared schema already refused (T-128-41). Inverting the two calls is
434    /// what `a_registered_validator_is_not_invoked_when_the_schema_refuses` fails
435    /// on.
436    ///
437    /// The refusal message is the VALIDATOR's own, authored outside this crate, so
438    /// unlike `render_refusal` above the toolkit cannot guarantee it is value-free.
439    /// That residual is documented on [`crate::policy::ArgumentRefusal`].
440    fn check_registered_validator(&self, args: &Value) -> pmcp::Result<()> {
441        let Some(validator) = self.validator.as_ref() else {
442            return Ok(());
443        };
444        validator
445            .validate(args)
446            .map_err(|refusal| pmcp::Error::Validation(refusal.message().to_string()))
447    }
448}
449
450#[cfg(feature = "input-validation")]
451#[async_trait]
452impl ToolHandler for ValidatingToolHandler {
453    async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> {
454        self.check(&args)?;
455        self.inner.handle(args, extra).await
456    }
457
458    fn metadata(&self) -> Option<ToolInfo> {
459        self.inner.metadata()
460    }
461
462    /// Validates, then DELEGATES to the inner handler's own `handle_output`.
463    ///
464    /// Implementing only `handle` would silently replace an inner handler's
465    /// `handle_output` override with the trait default
466    /// (`handle(..).map(ToolOutput::Payload)`), stripping that handler's ability to
467    /// own its `CallToolResult` envelope. A decorator must not narrow the contract
468    /// of what it wraps — and both entry points must validate, or an inner handler
469    /// that overrides `handle_output` becomes an unvalidated path.
470    async fn handle_output(
471        &self,
472        args: Value,
473        extra: RequestHandlerExtra,
474    ) -> pmcp::Result<pmcp::server::ToolOutput> {
475        self.check(&args)?;
476        self.inner.handle_output(args, extra).await
477    }
478}
479
480/// Flip widget metadata onto `info` when the declaration carries a
481/// `ui_resource_uri` (D-06 / REVIEWS M1).
482///
483/// Uses the feature-independent [`ToolInfo::with_meta_entry`] surface to insert
484/// `_meta.ui.resourceUri`. This is the verified-correct API: `with_widget_meta`
485/// is gated on pmcp's `mcp-apps` feature (which the toolkit does not enable),
486/// whereas `with_meta_entry` is always available and produces the `ui.resourceUri`
487/// shape that `ToolInfo::widget_meta()` recognises — so pmcp core's
488/// `with_widget_enrichment` populates `structuredContent`. Annotations on `info`
489/// are preserved (chained, not reconstructed).
490fn apply_widget_meta(info: ToolInfo, decl: &ToolDecl) -> ToolInfo {
491    match decl.ui_resource_uri.as_deref() {
492        Some(uri) => info.with_meta_entry("ui", json!({ "resourceUri": uri })),
493        None => info,
494    }
495}
496
497/// Build the [`ToolInfo`] for a synthesized tool from its declaration.
498///
499/// The schema + annotations + widget-meta sequence is identical for every tool
500/// kind (single-call HTTP, SQL, and script), so it lives here once — keeping the
501/// `#[non_exhaustive]` [`ToolInfo`] constructor discipline (the `with_annotations`
502/// vs `new` arms) in a single place rather than copy-pasted per synthesizer.
503fn build_tool_info(decl: &ToolDecl, validation: &ValidationSection) -> ToolInfo {
504    let schema = build_input_schema(decl, validation);
505    let annotations = build_annotations(decl.annotations.as_ref());
506    let base = match annotations {
507        Some(ann) => {
508            ToolInfo::with_annotations(decl.name.clone(), decl.description.clone(), schema, ann)
509        },
510        None => ToolInfo::new(decl.name.clone(), decl.description.clone(), schema),
511    };
512    apply_widget_meta(base, decl)
513}
514
515/// Build the JSON Schema `properties` + `required` envelope from a
516/// `[[tools.parameters]]` list.
517///
518/// Decomposed from [`synthesize_from_config`] to keep cognitive complexity ≤25
519/// (Phase 75 D-03 + PATTERNS §Pattern G).
520///
521/// `pub(crate)` since Phase 128 SC-2: [`crate::config::ServerConfig::validate`]
522/// builds each tool's schema through THIS function and compiles it at config time,
523/// so the schema the compile gate checks is byte-identical to the one the runtime
524/// validator enforces. A second construction path there would let the gate pass a
525/// schema the server never serves.
526///
527/// Takes the whole [`ToolDecl`] rather than just its parameters because the D3
528/// default cap is POSITION-scoped, and position is a property of the tool's `path`
529/// and `method` — see [`crate::config::ToolDecl::param_position`].
530///
531/// `properties` and `required` are emitted in `[[tools.parameters]]` declaration
532/// order, so two synthesis runs over one config produce byte-identical output.
533pub(crate) fn build_input_schema(decl: &ToolDecl, validation: &ValidationSection) -> Value {
534    let mut props = Map::new();
535    let mut required = Vec::new();
536    for p in &decl.parameters {
537        let mut prop = build_param_property(p);
538        apply_position_cap(&mut prop, p, decl.param_position(&p.name), validation);
539        props.insert(p.name.clone(), prop);
540        if p.required {
541            required.push(Value::String(p.name.clone()));
542        }
543    }
544    json!({
545        "type": "object",
546        "properties": props,
547        "required": required,
548        "additionalProperties": validation.additional_properties,
549    })
550}
551
552/// Apply the D3 position-scoped default length cap to ONE already-built property
553/// object (Phase 128).
554///
555/// Emits `maxLength = validation.default_max_length` only when ALL of:
556/// the parameter's effective type is `string`; it declares no `max_length` of its
557/// own; `default_max_length` is non-zero; and `position` is `Path` or `Query`.
558/// BODY position emits nothing — capping a `POST` payload's free-text field is
559/// exactly the breakage D-05 exists to avoid, and `ServerConfig::lint` surfaces
560/// those instead.
561///
562/// A DECLARED `max_length` is never overridden and never merged with the default in
563/// either direction: the author's number wins outright, and the always-on
564/// `PLACEHOLDER_MAX_LENGTH` floor is what bounds a URL segment regardless.
565///
566/// The four conditions live in `crate::config::default_cap_applies` rather than
567/// here, so `lint()` and this function cannot disagree about which parameters are
568/// covered — a disagreement would make `lint()` report a parameter as uncapped
569/// while the schema caps it, or the reverse.
570///
571/// Its own free function (not inlined into [`build_input_schema`]) to keep that
572/// function's cognitive complexity well under the 25 gate.
573fn apply_position_cap(
574    prop: &mut Value,
575    p: &ParamDecl,
576    position: crate::config::ParamPosition,
577    validation: &ValidationSection,
578) {
579    if crate::config::default_cap_applies(p, position, validation) {
580        prop["maxLength"] = json!(validation.default_max_length);
581    }
582}
583
584/// Build a single JSON Schema property object from a [`ParamDecl`].
585///
586/// Per-parameter constraints (`minimum`, `maximum`, `maxLength`, `minLength`,
587/// `pattern`, `format`, `maxItems`, `items`, `default`, `enum`) are folded in only
588/// when present — an undeclared keyword is ABSENT from the emitted schema, never
589/// present-and-empty, because an empty `pattern` matches everything and an empty
590/// `items` object constrains nothing while both read like rules.
591///
592/// The param's `param_type` defaults to `"string"` when omitted in TOML to match
593/// JSON Schema's permissive default.
594fn build_param_property(p: &ParamDecl) -> Value {
595    let ty = p.param_type.as_deref().unwrap_or("string");
596    let mut prop = json!({ "type": ty });
597    if let Some(desc) = &p.description {
598        prop["description"] = Value::String(desc.clone());
599    }
600    if let Some(min) = p.minimum {
601        prop["minimum"] = json!(min);
602    }
603    if let Some(max) = p.maximum {
604        prop["maximum"] = json!(max);
605    }
606    if let Some(max_len) = p.max_length {
607        prop["maxLength"] = json!(max_len);
608    }
609    if let Some(min_len) = p.min_length {
610        prop["minLength"] = json!(min_len);
611    }
612    if let Some(pattern) = &p.pattern {
613        prop["pattern"] = Value::String(pattern.clone());
614    }
615    if let Some(format) = &p.format {
616        prop["format"] = Value::String(format.clone());
617    }
618    if let Some(max_items) = p.max_items {
619        prop["maxItems"] = json!(max_items);
620    }
621    if let Some(items) = &p.items {
622        prop["items"] = build_items_property(items);
623    }
624    if let Some(default) = &p.default {
625        // toml::Value serializes losslessly into serde_json::Value via serde.
626        if let Ok(v) = serde_json::to_value(default) {
627            prop["default"] = v;
628        }
629    }
630    if let Some(enum_vals) = &p.enum_values {
631        if let Ok(v) = serde_json::to_value(enum_vals) {
632            prop["enum"] = v;
633        }
634    }
635    prop
636}
637
638/// Build the OBJECT-form JSON Schema `items` value from an [`ItemsDecl`]
639/// (Phase 128, D2).
640///
641/// A separate free function for two reasons. First, cognitive complexity:
642/// [`build_param_property`] gained five arms in this phase and RESEARCH assumption
643/// A4 (that they stay under cog 25) was explicitly unmeasured, so the construction
644/// lives outside it. Second, and more importantly, there is exactly ONE place that
645/// can decide the SHAPE of `items`, and it returns a [`Value::Object`]
646/// unconditionally. The array form of `items` is a draft-07 tuple construct that
647/// does NOT compile under the Draft 2020-12 pin, and a schema that does not compile
648/// takes the whole tool's validator down — so "never an array here" is a safety
649/// property, not a style preference, and it is enforced by this function having no
650/// code path that builds a JSON array.
651fn build_items_property(items: &crate::config::ItemsDecl) -> Value {
652    let ty = items.item_type.as_deref().unwrap_or("string");
653    let mut prop = json!({ "type": ty });
654    if let Some(max_len) = items.max_length {
655        prop["maxLength"] = json!(max_len);
656    }
657    if let Some(pattern) = &items.pattern {
658        prop["pattern"] = Value::String(pattern.clone());
659    }
660    prop
661}
662
663/// Build [`ToolAnnotations`] from an optional `[tools.annotations]` block.
664///
665/// Per PATTERNS §Pattern C, the constructor + fluent builder is used (never a
666/// struct literal — [`ToolAnnotations`] is `#[non_exhaustive]`). The `cost_hint`
667/// field has no `ToolAnnotations` accessor and is therefore not propagated at
668/// this layer; it lives on the toolkit's [`AnnotationsDecl`] and is consumed
669/// by future plans that surface cost into rate-limiting policy.
670fn build_annotations(decl: Option<&AnnotationsDecl>) -> Option<ToolAnnotations> {
671    let d = decl?;
672    let a = ToolAnnotations::new()
673        .with_read_only(d.read_only_hint)
674        .with_destructive(d.destructive_hint)
675        .with_idempotent(d.idempotent_hint)
676        .with_open_world(d.open_world_hint);
677    Some(a)
678}
679
680// -----------------------------------------------------------------------------
681// SynthesizedToolHandler — crate-private
682// -----------------------------------------------------------------------------
683
684/// Crate-private handler wrapping a synthesized [`ToolInfo`].
685///
686/// [`ToolHandler::metadata`] MUST return `Some(self.info.clone())` — Phase 82's
687/// `tool_arc` consumes `handler.metadata()` at registration time; returning
688/// `None` would cause the builder to fall back to an empty schema (RESEARCH
689/// §Risks #2 — threat T-83-05-01). The unit + property tests in
690/// [`crate::tools`] and `tests/tool_synthesis_props.rs` lock this in.
691///
692/// `handle()` reads the declared `sql`, extracts named parameters from the
693/// validated args, and calls [`SqlConnector::execute`] when a `connector` is
694/// wired (handlers built via [`synthesize_from_config_with_connector`]).
695/// Handlers built via the no-connector [`synthesize_from_config`] carry
696/// `connector = None` and return an explicit `Err` on invocation — preserving
697/// P83 behaviour where the no-connector path was test-only (T-84-03-05). The
698/// `decl` is held so the handler can read `sql` / `ui_resource_uri` /
699/// `parameters` without re-walking the config.
700struct SynthesizedToolHandler {
701    info: ToolInfo,
702    decl: ToolDecl,
703    /// `Some` only for handlers built via [`synthesize_from_config_with_connector`].
704    connector: Option<Arc<dyn SqlConnector>>,
705}
706
707/// Extract the named `(name, value)` parameter pairs the connector binds from,
708/// filtering the caller's `args` against the declared parameter list
709/// (T-84-03-01: only declared parameter names reach `execute()`; an extra key is
710/// dropped HERE, and separately refused one layer out).
711///
712/// The refusal is not "upstream". It is
713/// `pmcp::server::schema_validation::validate_input` inside the
714/// `ValidatingToolHandler` decorator that [`enforce_input_schema`] wraps around
715/// this handler at its push site, so an undeclared key normally never reaches this
716/// function at all. Conditional on the `input-validation` feature AND on
717/// `[server.validation]` leaving both `enforce_input_schema` (`true`) and
718/// `additional_properties` (`false`) at their defaults — with `additional_properties
719/// = true` an undeclared key is ADMITTED by the schema and this filter is the only
720/// thing that keeps it out of `execute()`. That is why the drop stays here rather
721/// than being replaced by an assertion: the two layers close the case under
722/// different configurations. Backed by
723/// `tests/input_validation_acceptance.rs::input_validation_refuses_undeclared_argument_without_contacting_upstream`
724/// for the schema layer; the filter layer is covered by this module's
725/// `extract_params` unit tests.
726///
727/// Found by this phase's SC-6 candidate-phrase sweep, not by the plan's named
728/// list: the phrasing "rejects them upstream" is the same defect class as
729/// the retired `HttpToolHandler::handle` claim one function-group away, which
730/// located the same envelope check "upstream" in the same way.
731///
732/// The retired phrasings are deliberately NOT quoted verbatim anywhere in `src/`.
733/// This phase's SC-6 gates grep for each retired phrase and require a count of
734/// zero, and a gate that a historical citation can satisfy is a gate that has
735/// stopped distinguishing a surviving claim from a note about one — the first
736/// draft of THIS comment quoted one of them and turned the gate red on itself,
737/// which is the cheapest possible demonstration that the gate is sensitive. The
738/// full before/after text lives in `128-10-SUMMARY.md`.
739///
740/// When the caller omits an optional parameter that declares a `default`, the
741/// default is applied so the bound SQL sees a concrete value. Without this an
742/// omitted `:limit` / `:offset` would bind as unbound `NULL` and SQLite rejects
743/// `LIMIT NULL` with a "datatype mismatch" — so the declared default is the
744/// difference between a working and a broken tool call (the reference
745/// `search_tracks` / `list_artists` calls rely on it).
746///
747/// An EXPLICIT JSON `null` for a declared-default parameter is treated the SAME
748/// as an omitted parameter — the declared default is applied (85-10 WR-02
749/// secondary fix). Without the `is_null` filter a caller sending
750/// `{"limit": null}` would bind `LIMIT NULL` and SQLite would reject the query
751/// with "datatype mismatch", even though the tool declares `default = 20`.
752fn extract_named_params(decl: &ToolDecl, args: &Value) -> Vec<(String, Value)> {
753    decl.parameters
754        .iter()
755        .filter_map(|p| {
756            args.get(&p.name)
757                // Explicit JSON `null` falls through to the declared default,
758                // exactly like an omitted key (no `LIMIT NULL` bind).
759                .filter(|v| !v.is_null())
760                .cloned()
761                .or_else(|| {
762                    p.default
763                        .as_ref()
764                        .and_then(|d| serde_json::to_value(d).ok())
765                })
766                .map(|v| (p.name.clone(), v))
767        })
768        .collect()
769}
770
771#[async_trait]
772impl ToolHandler for SynthesizedToolHandler {
773    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
774        let sql = self.decl.sql.as_deref().ok_or_else(|| {
775            pmcp::Error::Internal(format!("tool '{}' has no `sql` declared", self.info.name))
776        })?;
777        let connector = self.connector.as_ref().ok_or_else(|| {
778            pmcp::Error::Internal(format!(
779                "tool '{}' requires connector wiring — build via synthesize_from_config_with_connector",
780                self.info.name
781            ))
782        })?;
783        let named_params = extract_named_params(&self.decl, &args);
784        // T-84-03-02: format!("{e}") uses ConnectorError::Display, which Plan 01
785        // Task 2 guarantees does not echo credentials.
786        let rows = connector
787            .execute(sql, &named_params)
788            .await
789            .map_err(|e| pmcp::Error::Internal(format!("connector error: {e}")))?;
790        Ok(Value::Array(rows))
791    }
792
793    fn metadata(&self) -> Option<ToolInfo> {
794        Some(self.info.clone())
795    }
796}
797
798// -----------------------------------------------------------------------------
799// Single-call HTTP synthesizer (Phase 90 OAPI-02a) — feature `http`
800// -----------------------------------------------------------------------------
801
802/// Synthesize one `ToolInfo` + handler per single-call `[[tools]]` entry,
803/// executing against a wired [`HttpConnector`] (Phase 90 OAPI-02a / D-01).
804///
805/// Mirrors [`synthesize_from_config_with_connector`] (the SQL analog) in shape.
806/// For each `[[tools]]` where [`ToolDecl::is_script_tool`] is `false` and a
807/// `path` + `method` pair is present, an [`Operation`] is built from the path
808/// template (the `{...}` segments become path parameters), the declared
809/// `[[tools.parameters]]` (non-path params become query params; `POST`/`PUT`/
810/// `PATCH` carry a request body), and the per-tool `base_url` override — which is
811/// reflected onto the [`Operation`] so the connector targets the per-tool host
812/// (never silently dropped, Codex MEDIUM). The synthesized [`ToolInfo`] uses the
813/// EXISTING [`build_input_schema`] (object envelope + `additionalProperties:false`)
814/// and [`build_annotations`] helpers; the handler calls
815/// [`HttpConnector::execute`] and returns the JSON.
816///
817/// # Script-tool seam (Plan 05)
818///
819/// A `script` tool encountered here returns a typed [`ToolkitError::Synth`] — it
820/// is an EXPLICIT, clearly-marked seam, NOT a silent skip and NOT a `todo!()`.
821/// Plan 05 widens this function's signature (adding the shared `http_exec` +
822/// `exec_config`) and fills the `is_script_tool()` arm with a `ScriptToolHandler`
823/// branch; that change is a localized, anticipated edit because the seam is
824/// surfaced here.
825///
826/// # Errors
827///
828/// Returns [`ToolkitError::Synth`] when a `[[tools]]` entry is a `script` tool
829/// (Plan 05 seam) or is neither a valid single-call (missing `path` OR `method`)
830/// nor a script tool (T-90-03-04 negative validation — an ill-formed tool is
831/// rejected, never silently registered).
832#[cfg(feature = "http")]
833pub fn synthesize_from_config_with_http_connector(
834    config: &ServerConfig,
835    connector: Arc<dyn HttpConnector>,
836) -> Result<Vec<SynthesizedTool>> {
837    // No script-tool builder is supplied on this (single-call-only) entry point,
838    // so the `is_script_tool()` arm of [`synthesize_http_inner`] returns the
839    // typed Plan 05 seam error. The OpenAPI Code Mode build calls
840    // [`synthesize_from_config_with_http_connector_and_scripts`], which supplies
841    // a [`ScriptToolHandler`] builder so a `script` tool synthesizes a real
842    // handler over the shared engine (OAPI-02b / D-01 / D-02).
843    synthesize_from_config_with_http_connector_and_hooks(
844        config,
845        connector,
846        &ToolkitHooks::default(),
847    )
848}
849
850/// [`synthesize_from_config_with_http_connector`] with registered E2 hooks
851/// (Phase 128).
852///
853/// PUBLIC for the reason given on [`synthesize_from_config_and_hooks`].
854///
855/// # Errors
856///
857/// As [`synthesize_from_config_with_http_connector`].
858#[cfg(feature = "http")]
859pub fn synthesize_from_config_with_http_connector_and_hooks(
860    config: &ServerConfig,
861    connector: Arc<dyn HttpConnector>,
862    hooks: &ToolkitHooks,
863) -> Result<Vec<SynthesizedTool>> {
864    // No script-tool builder is supplied on this (single-call-only) entry point,
865    // so the `is_script_tool()` arm of [`synthesize_http_inner`] returns the
866    // typed Plan 05 seam error. The OpenAPI Code Mode build calls
867    // [`synthesize_from_config_with_http_connector_and_scripts`], which supplies
868    // a [`ScriptToolHandler`] builder so a `script` tool synthesizes a real
869    // handler over the shared engine (OAPI-02b / D-01 / D-02).
870    synthesize_http_inner(
871        config,
872        connector,
873        |decl| {
874            Err(ToolkitError::Synth(format!(
875                "tool '{}' is a script tool — script tools require the `openapi-code-mode` \
876                 feature (use synthesize_from_config_with_http_connector_and_scripts)",
877                decl.name
878            )))
879        },
880        hooks,
881    )
882}
883
884/// Synthesize single-call AND script `[[tools]]` against a wired
885/// [`HttpConnector`] plus a shared [`HttpCodeExecutor`] + [`ExecutionConfig`]
886/// (Phase 90 OAPI-02b / D-01 / D-02).
887///
888/// This is the OpenAPI Code Mode entry point (gated `openapi-code-mode`): it
889/// adds the `http_exec` + `exec_config` the script-tool path needs, threading
890/// the SAME `HttpCodeExecutor` instance that feeds Code Mode (D-02 — one engine,
891/// two surfaces). Single-call tools synthesize exactly as in
892/// [`synthesize_from_config_with_http_connector`]; a `script` tool synthesizes a
893/// [`ScriptToolHandler`] that compiles + runs the embedded JS through the SAME
894/// `PlanCompiler` + `PlanExecutor` + `HttpCodeExecutor` seam Code Mode uses,
895/// with NO validate/token cycle (admin-authored, `ExecutionConfig`-bounded —
896/// Pitfall 7).
897///
898/// The binary (Plan 06) supplies `http_exec` (built once over the resolved
899/// backend `base_url` + auth provider) and `exec_config` (from the
900/// `[code_mode.limits]` / defaults: `max_api_calls=50`, `max_loop_iterations=100`,
901/// `timeout_seconds=30`).
902///
903/// # Errors
904///
905/// Returns [`ToolkitError::Synth`] when a `[[tools]]` entry is neither a valid
906/// single-call (missing `path` OR `method`) nor a script tool (T-90-03-04
907/// negative validation), or when a script tool fails to build its `ToolInfo`.
908#[cfg(feature = "openapi-code-mode")]
909pub fn synthesize_from_config_with_http_connector_and_scripts(
910    config: &ServerConfig,
911    connector: Arc<dyn HttpConnector>,
912    http_exec: HttpCodeExecutor,
913    exec_config: ExecutionConfig,
914) -> Result<Vec<SynthesizedTool>> {
915    synthesize_from_config_with_http_connector_and_scripts_and_hooks(
916        config,
917        connector,
918        http_exec,
919        exec_config,
920        &ToolkitHooks::default(),
921    )
922}
923
924/// [`synthesize_from_config_with_http_connector_and_scripts`] with registered E2
925/// hooks (Phase 128).
926///
927/// This is THE variant `crates/pmcp-openapi-server`'s `build_server` calls, which
928/// is why it is PUBLIC rather than `pub(crate)`: that crate reaches the
929/// synthesizer directly and never through [`crate::ServerBuilderExt`], so a
930/// crate-private variant would ship E2 as present-but-inert on this phase's
931/// principal HTTP surface (T-128-39b).
932///
933/// # Errors
934///
935/// As [`synthesize_from_config_with_http_connector_and_scripts`].
936#[cfg(feature = "openapi-code-mode")]
937pub fn synthesize_from_config_with_http_connector_and_scripts_and_hooks(
938    config: &ServerConfig,
939    connector: Arc<dyn HttpConnector>,
940    http_exec: HttpCodeExecutor,
941    exec_config: ExecutionConfig,
942    hooks: &ToolkitHooks,
943) -> Result<Vec<SynthesizedTool>> {
944    let validation = &config.server.validation;
945    synthesize_http_inner(
946        config,
947        connector,
948        |decl| {
949            let handler =
950                ScriptToolHandler::new(decl, http_exec.clone(), exec_config.clone(), validation)?;
951            let info = handler.tool_info.clone();
952            let arc: Arc<dyn ToolHandler> = Arc::new(handler);
953            Ok((info, arc))
954        },
955        hooks,
956    )
957}
958
959/// Shared synthesizer body for the single-call HTTP entry points.
960///
961/// `build_script_tool` is invoked for each `script` tool: the single-call-only
962/// entry point passes a closure that returns the typed Plan 05 / `openapi-code-mode`
963/// seam error, while the OpenAPI Code Mode entry point passes a closure that
964/// constructs a [`ScriptToolHandler`]. Decomposed per PATTERNS §Pattern G to keep
965/// the per-tool loop body under cog ≤25.
966#[cfg(feature = "http")]
967fn synthesize_http_inner(
968    config: &ServerConfig,
969    connector: Arc<dyn HttpConnector>,
970    mut build_script_tool: impl FnMut(&ToolDecl) -> Result<(ToolInfo, Arc<dyn ToolHandler>)>,
971    hooks: &ToolkitHooks,
972) -> Result<Vec<SynthesizedTool>> {
973    let validation = &config.server.validation;
974    let mut out = Vec::with_capacity(config.tools.len());
975    for decl in &config.tools {
976        if decl.is_script_tool() {
977            let (info, handler) = build_script_tool(decl)?;
978            // Push site 2 of 3 (SCRIPT tool) — D1 enforcement. This site is
979            // SEPARATE from the HTTP push below because of the `continue`; wrapping
980            // only the HTTP push would leave every script tool unvalidated.
981            let handler = enforce_input_schema(handler, &info, decl, validation, hooks);
982            out.push((decl.name.clone(), info, handler));
983            continue;
984        }
985
986        // Single-call requires BOTH path and method. A `[[tools]]` that is
987        // neither a valid single-call nor a script tool is rejected (T-90-03-04).
988        let (path, method) = match (decl.path.as_deref(), decl.method.as_deref()) {
989            (Some(p), Some(m)) => (p, m),
990            _ => {
991                return Err(ToolkitError::Synth(format!(
992                    "tool '{}' is not a valid single-call tool: both `path` and `method` are required",
993                    decl.name
994                )));
995            },
996        };
997
998        let operation = build_operation(path, method, decl);
999        let info = build_tool_info(decl, validation);
1000        let handler: Arc<dyn ToolHandler> = Arc::new(HttpToolHandler {
1001            info: info.clone(),
1002            operation,
1003            connector: connector.clone(),
1004        });
1005        // Push site 3 of 3 (single-call HTTP handler) — D1 enforcement + E2 validator.
1006        let handler = enforce_input_schema(handler, &info, decl, validation, hooks);
1007        out.push((decl.name.clone(), info, handler));
1008    }
1009    Ok(out)
1010}
1011
1012/// Build the [`Operation`] for a single-call tool from its `path` template,
1013/// `method`, declared parameters, and per-tool `base_url`.
1014///
1015/// Path parameters are the `{...}` segments of the path template. Every other
1016/// declared `[[tools.parameters]]` becomes a QUERY parameter, or — when the method
1017/// carries a request body (`POST`/`PUT`/`PATCH`) — a
1018/// [`ParameterLocation::Body`] parameter that
1019/// [`crate::http::HttpClient`]'s `build_body` folds into the JSON payload. The
1020/// per-tool `base_url` is reflected onto the [`Operation`] (Codex MEDIUM — never
1021/// dropped).
1022///
1023/// # Agreement with `ToolDecl::param_position` — structural, not documentary
1024///
1025/// Both splits this function performs read a shared helper, so neither can drift
1026/// from [`crate::config::ToolDecl::param_position`]:
1027///
1028/// - the PATH split and `param_position`'s `Path` arm both read
1029///   [`crate::config::path_placeholder_names`];
1030/// - the QUERY/BODY split and `param_position`'s `Query`/`Body` arms both read
1031///   `crate::config::method_carries_request_body`, which is also the sole source of
1032///   [`Operation::has_request_body`].
1033///
1034/// That second agreement is Phase 128 CR-02/CR-03. Before it, this function marked
1035/// every non-path declared parameter `ParameterLocation::Query` regardless of
1036/// method while `param_position` classified a mutating tool's as `Body`, and the
1037/// divergence was documented here as deliberate. It was not survivable: a `POST`
1038/// tool's declared parameters travelled in the URL — so the D3 cap was withheld
1039/// from values that genuinely land in a request line — and `build_body`, which
1040/// collects only args ABSENT from `operation.parameters`, found nothing, so no
1041/// payload reached the backend at all once `additionalProperties: false` began to
1042/// be enforced.
1043///
1044/// D-05 is still honoured, and now by the routing rather than despite it: a
1045/// `Body`-located parameter really is a payload field, and
1046/// `crate::config::default_cap_applies` emits no default `maxLength` for it.
1047#[cfg(feature = "http")]
1048fn build_operation(path: &str, method: &str, decl: &ToolDecl) -> Operation {
1049    let method_upper = method.to_uppercase();
1050    let path_param_names: Vec<&str> = crate::config::path_placeholder_names(path).collect();
1051
1052    let mut parameters = Vec::with_capacity(decl.parameters.len());
1053    // Path params (template `{...}` segments) — always required.
1054    //
1055    // Phase 128 D4(b): this loop iterates the TEMPLATE, not `decl.parameters`, so
1056    // the declared narrowing is looked up BY NAME and falls back to "no declared
1057    // rules" when the template names a segment the config never declared. Such a
1058    // parameter still gets the unconditional character floor and the always-on cap
1059    // at substitution time — it simply gets no narrowing on top (D-10).
1060    for name in &path_param_names {
1061        let declared = decl.parameters.iter().find(|p| p.name == **name);
1062        parameters.push(
1063            Parameter::new((*name).to_string(), ParameterLocation::Path, true).with_rules(
1064                declared.and_then(|p| p.pattern.clone()),
1065                declared.and_then(|p| p.max_length),
1066                // D-11: the config is the ONE legitimate source of this permission.
1067                declared.is_some_and(|p| p.allow_slash),
1068            ),
1069        );
1070    }
1071    // Remaining declared params → the QUERY STRING, or the JSON BODY when the
1072    // method carries one (Phase 128 CR-02).
1073    //
1074    // `has_request_body` and this location are derived from the SAME
1075    // `method_carries_request_body` predicate `ToolDecl::param_position` reads, so
1076    // a `Body`-located parameter on a body-less request is not constructible and
1077    // the D3 cap's scope cannot drift from the request's actual routing.
1078    let has_request_body = crate::config::method_carries_request_body(&method_upper);
1079    let non_path_location = if has_request_body {
1080        ParameterLocation::Body
1081    } else {
1082        ParameterLocation::Query
1083    };
1084    for p in &decl.parameters {
1085        if path_param_names.iter().any(|n| *n == p.name) {
1086            continue;
1087        }
1088        parameters.push(
1089            Parameter::new(p.name.clone(), non_path_location, p.required).with_rules(
1090                p.pattern.clone(),
1091                p.max_length,
1092                p.allow_slash,
1093            ),
1094        );
1095    }
1096
1097    Operation {
1098        method: method_upper,
1099        path: path.to_string(),
1100        parameters,
1101        has_request_body,
1102        base_url: decl.base_url.clone(),
1103    }
1104}
1105
1106/// Crate-private handler for a single-call HTTP tool (Phase 90 OAPI-02a).
1107///
1108/// Holds the synthesized [`ToolInfo`], the built [`Operation`], and the shared
1109/// [`HttpConnector`]. [`ToolHandler::metadata`] returns `Some(self.info.clone())`
1110/// (the same RESEARCH §Risks #2 invariant the SQL handler upholds); `handle()`
1111/// calls [`HttpConnector::execute`] and returns the JSON response.
1112#[cfg(feature = "http")]
1113struct HttpToolHandler {
1114    info: ToolInfo,
1115    operation: Operation,
1116    connector: Arc<dyn HttpConnector>,
1117}
1118
1119#[cfg(feature = "http")]
1120#[async_trait]
1121impl ToolHandler for HttpToolHandler {
1122    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
1123        // T-90-03-01: arg injection is bounded by TWO checks, both of which run
1124        // before this handler's connector call and both of which live in this
1125        // repository rather than "upstream":
1126        //
1127        // (1) the object-envelope schema (additionalProperties:false) is checked by
1128        //     `pmcp::server::schema_validation::validate_input`, called from the
1129        //     `ValidatingToolHandler` decorator that `enforce_input_schema` wraps
1130        //     around this handler at its push site — so a call carrying an
1131        //     undeclared key never reaches `handle` at all. Conditional on the
1132        //     `input-validation` feature and on `[server.validation]
1133        //     enforce_input_schema` not being `false`. Backed by
1134        //     `tests/input_validation_acceptance.rs::input_validation_refuses_undeclared_argument_without_contacting_upstream`,
1135        //     which fails if the decorator or its `validate_input` call is removed.
1136        //
1137        // (2) path substitution touches only declared `{params}`, and EVERY
1138        //     substituted value additionally faces
1139        //     `pmcp::server::schema_validation::validate_path_placeholder` inside
1140        //     `HttpClient::substitute_path`, with the composed result facing
1141        //     `validate_resolved_path` through `check_composed_path` before
1142        //     dispatch. Backed by
1143        //     `tests/curated_path_injection.rs::curated_path_injection_refuses_traversal_via_a_placeholder_value`
1144        //     and `..._refuses_a_query_via_a_placeholder_value`, with
1145        //     `..._accepts_a_compliant_call_with_one_upstream_request` as the
1146        //     passing control.
1147        //
1148        // Until Phase 128 this comment located the envelope check somewhere
1149        // "upstream" of here (the retired phrasing is quoted in full in
1150        // `128-10-SUMMARY.md` and deliberately nowhere in `src/`, so the SC-6 grep
1151        // gate keeps distinguishing a surviving claim from a note about one).
1152        // Core `pmcp`'s `tools/call` dispatch does not validate request arguments
1153        // against a declared `inputSchema` — that wiring is deliberately deferred
1154        // (D-01) — so the claim named a mitigation that did not exist. Restated
1155        // rather than deleted, because as of this phase it is true and local.
1156        // The connector's Display is redaction-safe (T-90-01-01); no URL/credential
1157        // reaches the client error.
1158        //
1159        // Phase 128 E1: `execute_for_tool`, not `execute`, so a registered
1160        // `RequestPolicy` is told WHICH tool the call came from. An `Operation`
1161        // describes an endpoint and cannot carry a tool name; the default trait
1162        // body delegates to `execute`, so an out-of-repo connector is unaffected.
1163        self.connector
1164            .execute_for_tool(&self.info.name, &self.operation, &args)
1165            .await
1166            .map_err(|e| pmcp::Error::Internal(format!("connector error: {e}")))
1167    }
1168
1169    fn metadata(&self) -> Option<ToolInfo> {
1170        Some(self.info.clone())
1171    }
1172}
1173
1174// -----------------------------------------------------------------------------
1175// Script-tool handler (Phase 90 OAPI-02b / D-01 / D-02) — feature
1176// `openapi-code-mode`
1177// -----------------------------------------------------------------------------
1178
1179/// Crate-private handler for a **script** `[[tools]]` entry (OAPI-02b / D-01).
1180///
1181/// A script tool runs admin-authored embedded JS through the EXACT SAME
1182/// `pmcp_code_mode` engine that Code Mode uses (D-02 — one engine, two
1183/// surfaces): [`pmcp_code_mode::PlanCompiler`] compiles the `script` to an
1184/// execution plan, then [`pmcp_code_mode::PlanExecutor`] over the shared
1185/// [`HttpCodeExecutor`] walks it. The client's validated `args` are bound to the
1186/// `args` variable BEFORE the script runs — identical to the `JsCodeExecutor`
1187/// path's `set_variable("args", …)`, which is what makes the engine-parity proof
1188/// (Plan 05 Task 2) hold byte-for-byte.
1189///
1190/// # No token cycle (Pitfall 7 / T-90-05-01)
1191///
1192/// A script tool is admin-authored + trusted (like a `sql=` curated query), so it
1193/// skips the Code Mode validation + HMAC-token gate entirely. It is bounded ONLY by
1194/// the [`ExecutionConfig`] caps (`max_api_calls`, `max_loop_iterations`,
1195/// `timeout_seconds`) the [`PlanExecutor`](pmcp_code_mode::PlanExecutor) enforces,
1196/// and by the `PlanCompiler`-accepted JS subset (no `eval` / FFI).
1197///
1198/// # Feature gate (RESEARCH Pitfall 4)
1199///
1200/// Gated `openapi-code-mode` (the umbrella that forwards
1201/// `pmcp-code-mode/js-runtime`) — `PlanCompiler` / `PlanExecutor` are NOT in
1202/// scope under bare `code-mode`, so the light / curated-only build (`http
1203/// code-mode`) compiles without this type (single-call only).
1204#[cfg(feature = "openapi-code-mode")]
1205struct ScriptToolHandler {
1206    /// The admin-authored script, compiled ONCE at synthesis (the body is fixed
1207    /// content). Executed per `handle` over a fresh
1208    /// [`PlanExecutor`](pmcp_code_mode::PlanExecutor).
1209    plan: pmcp_code_mode::ExecutionPlan,
1210    /// The SAME executor instance that feeds Code Mode (D-02). Cloned per request
1211    /// to construct a fresh [`PlanExecutor`](pmcp_code_mode::PlanExecutor).
1212    http_exec: HttpCodeExecutor,
1213    /// The execution bounds (Pitfall 7 — the only limit on an admin script).
1214    exec_config: ExecutionConfig,
1215    /// The synthesized `ToolInfo` (object-envelope schema from
1216    /// `[[tools.parameters]]`, `additionalProperties:false`).
1217    ///
1218    /// `args` are checked against THIS schema before the script runs (T-90-05-03),
1219    /// by `pmcp::server::schema_validation::validate_input` inside the
1220    /// `ValidatingToolHandler` decorator that [`enforce_input_schema`] wraps
1221    /// around this handler at its push site — so a refusal happens before
1222    /// [`ToolHandler::handle`] is entered and therefore before
1223    /// `PlanExecutor::execute` makes any backend call.
1224    ///
1225    /// Conditional on the `input-validation` feature and on `[server.validation]
1226    /// enforce_input_schema` not being `false`; with either off,
1227    /// [`enforce_input_schema`] logs the opt-out and returns the handler undecorated.
1228    ///
1229    /// Backed by
1230    /// `tests/script_tool.rs::script_tool_refuses_a_schema_violating_arg_before_the_script_runs`,
1231    /// which asserts the refusal AND that the mock backend observed zero requests —
1232    /// it fails if the decorator or its `validate_input` call is removed. The
1233    /// sibling `script_tool_args_max_lines_binding_is_honored` asserts argument
1234    /// BINDING, which is a different (also true) property; before this phase it was
1235    /// the only row carrying this threat ID, and a binding assertion is not a
1236    /// validation assertion.
1237    tool_info: ToolInfo,
1238}
1239
1240#[cfg(feature = "openapi-code-mode")]
1241impl ScriptToolHandler {
1242    /// Build a [`ScriptToolHandler`] from a script `[[tools]]` declaration,
1243    /// the shared [`HttpCodeExecutor`], and the [`ExecutionConfig`] bounds.
1244    ///
1245    /// The `tool_info` is built from `[[tools.parameters]]` via the SAME
1246    /// [`build_input_schema`] / [`build_annotations`] / [`apply_widget_meta`]
1247    /// helpers the single-call path uses, so a script tool's `args` are checked
1248    /// against an identically-shaped schema (object envelope,
1249    /// `additionalProperties:false` unless `[server.validation]
1250    /// additional_properties` opts out) by the same enforcer — one
1251    /// [`enforce_input_schema`] call per push site, one
1252    /// `pmcp::server::schema_validation::validate_input` inside
1253    /// `ValidatingToolHandler`. There is no second validator for script tools;
1254    /// see [`ScriptToolHandler::tool_info`] for the condition and the acceptance
1255    /// row.
1256    ///
1257    /// `validation` is threaded in so a script tool's schema is built under the SAME
1258    /// `[server.validation]` policy as every other tool kind — a script tool whose
1259    /// parameters escaped the D3 cap would be a hole in exactly the surface this
1260    /// phase closes. A script tool's parameters are BODY position (it declares no
1261    /// `path`/`method`), so in practice the cap does not apply to them; the point is
1262    /// that the decision is made by one rule rather than by which synthesizer ran.
1263    ///
1264    /// # Errors
1265    ///
1266    /// Returns [`ToolkitError::Synth`] if the declaration carries no `script`
1267    /// (a defensive guard — callers route only `is_script_tool()` entries here),
1268    /// or if the script fails to compile (surfaced here at server build time,
1269    /// failing fast rather than on the first tool call).
1270    fn new(
1271        decl: &ToolDecl,
1272        http_exec: HttpCodeExecutor,
1273        exec_config: ExecutionConfig,
1274        validation: &ValidationSection,
1275    ) -> Result<Self> {
1276        let script = decl.script.clone().ok_or_else(|| {
1277            ToolkitError::Synth(format!(
1278                "tool '{}' has no `script` body — not a script tool",
1279                decl.name
1280            ))
1281        })?;
1282        // Compile the admin-authored JS ONCE at synthesis time — the script is
1283        // fixed content, so compiling per request would re-run a full SWC parse
1284        // on the hot path (the PlanCompiler-accepted subset, no eval / FFI, is
1285        // the static bound). A compile error surfaces here at server build.
1286        let plan = pmcp_code_mode::PlanCompiler::with_config(&exec_config)
1287            .compile_code(&script)
1288            .map_err(|e| {
1289                ToolkitError::Synth(format!(
1290                    "tool '{}' script failed to compile: {e}",
1291                    decl.name
1292                ))
1293            })?;
1294        let tool_info = build_tool_info(decl, validation);
1295        Ok(Self {
1296            plan,
1297            // Phase 128 E1: this handler owns a per-tool CLONE of the shared
1298            // executor, which is the one place a script tool's name can be
1299            // attached — `HttpExecutor::execute_request` is a `pmcp-code-mode`
1300            // trait method and carries no tool name.
1301            http_exec: http_exec.with_tool_label(&decl.name),
1302            exec_config,
1303            tool_info,
1304        })
1305    }
1306}
1307
1308#[cfg(feature = "openapi-code-mode")]
1309#[pmcp_code_mode::async_trait]
1310impl ToolHandler for ScriptToolHandler {
1311    /// Run the pre-compiled admin-authored script over the shared engine, binding
1312    /// the validated `args` to the `args` variable (D-02 — identical to the
1313    /// `JsCodeExecutor` path's `set_variable("args", …)`).
1314    async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> {
1315        // (1) Execute the plan (compiled once in `new`) over a PER-REQUEST clone
1316        //     of the shared HttpCodeExecutor (D-02), threading the captured
1317        //     inbound MCP token (Plan 90-10 / OAPI-03 / OAPI-05) so an
1318        //     `oauth_passthrough` backend forwards it. Bounded by ExecutionConfig
1319        //     (Pitfall 7 — no token cycle, only these caps).
1320        let mut executor = pmcp_code_mode::PlanExecutor::new(
1321            crate::code_mode::request_executor_from_extra(&self.http_exec, &extra),
1322            self.exec_config.clone(),
1323        );
1324        // (2) Bind the client args to `args` (T-90-05-03) — byte-identical to
1325        //     compile_and_execute's set_variable("args", …). These args have
1326        //     ALREADY been checked against `self.tool_info.input_schema` by
1327        //     `pmcp::server::schema_validation::validate_input`, in the
1328        //     `ValidatingToolHandler` decorator wrapped around this handler — a
1329        //     violating call never reaches this line. That is a fact about the
1330        //     DECORATOR, not about this function: `handle` performs no validation
1331        //     of its own and must not be read as if it did. Condition and
1332        //     acceptance row: see `ScriptToolHandler::tool_info`.
1333        executor.set_variable("args", args);
1334
1335        let result = executor
1336            .execute(&self.plan)
1337            .await
1338            .map_err(|e| pmcp::Error::Internal(format!("script execution failed: {e}")))?;
1339        Ok(result.value)
1340    }
1341
1342    fn metadata(&self) -> Option<ToolInfo> {
1343        Some(self.tool_info.clone())
1344    }
1345}
1346
1347// -----------------------------------------------------------------------------
1348// Tests — Plan 05 Task 1 (RED) → GREEN in Task 2
1349// -----------------------------------------------------------------------------
1350
1351#[cfg(test)]
1352mod tests {
1353    use super::*;
1354    use crate::config::{
1355        AnnotationsDecl, ItemsDecl, ParamDecl, ServerConfig, ServerSection, ToolDecl,
1356        ValidationSection,
1357    };
1358    use serde_json::Value;
1359
1360    /// Construct a minimal `ServerConfig` that satisfies `validate()` (non-empty
1361    /// `name` + `version`) so the synthesizer path is the system under test —
1362    /// not the parser/validator from Plan 04.
1363    fn cfg_with_tools(tools: Vec<ToolDecl>) -> ServerConfig {
1364        ServerConfig {
1365            server: ServerSection {
1366                name: "demo".to_string(),
1367                version: "0.1.0".to_string(),
1368                ..Default::default()
1369            },
1370            tools,
1371            ..Default::default()
1372        }
1373    }
1374
1375    #[test]
1376    fn empty_tools_returns_empty_vec() {
1377        let cfg = cfg_with_tools(vec![]);
1378        let out = synthesize_from_config(&cfg).expect("synthesize");
1379        assert_eq!(out.len(), 0);
1380    }
1381
1382    #[test]
1383    fn one_tool_no_params_yields_object_schema() {
1384        let cfg = cfg_with_tools(vec![ToolDecl {
1385            name: "ping".to_string(),
1386            description: Some("Ping the server".to_string()),
1387            parameters: vec![],
1388            annotations: None,
1389            ..Default::default()
1390        }]);
1391        let out = synthesize_from_config(&cfg).expect("synthesize");
1392        assert_eq!(out.len(), 1);
1393        let (name, info, _handler) = &out[0];
1394        assert_eq!(name, "ping");
1395        assert_eq!(info.name, "ping");
1396        assert_eq!(info.description.as_deref(), Some("Ping the server"));
1397        let schema = &info.input_schema;
1398        assert_eq!(schema["type"], Value::String("object".to_string()));
1399        assert_eq!(schema["properties"], serde_json::json!({}));
1400        assert_eq!(schema["required"], serde_json::json!([]));
1401        assert_eq!(schema["additionalProperties"], Value::Bool(false));
1402    }
1403
1404    #[test]
1405    fn required_and_optional_params_partitioned() {
1406        let cfg = cfg_with_tools(vec![ToolDecl {
1407            name: "search".to_string(),
1408            description: Some("Search".to_string()),
1409            parameters: vec![
1410                ParamDecl {
1411                    name: "query".to_string(),
1412                    param_type: Some("string".to_string()),
1413                    description: Some("the search query".to_string()),
1414                    required: true,
1415                    ..Default::default()
1416                },
1417                ParamDecl {
1418                    name: "max_results".to_string(),
1419                    param_type: Some("integer".to_string()),
1420                    description: Some("maximum result count".to_string()),
1421                    required: false,
1422                    default: Some(toml::Value::Integer(100)),
1423                    minimum: Some(1.0),
1424                    maximum: Some(1000.0),
1425                    ..Default::default()
1426                },
1427            ],
1428            ..Default::default()
1429        }]);
1430        let out = synthesize_from_config(&cfg).expect("synthesize");
1431        let (_, info, _) = &out[0];
1432        let schema = &info.input_schema;
1433        assert_eq!(schema["required"], serde_json::json!(["query"]));
1434        let props = schema["properties"].as_object().expect("object");
1435        assert_eq!(props["query"]["type"], "string");
1436        assert_eq!(props["max_results"]["type"], "integer");
1437        assert_eq!(props["max_results"]["minimum"], serde_json::json!(1.0));
1438        assert_eq!(props["max_results"]["maximum"], serde_json::json!(1000.0));
1439        assert_eq!(props["max_results"]["default"], serde_json::json!(100));
1440    }
1441
1442    #[test]
1443    fn param_max_length_propagates() {
1444        let cfg = cfg_with_tools(vec![ToolDecl {
1445            name: "echo".to_string(),
1446            description: Some("Echo".to_string()),
1447            parameters: vec![ParamDecl {
1448                name: "text".to_string(),
1449                param_type: Some("string".to_string()),
1450                description: Some("input text".to_string()),
1451                required: true,
1452                max_length: Some(256),
1453                ..Default::default()
1454            }],
1455            ..Default::default()
1456        }]);
1457        let out = synthesize_from_config(&cfg).expect("synthesize");
1458        let (_, info, _) = &out[0];
1459        assert_eq!(
1460            info.input_schema["properties"]["text"]["maxLength"],
1461            serde_json::json!(256)
1462        );
1463    }
1464
1465    #[test]
1466    fn annotations_round_trip_via_fluent_builder() {
1467        let cfg = cfg_with_tools(vec![ToolDecl {
1468            name: "destroy_all".to_string(),
1469            description: Some("Destroy all data (test)".to_string()),
1470            parameters: vec![],
1471            annotations: Some(AnnotationsDecl {
1472                read_only_hint: false,
1473                destructive_hint: true,
1474                idempotent_hint: false,
1475                open_world_hint: false,
1476                cost_hint: Some("high".to_string()),
1477            }),
1478            ..Default::default()
1479        }]);
1480        let out = synthesize_from_config(&cfg).expect("synthesize");
1481        let (_, info, _) = &out[0];
1482        let ann = info.annotations.as_ref().expect("annotations");
1483        assert_eq!(ann.read_only_hint, Some(false));
1484        assert_eq!(ann.destructive_hint, Some(true));
1485        assert_eq!(ann.idempotent_hint, Some(false));
1486        assert_eq!(ann.open_world_hint, Some(false));
1487    }
1488
1489    /// REVIEWS H1 (in-plan widget_meta flip — no SqliteConnector dependency).
1490    ///
1491    /// When a `[[tools]]` entry declares `ui_resource_uri`, the synthesized
1492    /// `ToolInfo` must carry widget metadata so pmcp core's
1493    /// `with_widget_enrichment` (gated on `info.widget_meta().is_some()`)
1494    /// populates `structuredContent` (D-06). The flip lives in the shared
1495    /// `synthesize_inner` helper, so it fires for BOTH entry points; this test
1496    /// exercises it via the no-connector `synthesize_from_config` path.
1497    #[test]
1498    fn widget_meta_flips_when_ui_resource_uri_present() {
1499        let cfg = cfg_with_tools(vec![ToolDecl {
1500            name: "widget_tool".to_string(),
1501            description: Some("renders a widget".to_string()),
1502            ui_resource_uri: Some("ui://test".to_string()),
1503            ..Default::default()
1504        }]);
1505        let out = synthesize_from_config(&cfg).expect("synthesize");
1506        let (_, info, _) = &out[0];
1507        assert!(
1508            info.widget_meta().is_some(),
1509            "ui_resource_uri set ⇒ widget_meta() must be Some so D-06 structuredContent fires"
1510        );
1511    }
1512
1513    /// REVIEWS H1 negative case — a tool WITHOUT `ui_resource_uri` must NOT
1514    /// carry widget metadata (T-84-03-03: no accidental flip on non-widget
1515    /// tools).
1516    #[test]
1517    fn widget_meta_absent_when_ui_resource_uri_none() {
1518        let cfg = cfg_with_tools(vec![ToolDecl {
1519            name: "plain_tool".to_string(),
1520            description: Some("no widget".to_string()),
1521            ui_resource_uri: None,
1522            ..Default::default()
1523        }]);
1524        let out = synthesize_from_config(&cfg).expect("synthesize");
1525        let (_, info, _) = &out[0];
1526        assert!(
1527            info.widget_meta().is_none(),
1528            "ui_resource_uri absent ⇒ widget_meta() must be None (no accidental flip)"
1529        );
1530    }
1531
1532    #[tokio::test]
1533    async fn synthesized_handler_metadata_returns_some() {
1534        let cfg = cfg_with_tools(vec![ToolDecl {
1535            name: "ping".to_string(),
1536            description: Some("ping".to_string()),
1537            parameters: vec![],
1538            annotations: None,
1539            ..Default::default()
1540        }]);
1541        let out = synthesize_from_config(&cfg).expect("synthesize");
1542        let (_, expected_info, handler) = &out[0];
1543        let actual = handler.metadata();
1544        assert!(
1545            actual.is_some(),
1546            "RESEARCH §Risks #2 invariant: SynthesizedToolHandler::metadata() MUST return Some(ToolInfo)"
1547        );
1548        assert_eq!(actual.unwrap().name, expected_info.name);
1549    }
1550
1551    /// A `[[tools]]` declaration with one defaulted `limit` param (default=20),
1552    /// used to exercise [`extract_named_params`]'s default / explicit-null logic.
1553    fn decl_with_limit_default() -> ToolDecl {
1554        ToolDecl {
1555            name: "search".to_string(),
1556            description: Some("Search".to_string()),
1557            sql: Some("SELECT * FROM t LIMIT :limit".to_string()),
1558            parameters: vec![ParamDecl {
1559                name: "limit".to_string(),
1560                param_type: Some("integer".to_string()),
1561                description: Some("row limit".to_string()),
1562                required: false,
1563                default: Some(toml::Value::Integer(20)),
1564                ..Default::default()
1565            }],
1566            ..Default::default()
1567        }
1568    }
1569
1570    #[test]
1571    fn extract_named_params_applies_default_when_absent() {
1572        // `{}` → declared default (20) is bound (the reference search/list calls
1573        // rely on this so an omitted :limit never binds NULL).
1574        let decl = decl_with_limit_default();
1575        let params = extract_named_params(&decl, &serde_json::json!({}));
1576        assert_eq!(params, vec![("limit".to_string(), serde_json::json!(20))]);
1577    }
1578
1579    #[test]
1580    fn extract_named_params_explicit_null_applies_default() {
1581        // 85-10 WR-02 secondary fix: an EXPLICIT JSON null must NOT bind
1582        // `LIMIT NULL` — it falls through to the declared default exactly like
1583        // an omitted key.
1584        let decl = decl_with_limit_default();
1585        let params = extract_named_params(&decl, &serde_json::json!({ "limit": null }));
1586        assert_eq!(
1587            params,
1588            vec![("limit".to_string(), serde_json::json!(20))],
1589            "explicit null must apply the declared default, not bind LIMIT NULL"
1590        );
1591    }
1592
1593    #[test]
1594    fn extract_named_params_explicit_value_overrides_default() {
1595        // A concrete value wins over the default.
1596        let decl = decl_with_limit_default();
1597        let params = extract_named_params(&decl, &serde_json::json!({ "limit": 5 }));
1598        assert_eq!(params, vec![("limit".to_string(), serde_json::json!(5))]);
1599    }
1600
1601    // -------------------------------------------------------------------------
1602    // Phase 128 D2 — the six new `ParamDecl` keywords reach `inputSchema`
1603    // -------------------------------------------------------------------------
1604
1605    /// Fetch the synthesized property object for `param` of the single tool in
1606    /// `tools`.
1607    fn prop_of(tools: Vec<ToolDecl>, param: &str) -> Value {
1608        let cfg = cfg_with_tools(tools);
1609        let out = synthesize_from_config(&cfg).expect("synthesize");
1610        let (_name, info, _handler) = &out[0];
1611        info.input_schema["properties"][param].clone()
1612    }
1613
1614    /// D2 / SC-2: `pattern`, `minLength`, `format` and `maxItems` all reach the
1615    /// emitted `inputSchema`.
1616    #[test]
1617    fn input_schema_emits_d2_scalar_keywords() {
1618        let prop = prop_of(
1619            vec![ToolDecl {
1620                name: "lookup".to_string(),
1621                parameters: vec![ParamDecl {
1622                    name: "region".to_string(),
1623                    param_type: Some("string".to_string()),
1624                    required: true,
1625                    pattern: Some("^[A-Z]{3}$".to_string()),
1626                    min_length: Some(3),
1627                    format: Some("uuid".to_string()),
1628                    max_items: Some(5),
1629                    ..Default::default()
1630                }],
1631                ..Default::default()
1632            }],
1633            "region",
1634        );
1635        assert_eq!(prop["pattern"], serde_json::json!("^[A-Z]{3}$"));
1636        assert_eq!(prop["minLength"], serde_json::json!(3));
1637        assert_eq!(prop["format"], serde_json::json!("uuid"));
1638        assert_eq!(prop["maxItems"], serde_json::json!(5));
1639    }
1640
1641    /// D2 / RESEARCH Finding 1h: `items` is emitted in OBJECT form. The array
1642    /// (draft-07 tuple) form does not compile under the Draft 2020-12 pin and
1643    /// would take the whole tool's validator down.
1644    #[test]
1645    fn input_schema_emits_items_as_object_never_array() {
1646        let prop = prop_of(
1647            vec![ToolDecl {
1648                name: "batch".to_string(),
1649                parameters: vec![ParamDecl {
1650                    name: "codes".to_string(),
1651                    param_type: Some("array".to_string()),
1652                    required: true,
1653                    items: Some(ItemsDecl {
1654                        item_type: Some("string".to_string()),
1655                        max_length: Some(8),
1656                        pattern: Some("^[a-z]+$".to_string()),
1657                    }),
1658                    max_items: Some(10),
1659                    ..Default::default()
1660                }],
1661                ..Default::default()
1662            }],
1663            "codes",
1664        );
1665        assert!(
1666            prop["items"].is_object(),
1667            "items must be an OBJECT, got: {}",
1668            prop["items"]
1669        );
1670        assert!(
1671            !prop["items"].is_array(),
1672            "array-form items does not compile under the 2020-12 pin"
1673        );
1674        assert_eq!(
1675            prop["items"],
1676            serde_json::json!({
1677                "type": "string",
1678                "maxLength": 8,
1679                "pattern": "^[a-z]+$",
1680            })
1681        );
1682    }
1683
1684    /// D2 edge (empty): a `ParamDecl` carrying NONE of the six new keywords emits
1685    /// exactly the five it emits today — no empty `pattern` string and no empty
1686    /// `items` object appears.
1687    #[test]
1688    fn input_schema_omits_d2_keywords_when_undeclared() {
1689        let prop = prop_of(
1690            vec![ToolDecl {
1691                name: "legacy".to_string(),
1692                parameters: vec![ParamDecl {
1693                    name: "count".to_string(),
1694                    param_type: Some("integer".to_string()),
1695                    description: Some("how many".to_string()),
1696                    required: false,
1697                    minimum: Some(1.0),
1698                    maximum: Some(10.0),
1699                    max_length: Some(4),
1700                    ..Default::default()
1701                }],
1702                ..Default::default()
1703            }],
1704            "count",
1705        );
1706        let obj = prop.as_object().expect("property object");
1707        let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
1708        keys.sort_unstable();
1709        assert_eq!(
1710            keys,
1711            vec!["description", "maxLength", "maximum", "minimum", "type"],
1712            "exactly the five pre-D2 keywords, and no more"
1713        );
1714        for absent in ["pattern", "minLength", "format", "items", "maxItems"] {
1715            assert!(
1716                obj.get(absent).is_none(),
1717                "undeclared keyword {absent} must not be emitted"
1718            );
1719        }
1720    }
1721
1722    /// D2 edge (ordering): two synthesis runs over ONE config produce
1723    /// byte-identical `input_schema` values, so `properties` and `required` follow
1724    /// `[[tools.parameters]]` declaration order deterministically.
1725    #[test]
1726    fn input_schema_is_byte_identical_across_two_synthesis_runs() {
1727        let tools = vec![ToolDecl {
1728            name: "search".to_string(),
1729            parameters: vec![
1730                ParamDecl {
1731                    name: "zebra".to_string(),
1732                    param_type: Some("string".to_string()),
1733                    required: true,
1734                    pattern: Some("^z".to_string()),
1735                    ..Default::default()
1736                },
1737                ParamDecl {
1738                    name: "alpha".to_string(),
1739                    param_type: Some("string".to_string()),
1740                    required: true,
1741                    min_length: Some(1),
1742                    ..Default::default()
1743                },
1744                ParamDecl {
1745                    name: "middle".to_string(),
1746                    param_type: Some("integer".to_string()),
1747                    required: false,
1748                    ..Default::default()
1749                },
1750            ],
1751            ..Default::default()
1752        }];
1753        let cfg = cfg_with_tools(tools);
1754        let first = synthesize_from_config(&cfg).expect("synthesize")[0]
1755            .1
1756            .input_schema
1757            .to_string();
1758        let second = synthesize_from_config(&cfg).expect("synthesize")[0]
1759            .1
1760            .input_schema
1761            .to_string();
1762        assert_eq!(first, second, "synthesis must be byte-deterministic");
1763        // Declaration order, not sorted order.
1764        assert!(
1765            first.find("\"zebra\"").unwrap() < first.find("\"alpha\"").unwrap(),
1766            "properties must follow declaration order: {first}"
1767        );
1768    }
1769
1770    /// D2 edge (adjacency): `min_length == max_length` accepts exactly that length
1771    /// and refuses one code point either side, through the SAME core validator the
1772    /// D1 decorator uses.
1773    #[cfg(feature = "input-validation")]
1774    #[test]
1775    fn min_length_equal_to_max_length_accepts_exactly_that_length() {
1776        use pmcp::server::schema_validation::validate_input;
1777
1778        let cfg = cfg_with_tools(vec![ToolDecl {
1779            name: "exact".to_string(),
1780            parameters: vec![ParamDecl {
1781                name: "code".to_string(),
1782                param_type: Some("string".to_string()),
1783                required: true,
1784                min_length: Some(3),
1785                max_length: Some(3),
1786                ..Default::default()
1787            }],
1788            ..Default::default()
1789        }]);
1790        let out = synthesize_from_config(&cfg).expect("synthesize");
1791        let schema = &out[0].1.input_schema;
1792
1793        validate_input(schema, Some(&serde_json::json!({ "code": "abc" })), None)
1794            .expect("exactly three code points must be accepted");
1795        validate_input(schema, Some(&serde_json::json!({ "code": "ab" })), None)
1796            .expect_err("two code points must be refused");
1797        validate_input(schema, Some(&serde_json::json!({ "code": "abcd" })), None)
1798            .expect_err("four code points must be refused");
1799    }
1800
1801    // -------------------------------------------------------------------------
1802    // Phase 128 D3 — position-scoped default cap and `[server.validation]`
1803    // -------------------------------------------------------------------------
1804
1805    /// Synthesize `tools` under `validation` and return the property object for
1806    /// `param` of the FIRST tool.
1807    fn prop_under(tools: Vec<ToolDecl>, validation: ValidationSection, param: &str) -> Value {
1808        let mut cfg = cfg_with_tools(tools);
1809        cfg.server.validation = validation;
1810        let out = synthesize_from_config(&cfg).expect("synthesize");
1811        out[0].1.input_schema["properties"][param].clone()
1812    }
1813
1814    /// A single-call `GET` tool declaring one path parameter and one query
1815    /// parameter, both uncapped strings.
1816    fn get_tool_with_path_and_query() -> Vec<ToolDecl> {
1817        vec![ToolDecl {
1818            name: "line_status".to_string(),
1819            path: Some("/lines/{line_id}/status".to_string()),
1820            method: Some("GET".to_string()),
1821            parameters: vec![
1822                ParamDecl {
1823                    name: "line_id".to_string(),
1824                    param_type: Some("string".to_string()),
1825                    required: true,
1826                    ..Default::default()
1827                },
1828                ParamDecl {
1829                    name: "detail".to_string(),
1830                    param_type: Some("string".to_string()),
1831                    required: false,
1832                    ..Default::default()
1833                },
1834            ],
1835            ..Default::default()
1836        }]
1837    }
1838
1839    /// D3: a PATH-position string with no `max_length` is capped at the configured
1840    /// default.
1841    #[test]
1842    fn default_cap_applies_to_path_position_string() {
1843        let prop = prop_under(
1844            get_tool_with_path_and_query(),
1845            ValidationSection::default(),
1846            "line_id",
1847        );
1848        assert_eq!(prop["maxLength"], serde_json::json!(256));
1849    }
1850
1851    /// D3: a QUERY-position string (non-path parameter of a `GET` tool) with no
1852    /// `max_length` is capped at the configured default.
1853    #[test]
1854    fn default_cap_applies_to_query_position_string() {
1855        let prop = prop_under(
1856            get_tool_with_path_and_query(),
1857            ValidationSection::default(),
1858            "detail",
1859        );
1860        assert_eq!(prop["maxLength"], serde_json::json!(256));
1861    }
1862
1863    /// D3 / D-05 / T-128-13d: a BODY-position string on a mutating single-call tool
1864    /// receives NO cap. Capping a `POST` payload's free-text field is exactly the
1865    /// breakage review note C objected to.
1866    #[test]
1867    fn default_cap_never_applies_to_body_position_string() {
1868        let tools = vec![ToolDecl {
1869            name: "add_comment".to_string(),
1870            path: Some("/issues/{id}/comments".to_string()),
1871            method: Some("POST".to_string()),
1872            parameters: vec![
1873                ParamDecl {
1874                    name: "id".to_string(),
1875                    param_type: Some("string".to_string()),
1876                    required: true,
1877                    ..Default::default()
1878                },
1879                ParamDecl {
1880                    name: "body_text".to_string(),
1881                    param_type: Some("string".to_string()),
1882                    required: true,
1883                    ..Default::default()
1884                },
1885            ],
1886            ..Default::default()
1887        }];
1888        let path_prop = prop_under(tools.clone(), ValidationSection::default(), "id");
1889        assert_eq!(
1890            path_prop["maxLength"],
1891            serde_json::json!(256),
1892            "the path parameter of a POST tool IS still capped"
1893        );
1894        let body_prop = prop_under(tools, ValidationSection::default(), "body_text");
1895        assert!(
1896            body_prop.get("maxLength").is_none(),
1897            "a POST payload field must receive no default cap, got: {body_prop}"
1898        );
1899    }
1900
1901    /// D3 edge (adjacency): a DECLARED `max_length` is never overridden and never
1902    /// merged with the default, in either direction.
1903    #[test]
1904    fn declared_max_length_wins_over_the_default_cap() {
1905        let mut tools = get_tool_with_path_and_query();
1906        tools[0].parameters[0].max_length = Some(12);
1907        let prop = prop_under(tools, ValidationSection::default(), "line_id");
1908        assert_eq!(prop["maxLength"], serde_json::json!(12));
1909    }
1910
1911    /// D3 edge (empty): `default_max_length = 0` emits no `maxLength` in ANY
1912    /// position and disables the cap entirely.
1913    #[test]
1914    fn default_max_length_zero_disables_the_cap_in_every_position() {
1915        let validation = ValidationSection {
1916            default_max_length: 0,
1917            ..Default::default()
1918        };
1919        for param in ["line_id", "detail"] {
1920            let prop = prop_under(get_tool_with_path_and_query(), validation.clone(), param);
1921            assert!(
1922                prop.get("maxLength").is_none(),
1923                "{param} must carry no maxLength when the default is 0, got: {prop}"
1924            );
1925        }
1926    }
1927
1928    /// D3 edge (boundary + encoding): the cap is counted in Unicode CODE POINTS,
1929    /// not bytes — so exactly `default_max_length` multi-byte characters are
1930    /// accepted and one more is refused, through the SAME core validator.
1931    #[cfg(feature = "input-validation")]
1932    #[test]
1933    fn default_cap_boundary_is_counted_in_code_points_not_bytes() {
1934        use pmcp::server::schema_validation::validate_input;
1935
1936        let validation = ValidationSection {
1937            default_max_length: 8,
1938            ..Default::default()
1939        };
1940        let mut cfg = cfg_with_tools(get_tool_with_path_and_query());
1941        cfg.server.validation = validation;
1942        let out = synthesize_from_config(&cfg).expect("synthesize");
1943        let schema = &out[0].1.input_schema;
1944
1945        // Each `é` is TWO bytes and ONE code point. Eight of them are 16 bytes.
1946        let at_limit: String = "é".repeat(8);
1947        let over_limit: String = "é".repeat(9);
1948        assert_eq!(
1949            at_limit.len(),
1950            16,
1951            "the fixture must actually be multi-byte"
1952        );
1953        validate_input(
1954            schema,
1955            Some(&serde_json::json!({ "line_id": at_limit })),
1956            None,
1957        )
1958        .expect("exactly 8 code points must be accepted even though they are 16 bytes");
1959        validate_input(
1960            schema,
1961            Some(&serde_json::json!({ "line_id": over_limit })),
1962            None,
1963        )
1964        .expect_err("9 code points must be refused");
1965    }
1966
1967    /// `additional_properties = true` flips the emitted envelope, re-opening the
1968    /// unknown-argument class for this server.
1969    #[test]
1970    fn additional_properties_opt_out_flips_the_envelope() {
1971        let mut cfg = cfg_with_tools(get_tool_with_path_and_query());
1972        cfg.server.validation = ValidationSection {
1973            additional_properties: true,
1974            ..Default::default()
1975        };
1976        let out = synthesize_from_config(&cfg).expect("synthesize");
1977        assert_eq!(
1978            out[0].1.input_schema["additionalProperties"],
1979            Value::Bool(true)
1980        );
1981
1982        let mut cfg = cfg_with_tools(get_tool_with_path_and_query());
1983        cfg.server.validation = ValidationSection::default();
1984        let out = synthesize_from_config(&cfg).expect("synthesize");
1985        assert_eq!(
1986            out[0].1.input_schema["additionalProperties"],
1987            Value::Bool(false),
1988            "the default must remain a closed envelope"
1989        );
1990    }
1991
1992    /// T-128-13c: `enforce_input_schema = false` skips the SCHEMA CHECK, not the
1993    /// decorator. This test pins the separation the E2 argument-validator registry
1994    /// will depend on: with the flag off an undeclared argument is ACCEPTED (schema
1995    /// off), while the inner handler's own rule still REFUSES (the non-schema
1996    /// enforcement is untouched). Without this test the two collapse back together
1997    /// on the next refactor.
1998    #[cfg(feature = "input-validation")]
1999    #[tokio::test]
2000    async fn enforce_input_schema_false_skips_the_check_not_the_decorator() {
2001        /// Stands in for an explicitly-registered argument validator: a rule that
2002        /// lives INSIDE the decorated stack and is not the JSON Schema check.
2003        struct RefusingInner;
2004
2005        #[async_trait]
2006        impl ToolHandler for RefusingInner {
2007            async fn handle(
2008                &self,
2009                _args: Value,
2010                _extra: RequestHandlerExtra,
2011            ) -> pmcp::Result<Value> {
2012                Err(pmcp::Error::Validation(
2013                    "the registered validator refused".to_string(),
2014                ))
2015            }
2016        }
2017
2018        let decl = ToolDecl {
2019            name: "guarded".to_string(),
2020            parameters: vec![ParamDecl {
2021                name: "declared".to_string(),
2022                param_type: Some("string".to_string()),
2023                required: false,
2024                ..Default::default()
2025            }],
2026            ..Default::default()
2027        };
2028        let info = build_tool_info(&decl, &ValidationSection::default());
2029        let undeclared = serde_json::json!({ "not_declared_at_all": "x" });
2030
2031        // (a) enforcement ON: the SCHEMA refuses before the inner handler runs, so
2032        //     the message is the schema refusal, not the inner one.
2033        let on = ValidatingToolHandler::wrap(Arc::new(RefusingInner), &info, &decl, true, None);
2034        let err = on
2035            .handle(undeclared.clone(), RequestHandlerExtra::default())
2036            .await
2037            .expect_err("an undeclared argument must be refused when enforcement is on");
2038        assert!(
2039            !err.to_string().contains("registered validator"),
2040            "the schema check must run FIRST when enforcement is on: {err}"
2041        );
2042
2043        // (b) enforcement OFF: the schema check is skipped (the undeclared argument
2044        //     is accepted by it), and the inner rule STILL refuses. That is the
2045        //     separation — A off must not silently turn B off.
2046        let off = ValidatingToolHandler::wrap(Arc::new(RefusingInner), &info, &decl, false, None);
2047        let err = off
2048            .handle(undeclared, RequestHandlerExtra::default())
2049            .await
2050            .expect_err("the inner (non-schema) rule must still refuse");
2051        assert!(
2052            err.to_string().contains("registered validator"),
2053            "with the schema check off, the refusal must come from the inner rule: {err}"
2054        );
2055    }
2056}
2057
2058// -----------------------------------------------------------------------------
2059// Tests — Phase 128 D4(b): `build_operation` carries the declared placeholder
2060// rules onto every `Parameter`.
2061// -----------------------------------------------------------------------------
2062
2063/// Named `build_operation` deliberately: libtest matches the FULL test path, so a
2064/// test placed in the crate's existing `mod tests` would be
2065/// `tools::tests::build_operation_…` and this plan's `--lib tools::build_operation`
2066/// verify filter would select ZERO tests while exiting 0. As a SIBLING of `tests`
2067/// at the `tools` module level the filter resolves as written. Do not fold these
2068/// into `mod tests`.
2069#[cfg(all(test, feature = "http"))]
2070mod build_operation {
2071    use super::*;
2072
2073    /// A single-call `GET` tool on `path` carrying `parameters`.
2074    fn decl(path: &str, parameters: Vec<ParamDecl>) -> ToolDecl {
2075        ToolDecl {
2076            name: "t".to_string(),
2077            description: Some("t".to_string()),
2078            path: Some(path.to_string()),
2079            method: Some("GET".to_string()),
2080            parameters,
2081            ..Default::default()
2082        }
2083    }
2084
2085    fn param_named<'a>(op: &'a Operation, name: &str) -> &'a Parameter {
2086        op.parameters
2087            .iter()
2088            .find(|p| p.name == name)
2089            .unwrap_or_else(|| panic!("parameter {name} present"))
2090    }
2091
2092    /// A path parameter declaring a `pattern` produces a `Parameter` carrying it.
2093    #[test]
2094    fn build_operation_carries_a_declared_path_pattern() {
2095        let d = decl(
2096            "/content/{version}",
2097            vec![ParamDecl {
2098                name: "version".to_string(),
2099                param_type: Some("string".to_string()),
2100                required: true,
2101                pattern: Some("^C[0-9]+$".to_string()),
2102                ..Default::default()
2103            }],
2104        );
2105        let op = super::build_operation("/content/{version}", "GET", &d);
2106        let p = param_named(&op, "version");
2107        assert_eq!(p.location, ParameterLocation::Path);
2108        assert_eq!(p.pattern.as_deref(), Some("^C[0-9]+$"));
2109    }
2110
2111    /// A query parameter declaring `max_length = 64` produces a `Parameter`
2112    /// carrying `max_length: Some(64)`.
2113    #[test]
2114    fn build_operation_carries_a_declared_query_max_length() {
2115        let d = decl(
2116            "/search",
2117            vec![ParamDecl {
2118                name: "q".to_string(),
2119                param_type: Some("string".to_string()),
2120                max_length: Some(64),
2121                ..Default::default()
2122            }],
2123        );
2124        let op = super::build_operation("/search", "GET", &d);
2125        let p = param_named(&op, "q");
2126        assert_eq!(p.location, ParameterLocation::Query);
2127        assert_eq!(p.max_length, Some(64));
2128    }
2129
2130    /// A template segment the config never declared carries NO declared rules and
2131    /// `allow_slash: false` — the path loop iterates the TEMPLATE, so it must look
2132    /// the `ParamDecl` up by name and fall back cleanly when there is none.
2133    #[test]
2134    fn build_operation_leaves_rules_absent_for_an_undeclared_template_segment() {
2135        let d = decl("/content/{version}", vec![]);
2136        let op = super::build_operation("/content/{version}", "GET", &d);
2137        let p = param_named(&op, "version");
2138        assert_eq!(p.pattern, None);
2139        assert_eq!(p.max_length, None);
2140        assert!(!p.allow_slash);
2141        assert!(p.required, "a template path parameter stays required");
2142    }
2143
2144    /// D-11: `allow_slash` reaches the `Parameter` from the server's OWN config —
2145    /// the one legitimate source.
2146    #[test]
2147    fn build_operation_carries_allow_slash_from_the_config() {
2148        let d = decl(
2149            "/files/{subpath}",
2150            vec![ParamDecl {
2151                name: "subpath".to_string(),
2152                param_type: Some("string".to_string()),
2153                required: true,
2154                allow_slash: true,
2155                ..Default::default()
2156            }],
2157        );
2158        let op = super::build_operation("/files/{subpath}", "GET", &d);
2159        assert!(param_named(&op, "subpath").allow_slash);
2160    }
2161
2162    /// The three rule fields round-trip into the core rules type the substitution
2163    /// point reads.
2164    #[cfg(feature = "input-validation")]
2165    #[test]
2166    fn build_operation_rules_reach_placeholder_rules() {
2167        let d = decl(
2168            "/files/{subpath}",
2169            vec![ParamDecl {
2170                name: "subpath".to_string(),
2171                param_type: Some("string".to_string()),
2172                required: true,
2173                pattern: Some("^[a-z/]+$".to_string()),
2174                max_length: Some(128),
2175                allow_slash: true,
2176                ..Default::default()
2177            }],
2178        );
2179        let op = super::build_operation("/files/{subpath}", "GET", &d);
2180        let rules = param_named(&op, "subpath").placeholder_rules();
2181        assert_eq!(rules.declared_pattern, Some("^[a-z/]+$"));
2182        assert_eq!(rules.declared_max_length, Some(128));
2183        assert!(rules.allow_slash);
2184    }
2185
2186    /// Phase 128 CR-02 — a body-bearing method routes its non-path declared
2187    /// parameters to [`ParameterLocation::Body`], not to the query string.
2188    ///
2189    /// Before the fix every one of these was `Query`, so the payload
2190    /// `build_body` assembles was empty and the values travelled in the URL.
2191    #[test]
2192    fn build_operation_routes_a_post_non_path_param_to_the_body() {
2193        for method in ["POST", "PUT", "PATCH", "post"] {
2194            let mut d = decl(
2195                "/issues/{id}/comments",
2196                vec![
2197                    ParamDecl {
2198                        name: "id".to_string(),
2199                        param_type: Some("string".to_string()),
2200                        required: true,
2201                        ..Default::default()
2202                    },
2203                    ParamDecl {
2204                        name: "body_text".to_string(),
2205                        param_type: Some("string".to_string()),
2206                        required: true,
2207                        ..Default::default()
2208                    },
2209                ],
2210            );
2211            d.method = Some(method.to_string());
2212            let op = super::build_operation("/issues/{id}/comments", method, &d);
2213            assert!(
2214                op.has_request_body,
2215                "{method} carries a request body by definition"
2216            );
2217            assert_eq!(param_named(&op, "id").location, ParameterLocation::Path);
2218            assert_eq!(
2219                param_named(&op, "body_text").location,
2220                ParameterLocation::Body,
2221                "{method}: a non-path declared parameter must be routed to the payload"
2222            );
2223            assert_eq!(
2224                op.body_parameters().len(),
2225                1,
2226                "{method}: exactly the one non-path parameter is a body parameter"
2227            );
2228            assert!(
2229                op.query_parameters().is_empty(),
2230                "{method}: nothing may travel in the query string as well"
2231            );
2232        }
2233    }
2234
2235    /// The other half of the same rule: a method that carries NO request body has
2236    /// nowhere but the URL to put a non-path parameter, so it stays `Query`. Without
2237    /// this row the fix above is satisfiable by routing everything to the body,
2238    /// which would drop a `GET` tool's parameters entirely.
2239    #[test]
2240    fn build_operation_keeps_a_body_less_method_non_path_param_in_the_query() {
2241        for method in ["GET", "HEAD", "DELETE", "OPTIONS"] {
2242            let mut d = decl(
2243                "/search",
2244                vec![ParamDecl {
2245                    name: "q".to_string(),
2246                    param_type: Some("string".to_string()),
2247                    required: true,
2248                    ..Default::default()
2249                }],
2250            );
2251            d.method = Some(method.to_string());
2252            let op = super::build_operation("/search", method, &d);
2253            assert!(!op.has_request_body, "{method} carries no request body");
2254            assert_eq!(
2255                param_named(&op, "q").location,
2256                ParameterLocation::Query,
2257                "{method}: a non-path parameter must stay in the query string"
2258            );
2259            assert!(
2260                op.body_parameters().is_empty(),
2261                "{method}: a body-less request can have no body parameter"
2262            );
2263        }
2264    }
2265
2266    /// Phase 128 CR-03 — the D3 cap's POSITION and the request's ROUTING agree for
2267    /// EVERY method this connector can send, so the cap can no longer be withheld
2268    /// from a value that travels in the query string.
2269    ///
2270    /// This is the row that would have caught the original defect: it does not
2271    /// assert a particular location, it asserts the PAIRING. Before the fix
2272    /// `param_position` said `Body` for `POST` while `build_operation` said `Query`,
2273    /// and `apply_position_cap` emitted nothing for `Body` — so a `POST` parameter
2274    /// reached the request line with no `maxLength`.
2275    ///
2276    /// Fails on removal: point either side at its own method list again and the
2277    /// `POST`/`PUT`/`PATCH` rows (or the `OPTIONS` row) go red.
2278    #[test]
2279    fn param_position_agrees_with_the_built_parameter_location_for_every_method() {
2280        use crate::config::ParamPosition;
2281
2282        // Every method `HttpClient::convert_method` accepts.
2283        for method in [
2284            "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "patch",
2285        ] {
2286            let mut d = decl(
2287                "/things/{id}",
2288                vec![
2289                    ParamDecl {
2290                        name: "id".to_string(),
2291                        param_type: Some("string".to_string()),
2292                        required: true,
2293                        ..Default::default()
2294                    },
2295                    ParamDecl {
2296                        name: "note".to_string(),
2297                        param_type: Some("string".to_string()),
2298                        required: false,
2299                        ..Default::default()
2300                    },
2301                ],
2302            );
2303            d.method = Some(method.to_string());
2304            let op = super::build_operation("/things/{id}", method, &d);
2305
2306            for p in &op.parameters {
2307                let expected_location = match d.param_position(&p.name) {
2308                    ParamPosition::Path => ParameterLocation::Path,
2309                    ParamPosition::Query => ParameterLocation::Query,
2310                    ParamPosition::Body => ParameterLocation::Body,
2311                };
2312                assert_eq!(
2313                    p.location,
2314                    expected_location,
2315                    "{method}: `{}` is {:?} for the D3 cap but {:?} on the wire — the cap's \
2316                     scope and the request's routing must not disagree",
2317                    p.name,
2318                    d.param_position(&p.name),
2319                    p.location
2320                );
2321            }
2322
2323            // And the routing is self-consistent: a Body location exists exactly
2324            // when the request carries a body.
2325            assert_eq!(
2326                op.has_request_body,
2327                !op.body_parameters().is_empty(),
2328                "{method}: a Body-located parameter on a body-less request would be \
2329                 silently dropped"
2330            );
2331        }
2332    }
2333
2334    /// The consequence CR-03 is really about: the D3 default `maxLength` is emitted
2335    /// for a parameter that travels in the query string and withheld from one that
2336    /// travels in the JSON payload — checked against the BUILT location rather than
2337    /// against a method list, so the two cannot drift.
2338    #[test]
2339    fn the_default_cap_is_emitted_exactly_where_the_value_travels_in_the_url() {
2340        use crate::config::{default_cap_applies, ValidationSection};
2341
2342        let validation = ValidationSection::default();
2343        assert_ne!(
2344            validation.default_max_length, 0,
2345            "the default cap must be on for this row to mean anything"
2346        );
2347
2348        for method in ["GET", "HEAD", "DELETE", "OPTIONS", "POST", "PUT", "PATCH"] {
2349            let param = ParamDecl {
2350                name: "note".to_string(),
2351                param_type: Some("string".to_string()),
2352                required: false,
2353                ..Default::default()
2354            };
2355            let mut d = decl("/things", vec![param.clone()]);
2356            d.method = Some(method.to_string());
2357            let op = super::build_operation("/things", method, &d);
2358            let location = param_named(&op, "note").location;
2359            let capped = default_cap_applies(&param, d.param_position("note"), &validation);
2360
2361            assert_eq!(
2362                capped,
2363                location == ParameterLocation::Query,
2364                "{method}: an uncapped string in a {location:?} position is the CR-03 defect \
2365                 when that position is the query string, and the D-05 requirement when it is \
2366                 the payload"
2367            );
2368        }
2369    }
2370}
2371
2372// -----------------------------------------------------------------------------
2373// Tests — Phase 90 OAPI-02a single-call HTTP synthesizer (feature `http`)
2374// -----------------------------------------------------------------------------
2375
2376#[cfg(all(test, feature = "http"))]
2377mod synth_http_tests {
2378    use super::*;
2379    use crate::config::{ParamDecl, ServerConfig, ServerSection, ToolDecl};
2380    use crate::http::{HttpConnector, HttpConnectorError, Operation};
2381    use pmcp::RequestHandlerExtra;
2382    use serde_json::{json, Value};
2383    use std::sync::{Arc, Mutex};
2384
2385    /// A mock [`HttpConnector`] that records the [`Operation`] it last received
2386    /// and returns a fixed JSON payload — so a synthesized handler can be
2387    /// invoked without any network.
2388    struct MockHttpConnector {
2389        last: Mutex<Option<Operation>>,
2390        payload: Value,
2391    }
2392
2393    impl MockHttpConnector {
2394        fn new(payload: Value) -> Arc<Self> {
2395            Arc::new(Self {
2396                last: Mutex::new(None),
2397                payload,
2398            })
2399        }
2400    }
2401
2402    #[async_trait]
2403    impl HttpConnector for MockHttpConnector {
2404        async fn execute(
2405            &self,
2406            operation: &Operation,
2407            _args: &Value,
2408        ) -> std::result::Result<Value, HttpConnectorError> {
2409            *self.last.lock().unwrap() = Some(operation.clone());
2410            Ok(self.payload.clone())
2411        }
2412        fn base_url(&self) -> &str {
2413            "https://mock.example.com"
2414        }
2415    }
2416
2417    fn cfg_with_tools(tools: Vec<ToolDecl>) -> ServerConfig {
2418        ServerConfig {
2419            server: ServerSection {
2420                name: "demo".to_string(),
2421                version: "0.1.0".to_string(),
2422                ..Default::default()
2423            },
2424            tools,
2425            ..Default::default()
2426        }
2427    }
2428
2429    /// (1) A single-call `[[tools]]` with a `{id}` path param synthesizes a
2430    /// `ToolInfo` whose input schema marks `id` required (object envelope), and
2431    /// the handler (wired to a mock connector) returns the mocked JSON.
2432    #[tokio::test]
2433    async fn synth_http_single_call_path_param_required_and_handler_returns_json() {
2434        let cfg = cfg_with_tools(vec![ToolDecl {
2435            name: "line_status".to_string(),
2436            description: Some("Line status".to_string()),
2437            path: Some("/Line/{id}/Status".to_string()),
2438            method: Some("GET".to_string()),
2439            parameters: vec![ParamDecl {
2440                name: "id".to_string(),
2441                param_type: Some("string".to_string()),
2442                required: true,
2443                ..Default::default()
2444            }],
2445            ..Default::default()
2446        }]);
2447        let connector = MockHttpConnector::new(json!({ "status": "Good Service" }));
2448        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
2449            .expect("synthesize");
2450        assert_eq!(out.len(), 1);
2451        let (name, info, handler) = &out[0];
2452        assert_eq!(name, "line_status");
2453        let schema = &info.input_schema;
2454        assert_eq!(schema["type"], "object");
2455        assert_eq!(schema["required"], json!(["id"]));
2456        assert_eq!(schema["additionalProperties"], Value::Bool(false));
2457
2458        let extra = RequestHandlerExtra::default();
2459        let result = handler
2460            .handle(json!({ "id": "victoria" }), extra)
2461            .await
2462            .expect("handle");
2463        assert_eq!(result, json!({ "status": "Good Service" }));
2464
2465        // The operation carried the `{id}` path param as a Path parameter.
2466        let op = connector
2467            .last
2468            .lock()
2469            .unwrap()
2470            .clone()
2471            .expect("operation recorded");
2472        let path_params: Vec<&str> = op
2473            .path_parameters()
2474            .iter()
2475            .map(|p| p.name.as_str())
2476            .collect();
2477        assert_eq!(path_params, vec!["id"]);
2478    }
2479
2480    /// (2) A `POST` tool routes non-path args to the request body
2481    /// (`has_request_body` true) and the non-path param is NOT a path param.
2482    #[tokio::test]
2483    async fn synth_http_post_sets_request_body() {
2484        let cfg = cfg_with_tools(vec![ToolDecl {
2485            name: "create_item".to_string(),
2486            description: Some("Create".to_string()),
2487            path: Some("/items".to_string()),
2488            method: Some("post".to_string()),
2489            parameters: vec![ParamDecl {
2490                name: "title".to_string(),
2491                param_type: Some("string".to_string()),
2492                required: true,
2493                ..Default::default()
2494            }],
2495            ..Default::default()
2496        }]);
2497        let connector = MockHttpConnector::new(json!({ "ok": true }));
2498        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
2499            .expect("synthesize");
2500        let (_, _, handler) = &out[0];
2501        let extra = RequestHandlerExtra::default();
2502        handler
2503            .handle(json!({ "title": "widget" }), extra)
2504            .await
2505            .expect("handle");
2506        let op = connector
2507            .last
2508            .lock()
2509            .unwrap()
2510            .clone()
2511            .expect("operation recorded");
2512        assert_eq!(op.method, "POST");
2513        assert!(op.has_request_body, "POST must carry a request body");
2514        assert!(op.path_parameters().is_empty());
2515    }
2516
2517    /// (3) A tool with path + query params lands them in the right schema slots /
2518    /// `Operation` parameter locations.
2519    #[tokio::test]
2520    async fn synth_http_path_and_query_param_slots() {
2521        let cfg = cfg_with_tools(vec![ToolDecl {
2522            name: "search".to_string(),
2523            description: Some("Search".to_string()),
2524            path: Some("/repos/{owner}/issues".to_string()),
2525            method: Some("GET".to_string()),
2526            parameters: vec![
2527                ParamDecl {
2528                    name: "owner".to_string(),
2529                    param_type: Some("string".to_string()),
2530                    required: true,
2531                    ..Default::default()
2532                },
2533                ParamDecl {
2534                    name: "state".to_string(),
2535                    param_type: Some("string".to_string()),
2536                    required: false,
2537                    ..Default::default()
2538                },
2539            ],
2540            ..Default::default()
2541        }]);
2542        let connector = MockHttpConnector::new(json!([]));
2543        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
2544            .expect("synthesize");
2545        let (_, _, handler) = &out[0];
2546        let extra = RequestHandlerExtra::default();
2547        handler
2548            .handle(json!({ "owner": "rust-lang", "state": "open" }), extra)
2549            .await
2550            .expect("handle");
2551        let op = connector
2552            .last
2553            .lock()
2554            .unwrap()
2555            .clone()
2556            .expect("operation recorded");
2557        let path_params: Vec<&str> = op
2558            .path_parameters()
2559            .iter()
2560            .map(|p| p.name.as_str())
2561            .collect();
2562        assert_eq!(path_params, vec!["owner"]);
2563        let query_params: Vec<&str> = op
2564            .query_parameters()
2565            .iter()
2566            .map(|p| p.name.as_str())
2567            .collect();
2568        assert_eq!(query_params, vec!["state"]);
2569    }
2570
2571    /// (4) A per-tool `base_url` is reflected in the synthesized `Operation`
2572    /// (Codex MEDIUM — not dropped).
2573    #[tokio::test]
2574    async fn synth_http_per_tool_base_url_reflected() {
2575        let cfg = cfg_with_tools(vec![ToolDecl {
2576            name: "other_host".to_string(),
2577            description: Some("Other host".to_string()),
2578            path: Some("/ping".to_string()),
2579            method: Some("GET".to_string()),
2580            base_url: Some("https://other.example.com/v2".to_string()),
2581            ..Default::default()
2582        }]);
2583        let connector = MockHttpConnector::new(json!({ "pong": true }));
2584        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
2585            .expect("synthesize");
2586        let (_, _, handler) = &out[0];
2587        let extra = RequestHandlerExtra::default();
2588        handler.handle(json!({}), extra).await.expect("handle");
2589        let op = connector
2590            .last
2591            .lock()
2592            .unwrap()
2593            .clone()
2594            .expect("operation recorded");
2595        assert_eq!(
2596            op.base_url.as_deref(),
2597            Some("https://other.example.com/v2"),
2598            "per-tool base_url must be reflected on the Operation, not dropped"
2599        );
2600    }
2601
2602    /// (5) NEGATIVE: a `[[tools]]` missing `method` (and without `script`) is
2603    /// rejected with a typed `ToolkitError` (T-90-03-04 — never silently
2604    /// registered).
2605    #[test]
2606    fn synth_http_missing_method_rejected() {
2607        let cfg = cfg_with_tools(vec![ToolDecl {
2608            name: "broken".to_string(),
2609            description: Some("missing method".to_string()),
2610            path: Some("/items".to_string()),
2611            method: None,
2612            ..Default::default()
2613        }]);
2614        let connector = MockHttpConnector::new(json!(null));
2615        let err = synthesize_from_config_with_http_connector(&cfg, connector)
2616            .err()
2617            .expect("ill-formed single-call tool must be rejected");
2618        assert!(matches!(err, ToolkitError::Synth(_)));
2619    }
2620
2621    /// (5b) NEGATIVE: on the single-call-only entry point, a `script` tool is
2622    /// rejected with a typed `ToolkitError` pointing at the `openapi-code-mode`
2623    /// script path — NOT a silent skip, NOT a panic. (The OpenAPI Code Mode
2624    /// entry point `synthesize_from_config_with_http_connector_and_scripts`
2625    /// synthesizes a real `ScriptToolHandler` — proven in `script_tool` tests.)
2626    #[test]
2627    fn synth_http_script_tool_without_engine_is_rejected() {
2628        let cfg = cfg_with_tools(vec![ToolDecl {
2629            name: "scripted".to_string(),
2630            description: Some("script tool".to_string()),
2631            script: Some("await api.get('/x')".to_string()),
2632            ..Default::default()
2633        }]);
2634        let connector = MockHttpConnector::new(json!(null));
2635        let err = synthesize_from_config_with_http_connector(&cfg, connector)
2636            .err()
2637            .expect("script tool on the single-call-only entry point must be rejected");
2638        match err {
2639            ToolkitError::Synth(msg) => {
2640                assert!(
2641                    msg.contains("openapi-code-mode"),
2642                    "seam message must point at the openapi-code-mode script path: {msg}"
2643                );
2644            },
2645            other => panic!("expected Synth error, got {other:?}"),
2646        }
2647    }
2648}
2649
2650// -----------------------------------------------------------------------------
2651// Phase 128 E2 — the per-tool ArgumentValidator seam.
2652//
2653// A SIBLING of `mod tests`, selected by the `--lib tools::` filter. It drives the
2654// PUBLIC `*_and_hooks` synthesizer so the assertions hold through the surface an
2655// out-of-crate caller (`pmcp-openapi-server`) actually uses.
2656// -----------------------------------------------------------------------------
2657
2658/// E2 ordering, the schema opt-out, and the empty case.
2659#[cfg(all(test, feature = "input-validation"))]
2660mod argument_validator_seam {
2661    use super::synthesize_from_config_and_hooks;
2662    use crate::config::ServerConfig;
2663    use crate::policy::{ArgumentRefusal, ArgumentValidator, ToolkitHooks};
2664    use pmcp::RequestHandlerExtra;
2665    use serde_json::{json, Value};
2666    use std::sync::atomic::{AtomicUsize, Ordering};
2667    use std::sync::Arc;
2668
2669    /// Counts its invocations, then refuses when `end < start`. The refusal is a
2670    /// FIXED string: it names the rule, never a value.
2671    struct EndAfterStart {
2672        calls: Arc<AtomicUsize>,
2673    }
2674
2675    impl ArgumentValidator for EndAfterStart {
2676        fn validate(&self, args: &Value) -> Result<(), ArgumentRefusal> {
2677            self.calls.fetch_add(1, Ordering::SeqCst);
2678            let start = args.get("start").and_then(Value::as_i64);
2679            let end = args.get("end").and_then(Value::as_i64);
2680            match (start, end) {
2681                (Some(s), Some(e)) if e < s => {
2682                    Err(ArgumentRefusal::new("`end` must not precede `start`"))
2683                },
2684                _ => Ok(()),
2685            }
2686        }
2687    }
2688
2689    /// A `[[tools]]` whose declared schema permits any two integers, so the
2690    /// cross-field rule is one only E2 can express.
2691    fn cfg(enforce: bool) -> ServerConfig {
2692        let toml = format!(
2693            r#"
2694[server]
2695name = "range"
2696version = "0.1.0"
2697
2698[server.validation]
2699enforce_input_schema = {enforce}
2700
2701[[tools]]
2702name = "range_query"
2703description = "Query a range"
2704sql = "SELECT 1"
2705
2706[[tools.parameters]]
2707name = "start"
2708type = "integer"
2709required = true
2710
2711[[tools.parameters]]
2712name = "end"
2713type = "integer"
2714required = true
2715"#
2716        );
2717        ServerConfig::from_toml_strict_validated(&toml).expect("parse")
2718    }
2719
2720    fn extra() -> RequestHandlerExtra {
2721        RequestHandlerExtra::default()
2722    }
2723
2724    fn hooks(calls: &Arc<AtomicUsize>) -> ToolkitHooks {
2725        ToolkitHooks::default().with_argument_validator(
2726            "range_query",
2727            Arc::new(EndAfterStart {
2728                calls: Arc::clone(calls),
2729            }),
2730        )
2731    }
2732
2733    #[tokio::test]
2734    async fn a_registered_validator_refuses_a_combination_the_schema_permits() {
2735        let calls = Arc::new(AtomicUsize::new(0));
2736        let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
2737        let (_name, _info, handler) = &tools[0];
2738        let err = handler
2739            .handle(json!({ "start": 10, "end": 2 }), extra())
2740            .await
2741            .expect_err("the validator refuses");
2742        assert!(
2743            err.to_string().contains("`end` must not precede `start`"),
2744            "the refusal must carry the validator's own message, got: {err}"
2745        );
2746        assert_eq!(calls.load(Ordering::SeqCst), 1);
2747    }
2748
2749    #[tokio::test]
2750    async fn a_registered_validator_allows_a_valid_combination() {
2751        let calls = Arc::new(AtomicUsize::new(0));
2752        let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
2753        let (_name, _info, handler) = &tools[0];
2754        // No SQL connector is wired, so the INNER handler errors — which is itself
2755        // the proof that the validator allowed the call through to it.
2756        let err = handler
2757            .handle(json!({ "start": 2, "end": 10 }), extra())
2758            .await
2759            .expect_err("no connector is wired");
2760        assert!(
2761            !err.to_string().contains("must not precede"),
2762            "the validator must NOT have refused, got: {err}"
2763        );
2764        assert_eq!(calls.load(Ordering::SeqCst), 1);
2765    }
2766
2767    /// T-128-41: the ordering is the contract. A validator must never see
2768    /// arguments that failed the declared schema.
2769    #[tokio::test]
2770    async fn a_registered_validator_is_not_invoked_when_the_schema_refuses() {
2771        let calls = Arc::new(AtomicUsize::new(0));
2772        let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
2773        let (_name, _info, handler) = &tools[0];
2774        // `start` is a declared integer; a string violates the schema.
2775        let err = handler
2776            .handle(json!({ "start": "ten", "end": 2 }), extra())
2777            .await
2778            .expect_err("D1 refuses");
2779        assert!(
2780            !err.to_string().contains("must not precede"),
2781            "D1 must be the refuser, not E2, got: {err}"
2782        );
2783        assert_eq!(
2784            calls.load(Ordering::SeqCst),
2785            0,
2786            "the validator was invoked on arguments the schema already refused"
2787        );
2788    }
2789
2790    /// The joint test also present in plan 03: turning off one enforcement must
2791    /// never silently turn off another.
2792    #[tokio::test]
2793    async fn a_registered_validator_still_runs_with_enforce_input_schema_false() {
2794        let calls = Arc::new(AtomicUsize::new(0));
2795        let tools = synthesize_from_config_and_hooks(&cfg(false), &hooks(&calls)).expect("synth");
2796        let (_name, _info, handler) = &tools[0];
2797
2798        // Half one: the SCHEMA check is off, so an undeclared key is accepted.
2799        let err = handler
2800            .handle(json!({ "start": 2, "end": 10, "undeclared": 1 }), extra())
2801            .await
2802            .expect_err("no connector is wired");
2803        assert!(
2804            !err.to_string().contains("undeclared"),
2805            "with enforce_input_schema=false an undeclared key must be accepted, got: {err}"
2806        );
2807
2808        // Half two: the VALIDATOR still refuses.
2809        let err = handler
2810            .handle(json!({ "start": 10, "end": 2 }), extra())
2811            .await
2812            .expect_err("the validator refuses");
2813        assert!(
2814            err.to_string().contains("`end` must not precede `start`"),
2815            "a registered validator must survive the schema opt-out, got: {err}"
2816        );
2817        assert_eq!(calls.load(Ordering::SeqCst), 2);
2818    }
2819
2820    #[tokio::test]
2821    async fn a_tool_with_no_registered_validator_runs_d1_only() {
2822        let tools =
2823            synthesize_from_config_and_hooks(&cfg(true), &ToolkitHooks::default()).expect("synth");
2824        let (_name, _info, handler) = &tools[0];
2825        // D1 still refuses a schema violation.
2826        handler
2827            .handle(json!({ "start": "ten", "end": 2 }), extra())
2828            .await
2829            .expect_err("D1 refuses");
2830        // And a schema-valid call reaches the inner handler (which has no connector).
2831        let err = handler
2832            .handle(json!({ "start": 10, "end": 2 }), extra())
2833            .await
2834            .expect_err("no connector is wired");
2835        assert!(!err.to_string().contains("must not precede"));
2836    }
2837
2838    /// `handle_output` must validate too, or an inner handler that overrides it
2839    /// becomes a path with schema enforcement and no custom rule.
2840    #[tokio::test]
2841    async fn handle_output_runs_the_validator_as_well() {
2842        let calls = Arc::new(AtomicUsize::new(0));
2843        let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
2844        let (_name, _info, handler) = &tools[0];
2845        let err = handler
2846            .handle_output(json!({ "start": 10, "end": 2 }), extra())
2847            .await
2848            .expect_err("the validator refuses");
2849        assert!(
2850            err.to_string().contains("`end` must not precede `start`"),
2851            "handle_output must run the validator too, got: {err}"
2852        );
2853        assert_eq!(calls.load(Ordering::SeqCst), 1);
2854    }
2855}
2856
2857/// The once-at-startup enforcement report's line formats (Phase 128 D-07).
2858#[cfg(all(test, feature = "input-validation"))]
2859mod enforcement_report {
2860    use crate::config::ServerConfig;
2861    use crate::policy::{
2862        render_validation_report, ArgumentRefusal, ArgumentValidator, ReportLevel, ToolkitHooks,
2863    };
2864    use serde_json::Value;
2865    use std::sync::Arc;
2866
2867    struct Never;
2868    impl ArgumentValidator for Never {
2869        fn validate(&self, _args: &Value) -> Result<(), ArgumentRefusal> {
2870            Err(ArgumentRefusal::new("disabled"))
2871        }
2872    }
2873
2874    fn cfg(extra_validation: &str) -> ServerConfig {
2875        let toml = format!(
2876            r#"
2877[server]
2878name = "report"
2879version = "0.1.0"
2880
2881[server.validation]
2882{extra_validation}
2883
2884[[tools]]
2885name = "get_thing"
2886description = "Get a thing"
2887path = "/things/{{id}}"
2888method = "GET"
2889
2890[[tools.parameters]]
2891name = "id"
2892type = "string"
2893required = true
2894pattern = "^[0-9]+$"
2895"#
2896        );
2897        ServerConfig::from_toml_strict_validated(&toml).expect("parse")
2898    }
2899
2900    fn texts(lines: &[crate::policy::ReportLine]) -> String {
2901        lines
2902            .iter()
2903            .map(|l| l.text.clone())
2904            .collect::<Vec<_>>()
2905            .join("\n")
2906    }
2907
2908    #[test]
2909    fn a_fully_enforcing_config_states_that_no_opt_out_is_active() {
2910        let lines = render_validation_report(&cfg(""), &ToolkitHooks::default());
2911        let joined = texts(&lines);
2912        assert!(
2913            joined.contains("input validation: schema_check=ON"),
2914            "{joined}"
2915        );
2916        assert!(
2917            joined.contains("tool 'get_thing' enforces"),
2918            "one line per tool: {joined}"
2919        );
2920        assert!(
2921            joined.contains("no [server.validation] opt-out is active"),
2922            "the report must state the ABSENCE of an opt-out, not stay silent: {joined}"
2923        );
2924        assert!(
2925            joined.contains("E1 RequestPolicy registered=false"),
2926            "{joined}"
2927        );
2928        assert!(
2929            joined.contains("no E2 ArgumentValidator is registered"),
2930            "{joined}"
2931        );
2932        assert!(
2933            lines.iter().all(|l| l.level == ReportLevel::Info),
2934            "a fully enforcing config emits no warning"
2935        );
2936    }
2937
2938    #[test]
2939    fn every_active_opt_out_is_reported_at_warn_level() {
2940        let lines = render_validation_report(
2941            &cfg("enforce_input_schema = false\ndefault_max_length = 0\nadditional_properties = true"),
2942            &ToolkitHooks::default(),
2943        );
2944        let joined = texts(&lines);
2945        assert!(joined.contains("schema_check=OFF"), "{joined}");
2946        let warns: Vec<&str> = lines
2947            .iter()
2948            .filter(|l| l.level == ReportLevel::Warn)
2949            .map(|l| l.text.as_str())
2950            .collect();
2951        assert!(
2952            warns.len() >= 4,
2953            "an enforcement that is OFF must never read as on; got {warns:#?}"
2954        );
2955        assert!(
2956            warns.iter().any(|w| w.contains("opt-out ACTIVE")),
2957            "{warns:#?}"
2958        );
2959    }
2960
2961    #[test]
2962    fn a_validator_for_an_undeclared_tool_name_is_warned_not_refused() {
2963        let hooks = ToolkitHooks::default()
2964            .with_argument_validator("get_thing", Arc::new(Never))
2965            .with_argument_validator("typoed_name", Arc::new(Never));
2966        let lines = render_validation_report(&cfg(""), &hooks);
2967        let joined = texts(&lines);
2968        assert!(
2969            joined.contains("E2 ArgumentValidator registered for get_thing, typoed_name"),
2970            "{joined}"
2971        );
2972        let warns: Vec<&str> = lines
2973            .iter()
2974            .filter(|l| l.level == ReportLevel::Warn)
2975            .map(|l| l.text.as_str())
2976            .collect();
2977        assert_eq!(warns.len(), 1, "exactly the typo warns: {warns:#?}");
2978        assert!(warns[0].contains("'typoed_name'"), "{:?}", warns[0]);
2979        assert!(warns[0].contains("will never run"), "{:?}", warns[0]);
2980    }
2981
2982    /// SC-7: the report is built from declarations, so it cannot carry request
2983    /// data. Asserted rather than trusted, because the log is a channel.
2984    #[test]
2985    fn the_report_never_echoes_an_argument_value() {
2986        let lines = render_validation_report(&cfg(""), &ToolkitHooks::default());
2987        let joined = texts(&lines);
2988        for forbidden in ["Bearer", "app_key", "super-secret"] {
2989            assert!(!joined.contains(forbidden), "{joined}");
2990        }
2991    }
2992}