use std::sync::Arc;
use async_trait::async_trait;
use pmcp::server::ToolHandler;
use pmcp::types::{ToolAnnotations, ToolInfo};
use pmcp::RequestHandlerExtra;
use serde_json::{json, Map, Value};
use crate::config::{AnnotationsDecl, ParamDecl, ServerConfig, ToolDecl};
use crate::error::Result;
use crate::sql::SqlConnector;
#[cfg(feature = "http")]
use crate::error::ToolkitError;
#[cfg(feature = "http")]
use crate::http::{HttpConnector, Operation, Parameter, ParameterLocation};
#[cfg(feature = "openapi-code-mode")]
use crate::code_mode::HttpCodeExecutor;
#[cfg(feature = "openapi-code-mode")]
use pmcp_code_mode::ExecutionConfig;
pub type SynthesizedTool = (String, ToolInfo, Arc<dyn ToolHandler>);
pub fn synthesize_from_config(config: &ServerConfig) -> Result<Vec<SynthesizedTool>> {
synthesize_inner(config, None)
}
pub fn synthesize_from_config_with_connector(
config: &ServerConfig,
connector: Arc<dyn SqlConnector>,
) -> Result<Vec<SynthesizedTool>> {
synthesize_inner(config, Some(connector))
}
fn synthesize_inner(
config: &ServerConfig,
connector: Option<Arc<dyn SqlConnector>>,
) -> Result<Vec<SynthesizedTool>> {
let mut out = Vec::with_capacity(config.tools.len());
for decl in &config.tools {
let info = build_tool_info(decl);
let handler: Arc<dyn ToolHandler> = Arc::new(SynthesizedToolHandler {
info: info.clone(),
decl: decl.clone(),
connector: connector.clone(),
});
out.push((decl.name.clone(), info, handler));
}
Ok(out)
}
fn apply_widget_meta(info: ToolInfo, decl: &ToolDecl) -> ToolInfo {
match decl.ui_resource_uri.as_deref() {
Some(uri) => info.with_meta_entry("ui", json!({ "resourceUri": uri })),
None => info,
}
}
fn build_tool_info(decl: &ToolDecl) -> ToolInfo {
let schema = build_input_schema(&decl.parameters);
let annotations = build_annotations(decl.annotations.as_ref());
let base = match annotations {
Some(ann) => {
ToolInfo::with_annotations(decl.name.clone(), decl.description.clone(), schema, ann)
},
None => ToolInfo::new(decl.name.clone(), decl.description.clone(), schema),
};
apply_widget_meta(base, decl)
}
fn build_input_schema(params: &[ParamDecl]) -> Value {
let mut props = Map::new();
let mut required = Vec::new();
for p in params {
props.insert(p.name.clone(), build_param_property(p));
if p.required {
required.push(Value::String(p.name.clone()));
}
}
json!({
"type": "object",
"properties": props,
"required": required,
"additionalProperties": false,
})
}
fn build_param_property(p: &ParamDecl) -> Value {
let ty = p.param_type.as_deref().unwrap_or("string");
let mut prop = json!({ "type": ty });
if let Some(desc) = &p.description {
prop["description"] = Value::String(desc.clone());
}
if let Some(min) = p.minimum {
prop["minimum"] = json!(min);
}
if let Some(max) = p.maximum {
prop["maximum"] = json!(max);
}
if let Some(max_len) = p.max_length {
prop["maxLength"] = json!(max_len);
}
if let Some(default) = &p.default {
if let Ok(v) = serde_json::to_value(default) {
prop["default"] = v;
}
}
if let Some(enum_vals) = &p.enum_values {
if let Ok(v) = serde_json::to_value(enum_vals) {
prop["enum"] = v;
}
}
prop
}
fn build_annotations(decl: Option<&AnnotationsDecl>) -> Option<ToolAnnotations> {
let d = decl?;
let a = ToolAnnotations::new()
.with_read_only(d.read_only_hint)
.with_destructive(d.destructive_hint)
.with_idempotent(d.idempotent_hint)
.with_open_world(d.open_world_hint);
Some(a)
}
struct SynthesizedToolHandler {
info: ToolInfo,
decl: ToolDecl,
connector: Option<Arc<dyn SqlConnector>>,
}
fn extract_named_params(decl: &ToolDecl, args: &Value) -> Vec<(String, Value)> {
decl.parameters
.iter()
.filter_map(|p| {
args.get(&p.name)
.filter(|v| !v.is_null())
.cloned()
.or_else(|| {
p.default
.as_ref()
.and_then(|d| serde_json::to_value(d).ok())
})
.map(|v| (p.name.clone(), v))
})
.collect()
}
#[async_trait]
impl ToolHandler for SynthesizedToolHandler {
async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
let sql = self.decl.sql.as_deref().ok_or_else(|| {
pmcp::Error::Internal(format!("tool '{}' has no `sql` declared", self.info.name))
})?;
let connector = self.connector.as_ref().ok_or_else(|| {
pmcp::Error::Internal(format!(
"tool '{}' requires connector wiring — build via synthesize_from_config_with_connector",
self.info.name
))
})?;
let named_params = extract_named_params(&self.decl, &args);
let rows = connector
.execute(sql, &named_params)
.await
.map_err(|e| pmcp::Error::Internal(format!("connector error: {e}")))?;
Ok(Value::Array(rows))
}
fn metadata(&self) -> Option<ToolInfo> {
Some(self.info.clone())
}
}
#[cfg(feature = "http")]
pub fn synthesize_from_config_with_http_connector(
config: &ServerConfig,
connector: Arc<dyn HttpConnector>,
) -> Result<Vec<SynthesizedTool>> {
synthesize_http_inner(config, connector, |decl| {
Err(ToolkitError::Synth(format!(
"tool '{}' is a script tool — script tools require the `openapi-code-mode` \
feature (use synthesize_from_config_with_http_connector_and_scripts)",
decl.name
)))
})
}
#[cfg(feature = "openapi-code-mode")]
pub fn synthesize_from_config_with_http_connector_and_scripts(
config: &ServerConfig,
connector: Arc<dyn HttpConnector>,
http_exec: HttpCodeExecutor,
exec_config: ExecutionConfig,
) -> Result<Vec<SynthesizedTool>> {
synthesize_http_inner(config, connector, |decl| {
let handler = ScriptToolHandler::new(decl, http_exec.clone(), exec_config.clone())?;
let info = handler.tool_info.clone();
let arc: Arc<dyn ToolHandler> = Arc::new(handler);
Ok((info, arc))
})
}
#[cfg(feature = "http")]
fn synthesize_http_inner(
config: &ServerConfig,
connector: Arc<dyn HttpConnector>,
mut build_script_tool: impl FnMut(&ToolDecl) -> Result<(ToolInfo, Arc<dyn ToolHandler>)>,
) -> Result<Vec<SynthesizedTool>> {
let mut out = Vec::with_capacity(config.tools.len());
for decl in &config.tools {
if decl.is_script_tool() {
let (info, handler) = build_script_tool(decl)?;
out.push((decl.name.clone(), info, handler));
continue;
}
let (path, method) = match (decl.path.as_deref(), decl.method.as_deref()) {
(Some(p), Some(m)) => (p, m),
_ => {
return Err(ToolkitError::Synth(format!(
"tool '{}' is not a valid single-call tool: both `path` and `method` are required",
decl.name
)));
},
};
let operation = build_operation(path, method, decl);
let info = build_tool_info(decl);
let handler: Arc<dyn ToolHandler> = Arc::new(HttpToolHandler {
info: info.clone(),
operation,
connector: connector.clone(),
});
out.push((decl.name.clone(), info, handler));
}
Ok(out)
}
#[cfg(feature = "http")]
fn build_operation(path: &str, method: &str, decl: &ToolDecl) -> Operation {
let method_upper = method.to_uppercase();
let path_param_names: Vec<&str> = path
.split('/')
.filter(|s| s.starts_with('{') && s.ends_with('}') && s.len() > 2)
.map(|s| &s[1..s.len() - 1])
.collect();
let mut parameters = Vec::with_capacity(decl.parameters.len());
for name in &path_param_names {
parameters.push(Parameter::new(
(*name).to_string(),
ParameterLocation::Path,
true,
));
}
for p in &decl.parameters {
if path_param_names.iter().any(|n| *n == p.name) {
continue;
}
parameters.push(Parameter::new(
p.name.clone(),
ParameterLocation::Query,
p.required,
));
}
let has_request_body = matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH");
Operation {
method: method_upper,
path: path.to_string(),
parameters,
has_request_body,
base_url: decl.base_url.clone(),
}
}
#[cfg(feature = "http")]
struct HttpToolHandler {
info: ToolInfo,
operation: Operation,
connector: Arc<dyn HttpConnector>,
}
#[cfg(feature = "http")]
#[async_trait]
impl ToolHandler for HttpToolHandler {
async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
self.connector
.execute(&self.operation, &args)
.await
.map_err(|e| pmcp::Error::Internal(format!("connector error: {e}")))
}
fn metadata(&self) -> Option<ToolInfo> {
Some(self.info.clone())
}
}
#[cfg(feature = "openapi-code-mode")]
struct ScriptToolHandler {
plan: pmcp_code_mode::ExecutionPlan,
http_exec: HttpCodeExecutor,
exec_config: ExecutionConfig,
tool_info: ToolInfo,
}
#[cfg(feature = "openapi-code-mode")]
impl ScriptToolHandler {
fn new(
decl: &ToolDecl,
http_exec: HttpCodeExecutor,
exec_config: ExecutionConfig,
) -> Result<Self> {
let script = decl.script.clone().ok_or_else(|| {
ToolkitError::Synth(format!(
"tool '{}' has no `script` body — not a script tool",
decl.name
))
})?;
let plan = pmcp_code_mode::PlanCompiler::with_config(&exec_config)
.compile_code(&script)
.map_err(|e| {
ToolkitError::Synth(format!(
"tool '{}' script failed to compile: {e}",
decl.name
))
})?;
let tool_info = build_tool_info(decl);
Ok(Self {
plan,
http_exec,
exec_config,
tool_info,
})
}
}
#[cfg(feature = "openapi-code-mode")]
#[pmcp_code_mode::async_trait]
impl ToolHandler for ScriptToolHandler {
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> {
let mut executor = pmcp_code_mode::PlanExecutor::new(
crate::code_mode::request_executor_from_extra(&self.http_exec, &extra),
self.exec_config.clone(),
);
executor.set_variable("args", args);
let result = executor
.execute(&self.plan)
.await
.map_err(|e| pmcp::Error::Internal(format!("script execution failed: {e}")))?;
Ok(result.value)
}
fn metadata(&self) -> Option<ToolInfo> {
Some(self.tool_info.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AnnotationsDecl, ParamDecl, ServerConfig, ServerSection, ToolDecl};
use serde_json::Value;
fn cfg_with_tools(tools: Vec<ToolDecl>) -> ServerConfig {
ServerConfig {
server: ServerSection {
name: "demo".to_string(),
version: "0.1.0".to_string(),
..Default::default()
},
tools,
..Default::default()
}
}
#[test]
fn empty_tools_returns_empty_vec() {
let cfg = cfg_with_tools(vec![]);
let out = synthesize_from_config(&cfg).expect("synthesize");
assert_eq!(out.len(), 0);
}
#[test]
fn one_tool_no_params_yields_object_schema() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "ping".to_string(),
description: Some("Ping the server".to_string()),
parameters: vec![],
annotations: None,
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
assert_eq!(out.len(), 1);
let (name, info, _handler) = &out[0];
assert_eq!(name, "ping");
assert_eq!(info.name, "ping");
assert_eq!(info.description.as_deref(), Some("Ping the server"));
let schema = &info.input_schema;
assert_eq!(schema["type"], Value::String("object".to_string()));
assert_eq!(schema["properties"], serde_json::json!({}));
assert_eq!(schema["required"], serde_json::json!([]));
assert_eq!(schema["additionalProperties"], Value::Bool(false));
}
#[test]
fn required_and_optional_params_partitioned() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "search".to_string(),
description: Some("Search".to_string()),
parameters: vec![
ParamDecl {
name: "query".to_string(),
param_type: Some("string".to_string()),
description: Some("the search query".to_string()),
required: true,
..Default::default()
},
ParamDecl {
name: "max_results".to_string(),
param_type: Some("integer".to_string()),
description: Some("maximum result count".to_string()),
required: false,
default: Some(toml::Value::Integer(100)),
minimum: Some(1.0),
maximum: Some(1000.0),
..Default::default()
},
],
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_, info, _) = &out[0];
let schema = &info.input_schema;
assert_eq!(schema["required"], serde_json::json!(["query"]));
let props = schema["properties"].as_object().expect("object");
assert_eq!(props["query"]["type"], "string");
assert_eq!(props["max_results"]["type"], "integer");
assert_eq!(props["max_results"]["minimum"], serde_json::json!(1.0));
assert_eq!(props["max_results"]["maximum"], serde_json::json!(1000.0));
assert_eq!(props["max_results"]["default"], serde_json::json!(100));
}
#[test]
fn param_max_length_propagates() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "echo".to_string(),
description: Some("Echo".to_string()),
parameters: vec![ParamDecl {
name: "text".to_string(),
param_type: Some("string".to_string()),
description: Some("input text".to_string()),
required: true,
max_length: Some(256),
..Default::default()
}],
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_, info, _) = &out[0];
assert_eq!(
info.input_schema["properties"]["text"]["maxLength"],
serde_json::json!(256)
);
}
#[test]
fn annotations_round_trip_via_fluent_builder() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "destroy_all".to_string(),
description: Some("Destroy all data (test)".to_string()),
parameters: vec![],
annotations: Some(AnnotationsDecl {
read_only_hint: false,
destructive_hint: true,
idempotent_hint: false,
open_world_hint: false,
cost_hint: Some("high".to_string()),
}),
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_, info, _) = &out[0];
let ann = info.annotations.as_ref().expect("annotations");
assert_eq!(ann.read_only_hint, Some(false));
assert_eq!(ann.destructive_hint, Some(true));
assert_eq!(ann.idempotent_hint, Some(false));
assert_eq!(ann.open_world_hint, Some(false));
}
#[test]
fn widget_meta_flips_when_ui_resource_uri_present() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "widget_tool".to_string(),
description: Some("renders a widget".to_string()),
ui_resource_uri: Some("ui://test".to_string()),
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_, info, _) = &out[0];
assert!(
info.widget_meta().is_some(),
"ui_resource_uri set ⇒ widget_meta() must be Some so D-06 structuredContent fires"
);
}
#[test]
fn widget_meta_absent_when_ui_resource_uri_none() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "plain_tool".to_string(),
description: Some("no widget".to_string()),
ui_resource_uri: None,
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_, info, _) = &out[0];
assert!(
info.widget_meta().is_none(),
"ui_resource_uri absent ⇒ widget_meta() must be None (no accidental flip)"
);
}
#[tokio::test]
async fn synthesized_handler_metadata_returns_some() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "ping".to_string(),
description: Some("ping".to_string()),
parameters: vec![],
annotations: None,
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_, expected_info, handler) = &out[0];
let actual = handler.metadata();
assert!(
actual.is_some(),
"RESEARCH §Risks #2 invariant: SynthesizedToolHandler::metadata() MUST return Some(ToolInfo)"
);
assert_eq!(actual.unwrap().name, expected_info.name);
}
fn decl_with_limit_default() -> ToolDecl {
ToolDecl {
name: "search".to_string(),
description: Some("Search".to_string()),
sql: Some("SELECT * FROM t LIMIT :limit".to_string()),
parameters: vec![ParamDecl {
name: "limit".to_string(),
param_type: Some("integer".to_string()),
description: Some("row limit".to_string()),
required: false,
default: Some(toml::Value::Integer(20)),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn extract_named_params_applies_default_when_absent() {
let decl = decl_with_limit_default();
let params = extract_named_params(&decl, &serde_json::json!({}));
assert_eq!(params, vec![("limit".to_string(), serde_json::json!(20))]);
}
#[test]
fn extract_named_params_explicit_null_applies_default() {
let decl = decl_with_limit_default();
let params = extract_named_params(&decl, &serde_json::json!({ "limit": null }));
assert_eq!(
params,
vec![("limit".to_string(), serde_json::json!(20))],
"explicit null must apply the declared default, not bind LIMIT NULL"
);
}
#[test]
fn extract_named_params_explicit_value_overrides_default() {
let decl = decl_with_limit_default();
let params = extract_named_params(&decl, &serde_json::json!({ "limit": 5 }));
assert_eq!(params, vec![("limit".to_string(), serde_json::json!(5))]);
}
}
#[cfg(all(test, feature = "http"))]
mod synth_http_tests {
use super::*;
use crate::config::{ParamDecl, ServerConfig, ServerSection, ToolDecl};
use crate::http::{HttpConnector, HttpConnectorError, Operation};
use pmcp::RequestHandlerExtra;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
struct MockHttpConnector {
last: Mutex<Option<Operation>>,
payload: Value,
}
impl MockHttpConnector {
fn new(payload: Value) -> Arc<Self> {
Arc::new(Self {
last: Mutex::new(None),
payload,
})
}
}
#[async_trait]
impl HttpConnector for MockHttpConnector {
async fn execute(
&self,
operation: &Operation,
_args: &Value,
) -> std::result::Result<Value, HttpConnectorError> {
*self.last.lock().unwrap() = Some(operation.clone());
Ok(self.payload.clone())
}
fn base_url(&self) -> &str {
"https://mock.example.com"
}
}
fn cfg_with_tools(tools: Vec<ToolDecl>) -> ServerConfig {
ServerConfig {
server: ServerSection {
name: "demo".to_string(),
version: "0.1.0".to_string(),
..Default::default()
},
tools,
..Default::default()
}
}
#[tokio::test]
async fn synth_http_single_call_path_param_required_and_handler_returns_json() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "line_status".to_string(),
description: Some("Line status".to_string()),
path: Some("/Line/{id}/Status".to_string()),
method: Some("GET".to_string()),
parameters: vec![ParamDecl {
name: "id".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
}],
..Default::default()
}]);
let connector = MockHttpConnector::new(json!({ "status": "Good Service" }));
let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
.expect("synthesize");
assert_eq!(out.len(), 1);
let (name, info, handler) = &out[0];
assert_eq!(name, "line_status");
let schema = &info.input_schema;
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"], json!(["id"]));
assert_eq!(schema["additionalProperties"], Value::Bool(false));
let extra = RequestHandlerExtra::default();
let result = handler
.handle(json!({ "id": "victoria" }), extra)
.await
.expect("handle");
assert_eq!(result, json!({ "status": "Good Service" }));
let op = connector
.last
.lock()
.unwrap()
.clone()
.expect("operation recorded");
let path_params: Vec<&str> = op
.path_parameters()
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(path_params, vec!["id"]);
}
#[tokio::test]
async fn synth_http_post_sets_request_body() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "create_item".to_string(),
description: Some("Create".to_string()),
path: Some("/items".to_string()),
method: Some("post".to_string()),
parameters: vec![ParamDecl {
name: "title".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
}],
..Default::default()
}]);
let connector = MockHttpConnector::new(json!({ "ok": true }));
let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
.expect("synthesize");
let (_, _, handler) = &out[0];
let extra = RequestHandlerExtra::default();
handler
.handle(json!({ "title": "widget" }), extra)
.await
.expect("handle");
let op = connector
.last
.lock()
.unwrap()
.clone()
.expect("operation recorded");
assert_eq!(op.method, "POST");
assert!(op.has_request_body, "POST must carry a request body");
assert!(op.path_parameters().is_empty());
}
#[tokio::test]
async fn synth_http_path_and_query_param_slots() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "search".to_string(),
description: Some("Search".to_string()),
path: Some("/repos/{owner}/issues".to_string()),
method: Some("GET".to_string()),
parameters: vec![
ParamDecl {
name: "owner".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
ParamDecl {
name: "state".to_string(),
param_type: Some("string".to_string()),
required: false,
..Default::default()
},
],
..Default::default()
}]);
let connector = MockHttpConnector::new(json!([]));
let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
.expect("synthesize");
let (_, _, handler) = &out[0];
let extra = RequestHandlerExtra::default();
handler
.handle(json!({ "owner": "rust-lang", "state": "open" }), extra)
.await
.expect("handle");
let op = connector
.last
.lock()
.unwrap()
.clone()
.expect("operation recorded");
let path_params: Vec<&str> = op
.path_parameters()
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(path_params, vec!["owner"]);
let query_params: Vec<&str> = op
.query_parameters()
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(query_params, vec!["state"]);
}
#[tokio::test]
async fn synth_http_per_tool_base_url_reflected() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "other_host".to_string(),
description: Some("Other host".to_string()),
path: Some("/ping".to_string()),
method: Some("GET".to_string()),
base_url: Some("https://other.example.com/v2".to_string()),
..Default::default()
}]);
let connector = MockHttpConnector::new(json!({ "pong": true }));
let out = synthesize_from_config_with_http_connector(&cfg, connector.clone())
.expect("synthesize");
let (_, _, handler) = &out[0];
let extra = RequestHandlerExtra::default();
handler.handle(json!({}), extra).await.expect("handle");
let op = connector
.last
.lock()
.unwrap()
.clone()
.expect("operation recorded");
assert_eq!(
op.base_url.as_deref(),
Some("https://other.example.com/v2"),
"per-tool base_url must be reflected on the Operation, not dropped"
);
}
#[test]
fn synth_http_missing_method_rejected() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "broken".to_string(),
description: Some("missing method".to_string()),
path: Some("/items".to_string()),
method: None,
..Default::default()
}]);
let connector = MockHttpConnector::new(json!(null));
let err = synthesize_from_config_with_http_connector(&cfg, connector)
.err()
.expect("ill-formed single-call tool must be rejected");
assert!(matches!(err, ToolkitError::Synth(_)));
}
#[test]
fn synth_http_script_tool_without_engine_is_rejected() {
let cfg = cfg_with_tools(vec![ToolDecl {
name: "scripted".to_string(),
description: Some("script tool".to_string()),
script: Some("await api.get('/x')".to_string()),
..Default::default()
}]);
let connector = MockHttpConnector::new(json!(null));
let err = synthesize_from_config_with_http_connector(&cfg, connector)
.err()
.expect("script tool on the single-call-only entry point must be rejected");
match err {
ToolkitError::Synth(msg) => {
assert!(
msg.contains("openapi-code-mode"),
"seam message must point at the openapi-code-mode script path: {msg}"
);
},
other => panic!("expected Synth error, got {other:?}"),
}
}
}