1use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use super::package::PackageIssue;
13use super::signature::canonical_json;
14use super::{is_js_space, js_trim};
15
16pub const TOOL_ID_PATTERN: &str = "^[a-z][a-z0-9_-]{0,31}$";
18pub const TOOL_KINDS: &[&str] = &["mcp", "script"];
19pub const TOOL_TRANSPORTS: &[&str] = &["http", "stdio", "script"];
20pub const TOOL_FUNCTION_PREFIX: &str = "tools.";
22
23fn is_id_char(c: char) -> bool {
24 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
25}
26
27pub fn is_tool_id(id: &str) -> bool {
29 id.chars().next().is_some_and(|c| c.is_ascii_lowercase()) && id.len() <= 32 && id.chars().all(is_id_char)
30}
31
32fn is_env_name(name: &str) -> bool {
34 name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
35}
36
37fn is_string_list(v: &Value) -> bool {
38 v.as_array().is_some_and(|l| l.iter().all(Value::is_string))
39}
40
41fn is_http_url(s: &str) -> bool {
43 url::Url::parse(s).is_ok_and(|u| matches!(u.scheme(), "http" | "https") && u.host_str().is_some_and(|h| !h.is_empty()))
44}
45
46fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
47 PackageIssue { path: path.into(), message: message.into() }
48}
49
50pub fn validate_tools(tools: &Value) -> Vec<PackageIssue> {
52 let Some(tools) = tools.as_object() else { return vec![issue("/tools", "tools is an object of tools")] };
53 let mut issues = Vec::new();
54 let mut ids: Vec<&String> = tools.keys().collect();
55 ids.sort_by(|a, b| super::utf16_cmp(a, b));
56 for id in ids {
57 let at = format!("/tools/{id}");
58 if !is_tool_id(id) {
59 issues.push(issue(&at, "a tool id is lowercase letters, digits, - and _"));
60 }
61 let Some(tool) = tools[id].as_object() else {
62 issues.push(issue(&at, "a tool is {kind, title, url | command}"));
63 continue;
64 };
65 let kind = tool.get("kind").and_then(Value::as_str);
66 if !kind.is_some_and(|k| TOOL_KINDS.contains(&k)) {
67 issues.push(issue(format!("{at}/kind"), "kind is mcp or script"));
68 }
69 if tool.get("title").and_then(Value::as_str).is_none_or(|t| js_trim(t).is_empty()) {
70 issues.push(issue(format!("{at}/title"), "title is a required string"));
71 }
72 if tool.get("description").is_some_and(|d| !d.is_string()) {
73 issues.push(issue(format!("{at}/description"), "description is a string"));
74 }
75 let url = tool.get("url");
77 let command = tool.get("command");
78 let (has_url, has_command) = (url.is_some(), command.is_some());
79 if kind == Some("mcp") && has_url == has_command {
80 issues.push(issue(&at, "an mcp tool has a url or a command"));
81 }
82 if kind == Some("script") && has_url {
83 issues.push(issue(format!("{at}/url"), "a script runs a command, not a url"));
84 }
85 if kind == Some("script") && !has_command {
86 issues.push(issue(format!("{at}/command"), "command is a non-empty string"));
87 }
88 if url.is_some_and(|u| !u.as_str().is_some_and(is_http_url)) {
89 issues.push(issue(format!("{at}/url"), "url is an absolute http(s) url"));
90 }
91 if command.is_some_and(|c| c.as_str().is_none_or(|c| js_trim(c).is_empty())) {
92 issues.push(issue(format!("{at}/command"), "command is a non-empty string"));
93 }
94 if tool.get("args").is_some_and(|a| !is_string_list(a) || has_url) {
95 issues.push(issue(format!("{at}/args"), "args is a list of strings beside a command"));
96 }
97 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) {
98 issues.push(issue(format!("{at}/env"), "env is a list of variable names beside a command"));
99 }
100 }
101 issues
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ToolConsentRow {
107 pub id: String,
108 pub kind: String,
110 pub transport: String,
112 pub title: String,
113 pub description: Option<String>,
114 pub target: String,
116 pub env: Vec<String>,
117}
118
119fn shell_arg(arg: &str) -> String {
122 if arg.is_empty() || arg.chars().any(|c| is_js_space(c) || matches!(c, '"' | '\'' | '\\')) {
123 canonical_json(&Value::String(arg.to_string()))
124 } else {
125 arg.to_string()
126 }
127}
128
129fn strings(v: Option<&Value>) -> Vec<String> {
130 v.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
131}
132
133pub fn tool_consent(pkg: &Value) -> Vec<ToolConsentRow> {
135 let Some(tools) = pkg.get("tools").and_then(Value::as_object) else { return Vec::new() };
136 let mut ids: Vec<&String> = tools.keys().collect();
137 ids.sort_by(|a, b| super::utf16_cmp(a, b));
138 ids.into_iter()
139 .map(|id| {
140 let tool = &tools[id];
141 let kind = tool.get("kind").and_then(Value::as_str).unwrap_or("").to_string();
142 let transport = if kind == "script" {
143 "script"
144 } else if tool.get("url").is_some() {
145 "http"
146 } else {
147 "stdio"
148 };
149 let target = match tool.get("url").filter(|u| !u.is_null()) {
151 Some(url) => url.as_str().unwrap_or("").to_string(),
152 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(" "),
153 };
154 ToolConsentRow {
155 id: id.clone(),
156 kind,
157 transport: transport.to_string(),
158 title: tool.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
159 description: tool.get("description").and_then(Value::as_str).map(str::to_string),
160 target,
161 env: strings(tool.get("env")),
162 }
163 })
164 .collect()
165}
166
167pub fn tool_consent_key(package_id: &str, tool_id: &str, tool: &Value) -> String {
170 format!("{package_id}/{tool_id}:{}", canonical_json(tool))
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct ToolCall {
177 pub tool_id: String,
178 pub tool: Option<String>,
180}
181
182pub fn tool_call(name: &str) -> Option<ToolCall> {
186 let rest = name.strip_prefix(TOOL_FUNCTION_PREFIX)?;
187 let end = rest.find(|c: char| !is_id_char(c)).unwrap_or(rest.len());
188 let (id, tail) = rest.split_at(end);
189 if !is_tool_id(id) {
190 return None;
191 }
192 let tool = match tail {
193 "" => None,
194 t => {
195 let t = t.strip_prefix('.').filter(|t| !t.is_empty() && !t.chars().any(|c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')))?;
196 Some(t.to_string())
197 }
198 };
199 Some(ToolCall { tool_id: id.to_string(), tool })
200}