Skip to main content

eui_core/host/
exposes.rs

1//! VAPP-180: the MCP tools a package EXPOSES (docs/package-format.md
2//! "Exposes"): declarative bindings `{name, title, description, input,
3//! access, run}` a host's MCP broker lists and runs as the calling person,
4//! never code. The format half of the eMCP contract; what a run may reach on
5//! a given host is that host's own check. Mirrors
6//! `packages/exponential-ui/src/host/exposes.ts` line by line;
7//! `fixtures/host-router.json` (validation) replays it.
8
9use serde::Serialize;
10use serde_json::{Map, Value};
11
12use super::js_trim;
13use super::package::PackageIssue;
14
15/// `package.exposes.limits`.
16pub const EXPOSE_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
17pub const EXPOSE_NAME_MAX: usize = 40;
18pub const EXPOSE_TITLE_MAX: usize = 80;
19pub const EXPOSE_DESCRIPTION_MAX: usize = 1024;
20pub const EXPOSE_MAX_TOOLS: usize = 32;
21pub const EXPOSE_SEPARATOR: &str = "__";
22pub const EXPOSE_RUN_KINDS: &[&str] = &["records", "action", "upstream"];
23pub const EXPOSE_RECORD_OPS: &[&str] = &["list", "get", "upsert"];
24pub const EXPOSE_READ_RECORD_OPS: &[&str] = &["list", "get"];
25pub const EXPOSE_READ_OP_WORDS: &[&str] = &["list", "get", "show", "catalog", "activity", "files"];
26pub const EXPOSE_DESTRUCTIVE_WORDS: &[&str] = &["delete", "merge", "close", "end", "remove", "trash"];
27/// `package.exposes.actionPattern`.
28pub const EXPOSE_ACTION_PATTERN: &str = "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$";
29
30/// One problem with a binding: the eMCP error `code`, the binding's `index`
31/// (`None` = the list) and the package issue.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct ExposeIssue {
34    pub index: Option<usize>,
35    pub code: String,
36    pub path: String,
37    pub message: String,
38}
39
40fn is_ident_start(c: u8) -> bool {
41    c.is_ascii_alphabetic() || c == b'_'
42}
43
44fn is_ident(c: u8) -> bool {
45    c.is_ascii_alphanumeric() || c == b'_'
46}
47
48/// The path of a `${input.<path>}` template starting at `at`, and the index
49/// after its `}`.
50fn template_at(s: &[u8], at: usize) -> Option<(usize, usize)> {
51    const OPEN: &[u8] = b"${input.";
52    if !s[at..].starts_with(OPEN) {
53        return None;
54    }
55    let start = at + OPEN.len();
56    let mut i = start;
57    loop {
58        if !s.get(i).copied().is_some_and(is_ident_start) {
59            return None;
60        }
61        while s.get(i).copied().is_some_and(is_ident) {
62            i += 1;
63        }
64        if s.get(i) == Some(&b'.') && s.get(i + 1).copied().is_some_and(is_ident_start) {
65            i += 1;
66            continue;
67        }
68        break;
69    }
70    (s.get(i) == Some(&b'}')).then_some((i, i + 1))
71}
72
73fn string_templates(s: &str, out: &mut Vec<String>) {
74    let bytes = s.as_bytes();
75    let mut i = 0;
76    while i < bytes.len() {
77        if let Some((end, next)) = template_at(bytes, i) {
78            out.push(s[i + 8..end].to_string());
79            i = next;
80        } else {
81            i += 1;
82        }
83    }
84}
85
86/// The input paths a run's templates name (`${input.a.b}` → `a.b`), in
87/// document order.
88pub fn expose_template_paths(value: &Value) -> Vec<String> {
89    fn walk(v: &Value, out: &mut Vec<String>) {
90        match v {
91            Value::String(s) => string_templates(s, out),
92            Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
93            Value::Object(map) => map.values().for_each(|i| walk(i, out)),
94            _ => {}
95        }
96    }
97    let mut out = Vec::new();
98    walk(value, &mut out);
99    out
100}
101
102/// The words of a function or tool name (the lowercase letter runs).
103fn words(name: &str) -> Vec<String> {
104    name.to_lowercase().split(|c: char| !c.is_ascii_lowercase()).filter(|w| !w.is_empty()).map(str::to_string).collect()
105}
106
107/// An action reads when the last word of its name is a read word; a verb
108/// writes.
109pub fn expose_action_access(action: &str) -> &'static str {
110    let last = words(action.rsplit('.').next().unwrap_or("")).pop();
111    if last.is_some_and(|w| EXPOSE_READ_OP_WORDS.contains(&w.as_str())) {
112        "read"
113    } else {
114        "write"
115    }
116}
117
118/// A tool or action name holding a destructive word is never exposed.
119pub fn expose_name_destructive(name: &str) -> bool {
120    words(name).iter().any(|w| EXPOSE_DESTRUCTIVE_WORDS.contains(&w.as_str()))
121}
122
123fn is_expose_name(name: &str) -> bool {
124    let b = name.as_bytes();
125    b.first().is_some_and(u8::is_ascii_lowercase) && b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_')
126}
127
128/// [`EXPOSE_ACTION_PATTERN`].
129fn is_action_name(name: &str) -> bool {
130    let parts: Vec<&str> = name.split('.').collect();
131    parts.len() >= 2 && parts.iter().all(|p| is_expose_name(p))
132}
133
134fn utf16_len(s: &str) -> usize {
135    s.encode_utf16().count()
136}
137
138/// Every problem with a package's `upstreams`: the MCP server names its
139/// upstream bindings may call.
140pub fn validate_upstreams(upstreams: &Value) -> Vec<PackageIssue> {
141    let Some(list) = upstreams.as_array().filter(|l| l.iter().all(|u| u.as_str().is_some_and(|s| !s.is_empty()))) else {
142        return vec![PackageIssue { path: "/upstreams".into(), message: "upstreams is a list of MCP server names".into() }];
143    };
144    let mut issues = Vec::new();
145    for (i, name) in list.iter().enumerate() {
146        if list.iter().position(|u| u == name) != Some(i) {
147            issues.push(PackageIssue { path: format!("/upstreams/{i}"), message: format!("duplicate upstream {}", name.as_str().unwrap_or("")) });
148        }
149    }
150    issues
151}
152
153/// Every problem with a package's `exposes`, in binding order, with its
154/// eMCP code. `records` and `upstreams` = the package's.
155pub fn expose_issues(exposes: &Value, records: Option<&Value>, upstreams: Option<&Value>) -> Vec<ExposeIssue> {
156    let Some(list) = exposes.as_array() else {
157        return vec![ExposeIssue { index: None, code: "run".into(), path: "/exposes".into(), message: "exposes is a list of tools".into() }];
158    };
159    let mut issues = Vec::new();
160    if list.len() > EXPOSE_MAX_TOOLS {
161        issues.push(ExposeIssue { index: None, code: "tooMany".into(), path: "/exposes".into(), message: format!("exposes lists at most {EXPOSE_MAX_TOOLS} tools") });
162    }
163    let kinds: Vec<&String> = records.and_then(Value::as_object).map(|r| r.keys().collect()).unwrap_or_default();
164    let servers: Vec<&str> = upstreams.and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).filter(|s| !s.is_empty()).collect()).unwrap_or_default();
165    let mut seen: Vec<&str> = Vec::new();
166    let empty = Map::new();
167    for (index, expose) in list.iter().enumerate() {
168        let at = format!("/exposes/{index}");
169        let mut fail = |code: &str, path: &str, message: String| issues.push(ExposeIssue { index: Some(index), code: code.into(), path: format!("{at}{path}"), message });
170        let Some(expose) = expose.as_object() else {
171            fail("run", "", "a tool is {name, title, description, input, access, run}".into());
172            continue;
173        };
174        match expose.get("name").and_then(Value::as_str) {
175            Some(name) if is_expose_name(name) && name.len() <= EXPOSE_NAME_MAX && !name.contains(EXPOSE_SEPARATOR) => {
176                if seen.contains(&name) {
177                    fail("duplicate", "/name", format!("duplicate tool {name}"));
178                } else {
179                    seen.push(name);
180                }
181            }
182            _ => fail("name", "/name", format!("name is lowercase letters, digits and _ (at most {EXPOSE_NAME_MAX}, no {EXPOSE_SEPARATOR})")),
183        }
184        if expose.get("title").and_then(Value::as_str).is_none_or(|t| js_trim(t).is_empty() || utf16_len(t) > EXPOSE_TITLE_MAX) {
185            fail("title", "/title", format!("title is a non-blank string of at most {EXPOSE_TITLE_MAX} characters"));
186        }
187        if expose.get("description").and_then(Value::as_str).is_none_or(|t| js_trim(t).is_empty() || utf16_len(t) > EXPOSE_DESCRIPTION_MAX) {
188            fail("description", "/description", format!("description is a non-blank string of at most {EXPOSE_DESCRIPTION_MAX} characters"));
189        }
190        let input = expose.get("input").and_then(Value::as_object);
191        let input_ok = input.is_some_and(|i| i.get("type").and_then(Value::as_str) == Some("object") && i.get("properties").is_none_or(Value::is_object));
192        if !input_ok {
193            fail("input", "/input", "input is a JSON Schema of type object".into());
194        }
195        let access = expose.get("access").and_then(Value::as_str);
196        if !matches!(access, Some("read" | "write")) {
197            fail("access", "/access", "access is read or write".into());
198        }
199        let properties = input.and_then(|i| i.get("properties")).and_then(Value::as_object).unwrap_or(&empty);
200        let Some(run) = expose.get("run").and_then(Value::as_object).filter(|r| EXPOSE_RUN_KINDS.iter().filter(|k| r.contains_key(**k)).count() == 1) else {
201            fail("run", "/run", "run holds exactly one of records, action, upstream".into());
202            continue;
203        };
204        for path in expose_template_paths(&Value::Object(run.clone())) {
205            let first = path.split('.').next().unwrap_or("");
206            if !properties.contains_key(first) {
207                fail("template", "/run", format!("${{input.{path}}} names no input property"));
208            }
209        }
210        let level: &str;
211        if let Some(records) = run.get("records") {
212            let op = records.as_object().and_then(|r| r.get("op"));
213            let Some(op) = op.and_then(Value::as_str).filter(|o| EXPOSE_RECORD_OPS.contains(o)) else {
214                if op.and_then(Value::as_str) == Some("delete") {
215                    fail("destructive", "/run/records/op", "a records delete is destructive: never exposed".into());
216                } else {
217                    fail("run", "/run/records/op", format!("op is {}", EXPOSE_RECORD_OPS.join(", ")));
218                }
219                continue;
220            };
221            let kind = records.get("kind").and_then(Value::as_str);
222            if !kind.is_some_and(|k| kinds.iter().any(|d| d.as_str() == k)) {
223                fail("kind", "/run/records/kind", "kind names no declared record kind".into());
224            }
225            level = if EXPOSE_READ_RECORD_OPS.contains(&op) { "read" } else { "write" };
226        } else if let Some(action) = run.get("action") {
227            let Some(action) = action.as_str().filter(|a| is_action_name(a)) else {
228                fail("action", "/run/action", "action is a function name like namespace.function".into());
229                continue;
230            };
231            if expose_name_destructive(action) {
232                fail("destructive", "/run/action", "a destructive action is never exposed".into());
233                continue;
234            }
235            level = expose_action_access(action);
236        } else {
237            if !run.get("upstream").and_then(Value::as_str).is_some_and(|u| servers.contains(&u)) {
238                fail("upstream", "/run/upstream", "upstream names none of the package's upstreams".into());
239            }
240            let Some(tool) = run.get("tool").and_then(Value::as_str).filter(|t| !t.is_empty()) else {
241                fail("run", "/run/tool", "tool is a non-empty string".into());
242                continue;
243            };
244            if expose_name_destructive(tool) {
245                fail("destructive", "/run/tool", "a destructive tool is never exposed".into());
246            }
247            // An upstream tool's level is its live annotations': the host checks it at call.
248            continue;
249        }
250        if access == Some("read") && level == "write" {
251            fail("accessTooLow", "/access", "the run writes: access is write".into());
252        }
253    }
254    issues
255}
256
257/// Every problem with a package's `exposes` ([`expose_issues`] as package
258/// issues).
259pub fn validate_package_exposes(exposes: &Value, records: Option<&Value>, upstreams: Option<&Value>) -> Vec<PackageIssue> {
260    expose_issues(exposes, records, upstreams).into_iter().map(|i| PackageIssue { path: i.path, message: i.message }).collect()
261}