use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::package::PackageIssue;
use super::signature::canonical_json;
use super::{is_js_space, js_trim};
pub const TOOL_ID_PATTERN: &str = "^[a-z][a-z0-9_-]{0,31}$";
pub const TOOL_KINDS: &[&str] = &["mcp", "script"];
pub const TOOL_TRANSPORTS: &[&str] = &["http", "stdio", "script"];
pub const TOOL_FUNCTION_PREFIX: &str = "tools.";
fn is_id_char(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
}
pub fn is_tool_id(id: &str) -> bool {
id.chars().next().is_some_and(|c| c.is_ascii_lowercase()) && id.len() <= 32 && id.chars().all(is_id_char)
}
fn is_env_name(name: &str) -> bool {
name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn is_string_list(v: &Value) -> bool {
v.as_array().is_some_and(|l| l.iter().all(Value::is_string))
}
fn is_http_url(s: &str) -> bool {
url::Url::parse(s).is_ok_and(|u| matches!(u.scheme(), "http" | "https") && u.host_str().is_some_and(|h| !h.is_empty()))
}
fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
PackageIssue { path: path.into(), message: message.into() }
}
pub fn validate_tools(tools: &Value) -> Vec<PackageIssue> {
let Some(tools) = tools.as_object() else { return vec![issue("/tools", "tools is an object of tools")] };
let mut issues = Vec::new();
let mut ids: Vec<&String> = tools.keys().collect();
ids.sort_by(|a, b| super::utf16_cmp(a, b));
for id in ids {
let at = format!("/tools/{id}");
if !is_tool_id(id) {
issues.push(issue(&at, "a tool id is lowercase letters, digits, - and _"));
}
let Some(tool) = tools[id].as_object() else {
issues.push(issue(&at, "a tool is {kind, title, url | command}"));
continue;
};
let kind = tool.get("kind").and_then(Value::as_str);
if !kind.is_some_and(|k| TOOL_KINDS.contains(&k)) {
issues.push(issue(format!("{at}/kind"), "kind is mcp or script"));
}
if tool.get("title").and_then(Value::as_str).is_none_or(|t| js_trim(t).is_empty()) {
issues.push(issue(format!("{at}/title"), "title is a required string"));
}
if tool.get("description").is_some_and(|d| !d.is_string()) {
issues.push(issue(format!("{at}/description"), "description is a string"));
}
let url = tool.get("url");
let command = tool.get("command");
let (has_url, has_command) = (url.is_some(), command.is_some());
if kind == Some("mcp") && has_url == has_command {
issues.push(issue(&at, "an mcp tool has a url or a command"));
}
if kind == Some("script") && has_url {
issues.push(issue(format!("{at}/url"), "a script runs a command, not a url"));
}
if kind == Some("script") && !has_command {
issues.push(issue(format!("{at}/command"), "command is a non-empty string"));
}
if url.is_some_and(|u| !u.as_str().is_some_and(is_http_url)) {
issues.push(issue(format!("{at}/url"), "url is an absolute http(s) url"));
}
if command.is_some_and(|c| c.as_str().is_none_or(|c| js_trim(c).is_empty())) {
issues.push(issue(format!("{at}/command"), "command is a non-empty string"));
}
if tool.get("args").is_some_and(|a| !is_string_list(a) || has_url) {
issues.push(issue(format!("{at}/args"), "args is a list of strings beside a command"));
}
if tool.get("env").is_some_and(|e| !is_string_list(e) || e.as_array().into_iter().flatten().any(|n| !n.as_str().is_some_and(is_env_name)) || has_url) {
issues.push(issue(format!("{at}/env"), "env is a list of variable names beside a command"));
}
}
issues
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolConsentRow {
pub id: String,
pub kind: String,
pub transport: String,
pub title: String,
pub description: Option<String>,
pub target: String,
pub env: Vec<String>,
}
fn shell_arg(arg: &str) -> String {
if arg.is_empty() || arg.chars().any(|c| is_js_space(c) || matches!(c, '"' | '\'' | '\\')) {
canonical_json(&Value::String(arg.to_string()))
} else {
arg.to_string()
}
}
fn strings(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
}
pub fn tool_consent(pkg: &Value) -> Vec<ToolConsentRow> {
let Some(tools) = pkg.get("tools").and_then(Value::as_object) else { return Vec::new() };
let mut ids: Vec<&String> = tools.keys().collect();
ids.sort_by(|a, b| super::utf16_cmp(a, b));
ids.into_iter()
.map(|id| {
let tool = &tools[id];
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or("").to_string();
let transport = if kind == "script" {
"script"
} else if tool.get("url").is_some() {
"http"
} else {
"stdio"
};
let target = match tool.get("url").filter(|u| !u.is_null()) {
Some(url) => url.as_str().unwrap_or("").to_string(),
None => std::iter::once(tool.get("command").and_then(Value::as_str).unwrap_or("").to_string()).chain(strings(tool.get("args"))).map(|a| shell_arg(&a)).collect::<Vec<_>>().join(" "),
};
ToolConsentRow {
id: id.clone(),
kind,
transport: transport.to_string(),
title: tool.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
description: tool.get("description").and_then(Value::as_str).map(str::to_string),
target,
env: strings(tool.get("env")),
}
})
.collect()
}
pub fn tool_consent_key(package_id: &str, tool_id: &str, tool: &Value) -> String {
format!("{package_id}/{tool_id}:{}", canonical_json(tool))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCall {
pub tool_id: String,
pub tool: Option<String>,
}
pub fn tool_call(name: &str) -> Option<ToolCall> {
let rest = name.strip_prefix(TOOL_FUNCTION_PREFIX)?;
let end = rest.find(|c: char| !is_id_char(c)).unwrap_or(rest.len());
let (id, tail) = rest.split_at(end);
if !is_tool_id(id) {
return None;
}
let tool = match tail {
"" => None,
t => {
let t = t.strip_prefix('.').filter(|t| !t.is_empty() && !t.chars().any(|c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')))?;
Some(t.to_string())
}
};
Some(ToolCall { tool_id: id.to_string(), tool })
}