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}