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