use serde_json::Value;
use crate::tool_contract::ToolContract;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LashSchema {
pub schema: Value,
}
impl LashSchema {
pub fn new(schema: Value) -> Self {
Self { schema }
}
pub fn any() -> Self {
Self::new(serde_json::json!({}))
}
pub fn object(properties: serde_json::Map<String, Value>, required: Vec<String>) -> Self {
let mut schema = serde_json::Map::new();
schema.insert("type".to_string(), Value::String("object".to_string()));
schema.insert("properties".to_string(), Value::Object(properties));
if !required.is_empty() {
schema.insert(
"required".to_string(),
Value::Array(required.into_iter().map(Value::String).collect()),
);
}
schema.insert("additionalProperties".to_string(), Value::Bool(true));
Self::new(Value::Object(schema))
}
pub fn validate(&self, value: &Value) -> Result<(), String> {
validate_schema("", &self.schema, value)
}
}
pub fn validate_tool_input(contract: &ToolContract, args: &Value) -> Result<(), String> {
LashSchema::new(contract.input_schema.clone()).validate(args)
}
fn validate_schema(path: &str, schema: &Value, value: &Value) -> Result<(), String> {
if let Some(any_of) = schema
.get("anyOf")
.or_else(|| schema.get("oneOf"))
.and_then(Value::as_array)
{
if any_of
.iter()
.any(|subschema| validate_schema(path, subschema, value).is_ok())
{
return Ok(());
}
return Err(format!(
"{}: expected {}, got {}",
display_path(path),
expected_description(schema),
display_value(value)
));
}
if let Some(enum_values) = schema.get("enum").and_then(Value::as_array)
&& !enum_values.iter().any(|candidate| candidate == value)
{
return Err(format!(
"{}: expected one of {}, got {}",
display_path(path),
enum_values
.iter()
.map(display_value)
.collect::<Vec<_>>()
.join(", "),
display_value(value)
));
}
if let Some(type_value) = schema.get("type")
&& !matches_type(type_value, value)
{
return Err(format!(
"{}: expected {}, got {}",
display_path(path),
expected_description(schema),
display_value(value)
));
}
if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64)
&& value.as_f64().is_some_and(|actual| actual > maximum)
{
return Err(format!(
"{}: expected {}, got {}",
display_path(path),
expected_description(schema),
display_value(value)
));
}
if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64)
&& value.as_f64().is_some_and(|actual| actual < minimum)
{
return Err(format!(
"{}: expected {}, got {}",
display_path(path),
expected_description(schema),
display_value(value)
));
}
if let Some(max_length) = schema.get("maxLength").and_then(Value::as_u64)
&& value
.as_str()
.is_some_and(|actual| actual.chars().count() as u64 > max_length)
{
return Err(format!(
"{}: expected {}, got {}",
display_path(path),
expected_description(schema),
display_value(value)
));
}
if let Some(min_length) = schema.get("minLength").and_then(Value::as_u64)
&& value
.as_str()
.is_some_and(|actual| (actual.chars().count() as u64) < min_length)
{
return Err(format!(
"{}: expected {}, got {}",
display_path(path),
expected_description(schema),
display_value(value)
));
}
if let Some(items) = value.as_array() {
if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64)
&& items.len() as u64 > max_items
{
return Err(format!(
"{}: expected {}, got {} items",
display_path(path),
expected_description(schema),
items.len()
));
}
if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64)
&& (items.len() as u64) < min_items
{
return Err(format!(
"{}: expected {}, got {} items",
display_path(path),
expected_description(schema),
items.len()
));
}
if let Some(item_schema) = schema.get("items") {
for (idx, item) in items.iter().enumerate() {
validate_schema(&format!("{path}[{idx}]"), item_schema, item)?;
}
}
}
if let Some(object) = value.as_object() {
let required = schema
.get("required")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str);
for property in required {
if !object.contains_key(property) {
return Err(format!(
"{}: required property missing",
join_path(path, property)
));
}
}
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
for (name, property_schema) in properties {
if let Some(property_value) = object.get(name) {
validate_schema(&join_path(path, name), property_schema, property_value)?;
}
}
match schema.get("additionalProperties") {
Some(Value::Bool(true)) => {}
Some(Value::Object(additional_schema)) => {
let additional_schema = Value::Object(additional_schema.clone());
for (name, property_value) in object {
if is_internal_argument(name) || properties.contains_key(name) {
continue;
}
validate_schema(
&join_path(path, name),
&additional_schema,
property_value,
)?;
}
}
_ => {
for name in object.keys() {
if is_internal_argument(name) {
continue;
}
if !properties.contains_key(name) {
return Err(format!("{}: unexpected property", join_path(path, name)));
}
}
}
}
}
}
Ok(())
}
fn matches_type(type_value: &Value, value: &Value) -> bool {
match type_value {
Value::String(ty) => matches_single_type(ty, value),
Value::Array(types) => types
.iter()
.filter_map(Value::as_str)
.any(|ty| matches_single_type(ty, value)),
_ => true,
}
}
fn matches_single_type(ty: &str, value: &Value) -> bool {
match ty {
"null" => value.is_null(),
"boolean" => value.is_boolean(),
"string" => value.is_string(),
"integer" => value.as_i64().is_some() || value.as_u64().is_some(),
"number" => value.is_number(),
"array" => value.is_array(),
"object" => value.is_object(),
_ => true,
}
}
fn expected_description(schema: &Value) -> String {
let mut parts = Vec::new();
if let Some(type_value) = schema.get("type") {
parts.push(type_description(type_value, schema));
} else if schema.get("anyOf").is_some() || schema.get("oneOf").is_some() {
parts.push("matching schema".to_string());
}
if let Some(minimum) = schema.get("minimum") {
parts.push(format!(">= {}", display_value(minimum)));
}
if let Some(maximum) = schema.get("maximum") {
parts.push(format!("<= {}", display_value(maximum)));
}
if let Some(min_length) = schema.get("minLength") {
parts.push(format!("length >= {}", display_value(min_length)));
}
if let Some(max_length) = schema.get("maxLength") {
parts.push(format!("length <= {}", display_value(max_length)));
}
if let Some(min_items) = schema.get("minItems") {
parts.push(format!("items >= {}", display_value(min_items)));
}
if let Some(max_items) = schema.get("maxItems") {
parts.push(format!("items <= {}", display_value(max_items)));
}
if parts.is_empty() {
"valid value".to_string()
} else {
parts.join(" ")
}
}
fn type_description(type_value: &Value, schema: &Value) -> String {
match type_value {
Value::String(ty) => type_name(ty, schema).to_string(),
Value::Array(types) => types
.iter()
.filter_map(Value::as_str)
.map(|ty| type_name(ty, schema).to_string())
.collect::<Vec<_>>()
.join(" or "),
_ => "valid value".to_string(),
}
}
fn type_name<'a>(ty: &'a str, schema: &'a Value) -> &'a str {
if ty == "array" && schema.get("items").is_some() {
"array"
} else {
ty
}
}
fn display_path(path: &str) -> String {
if path.is_empty() {
"arguments".to_string()
} else {
path.to_string()
}
}
fn join_path(base: &str, key: &str) -> String {
if base.is_empty() {
key.to_string()
} else {
format!("{base}.{key}")
}
}
fn is_internal_argument(name: &str) -> bool {
name == "__session_id__"
}
fn display_value(value: &Value) -> String {
match value {
Value::String(text) => format!("{text:?}"),
_ => value.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ToolDefinition;
#[test]
fn validation_reports_missing_required_property_by_path() {
let tool = ToolDefinition::raw(
"tool:spotify",
"spotify",
"",
serde_json::json!({
"type": "object",
"properties": {
"access_token": { "type": "string" }
},
"required": ["access_token"],
"additionalProperties": false
}),
serde_json::json!({}),
);
let error = validate_tool_input(&tool.contract(), &serde_json::json!({})).unwrap_err();
assert_eq!(error, "access_token: required property missing");
}
#[test]
fn validation_reports_numeric_limits_by_path() {
let tool = ToolDefinition::raw(
"tool:spotify",
"spotify",
"",
serde_json::json!({
"type": "object",
"properties": {
"page_limit": { "type": "integer", "maximum": 20 }
},
"required": ["page_limit"],
"additionalProperties": false
}),
serde_json::json!({}),
);
let error =
validate_tool_input(&tool.contract(), &serde_json::json!({ "page_limit": 100 }))
.unwrap_err();
assert_eq!(error, "page_limit: expected integer <= 20, got 100");
}
#[test]
fn validation_rejects_unknown_property_when_additional_properties_is_omitted() {
let tool = ToolDefinition::raw(
"tool:mcp__appworld__venmo_show_transactions",
"mcp__appworld__venmo_show_transactions",
"",
serde_json::json!({
"type": "object",
"properties": {
"min_created_at": { "type": "string" },
"max_created_at": { "type": "string" },
"limit": { "type": "integer", "maximum": 100 }
},
"required": ["limit"]
}),
serde_json::json!({}),
);
let error = validate_tool_input(
&tool.contract(),
&serde_json::json!({
"min_datetime": "2024-01-01T00:00:00Z",
"limit": 20
}),
)
.unwrap_err();
assert_eq!(error, "min_datetime: unexpected property");
}
#[test]
fn validation_allows_unknown_property_when_additional_properties_is_true() {
let tool = ToolDefinition::raw(
"tool:open",
"open",
"",
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" }
},
"additionalProperties": true
}),
serde_json::json!({}),
);
validate_tool_input(
&tool.contract(),
&serde_json::json!({
"path": "README.md",
"unknown": "preserved"
}),
)
.unwrap();
}
#[test]
fn validation_preserves_internal_session_id_argument() {
let tool = ToolDefinition::raw(
"tool:lashlang_tool",
"lashlang_tool",
"",
serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" }
}
}),
serde_json::json!({}),
);
validate_tool_input(
&tool.contract(),
&serde_json::json!({
"query": "hello",
"__session_id__": "session"
}),
)
.unwrap();
}
}