- 1
//! Schema-driven validation for model-proposed tool calls. - 2
//! - 3
//! Tool schemas are the contract boundary. The validator intentionally - 4
//! implements the small, provider-neutral JSON Schema subset needed before a - 5
//! call can reach a tool; tool-specific execution remains responsible for - 6
//! deeper domain validation. - 7
- 8
use serde_json::Value; - 9
- 10
/// Every way `input` breaks `schema`, in one message: the first problem - 11
/// alone let a small model fix one field per turn (measured live: three - 12
/// retries of a card call that was missing both `semantic_type` and - 13
/// `payload`, each told only of the first). A missing or unexpected parameter - 14
/// also names the parameters the call takes. - 15
pub fn validate_input(schema: &Value, input: &Value) -> Result<(), String> { - 16
let mut problems = Vec::new(); - 17
check(schema, input, "arguments", &mut problems); - 18
if problems.is_empty() { - 19
Ok(()) - 20
} else { - 21
Err(format!("invalid tool arguments: {}", problems.join("; "))) - 22
} - 23
} - 24
- 25
fn check(schema: &Value, value: &Value, path: &str, problems: &mut Vec<String>) { - 26
if let Some(types) = schema.get("type") { - 27
let matches = match types { - 28
Value::String(expected) => type_matches(expected, value), - 29
Value::Array(expected) => expected - 30
.iter() - 31
.filter_map(Value::as_str) - 32
.any(|expected| type_matches(expected, value)), - 33
_ => false, - 34
}; - 35
if !matches { - 36
problems.push(format!("{path} must be {}", schema_type_label(types))); - 37
return; - 38
} - 39
} - 40
if let Some(enum_values) = schema.get("enum") - 41
&& enum_values - 42
.as_array() - 43
.is_some_and(|values| !values.iter().any(|candidate| candidate == value)) - 44
{ - 45
problems.push(format!("{path} is not an allowed value")); - 46
return; - 47
} - 48
// `oneOf`: the value must match exactly one branch. Used for conditional - 49
// contracts (e.g. the `mcp` broker where `server`/`tool` are required only - 50
// when `action == "call"`). Each branch carries its own required/properties, - 51
// so matching a branch validates the whole per-branch contract. - 52
if let Some(one_of) = schema.get("oneOf").and_then(Value::as_array) { - 53
// A tagged union (every branch fixes one key, like `op`, to a single - 54
// value): report against the branch the value names, or list the - 55
// names, rather than whichever branch happened to be checked last. - 56
if let Some((key, names)) = discriminator(one_of) { - 57
match value.get(key).and_then(Value::as_str) { - 58
Some(name) => match one_of - 59
.iter() - 60
.zip(&names) - 61
.find(|(_, candidate)| **candidate == name) - 62
{ - 63
Some((branch, _)) => check(branch, value, path, problems), - 64
None => problems.push(format!( - 65
"{path}.{key} {name:?} is not one of: {}", - 66
names.join(", ") - 67
)), - 68
}, - 69
None => problems.push(format!( - 70
"{path} needs `{key}`, one of: {}", - 71
names.join(", ") - 72
)), - 73
} - 74
return; - 75
} - 76
let mut matched = 0usize; - 77
let mut last = Vec::new(); - 78
for sub in one_of { - 79
let mut branch = Vec::new(); - 80
check(sub, value, &format!("{path} (oneOf)"), &mut branch); - 81
if branch.is_empty() { - 82
matched += 1; - 83
} else { - 84
last = branch; - 85
} - 86
} - 87
match matched { - 88
1 => {} - 89
0 => problems.extend(last), - 90
_ => problems.push(format!( - 91
"{path} matched {matched} of {} oneOf branches", - 92
one_of.len() - 93
)), - 94
} - 95
return; - 96
} - 97
let Some(object) = value.as_object() else { - 98
if let (Some(items), Some(array)) = (schema.get("items"), value.as_array()) { - 99
for (index, child) in array.iter().enumerate() { - 100
check(items, child, &format!("{path}[{index}]"), problems); - 101
} - 102
} - 103
return; - 104
}; - 105
let properties = schema.get("properties").and_then(Value::as_object); - 106
let required_keys: Vec<&str> = schema - 107
.get("required") - 108
.and_then(Value::as_array) - 109
.map(|keys| keys.iter().filter_map(Value::as_str).collect()) - 110
.unwrap_or_default(); - 111
// The required parameters first, in the schema's order, then the rest. - 112
let takes = || { - 113
let optional = properties - 114
.into_iter() - 115
.flat_map(|props| props.keys()) - 116
.map(String::as_str) - 117
.filter(|key| !required_keys.contains(key)); - 118
required_keys - 119
.iter() - 120
.copied() - 121
.chain(optional) - 122
.map(|key| format!("`{key}`")) - 123
.collect::<Vec<_>>() - 124
.join(", ") - 125
}; - 126
let before = problems.len(); - 127
for key in &required_keys { - 128
if !object.contains_key(*key) { - 129
problems.push(format!("required parameter `{key}` was omitted")); - 130
} - 131
} - 132
if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) - 133
|| problems.len() > before - 134
{ - 135
for key in object.keys() { - 136
if !properties.is_some_and(|props| props.contains_key(key)) { - 137
problems.push(format!("unexpected parameter `{key}` for {path}")); - 138
} - 139
} - 140
} - 141
if problems.len() > before && properties.is_some() { - 142
problems.push(format!("{path} takes {}", takes())); - 143
} - 144
if let Some(properties) = properties { - 145
for (key, child_schema) in properties { - 146
if let Some(child) = object.get(key) { - 147
check(child_schema, child, &format!("{path}.{key}"), problems); - 148
} - 149
} - 150
} - 151
} - 152
- 153
/// The key and per-branch names when every `oneOf` branch pins the same - 154
/// property to one allowed value. - 155
fn discriminator(branches: &[Value]) -> Option<(&str, Vec<&str>)> { - 156
let first = branches.first()?.get("properties")?.as_object()?; - 157
let key = first.iter().find_map(|(key, property)| { - 158
let values = property.get("enum")?.as_array()?; - 159
(values.len() == 1 && values[0].is_string()).then_some(key.as_str()) - 160
})?; - 161
let names = branches - 162
.iter() - 163
.map(|branch| { - 164
let values = branch - 165
.pointer(&format!("/properties/{key}/enum"))? - 166
.as_array()?; - 167
match values.as_slice() { - 168
[Value::String(name)] => Some(name.as_str()), - 169
_ => None, - 170
} - 171
}) - 172
.collect::<Option<Vec<&str>>>()?; - 173
Some((key, names)) - 174
} - 175
- 176
fn type_matches(expected: &str, value: &Value) -> bool { - 177
match expected { - 178
"object" => value.is_object(), - 179
"array" => value.is_array(), - 180
"string" => value.is_string(), - 181
"number" => value.is_number(), - 182
"integer" => value.as_i64().is_some() || value.as_u64().is_some(), - 183
"boolean" => value.is_boolean(), - 184
"null" => value.is_null(), - 185
_ => true, - 186
} - 187
} - 188
- 189
fn schema_type_label(types: &Value) -> String { - 190
match types { - 191
Value::String(value) => value.clone(), - 192
Value::Array(values) => values - 193
.iter() - 194
.filter_map(Value::as_str) - 195
.collect::<Vec<_>>() - 196
.join(" or "), - 197
_ => "the declared type".into(), - 198
} - 199
} - 200
- 201
#[cfg(test)] - 202
#[allow(clippy::unwrap_used)] - 203
mod tests { - 204
use super::validate_input; - 205
use serde_json::json; - 206
- 207
#[test] - 208
fn required_fields_are_checked_without_tool_names() { - 209
let schema = json!({ - 210
"type": "object", - 211
"properties": {"action": {"type": "string"}}, - 212
"required": ["action"] - 213
}); - 214
assert!(validate_input(&schema, &json!({})).is_err()); - 215
assert!(validate_input(&schema, &json!({"action": "list"})).is_ok()); - 216
} - 217
- 218
#[test] - 219
fn nested_types_and_enums_are_checked() { - 220
let schema = json!({ - 221
"type": "object", - 222
"properties": { - 223
"mode": {"type": "string", "enum": ["read", "write"]}, - 224
"items": {"type": "array", "items": {"type": "integer"}} - 225
} - 226
}); - 227
assert!(validate_input(&schema, &json!({"mode": "read", "items": [1, 2]})).is_ok()); - 228
assert!(validate_input(&schema, &json!({"mode": "other"})).is_err()); - 229
assert!(validate_input(&schema, &json!({"items": ["one"]})).is_err()); - 230
} - 231
- 232
#[test] - 233
fn one_of_enforces_exactly_one_branch() { - 234
let schema = json!({ - 235
"type": "object", - 236
"oneOf": [ - 237
{"properties": {"action": {"type": "string", "enum": ["list"]}}, "required": ["action"], "additionalProperties": false}, - 238
{"properties": {"action": {"type": "string", "enum": ["call"]}, "server": {"type": "string"}, "tool": {"type": "string"}}, "required": ["action", "server", "tool"], "additionalProperties": false} - 239
] - 240
}); - 241
// list branch - 242
assert!(validate_input(&schema, &json!({"action": "list"})).is_ok()); - 243
// call branch, complete - 244
assert!( - 245
validate_input( - 246
&schema, - 247
&json!({"action": "call", "server": "s", "tool": "t"}) - 248
) - 249
.is_ok() - 250
); - 251
// call missing required `server` -> no branch matches -> error - 252
assert!(validate_input(&schema, &json!({"action": "call", "tool": "t"})).is_err()); - 253
// unknown action -> neither branch matches -> error - 254
assert!(validate_input(&schema, &json!({"action": "ping"})).is_err()); - 255
// list branch but with an unexpected param -> list branch invalid, call - 256
// branch invalid (action not "call") -> exactly zero match -> error - 257
assert!(validate_input(&schema, &json!({"action": "list", "server": "s"})).is_err()); - 258
} - 259
- 260
#[test] - 261
fn every_problem_is_reported_at_once_with_the_parameters_the_call_takes() { - 262
let schema = json!({ - 263
"type": "object", - 264
"properties": { - 265
"semantic_type": {"type": "string"}, - 266
"payload": {"type": "object"} - 267
}, - 268
"required": ["semantic_type", "payload"] - 269
}); - 270
let error = validate_input( - 271
&schema, - 272
&json!({"command_run": "sed -n 3p big.txt", "output": "row 3"}), - 273
) - 274
.unwrap_err(); - 275
assert!(error.starts_with("invalid tool arguments: "), "{error}"); - 276
for part in [ - 277
"required parameter `semantic_type` was omitted", - 278
"required parameter `payload` was omitted", - 279
"unexpected parameter `command_run`", - 280
"unexpected parameter `output`", - 281
"arguments takes `semantic_type`, `payload`", - 282
] { - 283
assert!(error.contains(part), "missing {part:?} in {error}"); - 284
} - 285
} - 286
- 287
#[test] - 288
fn additional_properties_false_rejects_unknown_keys() { - 289
let schema = json!({ - 290
"type": "object", - 291
"properties": {"action": {"type": "string"}}, - 292
"required": ["action"], - 293
"additionalProperties": false - 294
}); - 295
assert!(validate_input(&schema, &json!({"action": "list"})).is_ok()); - 296
assert!(validate_input(&schema, &json!({"action": "list", "extra": 1})).is_err()); - 297
} - 298
} - 299
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.