Skip to main content

pmcp_server_toolkit/
tools.rs

1// Net-new code for Phase 83 TKIT-07.
2// Lands the `[[tools]]`-config-driven synthesizer that turns config rows into
3// `ToolInfo` + `Arc<dyn ToolHandler>` pairs.
4
5//! `[[tools]]` → `ToolInfo` + `Arc<dyn ToolHandler>` synthesizer.
6//!
7//! Net-new code for Phase 83 TKIT-07. Turns curated `[[tools]]` config entries
8//! into complete pmcp [`ToolInfo`] + [`Arc<dyn ToolHandler>`] pairs with zero
9//! per-tool Rust handlers.
10//!
11//! # Invariants enforced
12//!
13//! - **JSON Schema object envelope.** Every synthesized [`ToolInfo`] carries an
14//!   `input_schema` with `"type": "object"`, an explicit `properties` map, a
15//!   `required` array, and `"additionalProperties": false`. Unknown argument
16//!   keys are rejected by pmcp's request-validation path at `tools/call` time —
17//!   defence-in-depth against arg-injection (threat T-83-05-02).
18//! - **`handler.metadata()` returns `Some(ToolInfo)`.** Phase 82's `tool_arc`
19//!   consumes `handler.metadata()` at registration; returning `None` would
20//!   silently degrade the schema enforcement to "anything goes" (RESEARCH
21//!   §Risks #2 — threat T-83-05-01).
22//! - **Constructors, never struct-literals.** Both [`ToolInfo`] and
23//!   [`ToolAnnotations`] are `#[non_exhaustive]` (PATTERNS §Pattern C). The
24//!   synthesizer uses [`ToolInfo::with_annotations`] / [`ToolInfo::new`] and
25//!   the [`ToolAnnotations::new()`]-then-`.with_*` fluent builder.
26//! - **Cognitive complexity ≤25 per function.** Decomposed into
27//!   [`build_input_schema`], [`build_param_property`], and [`build_annotations`]
28//!   per Phase 75 D-03 + PATTERNS §Pattern G. No `#[allow]` annotations.
29
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use pmcp::server::ToolHandler;
34use pmcp::types::{ToolAnnotations, ToolInfo};
35use pmcp::RequestHandlerExtra;
36use serde_json::{json, Map, Value};
37
38use crate::config::{AnnotationsDecl, ParamDecl, ServerConfig, ToolDecl};
39use crate::error::Result;
40use crate::sql::SqlConnector;
41
42#[cfg(feature = "http")]
43use crate::error::ToolkitError;
44#[cfg(feature = "http")]
45use crate::http::{HttpConnector, Operation, Parameter, ParameterLocation};
46
47#[cfg(feature = "openapi-code-mode")]
48use crate::code_mode::HttpCodeExecutor;
49#[cfg(feature = "openapi-code-mode")]
50use pmcp_code_mode::ExecutionConfig;
51
52/// Type alias for one synthesized tool tuple: `(name, ToolInfo, Arc<dyn ToolHandler>)`.
53///
54/// Exists so [`synthesize_from_config`]'s return type does not trip
55/// `clippy::type_complexity` while preserving the exact `(name, ToolInfo, Arc)`
56/// shape consumers register with `pmcp::ServerBuilder::tool_arc` (PATTERNS §9).
57pub type SynthesizedTool = (String, ToolInfo, Arc<dyn ToolHandler>);
58
59/// Synthesize one `ToolInfo` + handler per `[[tools]]` config entry.
60///
61/// Each returned tuple is `(name, ToolInfo, Arc<dyn ToolHandler>)` and is
62/// ready to feed into `pmcp::ServerBuilder::tool_arc(name, handler)`. The
63/// `ToolInfo` carries the full input schema (synthesized from
64/// `[[tools.parameters]]`) and `ToolAnnotations` (from `[tools.annotations]`)
65/// so the builder's metadata cache will never fall back to the empty schema.
66///
67/// # Errors
68///
69/// Returns [`crate::ToolkitError::Synth`] if a tool declaration is internally
70/// inconsistent. The Plan 05 GREEN body never produces this error path —
71/// synthesis is total over the parsed [`ServerConfig`] surface — but the
72/// `Result` return is kept for forward compatibility with Plan 06 (code-mode
73/// wiring) and Phase 84 (SQL backend resolution).
74///
75/// # Example
76///
77/// ```
78/// use pmcp_server_toolkit::config::ServerConfig;
79/// use pmcp_server_toolkit::tools::synthesize_from_config;
80///
81/// let cfg = ServerConfig::default();
82/// let synthesized = synthesize_from_config(&cfg).unwrap();
83/// assert_eq!(synthesized.len(), 0);
84/// ```
85pub fn synthesize_from_config(config: &ServerConfig) -> Result<Vec<SynthesizedTool>> {
86    synthesize_inner(config, None)
87}
88
89/// Synthesize tools that execute against a wired [`SqlConnector`] (Phase 84
90/// CONN-01 / D-06). ADDITIVE variant alongside [`synthesize_from_config`] — the
91/// existing API is unchanged and all P83 callers compile without modification.
92///
93/// Each synthesized [`SynthesizedToolHandler`] holds the shared `connector`, so
94/// its `handle()` body calls [`SqlConnector::execute`] with the tool's declared
95/// `sql` + the named parameters extracted from the validated args. When a tool
96/// declares `ui_resource_uri`, the synthesized [`ToolInfo`] also carries widget
97/// metadata so pmcp core's `with_widget_enrichment` populates `structuredContent`
98/// (D-06) — that flip lives in the shared [`synthesize_inner`] helper and so
99/// fires for both entry points.
100///
101/// # Errors
102///
103/// Returns [`crate::ToolkitError::Synth`] if a tool declaration is internally
104/// inconsistent. Synthesis is total over the parsed [`ServerConfig`] surface —
105/// the connector is threaded into each handler for runtime use, not consulted at
106/// synthesis time.
107///
108/// # Example
109///
110/// ```no_run
111/// use std::sync::Arc;
112/// use pmcp_server_toolkit::config::ServerConfig;
113/// use pmcp_server_toolkit::sql::SqlConnector;
114/// use pmcp_server_toolkit::tools::synthesize_from_config_with_connector;
115///
116/// fn build(connector: Arc<dyn SqlConnector>) {
117///     let cfg = ServerConfig::default();
118///     let tools = synthesize_from_config_with_connector(&cfg, connector).unwrap();
119///     assert_eq!(tools.len(), 0);
120/// }
121/// ```
122pub fn synthesize_from_config_with_connector(
123    config: &ServerConfig,
124    connector: Arc<dyn SqlConnector>,
125) -> Result<Vec<SynthesizedTool>> {
126    synthesize_inner(config, Some(connector))
127}
128
129/// Shared synthesizer body for both [`synthesize_from_config`] (no connector)
130/// and [`synthesize_from_config_with_connector`] (connector wired).
131///
132/// Keeps the two public entry points one-liners so the widget_meta flip (D-06)
133/// and the handler construction logic are not duplicated. Decomposed per
134/// PATTERNS §Pattern G — the per-tool body delegates to [`build_input_schema`],
135/// [`build_annotations`], and [`apply_widget_meta`] to stay under cog 25.
136fn synthesize_inner(
137    config: &ServerConfig,
138    connector: Option<Arc<dyn SqlConnector>>,
139) -> Result<Vec<SynthesizedTool>> {
140    let mut out = Vec::with_capacity(config.tools.len());
141    for decl in &config.tools {
142        let info = build_tool_info(decl);
143        let handler: Arc<dyn ToolHandler> = Arc::new(SynthesizedToolHandler {
144            info: info.clone(),
145            decl: decl.clone(),
146            connector: connector.clone(),
147        });
148        out.push((decl.name.clone(), info, handler));
149    }
150    Ok(out)
151}
152
153/// Flip widget metadata onto `info` when the declaration carries a
154/// `ui_resource_uri` (D-06 / REVIEWS M1).
155///
156/// Uses the feature-independent [`ToolInfo::with_meta_entry`] surface to insert
157/// `_meta.ui.resourceUri`. This is the verified-correct API: `with_widget_meta`
158/// is gated on pmcp's `mcp-apps` feature (which the toolkit does not enable),
159/// whereas `with_meta_entry` is always available and produces the `ui.resourceUri`
160/// shape that `ToolInfo::widget_meta()` recognises — so pmcp core's
161/// `with_widget_enrichment` populates `structuredContent`. Annotations on `info`
162/// are preserved (chained, not reconstructed).
163fn apply_widget_meta(info: ToolInfo, decl: &ToolDecl) -> ToolInfo {
164    match decl.ui_resource_uri.as_deref() {
165        Some(uri) => info.with_meta_entry("ui", json!({ "resourceUri": uri })),
166        None => info,
167    }
168}
169
170/// Build the [`ToolInfo`] for a synthesized tool from its declaration.
171///
172/// The schema + annotations + widget-meta sequence is identical for every tool
173/// kind (single-call HTTP, SQL, and script), so it lives here once — keeping the
174/// `#[non_exhaustive]` [`ToolInfo`] constructor discipline (the `with_annotations`
175/// vs `new` arms) in a single place rather than copy-pasted per synthesizer.
176fn build_tool_info(decl: &ToolDecl) -> ToolInfo {
177    let schema = build_input_schema(&decl.parameters);
178    let annotations = build_annotations(decl.annotations.as_ref());
179    let base = match annotations {
180        Some(ann) => {
181            ToolInfo::with_annotations(decl.name.clone(), decl.description.clone(), schema, ann)
182        },
183        None => ToolInfo::new(decl.name.clone(), decl.description.clone(), schema),
184    };
185    apply_widget_meta(base, decl)
186}
187
188/// Build the JSON Schema `properties` + `required` envelope from a
189/// `[[tools.parameters]]` list.
190///
191/// Decomposed from [`synthesize_from_config`] to keep cognitive complexity ≤25
192/// (Phase 75 D-03 + PATTERNS §Pattern G).
193fn build_input_schema(params: &[ParamDecl]) -> Value {
194    let mut props = Map::new();
195    let mut required = Vec::new();
196    for p in params {
197        props.insert(p.name.clone(), build_param_property(p));
198        if p.required {
199            required.push(Value::String(p.name.clone()));
200        }
201    }
202    json!({
203        "type": "object",
204        "properties": props,
205        "required": required,
206        "additionalProperties": false,
207    })
208}
209
210/// Build a single JSON Schema property object from a [`ParamDecl`].
211///
212/// Per-parameter constraints (`minimum`, `maximum`, `maxLength`, `default`,
213/// `enum`) are folded in only when present; the param's `param_type` defaults
214/// to `"string"` when omitted in TOML to match JSON Schema's permissive
215/// default.
216fn build_param_property(p: &ParamDecl) -> Value {
217    let ty = p.param_type.as_deref().unwrap_or("string");
218    let mut prop = json!({ "type": ty });
219    if let Some(desc) = &p.description {
220        prop["description"] = Value::String(desc.clone());
221    }
222    if let Some(min) = p.minimum {
223        prop["minimum"] = json!(min);
224    }
225    if let Some(max) = p.maximum {
226        prop["maximum"] = json!(max);
227    }
228    if let Some(max_len) = p.max_length {
229        prop["maxLength"] = json!(max_len);
230    }
231    if let Some(default) = &p.default {
232        // toml::Value serializes losslessly into serde_json::Value via serde.
233        if let Ok(v) = serde_json::to_value(default) {
234            prop["default"] = v;
235        }
236    }
237    if let Some(enum_vals) = &p.enum_values {
238        if let Ok(v) = serde_json::to_value(enum_vals) {
239            prop["enum"] = v;
240        }
241    }
242    prop
243}
244
245/// Build [`ToolAnnotations`] from an optional `[tools.annotations]` block.
246///
247/// Per PATTERNS §Pattern C, the constructor + fluent builder is used (never a
248/// struct literal — [`ToolAnnotations`] is `#[non_exhaustive]`). The `cost_hint`
249/// field has no `ToolAnnotations` accessor and is therefore not propagated at
250/// this layer; it lives on the toolkit's [`AnnotationsDecl`] and is consumed
251/// by future plans that surface cost into rate-limiting policy.
252fn build_annotations(decl: Option<&AnnotationsDecl>) -> Option<ToolAnnotations> {
253    let d = decl?;
254    let a = ToolAnnotations::new()
255        .with_read_only(d.read_only_hint)
256        .with_destructive(d.destructive_hint)
257        .with_idempotent(d.idempotent_hint)
258        .with_open_world(d.open_world_hint);
259    Some(a)
260}
261
262// -----------------------------------------------------------------------------
263// SynthesizedToolHandler — crate-private
264// -----------------------------------------------------------------------------
265
266/// Crate-private handler wrapping a synthesized [`ToolInfo`].
267///
268/// [`ToolHandler::metadata`] MUST return `Some(self.info.clone())` — Phase 82's
269/// `tool_arc` consumes `handler.metadata()` at registration time; returning
270/// `None` would cause the builder to fall back to an empty schema (RESEARCH
271/// §Risks #2 — threat T-83-05-01). The unit + property tests in
272/// [`crate::tools`] and `tests/tool_synthesis_props.rs` lock this in.
273///
274/// `handle()` reads the declared `sql`, extracts named parameters from the
275/// validated args, and calls [`SqlConnector::execute`] when a `connector` is
276/// wired (handlers built via [`synthesize_from_config_with_connector`]).
277/// Handlers built via the no-connector [`synthesize_from_config`] carry
278/// `connector = None` and return an explicit `Err` on invocation — preserving
279/// P83 behaviour where the no-connector path was test-only (T-84-03-05). The
280/// `decl` is held so the handler can read `sql` / `ui_resource_uri` /
281/// `parameters` without re-walking the config.
282struct SynthesizedToolHandler {
283    info: ToolInfo,
284    decl: ToolDecl,
285    /// `Some` only for handlers built via [`synthesize_from_config_with_connector`].
286    connector: Option<Arc<dyn SqlConnector>>,
287}
288
289/// Extract the named `(name, value)` parameter pairs the connector binds from,
290/// filtering the caller's validated `args` against the declared parameter list
291/// (T-84-03-01: only declared parameter names reach `execute()`; extra keys are
292/// silently dropped — JSON-schema validation rejects them upstream).
293///
294/// When the caller omits an optional parameter that declares a `default`, the
295/// default is applied so the bound SQL sees a concrete value. Without this an
296/// omitted `:limit` / `:offset` would bind as unbound `NULL` and SQLite rejects
297/// `LIMIT NULL` with a "datatype mismatch" — so the declared default is the
298/// difference between a working and a broken tool call (the reference
299/// `search_tracks` / `list_artists` calls rely on it).
300///
301/// An EXPLICIT JSON `null` for a declared-default parameter is treated the SAME
302/// as an omitted parameter — the declared default is applied (85-10 WR-02
303/// secondary fix). Without the `is_null` filter a caller sending
304/// `{"limit": null}` would bind `LIMIT NULL` and SQLite would reject the query
305/// with "datatype mismatch", even though the tool declares `default = 20`.
306fn extract_named_params(decl: &ToolDecl, args: &Value) -> Vec<(String, Value)> {
307    decl.parameters
308        .iter()
309        .filter_map(|p| {
310            args.get(&p.name)
311                // Explicit JSON `null` falls through to the declared default,
312                // exactly like an omitted key (no `LIMIT NULL` bind).
313                .filter(|v| !v.is_null())
314                .cloned()
315                .or_else(|| {
316                    p.default
317                        .as_ref()
318                        .and_then(|d| serde_json::to_value(d).ok())
319                })
320                .map(|v| (p.name.clone(), v))
321        })
322        .collect()
323}
324
325#[async_trait]
326impl ToolHandler for SynthesizedToolHandler {
327    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
328        let sql = self.decl.sql.as_deref().ok_or_else(|| {
329            pmcp::Error::Internal(format!("tool '{}' has no `sql` declared", self.info.name))
330        })?;
331        let connector = self.connector.as_ref().ok_or_else(|| {
332            pmcp::Error::Internal(format!(
333                "tool '{}' requires connector wiring — build via synthesize_from_config_with_connector",
334                self.info.name
335            ))
336        })?;
337        let named_params = extract_named_params(&self.decl, &args);
338        // T-84-03-02: format!("{e}") uses ConnectorError::Display, which Plan 01
339        // Task 2 guarantees does not echo credentials.
340        let rows = connector
341            .execute(sql, &named_params)
342            .await
343            .map_err(|e| pmcp::Error::Internal(format!("connector error: {e}")))?;
344        Ok(Value::Array(rows))
345    }
346
347    fn metadata(&self) -> Option<ToolInfo> {
348        Some(self.info.clone())
349    }
350}
351
352// -----------------------------------------------------------------------------
353// Single-call HTTP synthesizer (Phase 90 OAPI-02a) — feature `http`
354// -----------------------------------------------------------------------------
355
356/// Synthesize one `ToolInfo` + handler per single-call `[[tools]]` entry,
357/// executing against a wired [`HttpConnector`] (Phase 90 OAPI-02a / D-01).
358///
359/// Mirrors [`synthesize_from_config_with_connector`] (the SQL analog) in shape.
360/// For each `[[tools]]` where [`ToolDecl::is_script_tool`] is `false` and a
361/// `path` + `method` pair is present, an [`Operation`] is built from the path
362/// template (the `{...}` segments become path parameters), the declared
363/// `[[tools.parameters]]` (non-path params become query params; `POST`/`PUT`/
364/// `PATCH` carry a request body), and the per-tool `base_url` override — which is
365/// reflected onto the [`Operation`] so the connector targets the per-tool host
366/// (never silently dropped, Codex MEDIUM). The synthesized [`ToolInfo`] uses the
367/// EXISTING [`build_input_schema`] (object envelope + `additionalProperties:false`)
368/// and [`build_annotations`] helpers; the handler calls
369/// [`HttpConnector::execute`] and returns the JSON.
370///
371/// # Script-tool seam (Plan 05)
372///
373/// A `script` tool encountered here returns a typed [`ToolkitError::Synth`] — it
374/// is an EXPLICIT, clearly-marked seam, NOT a silent skip and NOT a `todo!()`.
375/// Plan 05 widens this function's signature (adding the shared `http_exec` +
376/// `exec_config`) and fills the `is_script_tool()` arm with a `ScriptToolHandler`
377/// branch; that change is a localized, anticipated edit because the seam is
378/// surfaced here.
379///
380/// # Errors
381///
382/// Returns [`ToolkitError::Synth`] when a `[[tools]]` entry is a `script` tool
383/// (Plan 05 seam) or is neither a valid single-call (missing `path` OR `method`)
384/// nor a script tool (T-90-03-04 negative validation — an ill-formed tool is
385/// rejected, never silently registered).
386#[cfg(feature = "http")]
387pub fn synthesize_from_config_with_http_connector(
388    config: &ServerConfig,
389    connector: Arc<dyn HttpConnector>,
390) -> Result<Vec<SynthesizedTool>> {
391    // No script-tool builder is supplied on this (single-call-only) entry point,
392    // so the `is_script_tool()` arm of [`synthesize_http_inner`] returns the
393    // typed Plan 05 seam error. The OpenAPI Code Mode build calls
394    // [`synthesize_from_config_with_http_connector_and_scripts`], which supplies
395    // a [`ScriptToolHandler`] builder so a `script` tool synthesizes a real
396    // handler over the shared engine (OAPI-02b / D-01 / D-02).
397    synthesize_http_inner(config, connector, |decl| {
398        Err(ToolkitError::Synth(format!(
399            "tool '{}' is a script tool — script tools require the `openapi-code-mode` \
400             feature (use synthesize_from_config_with_http_connector_and_scripts)",
401            decl.name
402        )))
403    })
404}
405
406/// Synthesize single-call AND script `[[tools]]` against a wired
407/// [`HttpConnector`] plus a shared [`HttpCodeExecutor`] + [`ExecutionConfig`]
408/// (Phase 90 OAPI-02b / D-01 / D-02).
409///
410/// This is the OpenAPI Code Mode entry point (gated `openapi-code-mode`): it
411/// adds the `http_exec` + `exec_config` the script-tool path needs, threading
412/// the SAME `HttpCodeExecutor` instance that feeds Code Mode (D-02 — one engine,
413/// two surfaces). Single-call tools synthesize exactly as in
414/// [`synthesize_from_config_with_http_connector`]; a `script` tool synthesizes a
415/// [`ScriptToolHandler`] that compiles + runs the embedded JS through the SAME
416/// `PlanCompiler` + `PlanExecutor` + `HttpCodeExecutor` seam Code Mode uses,
417/// with NO validate/token cycle (admin-authored, `ExecutionConfig`-bounded —
418/// Pitfall 7).
419///
420/// The binary (Plan 06) supplies `http_exec` (built once over the resolved
421/// backend `base_url` + auth provider) and `exec_config` (from the
422/// `[code_mode.limits]` / defaults: `max_api_calls=50`, `max_loop_iterations=100`,
423/// `timeout_seconds=30`).
424///
425/// # Errors
426///
427/// Returns [`ToolkitError::Synth`] when a `[[tools]]` entry is neither a valid
428/// single-call (missing `path` OR `method`) nor a script tool (T-90-03-04
429/// negative validation), or when a script tool fails to build its `ToolInfo`.
430#[cfg(feature = "openapi-code-mode")]
431pub fn synthesize_from_config_with_http_connector_and_scripts(
432    config: &ServerConfig,
433    connector: Arc<dyn HttpConnector>,
434    http_exec: HttpCodeExecutor,
435    exec_config: ExecutionConfig,
436) -> Result<Vec<SynthesizedTool>> {
437    synthesize_http_inner(config, connector, |decl| {
438        let handler = ScriptToolHandler::new(decl, http_exec.clone(), exec_config.clone())?;
439        let info = handler.tool_info.clone();
440        let arc: Arc<dyn ToolHandler> = Arc::new(handler);
441        Ok((info, arc))
442    })
443}
444
445/// Shared synthesizer body for the single-call HTTP entry points.
446///
447/// `build_script_tool` is invoked for each `script` tool: the single-call-only
448/// entry point passes a closure that returns the typed Plan 05 / `openapi-code-mode`
449/// seam error, while the OpenAPI Code Mode entry point passes a closure that
450/// constructs a [`ScriptToolHandler`]. Decomposed per PATTERNS §Pattern G to keep
451/// the per-tool loop body under cog ≤25.
452#[cfg(feature = "http")]
453fn synthesize_http_inner(
454    config: &ServerConfig,
455    connector: Arc<dyn HttpConnector>,
456    mut build_script_tool: impl FnMut(&ToolDecl) -> Result<(ToolInfo, Arc<dyn ToolHandler>)>,
457) -> Result<Vec<SynthesizedTool>> {
458    let mut out = Vec::with_capacity(config.tools.len());
459    for decl in &config.tools {
460        if decl.is_script_tool() {
461            let (info, handler) = build_script_tool(decl)?;
462            out.push((decl.name.clone(), info, handler));
463            continue;
464        }
465
466        // Single-call requires BOTH path and method. A `[[tools]]` that is
467        // neither a valid single-call nor a script tool is rejected (T-90-03-04).
468        let (path, method) = match (decl.path.as_deref(), decl.method.as_deref()) {
469            (Some(p), Some(m)) => (p, m),
470            _ => {
471                return Err(ToolkitError::Synth(format!(
472                    "tool '{}' is not a valid single-call tool: both `path` and `method` are required",
473                    decl.name
474                )));
475            },
476        };
477
478        let operation = build_operation(path, method, decl);
479        let info = build_tool_info(decl);
480        let handler: Arc<dyn ToolHandler> = Arc::new(HttpToolHandler {
481            info: info.clone(),
482            operation,
483            connector: connector.clone(),
484        });
485        out.push((decl.name.clone(), info, handler));
486    }
487    Ok(out)
488}
489
490/// Build the [`Operation`] for a single-call tool from its `path` template,
491/// `method`, declared parameters, and per-tool `base_url`.
492///
493/// Path parameters are the `{...}` segments of the path template; every other
494/// declared `[[tools.parameters]]` becomes a query parameter (the reference
495/// `create_tool_from_config` mapping). `POST`/`PUT`/`PATCH` carry a request body
496/// so non-path/query args are sent as JSON. The per-tool `base_url` is reflected
497/// onto the [`Operation`] (Codex MEDIUM — never dropped).
498#[cfg(feature = "http")]
499fn build_operation(path: &str, method: &str, decl: &ToolDecl) -> Operation {
500    let method_upper = method.to_uppercase();
501    let path_param_names: Vec<&str> = path
502        .split('/')
503        .filter(|s| s.starts_with('{') && s.ends_with('}') && s.len() > 2)
504        .map(|s| &s[1..s.len() - 1])
505        .collect();
506
507    let mut parameters = Vec::with_capacity(decl.parameters.len());
508    // Path params (template `{...}` segments) — always required.
509    for name in &path_param_names {
510        parameters.push(Parameter::new(
511            (*name).to_string(),
512            ParameterLocation::Path,
513            true,
514        ));
515    }
516    // Remaining declared params → query params.
517    for p in &decl.parameters {
518        if path_param_names.iter().any(|n| *n == p.name) {
519            continue;
520        }
521        parameters.push(Parameter::new(
522            p.name.clone(),
523            ParameterLocation::Query,
524            p.required,
525        ));
526    }
527
528    let has_request_body = matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH");
529
530    Operation {
531        method: method_upper,
532        path: path.to_string(),
533        parameters,
534        has_request_body,
535        base_url: decl.base_url.clone(),
536    }
537}
538
539/// Crate-private handler for a single-call HTTP tool (Phase 90 OAPI-02a).
540///
541/// Holds the synthesized [`ToolInfo`], the built [`Operation`], and the shared
542/// [`HttpConnector`]. [`ToolHandler::metadata`] returns `Some(self.info.clone())`
543/// (the same RESEARCH §Risks #2 invariant the SQL handler upholds); `handle()`
544/// calls [`HttpConnector::execute`] and returns the JSON response.
545#[cfg(feature = "http")]
546struct HttpToolHandler {
547    info: ToolInfo,
548    operation: Operation,
549    connector: Arc<dyn HttpConnector>,
550}
551
552#[cfg(feature = "http")]
553#[async_trait]
554impl ToolHandler for HttpToolHandler {
555    async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
556        // T-90-03-01: arg injection is bounded by the object-envelope schema
557        // (additionalProperties:false) enforced upstream; path substitution in the
558        // connector touches only declared `{params}`.
559        // The connector's Display is redaction-safe (T-90-01-01); no URL/credential
560        // reaches the client error.
561        self.connector
562            .execute(&self.operation, &args)
563            .await
564            .map_err(|e| pmcp::Error::Internal(format!("connector error: {e}")))
565    }
566
567    fn metadata(&self) -> Option<ToolInfo> {
568        Some(self.info.clone())
569    }
570}
571
572// -----------------------------------------------------------------------------
573// Script-tool handler (Phase 90 OAPI-02b / D-01 / D-02) — feature
574// `openapi-code-mode`
575// -----------------------------------------------------------------------------
576
577/// Crate-private handler for a **script** `[[tools]]` entry (OAPI-02b / D-01).
578///
579/// A script tool runs admin-authored embedded JS through the EXACT SAME
580/// `pmcp_code_mode` engine that Code Mode uses (D-02 — one engine, two
581/// surfaces): [`pmcp_code_mode::PlanCompiler`] compiles the `script` to an
582/// execution plan, then [`pmcp_code_mode::PlanExecutor`] over the shared
583/// [`HttpCodeExecutor`] walks it. The client's validated `args` are bound to the
584/// `args` variable BEFORE the script runs — identical to the `JsCodeExecutor`
585/// path's `set_variable("args", …)`, which is what makes the engine-parity proof
586/// (Plan 05 Task 2) hold byte-for-byte.
587///
588/// # No token cycle (Pitfall 7 / T-90-05-01)
589///
590/// A script tool is admin-authored + trusted (like a `sql=` curated query), so it
591/// skips the Code Mode validation + HMAC-token gate entirely. It is bounded ONLY by
592/// the [`ExecutionConfig`] caps (`max_api_calls`, `max_loop_iterations`,
593/// `timeout_seconds`) the [`PlanExecutor`](pmcp_code_mode::PlanExecutor) enforces,
594/// and by the `PlanCompiler`-accepted JS subset (no `eval` / FFI).
595///
596/// # Feature gate (RESEARCH Pitfall 4)
597///
598/// Gated `openapi-code-mode` (the umbrella that forwards
599/// `pmcp-code-mode/js-runtime`) — `PlanCompiler` / `PlanExecutor` are NOT in
600/// scope under bare `code-mode`, so the light / curated-only build (`http
601/// code-mode`) compiles without this type (single-call only).
602#[cfg(feature = "openapi-code-mode")]
603struct ScriptToolHandler {
604    /// The admin-authored script, compiled ONCE at synthesis (the body is fixed
605    /// content). Executed per `handle` over a fresh
606    /// [`PlanExecutor`](pmcp_code_mode::PlanExecutor).
607    plan: pmcp_code_mode::ExecutionPlan,
608    /// The SAME executor instance that feeds Code Mode (D-02). Cloned per request
609    /// to construct a fresh [`PlanExecutor`](pmcp_code_mode::PlanExecutor).
610    http_exec: HttpCodeExecutor,
611    /// The execution bounds (Pitfall 7 — the only limit on an admin script).
612    exec_config: ExecutionConfig,
613    /// The synthesized `ToolInfo` (object-envelope schema from
614    /// `[[tools.parameters]]`, `additionalProperties:false`) — `args` are
615    /// schema-validated against this BEFORE the script runs (T-90-05-03).
616    tool_info: ToolInfo,
617}
618
619#[cfg(feature = "openapi-code-mode")]
620impl ScriptToolHandler {
621    /// Build a [`ScriptToolHandler`] from a script `[[tools]]` declaration,
622    /// the shared [`HttpCodeExecutor`], and the [`ExecutionConfig`] bounds.
623    ///
624    /// The `tool_info` is built from `[[tools.parameters]]` via the SAME
625    /// [`build_input_schema`] / [`build_annotations`] / [`apply_widget_meta`]
626    /// helpers the single-call path uses, so a script tool's `args` are
627    /// schema-validated identically (object envelope, `additionalProperties:false`).
628    ///
629    /// # Errors
630    ///
631    /// Returns [`ToolkitError::Synth`] if the declaration carries no `script`
632    /// (a defensive guard — callers route only `is_script_tool()` entries here),
633    /// or if the script fails to compile (surfaced here at server build time,
634    /// failing fast rather than on the first tool call).
635    fn new(
636        decl: &ToolDecl,
637        http_exec: HttpCodeExecutor,
638        exec_config: ExecutionConfig,
639    ) -> Result<Self> {
640        let script = decl.script.clone().ok_or_else(|| {
641            ToolkitError::Synth(format!(
642                "tool '{}' has no `script` body — not a script tool",
643                decl.name
644            ))
645        })?;
646        // Compile the admin-authored JS ONCE at synthesis time — the script is
647        // fixed content, so compiling per request would re-run a full SWC parse
648        // on the hot path (the PlanCompiler-accepted subset, no eval / FFI, is
649        // the static bound). A compile error surfaces here at server build.
650        let plan = pmcp_code_mode::PlanCompiler::with_config(&exec_config)
651            .compile_code(&script)
652            .map_err(|e| {
653                ToolkitError::Synth(format!(
654                    "tool '{}' script failed to compile: {e}",
655                    decl.name
656                ))
657            })?;
658        let tool_info = build_tool_info(decl);
659        Ok(Self {
660            plan,
661            http_exec,
662            exec_config,
663            tool_info,
664        })
665    }
666}
667
668#[cfg(feature = "openapi-code-mode")]
669#[pmcp_code_mode::async_trait]
670impl ToolHandler for ScriptToolHandler {
671    /// Run the pre-compiled admin-authored script over the shared engine, binding
672    /// the validated `args` to the `args` variable (D-02 — identical to the
673    /// `JsCodeExecutor` path's `set_variable("args", …)`).
674    async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> {
675        // (1) Execute the plan (compiled once in `new`) over a PER-REQUEST clone
676        //     of the shared HttpCodeExecutor (D-02), threading the captured
677        //     inbound MCP token (Plan 90-10 / OAPI-03 / OAPI-05) so an
678        //     `oauth_passthrough` backend forwards it. Bounded by ExecutionConfig
679        //     (Pitfall 7 — no token cycle, only these caps).
680        let mut executor = pmcp_code_mode::PlanExecutor::new(
681            crate::code_mode::request_executor_from_extra(&self.http_exec, &extra),
682            self.exec_config.clone(),
683        );
684        // (2) Bind the schema-validated client args to `args` (T-90-05-03) —
685        //     byte-identical to compile_and_execute's set_variable("args", …).
686        executor.set_variable("args", args);
687
688        let result = executor
689            .execute(&self.plan)
690            .await
691            .map_err(|e| pmcp::Error::Internal(format!("script execution failed: {e}")))?;
692        Ok(result.value)
693    }
694
695    fn metadata(&self) -> Option<ToolInfo> {
696        Some(self.tool_info.clone())
697    }
698}
699
700// -----------------------------------------------------------------------------
701// Tests — Plan 05 Task 1 (RED) → GREEN in Task 2
702// -----------------------------------------------------------------------------
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use crate::config::{AnnotationsDecl, ParamDecl, ServerConfig, ServerSection, ToolDecl};
708    use serde_json::Value;
709
710    /// Construct a minimal `ServerConfig` that satisfies `validate()` (non-empty
711    /// `name` + `version`) so the synthesizer path is the system under test —
712    /// not the parser/validator from Plan 04.
713    fn cfg_with_tools(tools: Vec<ToolDecl>) -> ServerConfig {
714        ServerConfig {
715            server: ServerSection {
716                name: "demo".to_string(),
717                version: "0.1.0".to_string(),
718                ..Default::default()
719            },
720            tools,
721            ..Default::default()
722        }
723    }
724
725    #[test]
726    fn empty_tools_returns_empty_vec() {
727        let cfg = cfg_with_tools(vec![]);
728        let out = synthesize_from_config(&cfg).expect("synthesize");
729        assert_eq!(out.len(), 0);
730    }
731
732    #[test]
733    fn one_tool_no_params_yields_object_schema() {
734        let cfg = cfg_with_tools(vec![ToolDecl {
735            name: "ping".to_string(),
736            description: Some("Ping the server".to_string()),
737            parameters: vec![],
738            annotations: None,
739            ..Default::default()
740        }]);
741        let out = synthesize_from_config(&cfg).expect("synthesize");
742        assert_eq!(out.len(), 1);
743        let (name, info, _handler) = &out[0];
744        assert_eq!(name, "ping");
745        assert_eq!(info.name, "ping");
746        assert_eq!(info.description.as_deref(), Some("Ping the server"));
747        let schema = &info.input_schema;
748        assert_eq!(schema["type"], Value::String("object".to_string()));
749        assert_eq!(schema["properties"], serde_json::json!({}));
750        assert_eq!(schema["required"], serde_json::json!([]));
751        assert_eq!(schema["additionalProperties"], Value::Bool(false));
752    }
753
754    #[test]
755    fn required_and_optional_params_partitioned() {
756        let cfg = cfg_with_tools(vec![ToolDecl {
757            name: "search".to_string(),
758            description: Some("Search".to_string()),
759            parameters: vec![
760                ParamDecl {
761                    name: "query".to_string(),
762                    param_type: Some("string".to_string()),
763                    description: Some("the search query".to_string()),
764                    required: true,
765                    ..Default::default()
766                },
767                ParamDecl {
768                    name: "max_results".to_string(),
769                    param_type: Some("integer".to_string()),
770                    description: Some("maximum result count".to_string()),
771                    required: false,
772                    default: Some(toml::Value::Integer(100)),
773                    minimum: Some(1.0),
774                    maximum: Some(1000.0),
775                    ..Default::default()
776                },
777            ],
778            ..Default::default()
779        }]);
780        let out = synthesize_from_config(&cfg).expect("synthesize");
781        let (_, info, _) = &out[0];
782        let schema = &info.input_schema;
783        assert_eq!(schema["required"], serde_json::json!(["query"]));
784        let props = schema["properties"].as_object().expect("object");
785        assert_eq!(props["query"]["type"], "string");
786        assert_eq!(props["max_results"]["type"], "integer");
787        assert_eq!(props["max_results"]["minimum"], serde_json::json!(1.0));
788        assert_eq!(props["max_results"]["maximum"], serde_json::json!(1000.0));
789        assert_eq!(props["max_results"]["default"], serde_json::json!(100));
790    }
791
792    #[test]
793    fn param_max_length_propagates() {
794        let cfg = cfg_with_tools(vec![ToolDecl {
795            name: "echo".to_string(),
796            description: Some("Echo".to_string()),
797            parameters: vec![ParamDecl {
798                name: "text".to_string(),
799                param_type: Some("string".to_string()),
800                description: Some("input text".to_string()),
801                required: true,
802                max_length: Some(256),
803                ..Default::default()
804            }],
805            ..Default::default()
806        }]);
807        let out = synthesize_from_config(&cfg).expect("synthesize");
808        let (_, info, _) = &out[0];
809        assert_eq!(
810            info.input_schema["properties"]["text"]["maxLength"],
811            serde_json::json!(256)
812        );
813    }
814
815    #[test]
816    fn annotations_round_trip_via_fluent_builder() {
817        let cfg = cfg_with_tools(vec![ToolDecl {
818            name: "destroy_all".to_string(),
819            description: Some("Destroy all data (test)".to_string()),
820            parameters: vec![],
821            annotations: Some(AnnotationsDecl {
822                read_only_hint: false,
823                destructive_hint: true,
824                idempotent_hint: false,
825                open_world_hint: false,
826                cost_hint: Some("high".to_string()),
827            }),
828            ..Default::default()
829        }]);
830        let out = synthesize_from_config(&cfg).expect("synthesize");
831        let (_, info, _) = &out[0];
832        let ann = info.annotations.as_ref().expect("annotations");
833        assert_eq!(ann.read_only_hint, Some(false));
834        assert_eq!(ann.destructive_hint, Some(true));
835        assert_eq!(ann.idempotent_hint, Some(false));
836        assert_eq!(ann.open_world_hint, Some(false));
837    }
838
839    /// REVIEWS H1 (in-plan widget_meta flip — no SqliteConnector dependency).
840    ///
841    /// When a `[[tools]]` entry declares `ui_resource_uri`, the synthesized
842    /// `ToolInfo` must carry widget metadata so pmcp core's
843    /// `with_widget_enrichment` (gated on `info.widget_meta().is_some()`)
844    /// populates `structuredContent` (D-06). The flip lives in the shared
845    /// `synthesize_inner` helper, so it fires for BOTH entry points; this test
846    /// exercises it via the no-connector `synthesize_from_config` path.
847    #[test]
848    fn widget_meta_flips_when_ui_resource_uri_present() {
849        let cfg = cfg_with_tools(vec![ToolDecl {
850            name: "widget_tool".to_string(),
851            description: Some("renders a widget".to_string()),
852            ui_resource_uri: Some("ui://test".to_string()),
853            ..Default::default()
854        }]);
855        let out = synthesize_from_config(&cfg).expect("synthesize");
856        let (_, info, _) = &out[0];
857        assert!(
858            info.widget_meta().is_some(),
859            "ui_resource_uri set ⇒ widget_meta() must be Some so D-06 structuredContent fires"
860        );
861    }
862
863    /// REVIEWS H1 negative case — a tool WITHOUT `ui_resource_uri` must NOT
864    /// carry widget metadata (T-84-03-03: no accidental flip on non-widget
865    /// tools).
866    #[test]
867    fn widget_meta_absent_when_ui_resource_uri_none() {
868        let cfg = cfg_with_tools(vec![ToolDecl {
869            name: "plain_tool".to_string(),
870            description: Some("no widget".to_string()),
871            ui_resource_uri: None,
872            ..Default::default()
873        }]);
874        let out = synthesize_from_config(&cfg).expect("synthesize");
875        let (_, info, _) = &out[0];
876        assert!(
877            info.widget_meta().is_none(),
878            "ui_resource_uri absent ⇒ widget_meta() must be None (no accidental flip)"
879        );
880    }
881
882    #[tokio::test]
883    async fn synthesized_handler_metadata_returns_some() {
884        let cfg = cfg_with_tools(vec![ToolDecl {
885            name: "ping".to_string(),
886            description: Some("ping".to_string()),
887            parameters: vec![],
888            annotations: None,
889            ..Default::default()
890        }]);
891        let out = synthesize_from_config(&cfg).expect("synthesize");
892        let (_, expected_info, handler) = &out[0];
893        let actual = handler.metadata();
894        assert!(
895            actual.is_some(),
896            "RESEARCH §Risks #2 invariant: SynthesizedToolHandler::metadata() MUST return Some(ToolInfo)"
897        );
898        assert_eq!(actual.unwrap().name, expected_info.name);
899    }
900
901    /// A `[[tools]]` declaration with one defaulted `limit` param (default=20),
902    /// used to exercise [`extract_named_params`]'s default / explicit-null logic.
903    fn decl_with_limit_default() -> ToolDecl {
904        ToolDecl {
905            name: "search".to_string(),
906            description: Some("Search".to_string()),
907            sql: Some("SELECT * FROM t LIMIT :limit".to_string()),
908            parameters: vec![ParamDecl {
909                name: "limit".to_string(),
910                param_type: Some("integer".to_string()),
911                description: Some("row limit".to_string()),
912                required: false,
913                default: Some(toml::Value::Integer(20)),
914                ..Default::default()
915            }],
916            ..Default::default()
917        }
918    }
919
920    #[test]
921    fn extract_named_params_applies_default_when_absent() {
922        // `{}` → declared default (20) is bound (the reference search/list calls
923        // rely on this so an omitted :limit never binds NULL).
924        let decl = decl_with_limit_default();
925        let params = extract_named_params(&decl, &serde_json::json!({}));
926        assert_eq!(params, vec![("limit".to_string(), serde_json::json!(20))]);
927    }
928
929    #[test]
930    fn extract_named_params_explicit_null_applies_default() {
931        // 85-10 WR-02 secondary fix: an EXPLICIT JSON null must NOT bind
932        // `LIMIT NULL` — it falls through to the declared default exactly like
933        // an omitted key.
934        let decl = decl_with_limit_default();
935        let params = extract_named_params(&decl, &serde_json::json!({ "limit": null }));
936        assert_eq!(
937            params,
938            vec![("limit".to_string(), serde_json::json!(20))],
939            "explicit null must apply the declared default, not bind LIMIT NULL"
940        );
941    }
942
943    #[test]
944    fn extract_named_params_explicit_value_overrides_default() {
945        // A concrete value wins over the default.
946        let decl = decl_with_limit_default();
947        let params = extract_named_params(&decl, &serde_json::json!({ "limit": 5 }));
948        assert_eq!(params, vec![("limit".to_string(), serde_json::json!(5))]);
949    }
950}
951
952// -----------------------------------------------------------------------------
953// Tests — Phase 90 OAPI-02a single-call HTTP synthesizer (feature `http`)
954// -----------------------------------------------------------------------------
955
956#[cfg(all(test, feature = "http"))]
957mod synth_http_tests {
958    use super::*;
959    use crate::config::{ParamDecl, ServerConfig, ServerSection, ToolDecl};
960    use crate::http::{HttpConnector, HttpConnectorError, Operation};
961    use pmcp::RequestHandlerExtra;
962    use serde_json::{json, Value};
963    use std::sync::{Arc, Mutex};
964
965    /// A mock [`HttpConnector`] that records the [`Operation`] it last received
966    /// and returns a fixed JSON payload — so a synthesized handler can be
967    /// invoked without any network.
968    struct MockHttpConnector {
969        last: Mutex<Option<Operation>>,
970        payload: Value,
971    }
972
973    impl MockHttpConnector {
974        fn new(payload: Value) -> Arc<Self> {
975            Arc::new(Self {
976                last: Mutex::new(None),
977                payload,
978            })
979        }
980    }
981
982    #[async_trait]
983    impl HttpConnector for MockHttpConnector {
984        async fn execute(
985            &self,
986            operation: &Operation,
987            _args: &Value,
988        ) -> std::result::Result<Value, HttpConnectorError> {
989            *self.last.lock().unwrap() = Some(operation.clone());
990            Ok(self.payload.clone())
991        }
992        fn base_url(&self) -> &str {
993            "https://mock.example.com"
994        }
995    }
996
997    fn cfg_with_tools(tools: Vec<ToolDecl>) -> ServerConfig {
998        ServerConfig {
999            server: ServerSection {
1000                name: "demo".to_string(),
1001                version: "0.1.0".to_string(),
1002                ..Default::default()
1003            },
1004            tools,
1005            ..Default::default()
1006        }
1007    }
1008
1009    /// (1) A single-call `[[tools]]` with a `{id}` path param synthesizes a
1010    /// `ToolInfo` whose input schema marks `id` required (object envelope), and
1011    /// the handler (wired to a mock connector) returns the mocked JSON.
1012    #[tokio::test]
1013    async fn synth_http_single_call_path_param_required_and_handler_returns_json() {
1014        let cfg = cfg_with_tools(vec![ToolDecl {
1015            name: "line_status".to_string(),
1016            description: Some("Line status".to_string()),
1017            path: Some("/Line/{id}/Status".to_string()),
1018            method: Some("GET".to_string()),
1019            parameters: vec![ParamDecl {
1020                name: "id".to_string(),
1021                param_type: Some("string".to_string()),
1022                required: true,
1023                ..Default::default()
1024            }],
1025            ..Default::default()
1026        }]);
1027        let connector = MockHttpConnector::new(json!({ "status": "Good Service" }));
1028        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
1029            .expect("synthesize");
1030        assert_eq!(out.len(), 1);
1031        let (name, info, handler) = &out[0];
1032        assert_eq!(name, "line_status");
1033        let schema = &info.input_schema;
1034        assert_eq!(schema["type"], "object");
1035        assert_eq!(schema["required"], json!(["id"]));
1036        assert_eq!(schema["additionalProperties"], Value::Bool(false));
1037
1038        let extra = RequestHandlerExtra::default();
1039        let result = handler
1040            .handle(json!({ "id": "victoria" }), extra)
1041            .await
1042            .expect("handle");
1043        assert_eq!(result, json!({ "status": "Good Service" }));
1044
1045        // The operation carried the `{id}` path param as a Path parameter.
1046        let op = connector
1047            .last
1048            .lock()
1049            .unwrap()
1050            .clone()
1051            .expect("operation recorded");
1052        let path_params: Vec<&str> = op
1053            .path_parameters()
1054            .iter()
1055            .map(|p| p.name.as_str())
1056            .collect();
1057        assert_eq!(path_params, vec!["id"]);
1058    }
1059
1060    /// (2) A `POST` tool routes non-path args to the request body
1061    /// (`has_request_body` true) and the non-path param is NOT a path param.
1062    #[tokio::test]
1063    async fn synth_http_post_sets_request_body() {
1064        let cfg = cfg_with_tools(vec![ToolDecl {
1065            name: "create_item".to_string(),
1066            description: Some("Create".to_string()),
1067            path: Some("/items".to_string()),
1068            method: Some("post".to_string()),
1069            parameters: vec![ParamDecl {
1070                name: "title".to_string(),
1071                param_type: Some("string".to_string()),
1072                required: true,
1073                ..Default::default()
1074            }],
1075            ..Default::default()
1076        }]);
1077        let connector = MockHttpConnector::new(json!({ "ok": true }));
1078        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
1079            .expect("synthesize");
1080        let (_, _, handler) = &out[0];
1081        let extra = RequestHandlerExtra::default();
1082        handler
1083            .handle(json!({ "title": "widget" }), extra)
1084            .await
1085            .expect("handle");
1086        let op = connector
1087            .last
1088            .lock()
1089            .unwrap()
1090            .clone()
1091            .expect("operation recorded");
1092        assert_eq!(op.method, "POST");
1093        assert!(op.has_request_body, "POST must carry a request body");
1094        assert!(op.path_parameters().is_empty());
1095    }
1096
1097    /// (3) A tool with path + query params lands them in the right schema slots /
1098    /// `Operation` parameter locations.
1099    #[tokio::test]
1100    async fn synth_http_path_and_query_param_slots() {
1101        let cfg = cfg_with_tools(vec![ToolDecl {
1102            name: "search".to_string(),
1103            description: Some("Search".to_string()),
1104            path: Some("/repos/{owner}/issues".to_string()),
1105            method: Some("GET".to_string()),
1106            parameters: vec![
1107                ParamDecl {
1108                    name: "owner".to_string(),
1109                    param_type: Some("string".to_string()),
1110                    required: true,
1111                    ..Default::default()
1112                },
1113                ParamDecl {
1114                    name: "state".to_string(),
1115                    param_type: Some("string".to_string()),
1116                    required: false,
1117                    ..Default::default()
1118                },
1119            ],
1120            ..Default::default()
1121        }]);
1122        let connector = MockHttpConnector::new(json!([]));
1123        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
1124            .expect("synthesize");
1125        let (_, _, handler) = &out[0];
1126        let extra = RequestHandlerExtra::default();
1127        handler
1128            .handle(json!({ "owner": "rust-lang", "state": "open" }), extra)
1129            .await
1130            .expect("handle");
1131        let op = connector
1132            .last
1133            .lock()
1134            .unwrap()
1135            .clone()
1136            .expect("operation recorded");
1137        let path_params: Vec<&str> = op
1138            .path_parameters()
1139            .iter()
1140            .map(|p| p.name.as_str())
1141            .collect();
1142        assert_eq!(path_params, vec!["owner"]);
1143        let query_params: Vec<&str> = op
1144            .query_parameters()
1145            .iter()
1146            .map(|p| p.name.as_str())
1147            .collect();
1148        assert_eq!(query_params, vec!["state"]);
1149    }
1150
1151    /// (4) A per-tool `base_url` is reflected in the synthesized `Operation`
1152    /// (Codex MEDIUM — not dropped).
1153    #[tokio::test]
1154    async fn synth_http_per_tool_base_url_reflected() {
1155        let cfg = cfg_with_tools(vec![ToolDecl {
1156            name: "other_host".to_string(),
1157            description: Some("Other host".to_string()),
1158            path: Some("/ping".to_string()),
1159            method: Some("GET".to_string()),
1160            base_url: Some("https://other.example.com/v2".to_string()),
1161            ..Default::default()
1162        }]);
1163        let connector = MockHttpConnector::new(json!({ "pong": true }));
1164        let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
1165            .expect("synthesize");
1166        let (_, _, handler) = &out[0];
1167        let extra = RequestHandlerExtra::default();
1168        handler.handle(json!({}), extra).await.expect("handle");
1169        let op = connector
1170            .last
1171            .lock()
1172            .unwrap()
1173            .clone()
1174            .expect("operation recorded");
1175        assert_eq!(
1176            op.base_url.as_deref(),
1177            Some("https://other.example.com/v2"),
1178            "per-tool base_url must be reflected on the Operation, not dropped"
1179        );
1180    }
1181
1182    /// (5) NEGATIVE: a `[[tools]]` missing `method` (and without `script`) is
1183    /// rejected with a typed `ToolkitError` (T-90-03-04 — never silently
1184    /// registered).
1185    #[test]
1186    fn synth_http_missing_method_rejected() {
1187        let cfg = cfg_with_tools(vec![ToolDecl {
1188            name: "broken".to_string(),
1189            description: Some("missing method".to_string()),
1190            path: Some("/items".to_string()),
1191            method: None,
1192            ..Default::default()
1193        }]);
1194        let connector = MockHttpConnector::new(json!(null));
1195        let err = synthesize_from_config_with_http_connector(&cfg, connector)
1196            .err()
1197            .expect("ill-formed single-call tool must be rejected");
1198        assert!(matches!(err, ToolkitError::Synth(_)));
1199    }
1200
1201    /// (5b) NEGATIVE: on the single-call-only entry point, a `script` tool is
1202    /// rejected with a typed `ToolkitError` pointing at the `openapi-code-mode`
1203    /// script path — NOT a silent skip, NOT a panic. (The OpenAPI Code Mode
1204    /// entry point `synthesize_from_config_with_http_connector_and_scripts`
1205    /// synthesizes a real `ScriptToolHandler` — proven in `script_tool` tests.)
1206    #[test]
1207    fn synth_http_script_tool_without_engine_is_rejected() {
1208        let cfg = cfg_with_tools(vec![ToolDecl {
1209            name: "scripted".to_string(),
1210            description: Some("script tool".to_string()),
1211            script: Some("await api.get('/x')".to_string()),
1212            ..Default::default()
1213        }]);
1214        let connector = MockHttpConnector::new(json!(null));
1215        let err = synthesize_from_config_with_http_connector(&cfg, connector)
1216            .err()
1217            .expect("script tool on the single-call-only entry point must be rejected");
1218        match err {
1219            ToolkitError::Synth(msg) => {
1220                assert!(
1221                    msg.contains("openapi-code-mode"),
1222                    "seam message must point at the openapi-code-mode script path: {msg}"
1223                );
1224            },
1225            other => panic!("expected Synth error, got {other:?}"),
1226        }
1227    }
1228}