1use serde::{Deserialize, Serialize};
9
10use super::footprint::FootprintSpec;
11use crate::capability::{EffectKind, RiskClass};
12use crate::error::{Result, SdkError};
13use crate::model::ToolSpec;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case", tag = "kind")]
20pub enum ProposalBinding {
21 Path {
22 field: String,
23 },
24 Command {
25 field: String,
26 },
27 Url {
28 field: String,
29 },
30 MultiValue {
32 field: String,
33 target: MultiValueTarget,
34 },
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum MultiValueTarget {
40 Path,
41 Command,
42 Url,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ToolOrigin {
49 Builtin,
51 Domain(String),
53 External(String),
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct ToolDescriptionLibrary {
64 pub base: String,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub overrides: Vec<(String, String)>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct ToolEntry {
75 pub name: String,
76 pub description: String,
77 #[serde(default, skip_serializing_if = "String::is_empty")]
81 pub discovery_summary: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub description_templates: Option<ToolDescriptionLibrary>,
86 pub effect: EffectKind,
87 pub risk: RiskClass,
88 pub schema: serde_json::Value,
90 pub footprint: FootprintSpec,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub proposal_bindings: Vec<ProposalBinding>,
95 pub durable: bool,
97 pub origin: ToolOrigin,
98 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103 pub hot: bool,
104}
105
106impl ToolEntry {
107 pub fn hot(mut self) -> Self {
109 self.hot = true;
110 self
111 }
112
113 pub fn validate(&self) -> Result<()> {
119 if self.name.is_empty() {
120 return Err(SdkError::Domain("tool entry with empty name".into()));
121 }
122 if self.schema.get("type").and_then(|t| t.as_str()) != Some("object") {
123 return Err(SdkError::Domain(format!(
124 "tool {:?}: argument schema must be an object schema",
125 self.name
126 )));
127 }
128 validate_schema_profile(&self.name, &self.schema)?;
129 validate_proposal_bindings(&self.name, &self.schema, &self.proposal_bindings)?;
130 let mutates = !self.effect.is_read_only() && self.effect != EffectKind::AskUser;
133 self.footprint
134 .validate(&self.schema, mutates)
135 .map_err(|e| SdkError::Domain(format!("tool {:?}: {e}", self.name)))
136 }
137
138 pub fn validate_arguments(&self, arguments: &serde_json::Value) -> Result<()> {
143 validate_object_value(&self.name, "arguments", arguments, &self.schema)
144 }
145
146 pub fn to_spec(&self, strict: bool) -> ToolSpec {
148 ToolSpec {
149 name: self.name.clone(),
150 description: self.description.clone(),
151 schema: self.schema.clone(),
152 strict,
153 }
154 }
155
156 pub fn to_spec_for_route(&self, route: &crate::prompt::PromptRoute, strict: bool) -> ToolSpec {
162 let mut spec = self.to_spec(strict);
163 if let Some(library) = &self.description_templates {
164 let family = format!("{:?}", route.family);
165 let exact = format!("{}/{family}", route.adapter);
166 spec.description = library
167 .overrides
168 .iter()
169 .find(|(selector, _)| {
170 *selector == exact || *selector == route.adapter || *selector == family
171 })
172 .map(|(_, description)| description.clone())
173 .unwrap_or_else(|| library.base.clone());
174 }
175 spec
176 }
177
178 pub fn discovery_text(&self) -> &str {
181 if self.discovery_summary.is_empty() {
182 &self.description
183 } else {
184 &self.discovery_summary
185 }
186 }
187}
188
189fn validate_schema_profile(tool: &str, schema: &serde_json::Value) -> Result<()> {
190 const ROOT_KEYS: &[&str] = &[
191 "$schema",
192 "title",
193 "description",
194 "type",
195 "properties",
196 "required",
197 "additionalProperties",
198 ];
199 let root = schema
200 .as_object()
201 .expect("object schema checked by ToolEntry::validate");
202 if let Some(key) = root.keys().find(|key| !ROOT_KEYS.contains(&key.as_str())) {
203 return Err(SdkError::Domain(format!(
204 "tool {tool:?}: unsupported argument-schema keyword {key:?}"
205 )));
206 }
207 let properties = root
208 .get("properties")
209 .and_then(serde_json::Value::as_object)
210 .ok_or_else(|| {
211 SdkError::Domain(format!(
212 "tool {tool:?}: schema must declare object properties"
213 ))
214 })?;
215 if let Some(additional) = root.get("additionalProperties") {
216 if !additional.is_boolean() {
217 return Err(SdkError::Domain(format!(
218 "tool {tool:?}: additionalProperties must be boolean"
219 )));
220 }
221 }
222 if let Some(required) = root.get("required") {
223 let required = required
224 .as_array()
225 .ok_or_else(|| SdkError::Domain(format!("tool {tool:?}: required must be an array")))?;
226 for field in required {
227 let field = field.as_str().ok_or_else(|| {
228 SdkError::Domain(format!("tool {tool:?}: required entries must be strings"))
229 })?;
230 if !properties.contains_key(field) {
231 return Err(SdkError::Domain(format!(
232 "tool {tool:?}: required argument {field:?} is not a property"
233 )));
234 }
235 }
236 }
237 for (field, property) in properties {
238 validate_property_schema(tool, field, property, 0)?;
239 }
240 Ok(())
241}
242
243fn validate_property_schema(
244 tool: &str,
245 field: &str,
246 schema: &serde_json::Value,
247 object_depth: u8,
248) -> Result<()> {
249 const KEYS: &[&str] = &[
250 "title",
251 "description",
252 "type",
253 "enum",
254 "default",
255 "items",
256 "minItems",
257 "maxItems",
258 "properties",
259 "required",
260 "additionalProperties",
261 ];
262 let property = schema.as_object().ok_or_else(|| {
263 SdkError::Domain(format!(
264 "tool {tool:?}: property {field:?} must be a schema object"
265 ))
266 })?;
267 if let Some(key) = property.keys().find(|key| !KEYS.contains(&key.as_str())) {
268 return Err(SdkError::Domain(format!(
269 "tool {tool:?}: property {field:?} uses unsupported keyword {key:?}"
270 )));
271 }
272 let kind = property
273 .get("type")
274 .and_then(serde_json::Value::as_str)
275 .ok_or_else(|| {
276 SdkError::Domain(format!("tool {tool:?}: property {field:?} has no type"))
277 })?;
278 match kind {
279 "string" | "integer" | "number" | "boolean" | "null" => {
280 validate_enum(tool, field, property, kind)?;
281 }
282 "array" => validate_array_schema(tool, field, property, object_depth)?,
283 "object" if object_depth == 0 => {
284 validate_object_schema(tool, field, property, object_depth + 1)?;
285 }
286 "object" => {
287 return Err(SdkError::Domain(format!(
288 "tool {tool:?}: property {field:?} exceeds one nested object level"
289 )));
290 }
291 _ => {
292 return Err(SdkError::Domain(format!(
293 "tool {tool:?}: property {field:?} has unsupported type {kind:?}"
294 )));
295 }
296 }
297 Ok(())
298}
299
300fn validate_array_schema(
301 tool: &str,
302 field: &str,
303 property: &serde_json::Map<String, serde_json::Value>,
304 object_depth: u8,
305) -> Result<()> {
306 let maximum = property
307 .get("maxItems")
308 .and_then(serde_json::Value::as_u64)
309 .filter(|maximum| *maximum > 0)
310 .ok_or_else(|| {
311 SdkError::Domain(format!(
312 "tool {tool:?}: array property {field:?} requires a positive maxItems"
313 ))
314 })?;
315 if maximum > 256 {
316 return Err(SdkError::Domain(format!(
317 "tool {tool:?}: array property {field:?} maxItems exceeds 256"
318 )));
319 }
320 let items = property.get("items").ok_or_else(|| {
321 SdkError::Domain(format!(
322 "tool {tool:?}: array property {field:?} requires items"
323 ))
324 })?;
325 let item_kind = items.get("type").and_then(serde_json::Value::as_str);
326 if !matches!(
327 item_kind,
328 Some("string" | "integer" | "number" | "boolean" | "null")
329 ) {
330 return Err(SdkError::Domain(format!(
331 "tool {tool:?}: array property {field:?} must contain scalars"
332 )));
333 }
334 validate_property_schema(tool, &format!("{field}[]"), items, object_depth)
335}
336
337fn validate_object_schema(
338 tool: &str,
339 field: &str,
340 property: &serde_json::Map<String, serde_json::Value>,
341 depth: u8,
342) -> Result<()> {
343 let children = property
344 .get("properties")
345 .and_then(serde_json::Value::as_object)
346 .ok_or_else(|| {
347 SdkError::Domain(format!(
348 "tool {tool:?}: object property {field:?} requires properties"
349 ))
350 })?;
351 validate_required(tool, field, property, children)?;
352 for (child, schema) in children {
353 validate_property_schema(tool, &format!("{field}.{child}"), schema, depth)?;
354 }
355 Ok(())
356}
357
358fn validate_required(
359 tool: &str,
360 field: &str,
361 schema: &serde_json::Map<String, serde_json::Value>,
362 properties: &serde_json::Map<String, serde_json::Value>,
363) -> Result<()> {
364 let Some(required) = schema.get("required") else {
365 return Ok(());
366 };
367 let required = required.as_array().ok_or_else(|| {
368 SdkError::Domain(format!(
369 "tool {tool:?}: {field:?} required must be an array"
370 ))
371 })?;
372 for name in required {
373 let name = name.as_str().ok_or_else(|| {
374 SdkError::Domain(format!(
375 "tool {tool:?}: {field:?} required entries must be strings"
376 ))
377 })?;
378 if !properties.contains_key(name) {
379 return Err(SdkError::Domain(format!(
380 "tool {tool:?}: required property {field}.{name} is not declared"
381 )));
382 }
383 }
384 Ok(())
385}
386
387fn validate_enum(
388 tool: &str,
389 field: &str,
390 property: &serde_json::Map<String, serde_json::Value>,
391 kind: &str,
392) -> Result<()> {
393 if let Some(values) = property.get("enum") {
394 let values = values.as_array().ok_or_else(|| {
395 SdkError::Domain(format!(
396 "tool {tool:?}: property {field:?} enum must be an array"
397 ))
398 })?;
399 if values.iter().any(|value| !value_matches_type(value, kind)) {
400 return Err(SdkError::Domain(format!(
401 "tool {tool:?}: property {field:?} enum contains a value outside type {kind}"
402 )));
403 }
404 }
405 Ok(())
406}
407
408fn validate_object_value(
409 tool: &str,
410 field: &str,
411 value: &serde_json::Value,
412 schema: &serde_json::Value,
413) -> Result<()> {
414 let object = value.as_object().ok_or_else(|| {
415 SdkError::Domain(format!("tool {tool:?}: argument {field:?} must be object"))
416 })?;
417 let properties = schema
418 .get("properties")
419 .and_then(serde_json::Value::as_object)
420 .expect("catalog validation requires object properties");
421 if let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) {
422 for required_field in required.iter().filter_map(serde_json::Value::as_str) {
423 if !object.contains_key(required_field) {
424 return Err(SdkError::Domain(format!(
425 "tool {tool:?}: missing required argument {field}.{required_field}"
426 )));
427 }
428 }
429 }
430 if schema.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
431 if let Some(unknown) = object.keys().find(|name| !properties.contains_key(*name)) {
432 return Err(SdkError::Domain(format!(
433 "tool {tool:?}: unknown argument {field}.{unknown}"
434 )));
435 }
436 }
437 for (name, child) in object {
438 if let Some(child_schema) = properties.get(name) {
439 validate_argument_value(tool, &format!("{field}.{name}"), child, child_schema)?;
440 }
441 }
442 Ok(())
443}
444
445fn validate_argument_value(
446 tool: &str,
447 field: &str,
448 value: &serde_json::Value,
449 schema: &serde_json::Value,
450) -> Result<()> {
451 let kind = schema
452 .get("type")
453 .and_then(serde_json::Value::as_str)
454 .expect("catalog validation requires property types");
455 if !value_matches_type(value, kind) {
456 return Err(SdkError::Domain(format!(
457 "tool {tool:?}: argument {field:?} must be {kind}"
458 )));
459 }
460 if let Some(allowed) = schema.get("enum").and_then(serde_json::Value::as_array) {
461 if !allowed.contains(value) {
462 return Err(SdkError::Domain(format!(
463 "tool {tool:?}: argument {field:?} is outside its enum"
464 )));
465 }
466 }
467 match kind {
468 "array" => {
469 let values = value.as_array().expect("array type checked");
470 let maximum = schema
471 .get("maxItems")
472 .and_then(serde_json::Value::as_u64)
473 .expect("array schema validated") as usize;
474 let minimum = schema
475 .get("minItems")
476 .and_then(serde_json::Value::as_u64)
477 .unwrap_or(0) as usize;
478 if values.len() < minimum || values.len() > maximum {
479 return Err(SdkError::Domain(format!(
480 "tool {tool:?}: argument {field:?} has an invalid item count"
481 )));
482 }
483 let item_schema = schema.get("items").expect("array schema validated");
484 for (index, item) in values.iter().enumerate() {
485 validate_argument_value(tool, &format!("{field}[{index}]"), item, item_schema)?;
486 }
487 }
488 "object" => validate_object_value(tool, field, value, schema)?,
489 _ => {}
490 }
491 Ok(())
492}
493
494fn validate_proposal_bindings(
495 tool: &str,
496 schema: &serde_json::Value,
497 bindings: &[ProposalBinding],
498) -> Result<()> {
499 let properties = schema
500 .get("properties")
501 .and_then(serde_json::Value::as_object)
502 .expect("schema profile validates properties first");
503 for binding in bindings {
504 let (field, expected) = match binding {
505 ProposalBinding::Path { field }
506 | ProposalBinding::Command { field }
507 | ProposalBinding::Url { field } => (field, "string"),
508 ProposalBinding::MultiValue { field, .. } => (field, "array"),
509 };
510 let actual = properties
511 .get(field)
512 .and_then(|property| property.get("type"))
513 .and_then(serde_json::Value::as_str);
514 if actual != Some(expected) {
515 return Err(SdkError::Domain(format!(
516 "tool {tool:?}: proposal binding {field:?} requires {expected} arguments"
517 )));
518 }
519 }
520 Ok(())
521}
522
523fn value_matches_type(value: &serde_json::Value, kind: &str) -> bool {
524 match kind {
525 "string" => value.is_string(),
526 "integer" => value.is_i64() || value.is_u64(),
527 "number" => value.is_number(),
528 "boolean" => value.is_boolean(),
529 "array" => value.is_array(),
530 "object" => value.is_object(),
531 "null" => value.is_null(),
532 _ => false,
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::toolset::footprint::{AccessMode, ResourceSelector};
540
541 fn entry() -> ToolEntry {
542 ToolEntry {
543 name: "edit_file".into(),
544 description: "Exact-string replace".into(),
545 discovery_summary: String::new(),
546 description_templates: None,
547 effect: EffectKind::ApplyPatch,
548 risk: RiskClass::Medium,
549 schema: serde_json::json!({
550 "type": "object",
551 "properties": {"path": {"type": "string"}},
552 "required": ["path"],
553 }),
554 footprint: FootprintSpec::new(vec![ResourceSelector::PathArgument {
555 field: "path".into(),
556 access: AccessMode::Write,
557 }]),
558 proposal_bindings: vec![ProposalBinding::Path {
559 field: "path".into(),
560 }],
561 durable: false,
562 origin: ToolOrigin::Builtin,
563 hot: false,
564 }
565 }
566
567 #[test]
568 fn a_well_formed_entry_validates() {
569 assert!(entry().validate().is_ok());
570 }
571
572 #[test]
573 fn a_non_object_schema_is_rejected() {
574 let mut bad = entry();
575 bad.schema = serde_json::json!({"type": "string"});
576 assert!(bad.validate().is_err());
577 }
578
579 #[test]
580 fn a_mutating_entry_without_a_footprint_is_rejected_at_assembly() {
581 let mut bad = entry();
582 bad.footprint = FootprintSpec::default();
583 assert!(bad.validate().is_err());
584 }
585
586 #[test]
587 fn spec_carries_schema_and_strictness() {
588 let spec = entry().to_spec(true);
589 assert_eq!(spec.name, "edit_file");
590 assert!(spec.strict);
591 }
592
593 #[test]
594 fn runtime_arguments_are_checked_against_the_admitted_profile() {
595 let mut entry = entry();
596 entry.schema["additionalProperties"] = serde_json::Value::Bool(false);
597 assert!(entry
598 .validate_arguments(&serde_json::json!({"path": "src/lib.rs"}))
599 .is_ok());
600 assert!(entry
601 .validate_arguments(&serde_json::json!({"path": 7}))
602 .is_err());
603 assert!(entry
604 .validate_arguments(&serde_json::json!({"path": "src/lib.rs", "extra": true}))
605 .is_err());
606 }
607
608 #[test]
609 fn unsupported_schema_constructs_fail_at_catalog_assembly() {
610 let mut nested = entry();
611 nested.schema["properties"]["path"]["pattern"] = serde_json::json!("^src/");
612 assert!(nested.validate().is_err());
613
614 let mut array = entry();
615 array.schema["properties"]["path"]["type"] = serde_json::json!("array");
616 assert!(array.validate().is_err());
617 }
618
619 #[test]
620 fn bounded_scalar_arrays_and_one_nested_object_are_supported() {
621 let mut composite = entry();
622 composite.effect = EffectKind::SystemProbe;
623 composite.footprint = FootprintSpec::default();
624 composite.proposal_bindings.clear();
625 composite.schema = serde_json::json!({
626 "type": "object",
627 "properties": {
628 "names": {
629 "type": "array",
630 "items": {"type": "string"},
631 "maxItems": 2
632 },
633 "options": {
634 "type": "object",
635 "properties": {"enabled": {"type": "boolean"}},
636 "required": ["enabled"],
637 "additionalProperties": false
638 }
639 },
640 "required": ["names", "options"],
641 "additionalProperties": false
642 });
643 assert!(composite.validate().is_ok());
644 assert!(composite
645 .validate_arguments(&serde_json::json!({
646 "names": ["one", "two"],
647 "options": {"enabled": true}
648 }))
649 .is_ok());
650 assert!(composite
651 .validate_arguments(&serde_json::json!({
652 "names": ["one", "two", "three"],
653 "options": {"enabled": true}
654 }))
655 .is_err());
656 }
657}