use crate::builtins::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;
use async_trait::async_trait;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
#[derive(Clone)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
pub tags: Vec<String>,
pub category: Option<String>,
}
impl ToolDef {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
input_schema: serde_json::Value::Object(Default::default()),
tags: Vec::new(),
category: None,
}
}
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.input_schema = schema;
self
}
pub fn with_tags(mut self, tags: &[&str]) -> Self {
self.tags = tags.iter().map(|s| s.to_string()).collect();
self
}
pub fn with_category(mut self, category: &str) -> Self {
self.category = Some(category.to_string());
self
}
}
pub struct ToolArgs {
pub params: serde_json::Value,
pub stdin: Option<String>,
}
impl ToolArgs {
pub fn param_str(&self, key: &str) -> Option<&str> {
self.params.get(key).and_then(|v| v.as_str())
}
pub fn param_i64(&self, key: &str) -> Option<i64> {
self.params.get(key).and_then(|v| v.as_i64())
}
pub fn param_f64(&self, key: &str) -> Option<f64> {
self.params.get(key).and_then(|v| v.as_f64())
}
pub fn param_bool(&self, key: &str) -> Option<bool> {
self.params.get(key).and_then(|v| v.as_bool())
}
}
pub type SyncToolExec = Arc<dyn Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync>;
pub type AsyncToolExec = Arc<
dyn Fn(ToolArgs) -> Pin<Box<dyn Future<Output = std::result::Result<String, String>> + Send>>
+ Send
+ Sync,
>;
pub type ToolCallback = SyncToolExec;
pub type AsyncToolCallback = AsyncToolExec;
#[derive(Clone)]
pub struct ToolImpl {
pub def: ToolDef,
pub exec: Option<AsyncToolExec>,
pub exec_sync: Option<SyncToolExec>,
}
impl ToolImpl {
pub fn new(def: ToolDef) -> Self {
Self {
def,
exec: None,
exec_sync: None,
}
}
pub fn with_exec<F, Fut>(mut self, f: F) -> Self
where
F: Fn(ToolArgs) -> Fut + Send + Sync + 'static,
Fut: Future<Output = std::result::Result<String, String>> + Send + 'static,
{
self.exec = Some(Arc::new(move |args| Box::pin(f(args))));
self
}
pub fn with_exec_sync(
mut self,
f: impl Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync + 'static,
) -> Self {
self.exec_sync = Some(Arc::new(f));
self
}
}
#[async_trait]
impl Builtin for ToolImpl {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let params = parse_flags(ctx.args, &self.def.input_schema)
.map_err(|e| crate::error::Error::Execution(format!("{}: {e}", self.def.name)))?;
let tool_args = ToolArgs {
params,
stdin: ctx.stdin.map(String::from),
};
let result = if let Some(cb) = &self.exec {
(cb)(tool_args).await
} else if let Some(cb) = &self.exec_sync {
(cb)(&tool_args)
} else {
return Err(crate::error::Error::Execution(format!(
"{}: no exec defined",
self.def.name
)));
};
match result {
Ok(stdout) => Ok(ExecResult::ok(stdout)),
Err(msg) => Ok(ExecResult::err(msg, 1)),
}
}
}
pub(crate) fn parse_flags(
raw_args: &[String],
schema: &serde_json::Value,
) -> std::result::Result<serde_json::Value, String> {
let properties = schema
.get("properties")
.and_then(|p| p.as_object())
.cloned()
.unwrap_or_default();
let mut result = serde_json::Map::new();
let mut i = 0;
while i < raw_args.len() {
let arg = &raw_args[i];
let Some(flag) = arg.strip_prefix("--") else {
return Err(format!("expected --flag, got: {arg}"));
};
if let Some((key, raw_value)) = flag.split_once('=') {
let value = coerce_value(raw_value, properties.get(key), schema);
result.insert(key.to_string(), value);
i += 1;
continue;
}
let key = flag.to_string();
let prop_schema = properties.get(&key).cloned();
let effective = prop_schema
.as_ref()
.map(|s| resolve_effective_type(s, schema, 0))
.unwrap_or(EffectiveType::Unknown);
i += 1;
match effective {
EffectiveType::Boolean => {
result.insert(key, serde_json::Value::Bool(true));
}
EffectiveType::Array => {
let items_schema = prop_schema.as_ref().and_then(|s| s.get("items")).cloned();
let items_effective = items_schema
.as_ref()
.map(|s| resolve_effective_type(s, schema, 0))
.unwrap_or(EffectiveType::Unknown);
match consume_array_value(
raw_args,
&mut i,
items_schema.as_ref(),
items_effective,
schema,
&key,
)? {
ArrayInput::Items(items) => {
let entry = result
.entry(key)
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let serde_json::Value::Array(arr) = entry {
arr.extend(items);
} else {
*entry = serde_json::Value::Array(items);
}
}
ArrayInput::Raw(value) => {
result.insert(key, value);
}
}
}
EffectiveType::Object => {
let value =
consume_object_value(raw_args, &mut i, prop_schema.as_ref(), schema, &key)?;
result.insert(key, value);
}
_ => {
if i < raw_args.len() && !raw_args[i].starts_with("--") {
let raw_value = &raw_args[i];
let value = coerce_value(raw_value, prop_schema.as_ref(), schema);
result.insert(key, value);
i += 1;
} else {
result.insert(key, serde_json::Value::Bool(true));
}
}
}
}
Ok(serde_json::Value::Object(result))
}
fn resolve_object_properties(
schema: &serde_json::Value,
root_schema: &serde_json::Value,
depth: usize,
) -> serde_json::Map<String, serde_json::Value> {
if depth > MAX_REF_DEPTH {
return Default::default();
}
if let Some(ref_str) = schema.get("$ref").and_then(|r| r.as_str()) {
if let Some(target) = resolve_ref(ref_str, root_schema) {
return resolve_object_properties(target, root_schema, depth + 1);
}
return Default::default();
}
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
return props.clone();
}
let mut merged = serde_json::Map::new();
for key in ["oneOf", "anyOf", "allOf"] {
if let Some(branches) = schema.get(key).and_then(|v| v.as_array()) {
for branch in branches {
let props = resolve_object_properties(branch, root_schema, depth + 1);
for (k, v) in props {
merged.entry(k).or_insert(v);
}
}
}
}
merged
}
fn collect_object_from_pairs(
args: &[String],
i: &mut usize,
object_schema: Option<&serde_json::Value>,
root_schema: &serde_json::Value,
flag_name: &str,
) -> std::result::Result<serde_json::Map<String, serde_json::Value>, String> {
let mut obj = serde_json::Map::new();
let inner_props = object_schema
.map(|s| resolve_object_properties(s, root_schema, 0))
.unwrap_or_default();
while *i < args.len() {
let arg = &args[*i];
if arg.starts_with("--") {
break;
}
let Some((k, v)) = arg.split_once('=') else {
return Err(format!(
"--{flag_name}: expected --flag or key=value, got '{arg}'"
));
};
if !inner_props.is_empty() && !inner_props.contains_key(k) {
let mut valid: Vec<&str> = inner_props.keys().map(|s| s.as_str()).collect();
valid.sort();
return Err(format!(
"--{flag_name}: unknown key '{k}'; valid keys: {}",
valid.join(", ")
));
}
let nested_schema = inner_props.get(k);
let value = coerce_value(v, nested_schema, root_schema);
obj.insert(k.to_string(), value);
*i += 1;
}
Ok(obj)
}
enum ArrayInput {
Items(Vec<serde_json::Value>),
Raw(serde_json::Value),
}
fn consume_array_value(
args: &[String],
i: &mut usize,
items_schema: Option<&serde_json::Value>,
items_effective: EffectiveType,
root_schema: &serde_json::Value,
flag_name: &str,
) -> std::result::Result<ArrayInput, String> {
if *i >= args.len() || args[*i].starts_with("--") {
return Ok(ArrayInput::Items(Vec::new()));
}
let next = &args[*i];
let trimmed = next.trim_start();
if trimmed.starts_with('[') {
if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::<serde_json::Value>(next) {
*i += 1;
return Ok(ArrayInput::Items(arr));
}
*i += 1;
return Ok(ArrayInput::Raw(serde_json::Value::String(next.clone())));
}
if items_effective == EffectiveType::Object {
if trimmed.starts_with('{') {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(next) {
*i += 1;
return Ok(ArrayInput::Items(vec![parsed]));
}
*i += 1;
return Ok(ArrayInput::Raw(serde_json::Value::String(next.clone())));
}
if next.contains('=') {
let obj = collect_object_from_pairs(args, i, items_schema, root_schema, flag_name)?;
return Ok(ArrayInput::Items(vec![serde_json::Value::Object(obj)]));
}
return Err(format!(
"--{flag_name}: expected JSON or key=value pairs, got '{next}'"
));
}
let mut out = Vec::new();
for part in next.split(',') {
out.push(coerce_value(part, items_schema, root_schema));
}
*i += 1;
Ok(ArrayInput::Items(out))
}
fn consume_object_value(
args: &[String],
i: &mut usize,
prop_schema: Option<&serde_json::Value>,
root_schema: &serde_json::Value,
flag_name: &str,
) -> std::result::Result<serde_json::Value, String> {
if *i >= args.len() || args[*i].starts_with("--") {
return Ok(serde_json::Value::Bool(true));
}
let next = &args[*i];
let trimmed = next.trim_start();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
let value = coerce_value(next, prop_schema, root_schema);
*i += 1;
return Ok(value);
}
if next.contains('=') {
let obj = collect_object_from_pairs(args, i, prop_schema, root_schema, flag_name)?;
return Ok(serde_json::Value::Object(obj));
}
let value = coerce_value(next, prop_schema, root_schema);
*i += 1;
Ok(value)
}
#[derive(PartialEq, Clone, Copy, Debug)]
enum EffectiveType {
String,
Integer,
Number,
Boolean,
Array,
Object,
Unknown,
}
const MAX_REF_DEPTH: usize = 16;
fn type_str_to_effective(s: &str) -> EffectiveType {
match s {
"string" => EffectiveType::String,
"integer" => EffectiveType::Integer,
"number" => EffectiveType::Number,
"boolean" => EffectiveType::Boolean,
"array" => EffectiveType::Array,
"object" => EffectiveType::Object,
_ => EffectiveType::Unknown,
}
}
fn resolve_ref<'a>(
ref_str: &str,
root_schema: &'a serde_json::Value,
) -> Option<&'a serde_json::Value> {
let suffix = ref_str.strip_prefix("#/")?;
let mut current = root_schema;
for segment in suffix.split('/') {
let decoded = segment.replace("~1", "/").replace("~0", "~");
current = current.get(&decoded)?;
}
Some(current)
}
fn resolve_effective_type(
schema: &serde_json::Value,
root_schema: &serde_json::Value,
depth: usize,
) -> EffectiveType {
if depth > MAX_REF_DEPTH {
return EffectiveType::Unknown;
}
if let Some(ref_str) = schema.get("$ref").and_then(|r| r.as_str()) {
if let Some(target) = resolve_ref(ref_str, root_schema) {
return resolve_effective_type(target, root_schema, depth + 1);
}
return EffectiveType::Unknown;
}
match schema.get("type") {
Some(serde_json::Value::String(s)) => return type_str_to_effective(s),
Some(serde_json::Value::Array(arr)) => {
for t in arr {
if let Some(s) = t.as_str()
&& (s == "array" || s == "object")
{
return type_str_to_effective(s);
}
}
for t in arr {
if let Some(s) = t.as_str()
&& s != "null"
{
return type_str_to_effective(s);
}
}
}
_ => {}
}
for key in ["oneOf", "anyOf", "allOf"] {
if let Some(branches) = schema.get(key).and_then(|v| v.as_array()) {
for branch in branches {
let et = resolve_effective_type(branch, root_schema, depth + 1);
if matches!(et, EffectiveType::Array | EffectiveType::Object) {
return et;
}
}
for branch in branches {
let et = resolve_effective_type(branch, root_schema, depth + 1);
if !matches!(et, EffectiveType::Unknown) {
return et;
}
}
}
}
if schema.get("items").is_some() {
return EffectiveType::Array;
}
if schema.get("properties").is_some() {
return EffectiveType::Object;
}
EffectiveType::Unknown
}
fn coerce_value(
raw: &str,
prop_schema: Option<&serde_json::Value>,
root_schema: &serde_json::Value,
) -> serde_json::Value {
let effective = prop_schema
.map(|s| resolve_effective_type(s, root_schema, 0))
.unwrap_or(EffectiveType::Unknown);
match effective {
EffectiveType::Integer => raw
.parse::<i64>()
.map(serde_json::Value::from)
.unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
EffectiveType::Number => raw
.parse::<f64>()
.map(|n| serde_json::json!(n))
.unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
EffectiveType::Boolean => match raw {
"true" | "1" | "yes" => serde_json::Value::Bool(true),
"false" | "0" | "no" => serde_json::Value::Bool(false),
_ => serde_json::Value::String(raw.to_string()),
},
EffectiveType::Array | EffectiveType::Object => {
let trimmed = raw.trim_start();
if (trimmed.starts_with('[') || trimmed.starts_with('{'))
&& let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw)
{
return parsed;
}
serde_json::Value::String(raw.to_string())
}
EffectiveType::String | EffectiveType::Unknown => {
serde_json::Value::String(raw.to_string())
}
}
}
pub(crate) fn usage_from_schema(schema: &serde_json::Value) -> Option<String> {
let props = schema.get("properties")?.as_object()?;
if props.is_empty() {
return None;
}
let flags: Vec<String> = props
.iter()
.map(|(key, prop)| {
let hint = match resolve_effective_type(prop, schema, 0) {
EffectiveType::Object => "<json|key=value...>".to_string(),
EffectiveType::Array => {
let items = prop.get("items");
let items_eff = items
.map(|s| resolve_effective_type(s, schema, 0))
.unwrap_or(EffectiveType::Unknown);
if items_eff == EffectiveType::Object {
"<json|key=value...>".to_string()
} else {
"<json|a,b,c>".to_string()
}
}
EffectiveType::Integer => "<integer>".to_string(),
EffectiveType::Number => "<number>".to_string(),
EffectiveType::Boolean => "<boolean>".to_string(),
EffectiveType::String => "<string>".to_string(),
EffectiveType::Unknown => {
let ty = prop.get("type").and_then(|t| t.as_str()).unwrap_or("value");
format!("<{ty}>")
}
};
format!("--{key} {hint}")
})
.collect();
Some(flags.join(" "))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_flags_basic() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"verbose": {"type": "boolean"}
}
});
let args = vec![
"--id".to_string(),
"42".to_string(),
"--name".to_string(),
"Alice".to_string(),
"--verbose".to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["id"], 42);
assert_eq!(result["name"], "Alice");
assert_eq!(result["verbose"], true);
}
#[test]
fn test_parse_flags_equals_syntax() {
let schema = serde_json::json!({
"type": "object",
"properties": {"id": {"type": "integer"}}
});
let args = vec!["--id=42".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["id"], 42);
}
#[test]
fn test_parse_flags_json_array_string() {
let schema = serde_json::json!({
"type": "object",
"properties": {"tags": {"type": "array", "items": {"type": "string"}}}
});
let args = vec!["--tags".to_string(), r#"["a","b","c"]"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["tags"], serde_json::json!(["a", "b", "c"]));
}
#[test]
fn test_parse_flags_json_object_string() {
let schema = serde_json::json!({
"type": "object",
"properties": {"server": {"type": "object"}}
});
let args = vec![
"--server".to_string(),
r#"{"name":"foo","port":8080}"#.to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(
result["server"],
serde_json::json!({"name": "foo", "port": 8080})
);
}
#[test]
fn test_parse_flags_nullable_array() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"tags": {"type": ["array", "null"], "items": {"type": "string"}}
}
});
let args = vec!["--tags".to_string(), r#"["x","y"]"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["tags"], serde_json::json!(["x", "y"]));
}
#[test]
fn test_parse_flags_oneof_null_and_ref() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"config": {
"oneOf": [
{"type": "null"},
{"$ref": "#/$defs/Config"}
]
}
},
"$defs": {
"Config": {"type": "object", "properties": {"k": {"type": "string"}}}
}
});
let args = vec!["--config".to_string(), r#"{"k":"v"}"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["config"], serde_json::json!({"k": "v"}));
}
#[test]
fn test_parse_flags_allof_composition() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"data": {
"allOf": [
{"type": "object"},
{"properties": {"x": {"type": "integer"}}}
]
}
}
});
let args = vec!["--data".to_string(), r#"{"x":1}"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["data"], serde_json::json!({"x": 1}));
}
#[test]
fn test_parse_flags_invalid_json_left_as_string() {
let schema = serde_json::json!({
"type": "object",
"properties": {"tags": {"type": "array"}}
});
let args = vec!["--tags".to_string(), "[1, 2,".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(
result["tags"],
serde_json::Value::String("[1, 2,".to_string())
);
}
#[test]
fn test_parse_flags_scalar_string_unchanged() {
let schema = serde_json::json!({
"type": "object",
"properties": {"name": {"type": "string"}}
});
let args = vec!["--name".to_string(), "Alice".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["name"], "Alice");
}
#[test]
fn test_parse_flags_implicit_array_from_items() {
let schema = serde_json::json!({
"type": "object",
"properties": {"tags": {"items": {"type": "string"}}}
});
let args = vec!["--tags".to_string(), r#"["p","q"]"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["tags"], serde_json::json!(["p", "q"]));
}
#[test]
fn test_parse_flags_implicit_object_from_properties() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {"properties": {"port": {"type": "integer"}}}
}
});
let args = vec!["--server".to_string(), r#"{"port":80}"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["server"], serde_json::json!({"port": 80}));
}
#[test]
fn test_parse_flags_ref_into_defs() {
let schema = serde_json::json!({
"type": "object",
"properties": {"items": {"$ref": "#/$defs/Items"}},
"$defs": {
"Items": {"type": "array", "items": {"type": "integer"}}
}
});
let args = vec!["--items".to_string(), "[1,2,3]".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["items"], serde_json::json!([1, 2, 3]));
}
#[test]
fn test_parse_flags_ref_into_definitions() {
let schema = serde_json::json!({
"type": "object",
"properties": {"items": {"$ref": "#/definitions/Items"}},
"definitions": {
"Items": {"type": "array"}
}
});
let args = vec!["--items".to_string(), "[1,2]".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["items"], serde_json::json!([1, 2]));
}
#[test]
fn test_parse_flags_ref_cycle_bounded() {
let schema = serde_json::json!({
"type": "object",
"properties": {"x": {"$ref": "#/$defs/A"}},
"$defs": {
"A": {"$ref": "#/$defs/B"},
"B": {"$ref": "#/$defs/A"}
}
});
let args = vec!["--x".to_string(), "value".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["x"], "value");
}
#[test]
fn test_parse_flags_array_value_not_starting_with_bracket() {
let schema = serde_json::json!({
"type": "object",
"properties": {"tags": {"type": "array", "items": {"type": "string"}}}
});
let args = vec!["--tags".to_string(), "abc".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["tags"], serde_json::json!(["abc"]));
}
#[test]
fn test_parse_flags_pair_object_single() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"}
}
}
}
});
let args = vec![
"--server".to_string(),
"name=foo".to_string(),
"url=https://example.com".to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(
result["server"],
serde_json::json!({"name": "foo", "url": "https://example.com"})
);
}
#[test]
fn test_parse_flags_pair_array_of_objects_repeated() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"mcp_server": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"}
}
}
}
}
});
let args = vec![
"--mcp_server".to_string(),
"name=a".to_string(),
"url=u1".to_string(),
"--mcp_server".to_string(),
"name=b".to_string(),
"url=u2".to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(
result["mcp_server"],
serde_json::json!([
{"name": "a", "url": "u1"},
{"name": "b", "url": "u2"}
])
);
}
#[test]
fn test_parse_flags_array_string_comma_split() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}}
}
});
let args = vec!["--tags".to_string(), "a,b,c".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["tags"], serde_json::json!(["a", "b", "c"]));
}
#[test]
fn test_parse_flags_array_string_repeated_appends() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}}
}
});
let args = vec![
"--tags".to_string(),
"x".to_string(),
"--tags".to_string(),
"y".to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["tags"], serde_json::json!(["x", "y"]));
}
#[test]
fn test_parse_flags_pair_nested_type_coercion() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"port": {"type": "integer"}
}
}
}
});
let args = vec![
"--server".to_string(),
"enabled=true".to_string(),
"port=8080".to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(
result["server"],
serde_json::json!({"enabled": true, "port": 8080})
);
}
#[test]
fn test_parse_flags_pair_unknown_key_errors() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
}
}
});
let args = vec!["--server".to_string(), "bogus=foo".to_string()];
let err = parse_flags(&args, &schema).unwrap_err();
assert!(err.contains("unknown key"), "got: {err}");
assert!(err.contains("bogus"), "got: {err}");
}
#[test]
fn test_parse_flags_object_json_form_unchanged() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "object",
"properties": {"name": {"type": "string"}}
}
}
});
let args = vec!["--server".to_string(), r#"{"name":"foo"}"#.to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["server"], serde_json::json!({"name": "foo"}));
}
#[test]
fn test_parse_flags_pair_mixed_with_json_rejected() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "object",
"properties": {"name": {"type": "string"}}
}
}
});
let args = vec![
"--server".to_string(),
r#"{"name":"foo"}"#.to_string(),
"name=bar".to_string(),
];
let err = parse_flags(&args, &schema).unwrap_err();
assert!(err.contains("expected --flag"), "got: {err}");
}
#[test]
fn test_parse_flags_array_of_objects_json_then_pair_appends() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"mcp_server": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
}
}
}
});
let args = vec![
"--mcp_server".to_string(),
r#"{"name":"j"}"#.to_string(),
"--mcp_server".to_string(),
"name=p".to_string(),
];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(
result["mcp_server"],
serde_json::json!([{"name": "j"}, {"name": "p"}])
);
}
#[test]
fn test_parse_flags_array_int_comma_split_coerced() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"ids": {"type": "array", "items": {"type": "integer"}}
}
});
let args = vec!["--ids".to_string(), "1,2,3".to_string()];
let result = parse_flags(&args, &schema).unwrap();
assert_eq!(result["ids"], serde_json::json!([1, 2, 3]));
}
#[test]
fn test_usage_from_schema_advertises_both_forms() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"server": {"type": "object"},
"tags": {"type": "array", "items": {"type": "string"}},
"id": {"type": "integer"}
}
});
let usage = usage_from_schema(&schema).expect("usage");
assert!(
usage.contains("--server <json|key=value...>"),
"got: {usage}"
);
assert!(usage.contains("--tags <json|a,b,c>"), "got: {usage}");
assert!(usage.contains("--id <integer>"), "got: {usage}");
}
#[test]
fn test_tool_impl_sync() {
let tool = ToolImpl::new(ToolDef::new("greet", "Greet a user").with_schema(
serde_json::json!({
"type": "object",
"properties": { "name": {"type": "string"} }
}),
))
.with_exec_sync(|args| {
let name = args.param_str("name").unwrap_or("world");
Ok(format!("hello {name}\n"))
});
assert!(tool.exec_sync.is_some());
assert!(tool.exec.is_none());
assert_eq!(tool.def.name, "greet");
}
#[tokio::test]
async fn test_tool_impl_as_builtin() {
let tool = ToolImpl::new(ToolDef::new("greet", "Greet a user").with_schema(
serde_json::json!({
"type": "object",
"properties": { "name": {"type": "string"} }
}),
))
.with_exec_sync(|args| {
let name = args.param_str("name").unwrap_or("world");
Ok(format!("hello {name}\n"))
});
let args = vec!["--name".to_string(), "Alice".to_string()];
let mut vars = std::collections::HashMap::new();
let env = std::collections::HashMap::new();
let mut cwd = std::path::PathBuf::from("/");
let fs = Arc::new(crate::fs::InMemoryFs::new());
let ctx = Context::new_for_test(&args, &env, &mut vars, &mut cwd, fs, None);
let result = tool.execute(ctx).await.unwrap();
assert_eq!(result.stdout, "hello Alice\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_tool_impl_async_exec() {
let tool =
ToolImpl::new(ToolDef::new("echo_async", "Async echo")).with_exec(|args| async move {
let msg = args.stdin.unwrap_or_default();
Ok(format!("async: {msg}"))
});
assert!(tool.exec.is_some());
assert!(tool.exec_sync.is_none());
}
#[tokio::test]
async fn test_tool_impl_no_exec_errors() {
let tool = ToolImpl::new(ToolDef::new("empty", "No exec"));
let args = vec![];
let mut vars = std::collections::HashMap::new();
let env = std::collections::HashMap::new();
let mut cwd = std::path::PathBuf::from("/");
let fs = Arc::new(crate::fs::InMemoryFs::new());
let ctx = Context::new_for_test(&args, &env, &mut vars, &mut cwd, fs, None);
let result = tool.execute(ctx).await;
assert!(result.is_err());
}
}