Skip to main content

eui_core/host/
tools.rs

1//! VAPP-161: a package's TOOLS (docs/package-format.md "Tools"): the MCP
2//! servers and scripts its functions call (`tools.<id>.<name>` for an MCP
3//! tool, `tools.<id>` for a script), each behind the consent card the host
4//! MUST show before the first call. Mirrors
5//! `packages/exponential-ui/src/host/tools.ts` line by line;
6//! `fixtures/host-router.json` (validation) and `fixtures/host-package.json`
7//! `consent` + `toolCalls` replay it.
8
9use 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
16/// `^[a-z][a-z0-9_-]{0,31}$`: a tool's id (`package.tools.idPattern`).
17pub 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"];
20/// The host function names a tool call starts with.
21pub 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
27/// [`TOOL_ID_PATTERN`].
28pub 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
32/// `^[A-Za-z_][A-Za-z0-9_]*$`: an env variable name.
33fn 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
41/// `new URL(s)` parses with an `http:` / `https:` protocol and a host.
42fn 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
50/// Every problem with a package's `tools`, ids in sorted order.
51pub 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        // `!== undefined`: a present `null` counts as set.
76        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/// One row of the consent card.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ToolConsentRow {
107    pub id: String,
108    /// `mcp` | `script`.
109    pub kind: String,
110    /// `http` | `stdio` | `script`.
111    pub transport: String,
112    pub title: String,
113    pub description: Option<String>,
114    /// The url, or the command line exactly as it runs.
115    pub target: String,
116    pub env: Vec<String>,
117}
118
119/// An arg as the consent card shows it: as is, or JSON-quoted when it is
120/// empty or holds a space, a quote or a backslash.
121fn 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
133/// The consent card's rows for a (valid) package's tools, by id.
134pub 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            // `tool.url ?? command line`: a null url is the command line.
150            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
167/// The key a remembered grant is stored under: ANY change to the tool's
168/// definition is a new key (the card asks again).
169pub 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/// The tool a host function name calls.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct ToolCall {
177    pub tool_id: String,
178    /// The MCP server's tool; `None` = a script.
179    pub tool: Option<String>,
180}
181
182/// `tools.<id>` (a script) or `tools.<id>.<name>` (an MCP server's tool);
183/// `None` = not a tool call (`^tools\.([a-z][a-z0-9_-]{0,31})(?:\.(.+))?$`,
184/// `.` matching no line terminator).
185pub 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}