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, ValidationSection};
use crate::error::Result;
use crate::policy::ToolkitHooks;
#[cfg(feature = "input-validation")]
use crate::policy::ArgumentValidator;
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, &ToolkitHooks::default())
}
pub fn synthesize_from_config_and_hooks(
config: &ServerConfig,
hooks: &ToolkitHooks,
) -> Result<Vec<SynthesizedTool>> {
synthesize_inner(config, None, hooks)
}
pub fn synthesize_from_config_with_connector(
config: &ServerConfig,
connector: Arc<dyn SqlConnector>,
) -> Result<Vec<SynthesizedTool>> {
synthesize_inner(config, Some(connector), &ToolkitHooks::default())
}
pub fn synthesize_from_config_with_connector_and_hooks(
config: &ServerConfig,
connector: Arc<dyn SqlConnector>,
hooks: &ToolkitHooks,
) -> Result<Vec<SynthesizedTool>> {
synthesize_inner(config, Some(connector), hooks)
}
fn synthesize_inner(
config: &ServerConfig,
connector: Option<Arc<dyn SqlConnector>>,
hooks: &ToolkitHooks,
) -> Result<Vec<SynthesizedTool>> {
let validation = &config.server.validation;
let mut out = Vec::with_capacity(config.tools.len());
for decl in &config.tools {
let info = build_tool_info(decl, validation);
let handler: Arc<dyn ToolHandler> = Arc::new(SynthesizedToolHandler {
info: info.clone(),
decl: decl.clone(),
connector: connector.clone(),
});
let handler = enforce_input_schema(handler, &info, decl, validation, hooks);
out.push((decl.name.clone(), info, handler));
}
Ok(out)
}
#[cfg(feature = "input-validation")]
fn enforce_input_schema(
handler: Arc<dyn ToolHandler>,
info: &ToolInfo,
decl: &ToolDecl,
validation: &ValidationSection,
hooks: &ToolkitHooks,
) -> Arc<dyn ToolHandler> {
let registered_validator = hooks.argument_validator_for(&decl.name);
if !validation.enforce_input_schema && registered_validator.is_none() {
tracing::warn!(
tool = %decl.name,
"[server.validation] enforce_input_schema = false: this tool's arguments are \
NOT checked against its declared inputSchema before the backend call"
);
return handler;
}
ValidatingToolHandler::wrap(
handler,
info,
decl,
validation.enforce_input_schema,
registered_validator,
)
}
#[cfg(not(feature = "input-validation"))]
fn enforce_input_schema(
handler: Arc<dyn ToolHandler>,
_info: &ToolInfo,
decl: &ToolDecl,
_validation: &ValidationSection,
hooks: &ToolkitHooks,
) -> Arc<dyn ToolHandler> {
tracing::warn!(
tool = %decl.name,
"the `input-validation` feature is OFF: this tool's arguments are NOT checked \
against its declared inputSchema before the backend call"
);
if hooks.argument_validator_for(&decl.name).is_some() {
tracing::warn!(
tool = %decl.name,
"an E2 ArgumentValidator IS registered for this tool and is DISCARDED: \
running it requires the `input-validation` feature"
);
}
handler
}
#[cfg(feature = "input-validation")]
struct ValidatingToolHandler {
inner: Arc<dyn ToolHandler>,
input_schema: Value,
schema_key: String,
declared: Vec<String>,
enforce_schema: bool,
validator: Option<Arc<dyn ArgumentValidator>>,
}
#[cfg(feature = "input-validation")]
impl ValidatingToolHandler {
fn wrap(
inner: Arc<dyn ToolHandler>,
info: &ToolInfo,
decl: &ToolDecl,
enforce_schema: bool,
validator: Option<Arc<dyn ArgumentValidator>>,
) -> Arc<dyn ToolHandler> {
let input_schema = info.input_schema.clone();
let schema_key = input_schema.to_string();
Arc::new(Self {
inner,
input_schema,
schema_key,
declared: decl.parameters.iter().map(|p| p.name.clone()).collect(),
enforce_schema,
validator,
})
}
fn check(&self, args: &Value) -> pmcp::Result<()> {
self.check_schema(args)?;
self.check_registered_validator(args)
}
fn check_schema(&self, args: &Value) -> pmcp::Result<()> {
use pmcp::server::schema_validation::{render_refusal, validate_input};
if !self.enforce_schema {
return Ok(());
}
validate_input(&self.input_schema, Some(args), Some(&self.schema_key)).map_err(
|violations| {
let declared: Vec<&str> = self.declared.iter().map(String::as_str).collect();
pmcp::Error::tool_rejected(render_refusal(&violations, &declared), None)
},
)
}
fn check_registered_validator(&self, args: &Value) -> pmcp::Result<()> {
let Some(validator) = self.validator.as_ref() else {
return Ok(());
};
validator
.validate(args)
.map_err(|refusal| pmcp::Error::tool_rejected(refusal.message().to_string(), None))
}
}
#[cfg(feature = "input-validation")]
#[async_trait]
impl ToolHandler for ValidatingToolHandler {
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> pmcp::Result<Value> {
self.check(&args)?;
self.inner.handle(args, extra).await
}
fn metadata(&self) -> Option<ToolInfo> {
self.inner.metadata()
}
async fn handle_output(
&self,
args: Value,
extra: RequestHandlerExtra,
) -> pmcp::Result<pmcp::server::ToolOutput> {
self.check(&args)?;
self.inner.handle_output(args, extra).await
}
}
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, validation: &ValidationSection) -> ToolInfo {
let schema = build_input_schema(decl, validation);
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)
}
pub(crate) fn build_input_schema(decl: &ToolDecl, validation: &ValidationSection) -> Value {
let mut props = Map::new();
let mut required = Vec::new();
for p in &decl.parameters {
let mut prop = build_param_property(p);
apply_position_cap(&mut prop, p, decl.param_position(&p.name), validation);
props.insert(p.name.clone(), prop);
if p.required {
required.push(Value::String(p.name.clone()));
}
}
json!({
"type": "object",
"properties": props,
"required": required,
"additionalProperties": validation.additional_properties,
})
}
fn apply_position_cap(
prop: &mut Value,
p: &ParamDecl,
position: crate::config::ParamPosition,
validation: &ValidationSection,
) {
if crate::config::default_cap_applies(p, position, validation) {
prop["maxLength"] = json!(validation.default_max_length);
}
}
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(min_len) = p.min_length {
prop["minLength"] = json!(min_len);
}
if let Some(pattern) = &p.pattern {
prop["pattern"] = Value::String(pattern.clone());
}
if let Some(format) = &p.format {
prop["format"] = Value::String(format.clone());
}
if let Some(max_items) = p.max_items {
prop["maxItems"] = json!(max_items);
}
if let Some(items) = &p.items {
prop["items"] = build_items_property(items);
}
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_items_property(items: &crate::config::ItemsDecl) -> Value {
let ty = items.item_type.as_deref().unwrap_or("string");
let mut prop = json!({ "type": ty });
if let Some(max_len) = items.max_length {
prop["maxLength"] = json!(max_len);
}
if let Some(pattern) = &items.pattern {
prop["pattern"] = Value::String(pattern.clone());
}
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_from_config_with_http_connector_and_hooks(
config,
connector,
&ToolkitHooks::default(),
)
}
#[cfg(feature = "http")]
pub fn synthesize_from_config_with_http_connector_and_hooks(
config: &ServerConfig,
connector: Arc<dyn HttpConnector>,
hooks: &ToolkitHooks,
) -> 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
)))
},
hooks,
)
}
#[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_from_config_with_http_connector_and_scripts_and_hooks(
config,
connector,
http_exec,
exec_config,
&ToolkitHooks::default(),
)
}
#[cfg(feature = "openapi-code-mode")]
pub fn synthesize_from_config_with_http_connector_and_scripts_and_hooks(
config: &ServerConfig,
connector: Arc<dyn HttpConnector>,
http_exec: HttpCodeExecutor,
exec_config: ExecutionConfig,
hooks: &ToolkitHooks,
) -> Result<Vec<SynthesizedTool>> {
let validation = &config.server.validation;
synthesize_http_inner(
config,
connector,
|decl| {
let handler =
ScriptToolHandler::new(decl, http_exec.clone(), exec_config.clone(), validation)?;
let info = handler.tool_info.clone();
let arc: Arc<dyn ToolHandler> = Arc::new(handler);
Ok((info, arc))
},
hooks,
)
}
#[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>)>,
hooks: &ToolkitHooks,
) -> Result<Vec<SynthesizedTool>> {
let validation = &config.server.validation;
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)?;
let handler = enforce_input_schema(handler, &info, decl, validation, hooks);
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, validation);
let handler: Arc<dyn ToolHandler> = Arc::new(HttpToolHandler {
info: info.clone(),
operation,
connector: connector.clone(),
});
let handler = enforce_input_schema(handler, &info, decl, validation, hooks);
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> = crate::config::path_placeholder_names(path).collect();
let mut parameters = Vec::with_capacity(decl.parameters.len());
for name in &path_param_names {
let declared = decl.parameters.iter().find(|p| p.name == **name);
parameters.push(
Parameter::new((*name).to_string(), ParameterLocation::Path, true).with_rules(
declared.and_then(|p| p.pattern.clone()),
declared.and_then(|p| p.max_length),
declared.is_some_and(|p| p.allow_slash),
),
);
}
let has_request_body = crate::config::method_carries_request_body(&method_upper);
let non_path_location = if has_request_body {
ParameterLocation::Body
} else {
ParameterLocation::Query
};
for p in &decl.parameters {
if path_param_names.iter().any(|n| *n == p.name) {
continue;
}
parameters.push(
Parameter::new(p.name.clone(), non_path_location, p.required).with_rules(
p.pattern.clone(),
p.max_length,
p.allow_slash,
),
);
}
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_for_tool(&self.info.name, &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,
validation: &ValidationSection,
) -> 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, validation);
Ok(Self {
plan,
http_exec: http_exec.with_tool_label(&decl.name),
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, ItemsDecl, ParamDecl, ServerConfig, ServerSection, ToolDecl,
ValidationSection,
};
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))]);
}
fn prop_of(tools: Vec<ToolDecl>, param: &str) -> Value {
let cfg = cfg_with_tools(tools);
let out = synthesize_from_config(&cfg).expect("synthesize");
let (_name, info, _handler) = &out[0];
info.input_schema["properties"][param].clone()
}
#[test]
fn input_schema_emits_d2_scalar_keywords() {
let prop = prop_of(
vec![ToolDecl {
name: "lookup".to_string(),
parameters: vec![ParamDecl {
name: "region".to_string(),
param_type: Some("string".to_string()),
required: true,
pattern: Some("^[A-Z]{3}$".to_string()),
min_length: Some(3),
format: Some("uuid".to_string()),
max_items: Some(5),
..Default::default()
}],
..Default::default()
}],
"region",
);
assert_eq!(prop["pattern"], serde_json::json!("^[A-Z]{3}$"));
assert_eq!(prop["minLength"], serde_json::json!(3));
assert_eq!(prop["format"], serde_json::json!("uuid"));
assert_eq!(prop["maxItems"], serde_json::json!(5));
}
#[test]
fn input_schema_emits_items_as_object_never_array() {
let prop = prop_of(
vec![ToolDecl {
name: "batch".to_string(),
parameters: vec![ParamDecl {
name: "codes".to_string(),
param_type: Some("array".to_string()),
required: true,
items: Some(ItemsDecl {
item_type: Some("string".to_string()),
max_length: Some(8),
pattern: Some("^[a-z]+$".to_string()),
}),
max_items: Some(10),
..Default::default()
}],
..Default::default()
}],
"codes",
);
assert!(
prop["items"].is_object(),
"items must be an OBJECT, got: {}",
prop["items"]
);
assert!(
!prop["items"].is_array(),
"array-form items does not compile under the 2020-12 pin"
);
assert_eq!(
prop["items"],
serde_json::json!({
"type": "string",
"maxLength": 8,
"pattern": "^[a-z]+$",
})
);
}
#[test]
fn input_schema_omits_d2_keywords_when_undeclared() {
let prop = prop_of(
vec![ToolDecl {
name: "legacy".to_string(),
parameters: vec![ParamDecl {
name: "count".to_string(),
param_type: Some("integer".to_string()),
description: Some("how many".to_string()),
required: false,
minimum: Some(1.0),
maximum: Some(10.0),
max_length: Some(4),
..Default::default()
}],
..Default::default()
}],
"count",
);
let obj = prop.as_object().expect("property object");
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["description", "maxLength", "maximum", "minimum", "type"],
"exactly the five pre-D2 keywords, and no more"
);
for absent in ["pattern", "minLength", "format", "items", "maxItems"] {
assert!(
obj.get(absent).is_none(),
"undeclared keyword {absent} must not be emitted"
);
}
}
#[test]
fn input_schema_is_byte_identical_across_two_synthesis_runs() {
let tools = vec![ToolDecl {
name: "search".to_string(),
parameters: vec![
ParamDecl {
name: "zebra".to_string(),
param_type: Some("string".to_string()),
required: true,
pattern: Some("^z".to_string()),
..Default::default()
},
ParamDecl {
name: "alpha".to_string(),
param_type: Some("string".to_string()),
required: true,
min_length: Some(1),
..Default::default()
},
ParamDecl {
name: "middle".to_string(),
param_type: Some("integer".to_string()),
required: false,
..Default::default()
},
],
..Default::default()
}];
let cfg = cfg_with_tools(tools);
let first = synthesize_from_config(&cfg).expect("synthesize")[0]
.1
.input_schema
.to_string();
let second = synthesize_from_config(&cfg).expect("synthesize")[0]
.1
.input_schema
.to_string();
assert_eq!(first, second, "synthesis must be byte-deterministic");
assert!(
first.find("\"zebra\"").unwrap() < first.find("\"alpha\"").unwrap(),
"properties must follow declaration order: {first}"
);
}
#[cfg(feature = "input-validation")]
#[test]
fn min_length_equal_to_max_length_accepts_exactly_that_length() {
use pmcp::server::schema_validation::validate_input;
let cfg = cfg_with_tools(vec![ToolDecl {
name: "exact".to_string(),
parameters: vec![ParamDecl {
name: "code".to_string(),
param_type: Some("string".to_string()),
required: true,
min_length: Some(3),
max_length: Some(3),
..Default::default()
}],
..Default::default()
}]);
let out = synthesize_from_config(&cfg).expect("synthesize");
let schema = &out[0].1.input_schema;
validate_input(schema, Some(&serde_json::json!({ "code": "abc" })), None)
.expect("exactly three code points must be accepted");
validate_input(schema, Some(&serde_json::json!({ "code": "ab" })), None)
.expect_err("two code points must be refused");
validate_input(schema, Some(&serde_json::json!({ "code": "abcd" })), None)
.expect_err("four code points must be refused");
}
fn prop_under(tools: Vec<ToolDecl>, validation: ValidationSection, param: &str) -> Value {
let mut cfg = cfg_with_tools(tools);
cfg.server.validation = validation;
let out = synthesize_from_config(&cfg).expect("synthesize");
out[0].1.input_schema["properties"][param].clone()
}
fn get_tool_with_path_and_query() -> Vec<ToolDecl> {
vec![ToolDecl {
name: "line_status".to_string(),
path: Some("/lines/{line_id}/status".to_string()),
method: Some("GET".to_string()),
parameters: vec![
ParamDecl {
name: "line_id".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
ParamDecl {
name: "detail".to_string(),
param_type: Some("string".to_string()),
required: false,
..Default::default()
},
],
..Default::default()
}]
}
#[test]
fn default_cap_applies_to_path_position_string() {
let prop = prop_under(
get_tool_with_path_and_query(),
ValidationSection::default(),
"line_id",
);
assert_eq!(prop["maxLength"], serde_json::json!(256));
}
#[test]
fn default_cap_applies_to_query_position_string() {
let prop = prop_under(
get_tool_with_path_and_query(),
ValidationSection::default(),
"detail",
);
assert_eq!(prop["maxLength"], serde_json::json!(256));
}
#[test]
fn default_cap_never_applies_to_body_position_string() {
let tools = vec![ToolDecl {
name: "add_comment".to_string(),
path: Some("/issues/{id}/comments".to_string()),
method: Some("POST".to_string()),
parameters: vec![
ParamDecl {
name: "id".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
ParamDecl {
name: "body_text".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
],
..Default::default()
}];
let path_prop = prop_under(tools.clone(), ValidationSection::default(), "id");
assert_eq!(
path_prop["maxLength"],
serde_json::json!(256),
"the path parameter of a POST tool IS still capped"
);
let body_prop = prop_under(tools, ValidationSection::default(), "body_text");
assert!(
body_prop.get("maxLength").is_none(),
"a POST payload field must receive no default cap, got: {body_prop}"
);
}
#[test]
fn declared_max_length_wins_over_the_default_cap() {
let mut tools = get_tool_with_path_and_query();
tools[0].parameters[0].max_length = Some(12);
let prop = prop_under(tools, ValidationSection::default(), "line_id");
assert_eq!(prop["maxLength"], serde_json::json!(12));
}
#[test]
fn default_max_length_zero_disables_the_cap_in_every_position() {
let validation = ValidationSection {
default_max_length: 0,
..Default::default()
};
for param in ["line_id", "detail"] {
let prop = prop_under(get_tool_with_path_and_query(), validation.clone(), param);
assert!(
prop.get("maxLength").is_none(),
"{param} must carry no maxLength when the default is 0, got: {prop}"
);
}
}
#[cfg(feature = "input-validation")]
#[test]
fn default_cap_boundary_is_counted_in_code_points_not_bytes() {
use pmcp::server::schema_validation::validate_input;
let validation = ValidationSection {
default_max_length: 8,
..Default::default()
};
let mut cfg = cfg_with_tools(get_tool_with_path_and_query());
cfg.server.validation = validation;
let out = synthesize_from_config(&cfg).expect("synthesize");
let schema = &out[0].1.input_schema;
let at_limit: String = "é".repeat(8);
let over_limit: String = "é".repeat(9);
assert_eq!(
at_limit.len(),
16,
"the fixture must actually be multi-byte"
);
validate_input(
schema,
Some(&serde_json::json!({ "line_id": at_limit })),
None,
)
.expect("exactly 8 code points must be accepted even though they are 16 bytes");
validate_input(
schema,
Some(&serde_json::json!({ "line_id": over_limit })),
None,
)
.expect_err("9 code points must be refused");
}
#[test]
fn additional_properties_opt_out_flips_the_envelope() {
let mut cfg = cfg_with_tools(get_tool_with_path_and_query());
cfg.server.validation = ValidationSection {
additional_properties: true,
..Default::default()
};
let out = synthesize_from_config(&cfg).expect("synthesize");
assert_eq!(
out[0].1.input_schema["additionalProperties"],
Value::Bool(true)
);
let mut cfg = cfg_with_tools(get_tool_with_path_and_query());
cfg.server.validation = ValidationSection::default();
let out = synthesize_from_config(&cfg).expect("synthesize");
assert_eq!(
out[0].1.input_schema["additionalProperties"],
Value::Bool(false),
"the default must remain a closed envelope"
);
}
#[cfg(feature = "input-validation")]
#[tokio::test]
async fn enforce_input_schema_false_skips_the_check_not_the_decorator() {
struct RefusingInner;
#[async_trait]
impl ToolHandler for RefusingInner {
async fn handle(
&self,
_args: Value,
_extra: RequestHandlerExtra,
) -> pmcp::Result<Value> {
Err(pmcp::Error::Validation(
"the registered validator refused".to_string(),
))
}
}
let decl = ToolDecl {
name: "guarded".to_string(),
parameters: vec![ParamDecl {
name: "declared".to_string(),
param_type: Some("string".to_string()),
required: false,
..Default::default()
}],
..Default::default()
};
let info = build_tool_info(&decl, &ValidationSection::default());
let undeclared = serde_json::json!({ "not_declared_at_all": "x" });
let on = ValidatingToolHandler::wrap(Arc::new(RefusingInner), &info, &decl, true, None);
let err = on
.handle(undeclared.clone(), RequestHandlerExtra::default())
.await
.expect_err("an undeclared argument must be refused when enforcement is on");
assert!(
!err.to_string().contains("registered validator"),
"the schema check must run FIRST when enforcement is on: {err}"
);
let off = ValidatingToolHandler::wrap(Arc::new(RefusingInner), &info, &decl, false, None);
let err = off
.handle(undeclared, RequestHandlerExtra::default())
.await
.expect_err("the inner (non-schema) rule must still refuse");
assert!(
err.to_string().contains("registered validator"),
"with the schema check off, the refusal must come from the inner rule: {err}"
);
}
}
#[cfg(all(test, feature = "http"))]
mod build_operation {
use super::*;
fn decl(path: &str, parameters: Vec<ParamDecl>) -> ToolDecl {
ToolDecl {
name: "t".to_string(),
description: Some("t".to_string()),
path: Some(path.to_string()),
method: Some("GET".to_string()),
parameters,
..Default::default()
}
}
fn param_named<'a>(op: &'a Operation, name: &str) -> &'a Parameter {
op.parameters
.iter()
.find(|p| p.name == name)
.unwrap_or_else(|| panic!("parameter {name} present"))
}
#[test]
fn build_operation_carries_a_declared_path_pattern() {
let d = decl(
"/content/{version}",
vec![ParamDecl {
name: "version".to_string(),
param_type: Some("string".to_string()),
required: true,
pattern: Some("^C[0-9]+$".to_string()),
..Default::default()
}],
);
let op = super::build_operation("/content/{version}", "GET", &d);
let p = param_named(&op, "version");
assert_eq!(p.location, ParameterLocation::Path);
assert_eq!(p.pattern.as_deref(), Some("^C[0-9]+$"));
}
#[test]
fn build_operation_carries_a_declared_query_max_length() {
let d = decl(
"/search",
vec![ParamDecl {
name: "q".to_string(),
param_type: Some("string".to_string()),
max_length: Some(64),
..Default::default()
}],
);
let op = super::build_operation("/search", "GET", &d);
let p = param_named(&op, "q");
assert_eq!(p.location, ParameterLocation::Query);
assert_eq!(p.max_length, Some(64));
}
#[test]
fn build_operation_leaves_rules_absent_for_an_undeclared_template_segment() {
let d = decl("/content/{version}", vec![]);
let op = super::build_operation("/content/{version}", "GET", &d);
let p = param_named(&op, "version");
assert_eq!(p.pattern, None);
assert_eq!(p.max_length, None);
assert!(!p.allow_slash);
assert!(p.required, "a template path parameter stays required");
}
#[test]
fn build_operation_carries_allow_slash_from_the_config() {
let d = decl(
"/files/{subpath}",
vec![ParamDecl {
name: "subpath".to_string(),
param_type: Some("string".to_string()),
required: true,
allow_slash: true,
..Default::default()
}],
);
let op = super::build_operation("/files/{subpath}", "GET", &d);
assert!(param_named(&op, "subpath").allow_slash);
}
#[cfg(feature = "input-validation")]
#[test]
fn build_operation_rules_reach_placeholder_rules() {
let d = decl(
"/files/{subpath}",
vec![ParamDecl {
name: "subpath".to_string(),
param_type: Some("string".to_string()),
required: true,
pattern: Some("^[a-z/]+$".to_string()),
max_length: Some(128),
allow_slash: true,
..Default::default()
}],
);
let op = super::build_operation("/files/{subpath}", "GET", &d);
let rules = param_named(&op, "subpath").placeholder_rules();
assert_eq!(rules.declared_pattern, Some("^[a-z/]+$"));
assert_eq!(rules.declared_max_length, Some(128));
assert!(rules.allow_slash);
}
#[test]
fn build_operation_routes_a_post_non_path_param_to_the_body() {
for method in ["POST", "PUT", "PATCH", "post"] {
let mut d = decl(
"/issues/{id}/comments",
vec![
ParamDecl {
name: "id".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
ParamDecl {
name: "body_text".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
],
);
d.method = Some(method.to_string());
let op = super::build_operation("/issues/{id}/comments", method, &d);
assert!(
op.has_request_body,
"{method} carries a request body by definition"
);
assert_eq!(param_named(&op, "id").location, ParameterLocation::Path);
assert_eq!(
param_named(&op, "body_text").location,
ParameterLocation::Body,
"{method}: a non-path declared parameter must be routed to the payload"
);
assert_eq!(
op.body_parameters().len(),
1,
"{method}: exactly the one non-path parameter is a body parameter"
);
assert!(
op.query_parameters().is_empty(),
"{method}: nothing may travel in the query string as well"
);
}
}
#[test]
fn build_operation_keeps_a_body_less_method_non_path_param_in_the_query() {
for method in ["GET", "HEAD", "DELETE", "OPTIONS"] {
let mut d = decl(
"/search",
vec![ParamDecl {
name: "q".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
}],
);
d.method = Some(method.to_string());
let op = super::build_operation("/search", method, &d);
assert!(!op.has_request_body, "{method} carries no request body");
assert_eq!(
param_named(&op, "q").location,
ParameterLocation::Query,
"{method}: a non-path parameter must stay in the query string"
);
assert!(
op.body_parameters().is_empty(),
"{method}: a body-less request can have no body parameter"
);
}
}
#[test]
fn param_position_agrees_with_the_built_parameter_location_for_every_method() {
use crate::config::ParamPosition;
for method in [
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "patch",
] {
let mut d = decl(
"/things/{id}",
vec![
ParamDecl {
name: "id".to_string(),
param_type: Some("string".to_string()),
required: true,
..Default::default()
},
ParamDecl {
name: "note".to_string(),
param_type: Some("string".to_string()),
required: false,
..Default::default()
},
],
);
d.method = Some(method.to_string());
let op = super::build_operation("/things/{id}", method, &d);
for p in &op.parameters {
let expected_location = match d.param_position(&p.name) {
ParamPosition::Path => ParameterLocation::Path,
ParamPosition::Query => ParameterLocation::Query,
ParamPosition::Body => ParameterLocation::Body,
};
assert_eq!(
p.location,
expected_location,
"{method}: `{}` is {:?} for the D3 cap but {:?} on the wire — the cap's \
scope and the request's routing must not disagree",
p.name,
d.param_position(&p.name),
p.location
);
}
assert_eq!(
op.has_request_body,
!op.body_parameters().is_empty(),
"{method}: a Body-located parameter on a body-less request would be \
silently dropped"
);
}
}
#[test]
fn the_default_cap_is_emitted_exactly_where_the_value_travels_in_the_url() {
use crate::config::{default_cap_applies, ValidationSection};
let validation = ValidationSection::default();
assert_ne!(
validation.default_max_length, 0,
"the default cap must be on for this row to mean anything"
);
for method in ["GET", "HEAD", "DELETE", "OPTIONS", "POST", "PUT", "PATCH"] {
let param = ParamDecl {
name: "note".to_string(),
param_type: Some("string".to_string()),
required: false,
..Default::default()
};
let mut d = decl("/things", vec![param.clone()]);
d.method = Some(method.to_string());
let op = super::build_operation("/things", method, &d);
let location = param_named(&op, "note").location;
let capped = default_cap_applies(¶m, d.param_position("note"), &validation);
assert_eq!(
capped,
location == ParameterLocation::Query,
"{method}: an uncapped string in a {location:?} position is the CR-03 defect \
when that position is the query string, and the D-05 requirement when it is \
the payload"
);
}
}
}
#[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:?}"),
}
}
}
#[cfg(all(test, feature = "input-validation"))]
mod argument_validator_seam {
use super::synthesize_from_config_and_hooks;
use crate::config::ServerConfig;
use crate::policy::{ArgumentRefusal, ArgumentValidator, ToolkitHooks};
use pmcp::RequestHandlerExtra;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct EndAfterStart {
calls: Arc<AtomicUsize>,
}
impl ArgumentValidator for EndAfterStart {
fn validate(&self, args: &Value) -> Result<(), ArgumentRefusal> {
self.calls.fetch_add(1, Ordering::SeqCst);
let start = args.get("start").and_then(Value::as_i64);
let end = args.get("end").and_then(Value::as_i64);
match (start, end) {
(Some(s), Some(e)) if e < s => {
Err(ArgumentRefusal::new("`end` must not precede `start`"))
},
_ => Ok(()),
}
}
}
fn cfg(enforce: bool) -> ServerConfig {
let toml = format!(
r#"
[server]
name = "range"
version = "0.1.0"
[server.validation]
enforce_input_schema = {enforce}
[[tools]]
name = "range_query"
description = "Query a range"
sql = "SELECT 1"
[[tools.parameters]]
name = "start"
type = "integer"
required = true
[[tools.parameters]]
name = "end"
type = "integer"
required = true
"#
);
ServerConfig::from_toml_strict_validated(&toml).expect("parse")
}
fn extra() -> RequestHandlerExtra {
RequestHandlerExtra::default()
}
fn hooks(calls: &Arc<AtomicUsize>) -> ToolkitHooks {
ToolkitHooks::default().with_argument_validator(
"range_query",
Arc::new(EndAfterStart {
calls: Arc::clone(calls),
}),
)
}
#[tokio::test]
async fn a_registered_validator_refuses_a_combination_the_schema_permits() {
let calls = Arc::new(AtomicUsize::new(0));
let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
let (_name, _info, handler) = &tools[0];
let err = handler
.handle(json!({ "start": 10, "end": 2 }), extra())
.await
.expect_err("the validator refuses");
assert!(
err.to_string().contains("`end` must not precede `start`"),
"the refusal must carry the validator's own message, got: {err}"
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_validator_refusal_is_a_tool_level_rejection_not_a_protocol_error() {
let calls = Arc::new(AtomicUsize::new(0));
let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
let (_name, _info, handler) = &tools[0];
let err = handler
.handle(json!({ "start": 10, "end": 2 }), extra())
.await
.expect_err("the validator refuses");
assert!(
matches!(err, pmcp::Error::ToolRejected { .. }),
"a validator refusal must be a tool-level rejection, got: {err:?}"
);
assert!(
err.to_string().contains("`end` must not precede `start`"),
"the validator's own message must survive the channel change, got: {err}"
);
}
#[tokio::test]
async fn a_registered_validator_allows_a_valid_combination() {
let calls = Arc::new(AtomicUsize::new(0));
let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
let (_name, _info, handler) = &tools[0];
let err = handler
.handle(json!({ "start": 2, "end": 10 }), extra())
.await
.expect_err("no connector is wired");
assert!(
!err.to_string().contains("must not precede"),
"the validator must NOT have refused, got: {err}"
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_registered_validator_is_not_invoked_when_the_schema_refuses() {
let calls = Arc::new(AtomicUsize::new(0));
let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
let (_name, _info, handler) = &tools[0];
let err = handler
.handle(json!({ "start": "ten", "end": 2 }), extra())
.await
.expect_err("D1 refuses");
assert!(
!err.to_string().contains("must not precede"),
"D1 must be the refuser, not E2, got: {err}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"the validator was invoked on arguments the schema already refused"
);
}
#[tokio::test]
async fn a_registered_validator_still_runs_with_enforce_input_schema_false() {
let calls = Arc::new(AtomicUsize::new(0));
let tools = synthesize_from_config_and_hooks(&cfg(false), &hooks(&calls)).expect("synth");
let (_name, _info, handler) = &tools[0];
let err = handler
.handle(json!({ "start": 2, "end": 10, "undeclared": 1 }), extra())
.await
.expect_err("no connector is wired");
assert!(
!err.to_string().contains("undeclared"),
"with enforce_input_schema=false an undeclared key must be accepted, got: {err}"
);
let err = handler
.handle(json!({ "start": 10, "end": 2 }), extra())
.await
.expect_err("the validator refuses");
assert!(
err.to_string().contains("`end` must not precede `start`"),
"a registered validator must survive the schema opt-out, got: {err}"
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_tool_with_no_registered_validator_runs_d1_only() {
let tools =
synthesize_from_config_and_hooks(&cfg(true), &ToolkitHooks::default()).expect("synth");
let (_name, _info, handler) = &tools[0];
handler
.handle(json!({ "start": "ten", "end": 2 }), extra())
.await
.expect_err("D1 refuses");
let err = handler
.handle(json!({ "start": 10, "end": 2 }), extra())
.await
.expect_err("no connector is wired");
assert!(!err.to_string().contains("must not precede"));
}
#[tokio::test]
async fn handle_output_runs_the_validator_as_well() {
let calls = Arc::new(AtomicUsize::new(0));
let tools = synthesize_from_config_and_hooks(&cfg(true), &hooks(&calls)).expect("synth");
let (_name, _info, handler) = &tools[0];
let err = handler
.handle_output(json!({ "start": 10, "end": 2 }), extra())
.await
.expect_err("the validator refuses");
assert!(
err.to_string().contains("`end` must not precede `start`"),
"handle_output must run the validator too, got: {err}"
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}
#[cfg(all(test, feature = "input-validation"))]
mod enforcement_report {
use crate::config::ServerConfig;
use crate::policy::{
render_validation_report, ArgumentRefusal, ArgumentValidator, ReportLevel, ToolkitHooks,
};
use serde_json::Value;
use std::sync::Arc;
struct Never;
impl ArgumentValidator for Never {
fn validate(&self, _args: &Value) -> Result<(), ArgumentRefusal> {
Err(ArgumentRefusal::new("disabled"))
}
}
fn cfg(extra_validation: &str) -> ServerConfig {
let toml = format!(
r#"
[server]
name = "report"
version = "0.1.0"
[server.validation]
{extra_validation}
[[tools]]
name = "get_thing"
description = "Get a thing"
path = "/things/{{id}}"
method = "GET"
[[tools.parameters]]
name = "id"
type = "string"
required = true
pattern = "^[0-9]+$"
"#
);
ServerConfig::from_toml_strict_validated(&toml).expect("parse")
}
fn texts(lines: &[crate::policy::ReportLine]) -> String {
lines
.iter()
.map(|l| l.text.clone())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn a_fully_enforcing_config_states_that_no_opt_out_is_active() {
let lines = render_validation_report(&cfg(""), &ToolkitHooks::default());
let joined = texts(&lines);
assert!(
joined.contains("input validation: schema_check=ON"),
"{joined}"
);
assert!(
joined.contains("tool 'get_thing' enforces"),
"one line per tool: {joined}"
);
assert!(
joined.contains("no [server.validation] opt-out is active"),
"the report must state the ABSENCE of an opt-out, not stay silent: {joined}"
);
assert!(
joined.contains("E1 RequestPolicy registered=false"),
"{joined}"
);
assert!(
joined.contains("no E2 ArgumentValidator is registered"),
"{joined}"
);
assert!(
lines.iter().all(|l| l.level == ReportLevel::Info),
"a fully enforcing config emits no warning"
);
}
#[test]
fn every_active_opt_out_is_reported_at_warn_level() {
let lines = render_validation_report(
&cfg("enforce_input_schema = false\ndefault_max_length = 0\nadditional_properties = true"),
&ToolkitHooks::default(),
);
let joined = texts(&lines);
assert!(joined.contains("schema_check=OFF"), "{joined}");
let warns: Vec<&str> = lines
.iter()
.filter(|l| l.level == ReportLevel::Warn)
.map(|l| l.text.as_str())
.collect();
assert!(
warns.len() >= 4,
"an enforcement that is OFF must never read as on; got {warns:#?}"
);
assert!(
warns.iter().any(|w| w.contains("opt-out ACTIVE")),
"{warns:#?}"
);
}
#[test]
fn a_validator_for_an_undeclared_tool_name_is_warned_not_refused() {
let hooks = ToolkitHooks::default()
.with_argument_validator("get_thing", Arc::new(Never))
.with_argument_validator("typoed_name", Arc::new(Never));
let lines = render_validation_report(&cfg(""), &hooks);
let joined = texts(&lines);
assert!(
joined.contains("E2 ArgumentValidator registered for get_thing, typoed_name"),
"{joined}"
);
let warns: Vec<&str> = lines
.iter()
.filter(|l| l.level == ReportLevel::Warn)
.map(|l| l.text.as_str())
.collect();
assert_eq!(warns.len(), 1, "exactly the typo warns: {warns:#?}");
assert!(warns[0].contains("'typoed_name'"), "{:?}", warns[0]);
assert!(warns[0].contains("will never run"), "{:?}", warns[0]);
}
#[test]
fn the_report_never_echoes_an_argument_value() {
let lines = render_validation_report(&cfg(""), &ToolkitHooks::default());
let joined = texts(&lines);
for forbidden in ["Bearer", "app_key", "super-secret"] {
assert!(!joined.contains(forbidden), "{joined}");
}
}
}