1#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ToolScheduling {
16 #[default]
18 Parallel,
19 Serial,
21}
22
23fn default_tool_scheduling() -> ToolScheduling {
24 ToolScheduling::default()
25}
26
27fn is_default_tool_scheduling(mode: &ToolScheduling) -> bool {
28 *mode == ToolScheduling::default()
29}
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum ToolRetryPolicy {
40 #[default]
42 Never,
43 Safe {
45 max_attempts: u32,
46 base_delay_ms: u64,
47 max_delay_ms: u64,
48 },
49 Idempotent {
52 max_attempts: u32,
53 base_delay_ms: u64,
54 max_delay_ms: u64,
55 },
56}
57
58impl ToolRetryPolicy {
59 pub fn safe(max_attempts: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
60 Self::Safe {
61 max_attempts,
62 base_delay_ms,
63 max_delay_ms,
64 }
65 }
66
67 pub fn idempotent(max_attempts: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
68 Self::Idempotent {
69 max_attempts,
70 base_delay_ms,
71 max_delay_ms,
72 }
73 }
74
75 pub fn max_attempts(self) -> u32 {
76 match self {
77 Self::Never => 1,
78 Self::Safe { max_attempts, .. } | Self::Idempotent { max_attempts, .. } => {
79 max_attempts.max(1)
80 }
81 }
82 }
83
84 pub fn delay_ms_for_retry(self, retry_index: u32, requested_after_ms: Option<u64>) -> u64 {
85 let (base_delay_ms, max_delay_ms) = match self {
86 Self::Never => return 0,
87 Self::Safe {
88 base_delay_ms,
89 max_delay_ms,
90 ..
91 }
92 | Self::Idempotent {
93 base_delay_ms,
94 max_delay_ms,
95 ..
96 } => (base_delay_ms, max_delay_ms),
97 };
98 let multiplier = 1_u64.checked_shl(retry_index).unwrap_or(u64::MAX);
99 let backoff = base_delay_ms.saturating_mul(multiplier);
100 let delay = requested_after_ms.unwrap_or(backoff);
101 if max_delay_ms == 0 {
102 delay
103 } else {
104 delay.min(max_delay_ms)
105 }
106 }
107
108 pub fn requires_replay_key(self) -> bool {
109 matches!(self, Self::Idempotent { .. })
110 }
111}
112
113fn default_tool_retry_policy() -> ToolRetryPolicy {
114 ToolRetryPolicy::default()
115}
116
117fn is_default_tool_retry_policy(policy: &ToolRetryPolicy) -> bool {
118 *policy == ToolRetryPolicy::default()
119}
120
121#[derive(
122 Clone,
123 Copy,
124 Debug,
125 Default,
126 PartialEq,
127 Eq,
128 PartialOrd,
129 Ord,
130 serde::Serialize,
131 serde::Deserialize,
132)]
133#[serde(rename_all = "snake_case")]
134pub enum ToolAvailability {
135 #[default]
141 Off,
142 Searchable,
145 Callable,
148 Showcased,
151}
152
153impl ToolAvailability {
154 pub fn is_searchable(self) -> bool {
155 self >= Self::Searchable
156 }
157
158 pub fn is_callable(self) -> bool {
159 self >= Self::Callable
160 }
161
162 pub fn is_showcased(self) -> bool {
163 self >= Self::Showcased
164 }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168pub struct ToolAvailabilityConfig {
169 pub base: ToolAvailability,
170}
171
172impl ToolAvailabilityConfig {
173 pub fn same(availability: ToolAvailability) -> Self {
174 Self { base: availability }
175 }
176
177 pub fn showcased() -> Self {
178 Self::same(ToolAvailability::Showcased)
179 }
180
181 pub fn callable() -> Self {
182 Self::same(ToolAvailability::Callable)
183 }
184
185 pub fn off() -> Self {
186 Self::same(ToolAvailability::Off)
187 }
188
189 pub fn base(&self) -> ToolAvailability {
190 self.base
191 }
192}
193
194impl Default for ToolAvailabilityConfig {
195 fn default() -> Self {
196 Self::showcased()
197 }
198}
199
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum ToolActivation {
203 #[default]
204 Always,
205 Internal,
206}
207
208fn is_default_tool_availability_config(config: &ToolAvailabilityConfig) -> bool {
209 *config == ToolAvailabilityConfig::default()
210}
211
212fn is_default_tool_activation(activation: &ToolActivation) -> bool {
213 *activation == ToolActivation::default()
214}
215
216#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
217pub struct ToolAgentSurface {
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub module_path: Vec<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub operation: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub authority_type: Option<String>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub aliases: Vec<String>,
226}
227
228impl ToolAgentSurface {
229 pub fn new(
230 module_path: impl IntoIterator<Item = impl Into<String>>,
231 operation: impl Into<String>,
232 ) -> Self {
233 Self {
234 module_path: module_path.into_iter().map(Into::into).collect(),
235 operation: Some(operation.into()),
236 authority_type: None,
237 aliases: Vec::new(),
238 }
239 }
240
241 pub fn with_authority_type(mut self, authority_type: impl Into<String>) -> Self {
242 self.authority_type = Some(authority_type.into());
243 self
244 }
245
246 pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
247 self.aliases = aliases.into_iter().map(Into::into).collect();
248 self
249 }
250
251 pub fn executable_for(&self, tool_name: &str) -> ToolAgentExecutableSurface {
252 let module_path = if self.module_path.is_empty() {
253 vec!["tools".to_string()]
254 } else {
255 self.module_path.clone()
256 };
257 let operation = self
258 .operation
259 .as_deref()
260 .filter(|operation| !operation.trim().is_empty())
261 .unwrap_or(tool_name)
262 .to_string();
263 let authority_type = self
264 .authority_type
265 .as_deref()
266 .filter(|authority_type| !authority_type.trim().is_empty())
267 .map(ToOwned::to_owned)
268 .unwrap_or_else(|| default_authority_type(&module_path));
269 ToolAgentExecutableSurface {
270 module_path,
271 operation,
272 authority_type,
273 aliases: self.aliases.clone(),
274 }
275 }
276
277 pub fn required_for_remote(
285 manifest: &ToolManifest,
286 ) -> Result<ToolAgentExecutableSurface, String> {
287 manifest
288 .agent_surface
289 .required_executable_for_remote(&manifest.name)
290 }
291
292 pub fn required_executable_for_remote(
293 &self,
294 tool_name: &str,
295 ) -> Result<ToolAgentExecutableSurface, String> {
296 if self.module_path.is_empty() {
297 return Err(format!(
298 "tool `{tool_name}` is missing an explicit remote module path"
299 ));
300 }
301 if let Some(empty) = self.module_path.iter().find(|part| part.trim().is_empty()) {
302 return Err(format!(
303 "tool `{tool_name}` has an empty remote module path segment `{empty}`"
304 ));
305 }
306 let Some(operation) = self
307 .operation
308 .as_deref()
309 .map(str::trim)
310 .filter(|operation| !operation.is_empty())
311 else {
312 return Err(format!(
313 "tool `{tool_name}` is missing an explicit remote operation"
314 ));
315 };
316 let authority_type = self
317 .authority_type
318 .as_deref()
319 .filter(|authority_type| !authority_type.trim().is_empty())
320 .map(ToOwned::to_owned)
321 .unwrap_or_else(|| default_authority_type(&self.module_path));
322 Ok(ToolAgentExecutableSurface {
323 module_path: self.module_path.clone(),
324 operation: operation.to_string(),
325 authority_type,
326 aliases: self.aliases.clone(),
327 })
328 }
329
330 pub fn is_empty(&self) -> bool {
331 self.module_path.is_empty()
332 && self.operation.is_none()
333 && self.authority_type.is_none()
334 && self.aliases.is_empty()
335 }
336}
337
338#[derive(Clone, Debug, PartialEq, Eq)]
339pub struct ToolAgentExecutableSurface {
340 pub module_path: Vec<String>,
341 pub operation: String,
342 pub authority_type: String,
343 pub aliases: Vec<String>,
344}
345
346impl ToolAgentExecutableSurface {
347 pub fn module_path_string(&self) -> String {
348 self.module_path.join(".")
349 }
350
351 pub fn call_path(&self) -> String {
352 format!("{}.{}", self.module_path_string(), self.operation)
353 }
354}
355
356fn default_authority_type(module_path: &[String]) -> String {
357 let base = module_path
358 .first()
359 .map(String::as_str)
360 .unwrap_or("tools")
361 .trim_matches('_');
362 let mut out = String::new();
363 for part in base.split('_').filter(|part| !part.is_empty()) {
364 let mut chars = part.chars();
365 if let Some(first) = chars.next() {
366 out.extend(first.to_uppercase());
367 out.extend(chars);
368 }
369 }
370 if out.is_empty() {
371 "Tools".to_string()
372 } else {
373 out
374 }
375}
376
377#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
378#[serde(tag = "kind", rename_all = "snake_case")]
379pub enum ToolOutputContract {
380 #[default]
381 Static,
382 FromInputSchema {
383 input_field: String,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 default_schema: Option<serde_json::Value>,
386 },
387}
388
389impl ToolOutputContract {
390 pub fn from_input_schema(
391 input_field: impl Into<String>,
392 default_schema: Option<serde_json::Value>,
393 ) -> Self {
394 Self::FromInputSchema {
395 input_field: input_field.into(),
396 default_schema,
397 }
398 }
399
400 pub fn is_static(&self) -> bool {
401 matches!(self, Self::Static)
402 }
403
404 fn return_type_label(&self, static_schema: &serde_json::Value) -> String {
405 match self {
406 Self::Static => compact_schema_label(static_schema),
407 Self::FromInputSchema { .. } => "T".to_string(),
408 }
409 }
410
411 fn type_parameter_suffix(&self) -> Option<String> {
412 match self {
413 Self::Static => None,
414 Self::FromInputSchema { default_schema, .. } => {
415 let default = default_schema
416 .as_ref()
417 .map(compact_schema_label)
418 .unwrap_or_else(|| "any".to_string());
419 Some(format!("<T = {default}>"))
420 }
421 }
422 }
423
424 fn apply_type_witness_parameter(&self, params: &mut [ParameterDoc]) {
425 let Self::FromInputSchema { input_field, .. } = self else {
426 return;
427 };
428 if let Some(param) = params.iter_mut().find(|param| param.name == *input_field) {
429 param.type_label = "TypeSpec<T>".to_string();
430 param.nullable = false;
431 param.default_value = None;
432 param.enum_values.clear();
433 param.minimum = None;
434 param.maximum = None;
435 param.min_length = None;
436 param.max_length = None;
437 param.min_items = None;
438 param.max_items = None;
439 param.item_type = None;
440 }
441 }
442
443 fn return_fields(&self, static_schema: &serde_json::Value) -> Vec<serde_json::Value> {
444 match self {
445 Self::Static => return_field_metadata(static_schema),
446 Self::FromInputSchema { .. } => Vec::new(),
447 }
448 }
449}
450
451#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
452#[serde(tag = "kind", rename_all = "snake_case")]
453pub enum ToolArgumentProjectionPolicy {
454 #[default]
455 MaterializeProjectedValues,
456 PreserveProjectedRefsInField {
457 field: String,
458 },
459}
460
461impl ToolArgumentProjectionPolicy {
462 pub fn preserve_projected_refs_in_field(field: impl Into<String>) -> Self {
463 Self::PreserveProjectedRefsInField {
464 field: field.into(),
465 }
466 }
467
468 pub fn is_materialize_projected_values(&self) -> bool {
469 matches!(self, Self::MaterializeProjectedValues)
470 }
471}
472
473fn is_default_tool_argument_projection_policy(policy: &ToolArgumentProjectionPolicy) -> bool {
474 policy.is_materialize_projected_values()
475}
476
477#[derive(
478 Clone,
479 Debug,
480 Default,
481 PartialEq,
482 Eq,
483 PartialOrd,
484 Ord,
485 Hash,
486 serde::Serialize,
487 serde::Deserialize,
488)]
489#[serde(transparent)]
490pub struct ToolId(String);
491
492impl ToolId {
493 pub fn new(id: impl Into<String>) -> Self {
494 let id = id.into();
495 assert!(!id.trim().is_empty(), "tool id must not be empty");
496 Self(id)
497 }
498
499 pub fn default_for_name(name: &str) -> Self {
500 Self::new(format!("tool:{name}"))
501 }
502
503 pub fn as_str(&self) -> &str {
504 &self.0
505 }
506}
507
508impl From<String> for ToolId {
509 fn from(id: String) -> Self {
510 Self::new(id)
511 }
512}
513
514impl From<&str> for ToolId {
515 fn from(id: &str) -> Self {
516 Self::new(id)
517 }
518}
519
520impl std::fmt::Display for ToolId {
521 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522 f.write_str(&self.0)
523 }
524}
525
526#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
530pub struct ToolManifest {
531 pub id: ToolId,
532 pub name: String,
533 #[serde(default, skip_serializing_if = "String::is_empty")]
534 pub description: String,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub compact_contract: Option<CompactToolContract>,
537 #[serde(default, skip_serializing_if = "is_default_tool_availability_config")]
538 pub availability: ToolAvailabilityConfig,
539 #[serde(default, skip_serializing_if = "is_default_tool_activation")]
540 pub activation: ToolActivation,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub availability_override: Option<ToolAvailability>,
543 #[serde(default, skip_serializing_if = "ToolAgentSurface::is_empty")]
544 pub agent_surface: ToolAgentSurface,
545 #[serde(
546 default,
547 skip_serializing_if = "is_default_tool_argument_projection_policy"
548 )]
549 pub argument_projection: ToolArgumentProjectionPolicy,
550 #[serde(
551 default = "default_tool_scheduling",
552 skip_serializing_if = "is_default_tool_scheduling"
553 )]
554 pub scheduling: ToolScheduling,
555 #[serde(
556 default = "default_tool_retry_policy",
557 skip_serializing_if = "is_default_tool_retry_policy"
558 )]
559 pub retry_policy: ToolRetryPolicy,
560}
561
562impl ToolManifest {
563 pub fn effective_availability(&self) -> ToolAvailability {
564 self.availability_override
565 .unwrap_or_else(|| self.availability.base())
566 }
567}
568
569#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
571pub struct ToolContract {
572 #[serde(default = "ToolContract::default_input_schema")]
573 pub input_schema: serde_json::Value,
574 #[serde(default)]
575 pub output_schema: serde_json::Value,
576 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 pub input_schema_projections: Vec<SchemaProjectionOverride>,
578 #[serde(default, skip_serializing_if = "Vec::is_empty")]
579 pub output_schema_projections: Vec<SchemaProjectionOverride>,
580 #[serde(default, skip_serializing_if = "ToolOutputContract::is_static")]
581 pub output_contract: ToolOutputContract,
582 #[serde(default, skip_serializing_if = "Vec::is_empty")]
583 pub examples: Vec<String>,
584}
585
586impl Default for ToolContract {
587 fn default() -> Self {
588 Self {
589 input_schema: Self::default_input_schema(),
590 output_schema: serde_json::Value::Null,
591 input_schema_projections: Vec::new(),
592 output_schema_projections: Vec::new(),
593 output_contract: ToolOutputContract::Static,
594 examples: Vec::new(),
595 }
596 }
597}
598
599impl ToolContract {
600 pub fn default_input_schema() -> serde_json::Value {
601 serde_json::json!({
602 "type": "object",
603 "properties": {},
604 "additionalProperties": true
605 })
606 }
607
608 pub fn compact_contract(&self, manifest: &ToolManifest) -> CompactToolContract {
609 self.compact_contract_with_example_limit(manifest, COMPACT_TOOL_EXAMPLE_LIMIT)
610 }
611
612 pub fn compact_contract_with_example_limit(
613 &self,
614 manifest: &ToolManifest,
615 example_limit: usize,
616 ) -> CompactToolContract {
617 let agent_surface = manifest.agent_surface.executable_for(&manifest.name);
618 CompactToolContract {
619 name: agent_surface.call_path(),
620 signature: self.input_signature(manifest),
621 returns: self.output_summary(),
622 parameters: self.parameter_metadata(),
623 return_fields: self.output_contract.return_fields(&self.output_schema),
624 description: manifest.description.trim().to_string(),
625 examples: compact_examples(&self.examples, example_limit),
626 }
627 }
628
629 pub fn input_signature(&self, manifest: &ToolManifest) -> String {
630 let agent_surface = manifest.agent_surface.executable_for(&manifest.name);
631 let params = self
632 .parameter_docs()
633 .into_iter()
634 .map(|p| p.signature_fragment())
635 .collect::<Vec<_>>();
636 let body = if params.is_empty() {
637 "{}".to_string()
638 } else {
639 format!("{{ {} }}", params.join(", "))
640 };
641 format!(
642 "await {}{}({})?",
643 agent_surface.call_path(),
644 self.output_contract
645 .type_parameter_suffix()
646 .unwrap_or_default(),
647 body
648 )
649 }
650
651 pub fn output_summary(&self) -> String {
652 self.output_contract.return_type_label(&self.output_schema)
653 }
654
655 pub fn parameter_metadata(&self) -> Vec<serde_json::Value> {
656 self.parameter_docs()
657 .into_iter()
658 .map(|param| param.into_value())
659 .collect()
660 }
661
662 pub fn model_tool(&self, manifest: &ToolManifest) -> ModelTool {
663 ModelTool {
664 name: manifest.name.clone(),
665 description: manifest.description.clone(),
666 input_schema: self.input_schema.clone(),
667 output_schema: self.output_schema.clone(),
668 input_schema_projections: self.input_schema_projections.clone(),
669 output_schema_projections: self.output_schema_projections.clone(),
670 }
671 }
672
673 fn parameter_docs(&self) -> Vec<ParameterDoc> {
674 let mut params = schema_parameter_docs(&self.input_schema);
675 self.output_contract
676 .apply_type_witness_parameter(&mut params);
677 params
678 }
679}
680
681#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
687pub struct ToolDefinition {
688 #[serde(flatten)]
689 pub manifest: ToolManifest,
690 #[serde(flatten)]
691 pub contract: ToolContract,
692}
693
694#[derive(Clone, Debug, PartialEq, Eq)]
695pub struct ModelTool {
696 pub name: String,
697 pub description: String,
698 pub input_schema: serde_json::Value,
699 pub output_schema: serde_json::Value,
700 pub input_schema_projections: Vec<SchemaProjectionOverride>,
701 pub output_schema_projections: Vec<SchemaProjectionOverride>,
702}
703
704#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
705pub struct SchemaProjectionOverride {
706 pub profile: String,
707 pub schema: serde_json::Value,
708}
709
710const COMPACT_TOOL_EXAMPLE_LIMIT: usize = 2;
711const COMPACT_TOOL_EXAMPLE_CHAR_LIMIT: usize = 240;
712
713#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
714pub struct CompactToolContract {
715 pub name: String,
716 pub signature: String,
717 pub returns: String,
718 #[serde(default, skip_serializing_if = "Vec::is_empty")]
719 pub parameters: Vec<serde_json::Value>,
720 #[serde(default, skip_serializing_if = "Vec::is_empty")]
721 pub return_fields: Vec<serde_json::Value>,
722 #[serde(default, skip_serializing_if = "String::is_empty")]
723 pub description: String,
724 #[serde(default, skip_serializing_if = "Vec::is_empty")]
725 pub examples: Vec<String>,
726}
727
728impl CompactToolContract {
729 pub fn render_signature_head(&self) -> String {
730 format!("{} -> {}", self.signature.trim(), self.returns.trim())
731 }
732
733 pub fn render_signature(&self) -> String {
734 let mut sections = vec![self.render_signature_head()];
735 let parameter_lines = self
736 .parameters
737 .iter()
738 .filter_map(compact_doc_line)
739 .collect::<Vec<_>>();
740 if !parameter_lines.is_empty() {
741 sections.push(format!("Parameters:\n{}", parameter_lines.join("\n")));
742 }
743 let return_field_lines = self
744 .return_fields
745 .iter()
746 .filter_map(compact_doc_line)
747 .collect::<Vec<_>>();
748 if !return_field_lines.is_empty() {
749 sections.push(format!("Return fields:\n{}", return_field_lines.join("\n")));
750 }
751 sections.join("\n")
752 }
753
754 pub fn render_returns(&self) -> String {
755 let mut sections = Vec::new();
756 let return_field_lines = self
757 .return_fields
758 .iter()
759 .filter_map(compact_doc_line)
760 .collect::<Vec<_>>();
761 if !return_field_lines.is_empty() {
762 sections.push(format!("Return fields:\n{}", return_field_lines.join("\n")));
763 }
764 sections.join("\n")
765 }
766
767 pub fn render_markdown(&self) -> String {
768 let mut sections = vec![format!("### {}", self.render_signature_head())];
769 if !self.description.trim().is_empty() {
770 sections.push(self.description.trim().to_string());
771 }
772 if !self.parameters.is_empty() {
773 sections.push(format!(
774 "Parameters:\n{}",
775 self.parameters
776 .iter()
777 .filter_map(compact_doc_line)
778 .collect::<Vec<_>>()
779 .join("\n")
780 ));
781 }
782 if !self.return_fields.is_empty() {
783 sections.push(format!(
784 "Return fields:\n{}",
785 self.return_fields
786 .iter()
787 .filter_map(compact_doc_line)
788 .collect::<Vec<_>>()
789 .join("\n")
790 ));
791 }
792 if !self.examples.is_empty() {
793 sections.push(format!("Examples: {}", self.examples.join("; ")));
794 }
795 sections.join("\n")
796 }
797}
798
799impl ToolDefinition {
800 pub fn raw_with_id(
801 id: impl Into<ToolId>,
802 name: impl Into<String>,
803 description: impl Into<String>,
804 input_schema: serde_json::Value,
805 output_schema: serde_json::Value,
806 ) -> Self {
807 Self {
808 manifest: ToolManifest {
809 id: id.into(),
810 name: name.into(),
811 description: description.into(),
812 compact_contract: None,
813 ..ToolManifest::default()
814 },
815 contract: ToolContract {
816 input_schema,
817 output_schema,
818 ..ToolContract::default()
819 },
820 }
821 }
822
823 pub fn raw_named(
824 name: impl Into<String>,
825 description: impl Into<String>,
826 input_schema: serde_json::Value,
827 output_schema: serde_json::Value,
828 ) -> Self {
829 let name = name.into();
830 Self::raw_with_id(
831 ToolId::default_for_name(&name),
832 name,
833 description,
834 input_schema,
835 output_schema,
836 )
837 }
838
839 pub fn typed_with_id<Args, Output>(
840 id: impl Into<ToolId>,
841 name: impl Into<String>,
842 description: impl Into<String>,
843 ) -> Self
844 where
845 Args: schemars::JsonSchema,
846 Output: schemars::JsonSchema,
847 {
848 Self::raw_with_id(
849 id,
850 name,
851 description,
852 schema_for::<Args>(),
853 schema_for::<Output>(),
854 )
855 }
856
857 pub fn typed<Args, Output>(name: impl Into<String>, description: impl Into<String>) -> Self
858 where
859 Args: schemars::JsonSchema,
860 Output: schemars::JsonSchema,
861 {
862 let name = name.into();
863 Self::typed_with_id::<Args, Output>(ToolId::default_for_name(&name), name, description)
864 }
865
866 pub fn raw(
867 id: impl Into<ToolId>,
868 name: impl Into<String>,
869 description: impl Into<String>,
870 input_schema: serde_json::Value,
871 output_schema: serde_json::Value,
872 ) -> Self {
873 Self::raw_with_id(id, name, description, input_schema, output_schema)
874 }
875
876 pub fn with_examples(mut self, examples: Vec<String>) -> Self {
877 self.contract.examples = examples;
878 self
879 }
880
881 pub fn with_availability(mut self, availability: ToolAvailabilityConfig) -> Self {
882 self.manifest.availability = availability;
883 self
884 }
885
886 pub fn with_activation(mut self, activation: ToolActivation) -> Self {
887 self.manifest.activation = activation;
888 self
889 }
890
891 pub fn with_agent_surface(mut self, agent_surface: ToolAgentSurface) -> Self {
892 self.manifest.agent_surface = agent_surface;
893 self
894 }
895
896 pub fn with_argument_projection(
897 mut self,
898 argument_projection: ToolArgumentProjectionPolicy,
899 ) -> Self {
900 self.manifest.argument_projection = argument_projection;
901 self
902 }
903
904 pub fn with_scheduling(mut self, scheduling: ToolScheduling) -> Self {
905 self.manifest.scheduling = scheduling;
906 self
907 }
908
909 pub fn with_retry_policy(mut self, retry_policy: ToolRetryPolicy) -> Self {
910 self.manifest.retry_policy = retry_policy;
911 self
912 }
913
914 pub fn with_output_contract(mut self, output_contract: ToolOutputContract) -> Self {
915 self.contract.output_contract = output_contract;
916 self
917 }
918
919 pub fn with_input_schema_projection(
920 mut self,
921 profile: impl Into<String>,
922 schema: serde_json::Value,
923 ) -> Self {
924 let profile = profile.into();
925 self.contract
926 .input_schema_projections
927 .retain(|projection| projection.profile != profile);
928 self.contract
929 .input_schema_projections
930 .push(SchemaProjectionOverride { profile, schema });
931 self
932 }
933
934 pub fn with_output_schema_projection(
935 mut self,
936 profile: impl Into<String>,
937 schema: serde_json::Value,
938 ) -> Self {
939 let profile = profile.into();
940 self.contract
941 .output_schema_projections
942 .retain(|projection| projection.profile != profile);
943 self.contract
944 .output_schema_projections
945 .push(SchemaProjectionOverride { profile, schema });
946 self
947 }
948
949 pub fn with_output_from_input_schema(
950 self,
951 input_field: impl Into<String>,
952 default_schema: Option<serde_json::Value>,
953 ) -> Self {
954 self.with_output_contract(ToolOutputContract::from_input_schema(
955 input_field,
956 default_schema,
957 ))
958 }
959
960 pub fn default_input_schema() -> serde_json::Value {
961 ToolContract::default_input_schema()
962 }
963
964 pub fn id(&self) -> &ToolId {
967 &self.manifest.id
968 }
969
970 pub fn name(&self) -> &str {
972 &self.manifest.name
973 }
974
975 pub fn description(&self) -> &str {
977 &self.manifest.description
978 }
979
980 pub fn input_signature(&self) -> String {
981 self.contract.input_signature(&self.manifest)
982 }
983
984 pub fn output_summary(&self) -> String {
985 self.contract.output_summary()
986 }
987
988 pub fn signature(&self) -> String {
989 format!("{} -> {}", self.input_signature(), self.output_summary())
990 }
991
992 pub fn compact_contract(&self) -> CompactToolContract {
993 self.compact_contract_with_example_limit(COMPACT_TOOL_EXAMPLE_LIMIT)
994 }
995
996 pub fn compact_contract_with_example_limit(&self, example_limit: usize) -> CompactToolContract {
997 self.contract
998 .compact_contract_with_example_limit(&self.manifest, example_limit)
999 }
1000
1001 pub fn effective_availability(&self) -> ToolAvailability {
1002 self.manifest.effective_availability()
1003 }
1004
1005 pub fn model_tool(&self) -> ModelTool {
1006 self.contract.model_tool(&self.manifest)
1007 }
1008
1009 pub fn manifest(&self) -> ToolManifest {
1012 let mut manifest = self.manifest.clone();
1013 manifest.compact_contract = Some(self.contract.compact_contract(&manifest));
1014 manifest
1015 }
1016
1017 pub fn contract(&self) -> ToolContract {
1018 self.contract.clone()
1019 }
1020
1021 pub fn from_parts(manifest: ToolManifest, contract: ToolContract) -> Self {
1024 Self { manifest, contract }
1025 }
1026
1027 pub fn format_tool_docs(tools: &[ToolDefinition]) -> String {
1028 Self::format_tool_docs_iter(tools.iter())
1029 }
1030
1031 pub fn format_tool_docs_iter<'a>(
1032 tools: impl IntoIterator<Item = &'a ToolDefinition>,
1033 ) -> String {
1034 tools
1035 .into_iter()
1036 .map(|tool| tool.compact_contract().render_markdown())
1037 .collect::<Vec<_>>()
1038 .join("\n\n")
1039 }
1040
1041 pub fn parameter_metadata(&self) -> Vec<serde_json::Value> {
1042 self.parameter_docs()
1043 .into_iter()
1044 .map(|param| param.into_value())
1045 .collect()
1046 }
1047
1048 fn parameter_docs(&self) -> Vec<ParameterDoc> {
1049 let mut params = schema_parameter_docs(&self.contract.input_schema);
1050 self.contract
1051 .output_contract
1052 .apply_type_witness_parameter(&mut params);
1053 params
1054 }
1055}
1056
1057mod schema_docs;
1058pub use schema_docs::schema_for;
1059use schema_docs::{
1060 ParameterDoc, compact_doc_line, compact_examples, compact_schema_label, return_field_metadata,
1061 schema_parameter_docs,
1062};
1063
1064mod schema_validation;
1065pub use schema_validation::{LashSchema, validate_tool_input};
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 #[test]
1071 fn tool_definition_uses_canonical_model_schemas() {
1072 let tool = ToolDefinition::raw_with_id(
1073 "tool:mcp__demo__search",
1074 "mcp__demo__search",
1075 "Search demo server",
1076 serde_json::json!({
1077 "type": "object",
1078 "properties": {
1079 "query": { "type": "string" },
1080 "limit": { "type": "integer" }
1081 },
1082 "required": ["query"],
1083 "additionalProperties": false
1084 }),
1085 serde_json::json!({
1086 "type": "object",
1087 "properties": {
1088 "hits": { "type": "array", "items": { "type": "string" } }
1089 },
1090 "required": ["hits"],
1091 "additionalProperties": false
1092 }),
1093 );
1094
1095 let model_tool = tool.model_tool();
1096 assert_eq!(
1097 model_tool.input_schema["properties"]["limit"]["type"],
1098 serde_json::json!("integer")
1099 );
1100 assert_eq!(
1101 model_tool.output_schema["properties"]["hits"]["type"],
1102 serde_json::json!("array")
1103 );
1104 }
1105
1106 #[test]
1107 fn tool_retry_policy_defaults_to_never_and_is_omitted_from_manifest_json() {
1108 let tool = ToolDefinition::raw_with_id(
1109 "tool:demo",
1110 "demo",
1111 "Demo",
1112 ToolDefinition::default_input_schema(),
1113 serde_json::json!({ "type": "string" }),
1114 );
1115
1116 assert_eq!(tool.manifest.retry_policy, ToolRetryPolicy::Never);
1117 let manifest = tool.manifest();
1118 assert_eq!(manifest.retry_policy, ToolRetryPolicy::Never);
1119 let encoded = serde_json::to_value(&manifest).expect("manifest json");
1120 assert!(encoded.get("retry_policy").is_none());
1121 }
1122
1123 #[test]
1124 fn tool_retry_policy_propagates_through_manifest_and_definition_roundtrip() {
1125 let tool = ToolDefinition::raw_with_id(
1126 "tool:demo",
1127 "demo",
1128 "Demo",
1129 ToolDefinition::default_input_schema(),
1130 serde_json::json!({ "type": "string" }),
1131 )
1132 .with_retry_policy(ToolRetryPolicy::safe(3, 10, 100));
1133
1134 let manifest = tool.manifest();
1135 assert_eq!(
1136 manifest.retry_policy,
1137 ToolRetryPolicy::Safe {
1138 max_attempts: 3,
1139 base_delay_ms: 10,
1140 max_delay_ms: 100,
1141 }
1142 );
1143
1144 let roundtrip = ToolDefinition::from_parts(manifest, tool.contract());
1145 assert_eq!(roundtrip.manifest.retry_policy, tool.manifest.retry_policy);
1146 let encoded = serde_json::to_value(roundtrip.manifest()).expect("manifest json");
1147 assert_eq!(encoded["retry_policy"]["type"], serde_json::json!("safe"));
1148 }
1149
1150 #[test]
1151 fn tool_argument_projection_defaults_to_materialize_and_is_omitted_from_manifest_json() {
1152 let tool = ToolDefinition::raw_with_id(
1153 "tool:demo",
1154 "demo",
1155 "Demo",
1156 ToolDefinition::default_input_schema(),
1157 serde_json::json!({ "type": "string" }),
1158 );
1159
1160 assert_eq!(
1161 tool.manifest.argument_projection,
1162 ToolArgumentProjectionPolicy::MaterializeProjectedValues
1163 );
1164 let manifest = tool.manifest();
1165 assert_eq!(
1166 manifest.argument_projection,
1167 ToolArgumentProjectionPolicy::MaterializeProjectedValues
1168 );
1169 let encoded = serde_json::to_value(&manifest).expect("manifest json");
1170 assert!(encoded.get("argument_projection").is_none());
1171 }
1172
1173 #[test]
1174 fn tool_argument_projection_propagates_through_manifest_and_definition_roundtrip() {
1175 let tool = ToolDefinition::raw_with_id(
1176 "tool:demo",
1177 "demo",
1178 "Demo",
1179 ToolDefinition::default_input_schema(),
1180 serde_json::json!({ "type": "string" }),
1181 )
1182 .with_argument_projection(
1183 ToolArgumentProjectionPolicy::preserve_projected_refs_in_field("seed"),
1184 );
1185
1186 let manifest = tool.manifest();
1187 assert_eq!(
1188 manifest.argument_projection,
1189 tool.manifest.argument_projection
1190 );
1191
1192 let roundtrip = ToolDefinition::from_parts(manifest, tool.contract());
1193 assert_eq!(
1194 roundtrip.manifest.argument_projection,
1195 tool.manifest.argument_projection
1196 );
1197 let encoded = serde_json::to_value(roundtrip.manifest()).expect("manifest json");
1198 assert_eq!(
1199 encoded["argument_projection"],
1200 serde_json::json!({
1201 "kind": "preserve_projected_refs_in_field",
1202 "field": "seed"
1203 })
1204 );
1205 }
1206
1207 #[test]
1208 fn model_tool_preserves_schema_projection_overrides() {
1209 let tool = ToolDefinition::raw_with_id(
1210 "tool:demo",
1211 "demo",
1212 "Demo",
1213 serde_json::json!({
1214 "type": "object",
1215 "properties": { "raw": { "const": "x" } }
1216 }),
1217 serde_json::json!({ "type": "object" }),
1218 )
1219 .with_input_schema_projection(
1220 "provider.tool_parameters",
1221 serde_json::json!({
1222 "type": "object",
1223 "properties": { "raw": { "type": "string", "enum": ["x"] } }
1224 }),
1225 )
1226 .with_output_schema_projection(
1227 "provider.structured_output",
1228 serde_json::json!({
1229 "type": "object",
1230 "properties": {},
1231 "required": [],
1232 "additionalProperties": false
1233 }),
1234 );
1235
1236 let model_tool = tool.model_tool();
1237 assert_eq!(model_tool.input_schema["properties"]["raw"]["const"], "x");
1238 assert_eq!(
1239 model_tool.input_schema_projections[0].schema["properties"]["raw"]["enum"],
1240 serde_json::json!(["x"])
1241 );
1242 assert_eq!(
1243 model_tool.output_schema_projections[0].profile,
1244 "provider.structured_output"
1245 );
1246 }
1247
1248 #[test]
1249 fn typed_tool_definition_generates_input_and_output_schema() {
1250 #[derive(schemars::JsonSchema)]
1251 #[allow(dead_code)]
1252 enum Mode {
1253 Fast,
1254 Slow,
1255 }
1256
1257 #[derive(schemars::JsonSchema)]
1258 #[allow(dead_code)]
1259 struct Args {
1260 query: String,
1261 #[schemars(range(max = 20))]
1262 page_limit: u8,
1263 #[schemars(length(min = 1, max = 3))]
1264 tags: Vec<String>,
1265 mode: Option<Mode>,
1266 }
1267
1268 #[derive(schemars::JsonSchema)]
1269 #[allow(dead_code)]
1270 struct Output {
1271 answer: String,
1272 #[schemars(range(min = 0))]
1273 confidence: f32,
1274 }
1275
1276 let tool = ToolDefinition::typed::<Args, Output>("demo", "Demo");
1277 let metadata = tool.parameter_metadata();
1278 assert!(metadata.iter().any(|param| {
1279 param["name"] == "page_limit"
1280 && param["type"] == "int"
1281 && param["maximum"].as_f64() == Some(20.0)
1282 }));
1283 assert!(metadata.iter().any(|param| {
1284 param["name"] == "tags"
1285 && param["type"] == "list[str]"
1286 && param["min_items"] == 1
1287 && param["max_items"] == 3
1288 }));
1289 assert!(
1290 metadata
1291 .iter()
1292 .any(|param| { param["name"] == "mode" && param["nullable"] == true })
1293 );
1294 assert_eq!(
1295 tool.contract.output_schema["properties"]["answer"]["type"],
1296 "string"
1297 );
1298 assert_eq!(
1299 tool.contract.output_schema["properties"]["confidence"]["minimum"].as_f64(),
1300 Some(0.0)
1301 );
1302 }
1303
1304 #[test]
1305 fn raw_tool_definition_preserves_caller_provided_schemas() {
1306 let input_schema = serde_json::json!({
1307 "type": "object",
1308 "properties": {
1309 "query": { "type": "string", "minLength": 3 }
1310 },
1311 "required": ["query"],
1312 "x-custom": { "keep": true }
1313 });
1314 let output_schema = serde_json::json!({
1315 "type": "object",
1316 "properties": {
1317 "ok": { "type": "boolean" }
1318 },
1319 "required": ["ok"],
1320 "x-result": ["exact"]
1321 });
1322
1323 let tool = ToolDefinition::raw_with_id(
1324 "tool:raw_demo",
1325 "raw_demo",
1326 "Raw demo",
1327 input_schema.clone(),
1328 output_schema.clone(),
1329 );
1330
1331 assert_eq!(tool.contract.input_schema, input_schema);
1332 assert_eq!(tool.contract.output_schema, output_schema);
1333 }
1334
1335 #[test]
1336 fn compact_tool_contract_renders_prompt_and_search_shape_from_schemas() {
1337 let tool = ToolDefinition::raw_with_id(
1338 "tool:search_docs",
1339 "search_docs",
1340 "Search indexed docs",
1341 serde_json::json!({
1342 "type": "object",
1343 "properties": {
1344 "query": { "type": "string" },
1345 "limit": { "type": "integer", "maximum": 10, "default": 5 }
1346 },
1347 "required": ["query"]
1348 }),
1349 serde_json::json!({
1350 "type": "object",
1351 "properties": {
1352 "matches": {
1353 "type": "array",
1354 "items": { "type": "string" }
1355 },
1356 "next_page": { "type": ["string", "null"] }
1357 },
1358 "required": ["matches"]
1359 }),
1360 )
1361 .with_examples(vec![
1362 "await tools.search_docs({ query: \"rust\" })?".to_string(),
1363 "await tools.search_docs({ query: \"rust\", limit: 3 })?".to_string(),
1364 "await tools.search_docs({ query: \"ignored\" })?".to_string(),
1365 ]);
1366
1367 let contract = tool.compact_contract();
1368 assert_eq!(
1369 contract.signature,
1370 "await tools.search_docs({ query: str, limit?: int <= 10 = 5 })?"
1371 );
1372 assert_eq!(
1373 contract.returns,
1374 "record{matches: list[str], next_page?: str | null}"
1375 );
1376 assert_eq!(
1377 contract.parameters,
1378 vec![
1379 serde_json::json!({
1380 "name": "query",
1381 "type": "str",
1382 "required": true,
1383 "signature": "query: str"
1384 }),
1385 serde_json::json!({
1386 "name": "limit",
1387 "type": "int",
1388 "required": false,
1389 "default": 5,
1390 "maximum": 10,
1391 "signature": "limit?: int <= 10 = 5"
1392 }),
1393 ]
1394 );
1395 assert_eq!(contract.examples.len(), 2);
1396
1397 let docs = ToolDefinition::format_tool_docs(&[tool]);
1398 assert!(docs.contains(
1399 "### await tools.search_docs({ query: str, limit?: int <= 10 = 5 })? -> record{matches: list[str], next_page?: str | null}"
1400 ));
1401 assert!(!docs.contains("Returns:"));
1402 assert!(docs.contains("Parameters:\n- `query: str`\n- `limit?: int <= 10 = 5`"));
1403 assert!(docs.contains(
1404 "Examples: await tools.search_docs({ query: \"rust\" })?; await tools.search_docs({ query: \"rust\", limit: 3 })?"
1405 ));
1406 }
1407
1408 #[test]
1409 fn compact_tool_contract_resolves_local_refs_in_string_or_list_parameters() {
1410 let tool = ToolDefinition::raw_with_id(
1411 "tool:search_tools",
1412 "search_tools",
1413 "Search tools",
1414 serde_json::json!({
1415 "$schema": "https://json-schema.org/draft/2020-12/schema",
1416 "$defs": {
1417 "ModuleFilter": {
1418 "anyOf": [
1419 { "type": "string" },
1420 {
1421 "type": "array",
1422 "items": { "type": "string" }
1423 }
1424 ]
1425 }
1426 },
1427 "type": "object",
1428 "properties": {
1429 "query": { "type": "string" },
1430 "module": {
1431 "anyOf": [
1432 { "$ref": "#/$defs/ModuleFilter" },
1433 { "type": "null" }
1434 ]
1435 }
1436 },
1437 "required": ["query"]
1438 }),
1439 serde_json::json!({
1440 "type": "array",
1441 "items": { "type": "object" }
1442 }),
1443 );
1444
1445 let signature = tool.compact_contract().render_signature();
1446
1447 assert!(
1448 signature.contains("module?: str | list[str] | null"),
1449 "{signature}"
1450 );
1451 assert!(!signature.contains("module?: any"), "{signature}");
1452 }
1453
1454 #[test]
1455 fn static_output_contract_keeps_existing_compact_docs_and_serde_shape() {
1456 let tool = ToolDefinition::raw_with_id(
1457 "tool:read_text",
1458 "read_text",
1459 "Read text",
1460 ToolDefinition::default_input_schema(),
1461 serde_json::json!({ "type": "string" }),
1462 );
1463 let explicit_static = tool
1464 .clone()
1465 .with_output_contract(ToolOutputContract::Static);
1466
1467 assert_eq!(
1468 ToolDefinition::format_tool_docs(std::slice::from_ref(&tool)),
1469 ToolDefinition::format_tool_docs(&[explicit_static])
1470 );
1471 assert_eq!(tool.compact_contract().returns, "str");
1472
1473 let serialized = serde_json::to_value(&tool).expect("serialize");
1474 assert!(serialized.get("output_contract").is_none());
1475 let deserialized: ToolDefinition = serde_json::from_value(serialized).expect("deserialize");
1476 assert!(deserialized.contract.output_contract.is_static());
1477 }
1478
1479 #[test]
1480 fn dynamic_output_contract_renders_schema_from_input_without_return_fields() {
1481 let tool = ToolDefinition::raw_with_id(
1482 "tool:spawn_agent",
1483 "spawn_agent",
1484 "Run a subagent",
1485 serde_json::json!({
1486 "type": "object",
1487 "properties": {
1488 "output": { "type": "object", "additionalProperties": true }
1489 }
1490 }),
1491 serde_json::json!({ "type": "object", "additionalProperties": true }),
1492 )
1493 .with_agent_surface(ToolAgentSurface::new(["agents"], "spawn"))
1494 .with_output_from_input_schema("output", None);
1495
1496 let contract = tool.compact_contract();
1497 assert_eq!(
1498 contract.signature,
1499 "await agents.spawn<T = any>({ output?: TypeSpec<T> })?"
1500 );
1501 assert_eq!(contract.returns, "T");
1502 assert!(contract.return_fields.is_empty());
1503 assert_eq!(contract.render_returns(), "");
1504 assert_eq!(
1505 ToolDefinition::format_tool_docs(&[tool]),
1506 "### await agents.spawn<T = any>({ output?: TypeSpec<T> })? -> T\nRun a subagent\nParameters:\n- `output?: TypeSpec<T>`"
1507 );
1508 }
1509
1510 #[test]
1511 fn dynamic_output_contract_renders_default_schema() {
1512 let tool = ToolDefinition::raw_with_id(
1513 "tool:llm_query",
1514 "llm_query",
1515 "Run a lightweight LLM query",
1516 serde_json::json!({
1517 "type": "object",
1518 "properties": {
1519 "task": { "type": "string" },
1520 "output": { "type": "object", "additionalProperties": true }
1521 },
1522 "required": ["task"]
1523 }),
1524 serde_json::json!({ "type": "object", "additionalProperties": true }),
1525 )
1526 .with_agent_surface(ToolAgentSurface::new(["llm"], "query"))
1527 .with_output_from_input_schema("output", Some(serde_json::json!({ "type": "string" })));
1528
1529 let contract = tool.compact_contract();
1530 assert_eq!(
1531 contract.signature,
1532 "await llm.query<T = str>({ task: str, output?: TypeSpec<T> })?"
1533 );
1534 assert_eq!(contract.returns, "T");
1535 assert!(contract.return_fields.is_empty());
1536 assert_eq!(contract.render_returns(), "");
1537 }
1538
1539 #[test]
1540 fn json_schema_loaded_contract_matches_hardcoded_renderer() {
1541 let tool: ToolDefinition = serde_json::from_value(serde_json::json!({
1542 "id": "tool:mcp__appworld__spotify_search_songs",
1543 "name": "mcp__appworld__spotify_search_songs",
1544 "description": "[MCP appworld] Search for songs with a query.",
1545 "examples": ["search songs by genre"],
1546 "input_schema": {
1547 "type": "object",
1548 "properties": {
1549 "access_token": {
1550 "type": "string",
1551 "description": "Access token obtained from spotify app login."
1552 },
1553 "genre": {
1554 "type": ["string", "null"],
1555 "description": "Only include songs from this genre.",
1556 "default": null
1557 },
1558 "page_limit": {
1559 "type": "integer",
1560 "description": "Maximum number of songs to return.",
1561 "minimum": 1,
1562 "maximum": 20,
1563 "default": 5
1564 },
1565 "sort_by": {
1566 "type": ["string", "null"],
1567 "description": "Field to sort by. Prefix with '-' for descending order.",
1568 "default": null
1569 }
1570 },
1571 "required": ["access_token"],
1572 "additionalProperties": false
1573 },
1574 "output_schema": {
1575 "anyOf": [
1576 {
1577 "type": "object",
1578 "properties": {
1579 "response": {
1580 "type": "array",
1581 "description": "Matched songs.",
1582 "items": {
1583 "type": "object",
1584 "properties": {
1585 "album_id": {
1586 "type": ["integer", "null"],
1587 "description": "Album identifier when the song belongs to an album."
1588 },
1589 "album_title": { "type": ["string", "null"] },
1590 "artists": {
1591 "type": "array",
1592 "items": {
1593 "type": "object",
1594 "properties": {
1595 "id": { "type": "integer" },
1596 "name": { "type": "string" }
1597 },
1598 "required": ["id", "name"]
1599 }
1600 },
1601 "duration": { "type": "integer" },
1602 "genre": { "type": "string" },
1603 "like_count": { "type": "integer" },
1604 "play_count": {
1605 "type": "integer",
1606 "description": "Number of times the song was played.",
1607 "minimum": 0
1608 },
1609 "rating": { "type": "number" },
1610 "release_date": {
1611 "type": "string",
1612 "description": "Song release date in YYYY-MM-DD format."
1613 },
1614 "song_id": {
1615 "type": "integer",
1616 "description": "Stable song identifier."
1617 },
1618 "title": {
1619 "type": "string",
1620 "description": "Song title."
1621 }
1622 },
1623 "required": [
1624 "album_id",
1625 "album_title",
1626 "artists",
1627 "duration",
1628 "genre",
1629 "like_count",
1630 "play_count",
1631 "rating",
1632 "release_date",
1633 "song_id",
1634 "title"
1635 ]
1636 }
1637 }
1638 },
1639 "required": ["response"]
1640 },
1641 {
1642 "type": "object",
1643 "properties": {
1644 "response": {
1645 "type": "object",
1646 "properties": {
1647 "message": {
1648 "type": "string",
1649 "description": "Failure or status message."
1650 }
1651 },
1652 "required": ["message"]
1653 }
1654 },
1655 "required": ["response"]
1656 }
1657 ]
1658 }
1659 }))
1660 .unwrap();
1661
1662 let contract = tool.compact_contract();
1663 assert_eq!(
1664 serde_json::to_value(&contract).unwrap(),
1665 serde_json::json!({
1666 "name": "tools.mcp__appworld__spotify_search_songs",
1667 "signature": "await tools.mcp__appworld__spotify_search_songs({ access_token: str, genre?: str | null = null, page_limit?: int >= 1 <= 20 = 5, sort_by?: str | null = null })?",
1668 "returns": "record{response: list[record{album_id: int | null, album_title: str | null, artists: list[record{id: int, name: str}], duration: int, genre: str, like_count: int, play_count: int, rating: float, release_date: str, song_id: int, title: str}]} | record{response: record{message: str}}",
1669 "parameters": [
1670 {
1671 "name": "access_token",
1672 "type": "str",
1673 "required": true,
1674 "description": "Access token obtained from spotify app login.",
1675 "signature": "access_token: str"
1676 },
1677 {
1678 "name": "genre",
1679 "type": "str | null",
1680 "required": false,
1681 "nullable": true,
1682 "description": "Only include songs from this genre.",
1683 "default": null,
1684 "signature": "genre?: str | null = null"
1685 },
1686 {
1687 "name": "page_limit",
1688 "type": "int",
1689 "required": false,
1690 "description": "Maximum number of songs to return.",
1691 "default": 5,
1692 "minimum": 1,
1693 "maximum": 20,
1694 "signature": "page_limit?: int >= 1 <= 20 = 5"
1695 },
1696 {
1697 "name": "sort_by",
1698 "type": "str | null",
1699 "required": false,
1700 "nullable": true,
1701 "description": "Field to sort by. Prefix with '-' for descending order.",
1702 "default": null,
1703 "signature": "sort_by?: str | null = null"
1704 }
1705 ],
1706 "return_fields": [
1707 {
1708 "path": "response",
1709 "type": "list[record]",
1710 "required": true,
1711 "description": "Matched songs.",
1712 "items": "record",
1713 "signature": "response: list[record]"
1714 },
1715 {
1716 "path": "response[].album_id",
1717 "type": "int | null",
1718 "required": true,
1719 "nullable": true,
1720 "description": "Album identifier when the song belongs to an album.",
1721 "signature": "response[].album_id: int | null"
1722 },
1723 {
1724 "path": "response[].album_title",
1725 "type": "str | null",
1726 "required": true,
1727 "nullable": true,
1728 "signature": "response[].album_title: str | null"
1729 },
1730 {
1731 "path": "response[].artists[].id",
1732 "type": "int",
1733 "required": true,
1734 "signature": "response[].artists[].id: int"
1735 },
1736 {
1737 "path": "response[].artists[].name",
1738 "type": "str",
1739 "required": true,
1740 "signature": "response[].artists[].name: str"
1741 },
1742 {
1743 "path": "response[].duration",
1744 "type": "int",
1745 "required": true,
1746 "signature": "response[].duration: int"
1747 },
1748 {
1749 "path": "response[].genre",
1750 "type": "str",
1751 "required": true,
1752 "signature": "response[].genre: str"
1753 },
1754 {
1755 "path": "response[].like_count",
1756 "type": "int",
1757 "required": true,
1758 "signature": "response[].like_count: int"
1759 },
1760 {
1761 "path": "response[].play_count",
1762 "type": "int",
1763 "required": true,
1764 "description": "Number of times the song was played.",
1765 "minimum": 0,
1766 "signature": "response[].play_count: int >= 0"
1767 },
1768 {
1769 "path": "response[].rating",
1770 "type": "float",
1771 "required": true,
1772 "signature": "response[].rating: float"
1773 },
1774 {
1775 "path": "response[].release_date",
1776 "type": "str",
1777 "required": true,
1778 "description": "Song release date in YYYY-MM-DD format.",
1779 "signature": "response[].release_date: str"
1780 },
1781 {
1782 "path": "response[].song_id",
1783 "type": "int",
1784 "required": true,
1785 "description": "Stable song identifier.",
1786 "signature": "response[].song_id: int"
1787 },
1788 {
1789 "path": "response[].title",
1790 "type": "str",
1791 "required": true,
1792 "description": "Song title.",
1793 "signature": "response[].title: str"
1794 },
1795 {
1796 "path": "response.message",
1797 "type": "str",
1798 "required": true,
1799 "description": "Failure or status message.",
1800 "signature": "response.message: str"
1801 }
1802 ],
1803 "description": "[MCP appworld] Search for songs with a query.",
1804 "examples": ["search songs by genre"]
1805 })
1806 );
1807
1808 assert_eq!(
1809 contract.render_markdown(),
1810 "### await tools.mcp__appworld__spotify_search_songs({ access_token: str, genre?: str | null = null, page_limit?: int >= 1 <= 20 = 5, sort_by?: str | null = null })? -> record{response: list[record{album_id: int | null, album_title: str | null, artists: list[record{id: int, name: str}], duration: int, genre: str, like_count: int, play_count: int, rating: float, release_date: str, song_id: int, title: str}]} | record{response: record{message: str}}\n[MCP appworld] Search for songs with a query.\nParameters:\n- `access_token: str` — Access token obtained from spotify app login.\n- `genre?: str | null = null` — Only include songs from this genre.\n- `page_limit?: int >= 1 <= 20 = 5` — Maximum number of songs to return.\n- `sort_by?: str | null = null` — Field to sort by. Prefix with '-' for descending order.\nReturn fields:\n- `response: list[record]` — Matched songs.\n- `response[].album_id: int | null` — Album identifier when the song belongs to an album.\n- `response[].album_title: str | null`\n- `response[].artists[].id: int`\n- `response[].artists[].name: str`\n- `response[].duration: int`\n- `response[].genre: str`\n- `response[].like_count: int`\n- `response[].play_count: int >= 0` — Number of times the song was played.\n- `response[].rating: float`\n- `response[].release_date: str` — Song release date in YYYY-MM-DD format.\n- `response[].song_id: int` — Stable song identifier.\n- `response[].title: str` — Song title.\n- `response.message: str` — Failure or status message.\nExamples: search songs by genre"
1811 );
1812 assert_eq!(
1813 contract.render_signature(),
1814 "await tools.mcp__appworld__spotify_search_songs({ access_token: str, genre?: str | null = null, page_limit?: int >= 1 <= 20 = 5, sort_by?: str | null = null })? -> record{response: list[record{album_id: int | null, album_title: str | null, artists: list[record{id: int, name: str}], duration: int, genre: str, like_count: int, play_count: int, rating: float, release_date: str, song_id: int, title: str}]} | record{response: record{message: str}}\nParameters:\n- `access_token: str` — Access token obtained from spotify app login.\n- `genre?: str | null = null` — Only include songs from this genre.\n- `page_limit?: int >= 1 <= 20 = 5` — Maximum number of songs to return.\n- `sort_by?: str | null = null` — Field to sort by. Prefix with '-' for descending order.\nReturn fields:\n- `response: list[record]` — Matched songs.\n- `response[].album_id: int | null` — Album identifier when the song belongs to an album.\n- `response[].album_title: str | null`\n- `response[].artists[].id: int`\n- `response[].artists[].name: str`\n- `response[].duration: int`\n- `response[].genre: str`\n- `response[].like_count: int`\n- `response[].play_count: int >= 0` — Number of times the song was played.\n- `response[].rating: float`\n- `response[].release_date: str` — Song release date in YYYY-MM-DD format.\n- `response[].song_id: int` — Stable song identifier.\n- `response[].title: str` — Song title.\n- `response.message: str` — Failure or status message."
1815 );
1816 assert_eq!(
1817 contract.render_returns(),
1818 "Return fields:\n- `response: list[record]` — Matched songs.\n- `response[].album_id: int | null` — Album identifier when the song belongs to an album.\n- `response[].album_title: str | null`\n- `response[].artists[].id: int`\n- `response[].artists[].name: str`\n- `response[].duration: int`\n- `response[].genre: str`\n- `response[].like_count: int`\n- `response[].play_count: int >= 0` — Number of times the song was played.\n- `response[].rating: float`\n- `response[].release_date: str` — Song release date in YYYY-MM-DD format.\n- `response[].song_id: int` — Stable song identifier.\n- `response[].title: str` — Song title.\n- `response.message: str` — Failure or status message."
1819 );
1820 }
1821
1822 #[test]
1823 fn json_schema_loaded_contract_merges_nullable_anyof_return_fields() {
1824 let tool: ToolDefinition = serde_json::from_value(serde_json::json!({
1825 "id": "tool:mcp__appworld__spotify_show_album_library",
1826 "name": "mcp__appworld__spotify_show_album_library",
1827 "description": "[MCP appworld] Search or show a list of albums in your album library.",
1828 "examples": ["show album library"],
1829 "input_schema": {
1830 "type": "object",
1831 "properties": {
1832 "access_token": {
1833 "type": "string",
1834 "description": "Access token obtained from spotify app login."
1835 },
1836 "page_index": {
1837 "type": "integer",
1838 "description": "The index of the page to return.",
1839 "minimum": 0,
1840 "default": 0
1841 },
1842 "page_limit": {
1843 "type": "integer",
1844 "description": "The maximum number of results to return per page.",
1845 "minimum": 1,
1846 "maximum": 20,
1847 "default": 5
1848 }
1849 },
1850 "required": ["access_token"]
1851 },
1852 "output_schema": {
1853 "type": "object",
1854 "properties": {
1855 "response": {
1856 "anyOf": [
1857 {
1858 "type": "array",
1859 "description": "Albums in the user's library.",
1860 "items": {
1861 "type": "object",
1862 "properties": {
1863 "added_at": {
1864 "description": "When the album was added to the library.",
1865 "anyOf": [
1866 { "type": "string" },
1867 { "type": "null" }
1868 ]
1869 },
1870 "album_id": { "type": "integer" },
1871 "genre": {
1872 "type": "string",
1873 "description": "Album genre.",
1874 "minLength": 1
1875 },
1876 "song_ids": {
1877 "type": "array",
1878 "items": { "type": "integer" }
1879 },
1880 "title": {
1881 "type": "string",
1882 "minLength": 1
1883 }
1884 },
1885 "required": ["added_at", "album_id", "genre", "song_ids", "title"]
1886 }
1887 },
1888 {
1889 "type": "object",
1890 "properties": {
1891 "message": {
1892 "type": "string",
1893 "description": "Failure or status message."
1894 }
1895 },
1896 "required": ["message"]
1897 }
1898 ]
1899 }
1900 },
1901 "required": ["response"]
1902 }
1903 }))
1904 .unwrap();
1905
1906 let contract = tool.compact_contract();
1907 assert_eq!(
1908 serde_json::to_value(&contract).unwrap(),
1909 serde_json::json!({
1910 "name": "tools.mcp__appworld__spotify_show_album_library",
1911 "signature": "await tools.mcp__appworld__spotify_show_album_library({ access_token: str, page_index?: int >= 0 = 0, page_limit?: int >= 1 <= 20 = 5 })?",
1912 "returns": "record{response: list[record{added_at: null | str, album_id: int, genre: str, song_ids: list[int], title: str}] | record{message: str}}",
1913 "parameters": [
1914 {
1915 "name": "access_token",
1916 "type": "str",
1917 "required": true,
1918 "description": "Access token obtained from spotify app login.",
1919 "signature": "access_token: str"
1920 },
1921 {
1922 "name": "page_index",
1923 "type": "int",
1924 "required": false,
1925 "description": "The index of the page to return.",
1926 "default": 0,
1927 "minimum": 0,
1928 "signature": "page_index?: int >= 0 = 0"
1929 },
1930 {
1931 "name": "page_limit",
1932 "type": "int",
1933 "required": false,
1934 "description": "The maximum number of results to return per page.",
1935 "default": 5,
1936 "minimum": 1,
1937 "maximum": 20,
1938 "signature": "page_limit?: int >= 1 <= 20 = 5"
1939 }
1940 ],
1941 "return_fields": [
1942 {
1943 "path": "response",
1944 "type": "list[record]",
1945 "required": true,
1946 "description": "Albums in the user's library.",
1947 "items": "record",
1948 "signature": "response: list[record]"
1949 },
1950 {
1951 "path": "response[].added_at",
1952 "type": "str | null",
1953 "required": true,
1954 "nullable": true,
1955 "description": "When the album was added to the library.",
1956 "signature": "response[].added_at: str | null"
1957 },
1958 {
1959 "path": "response[].album_id",
1960 "type": "int",
1961 "required": true,
1962 "signature": "response[].album_id: int"
1963 },
1964 {
1965 "path": "response[].genre",
1966 "type": "str",
1967 "required": true,
1968 "description": "Album genre.",
1969 "min_length": 1,
1970 "signature": "response[].genre: str min_len 1"
1971 },
1972 {
1973 "path": "response[].song_ids[]",
1974 "type": "int",
1975 "required": true,
1976 "signature": "response[].song_ids[]: int"
1977 },
1978 {
1979 "path": "response[].title",
1980 "type": "str",
1981 "required": true,
1982 "min_length": 1,
1983 "signature": "response[].title: str min_len 1"
1984 },
1985 {
1986 "path": "response.message",
1987 "type": "str",
1988 "required": true,
1989 "description": "Failure or status message.",
1990 "signature": "response.message: str"
1991 }
1992 ],
1993 "description": "[MCP appworld] Search or show a list of albums in your album library.",
1994 "examples": ["show album library"]
1995 })
1996 );
1997 assert_eq!(
1998 contract.render_markdown(),
1999 "### await tools.mcp__appworld__spotify_show_album_library({ access_token: str, page_index?: int >= 0 = 0, page_limit?: int >= 1 <= 20 = 5 })? -> record{response: list[record{added_at: null | str, album_id: int, genre: str, song_ids: list[int], title: str}] | record{message: str}}\n[MCP appworld] Search or show a list of albums in your album library.\nParameters:\n- `access_token: str` — Access token obtained from spotify app login.\n- `page_index?: int >= 0 = 0` — The index of the page to return.\n- `page_limit?: int >= 1 <= 20 = 5` — The maximum number of results to return per page.\nReturn fields:\n- `response: list[record]` — Albums in the user's library.\n- `response[].added_at: str | null` — When the album was added to the library.\n- `response[].album_id: int`\n- `response[].genre: str min_len 1` — Album genre.\n- `response[].song_ids[]: int`\n- `response[].title: str min_len 1`\n- `response.message: str` — Failure or status message.\nExamples: show album library"
2000 );
2001 }
2002
2003 #[test]
2004 fn tool_agent_surface_serde_defaults_are_empty() {
2005 let tool: ToolDefinition = serde_json::from_value(serde_json::json!({
2006 "id": "tool:read_file",
2007 "name": "read_file",
2008 "description": "Read a file"
2009 }))
2010 .unwrap();
2011 assert!(tool.manifest.agent_surface.is_empty());
2012 }
2013
2014 #[test]
2015 fn tool_agent_surface_controls_prompt_call_form() {
2016 let mut with_metadata = ToolDefinition::raw_with_id(
2017 "tool:read_file",
2018 "read_file",
2019 "Read a file",
2020 ToolDefinition::default_input_schema(),
2021 serde_json::json!({"type": "string"}),
2022 );
2023 with_metadata.manifest.agent_surface =
2024 ToolAgentSurface::new(["fs"], "read").with_aliases(["cat"]);
2025
2026 assert!(
2027 ToolDefinition::format_tool_docs(&[with_metadata])
2028 .contains("### await fs.read({})? -> str")
2029 );
2030 }
2031}