1use serde::Serialize;
10use serde_json::{Map, Value};
11
12use super::js_trim;
13use super::package::PackageIssue;
14
15pub 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", "agent"];
23pub const EXPOSE_AGENT_SLUG_MAX: usize = 64;
25pub const EXPOSE_AGENT_PROMPT_MAX: usize = 4000;
26pub const EXPOSE_AGENT_DEVICES: &[&str] = &["default"];
27
28fn agent_slug_ok(slug: &str) -> bool {
29 !slug.is_empty()
30 && slug.len() <= EXPOSE_AGENT_SLUG_MAX
31 && slug.split('-').all(|word| !word.is_empty() && word.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()))
32}
33pub const EXPOSE_RECORD_OPS: &[&str] = &["list", "get", "upsert"];
34pub const EXPOSE_READ_RECORD_OPS: &[&str] = &["list", "get"];
35pub const EXPOSE_READ_OP_WORDS: &[&str] = &["list", "get", "show", "catalog", "activity", "files"];
36pub const EXPOSE_DESTRUCTIVE_WORDS: &[&str] = &["delete", "merge", "close", "end", "remove", "trash"];
37pub const EXPOSE_ACTION_PATTERN: &str = "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$";
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct ExposeIssue {
44 pub index: Option<usize>,
45 pub code: String,
46 pub path: String,
47 pub message: String,
48}
49
50fn is_ident_start(c: u8) -> bool {
51 c.is_ascii_alphabetic() || c == b'_'
52}
53
54fn is_ident(c: u8) -> bool {
55 c.is_ascii_alphanumeric() || c == b'_'
56}
57
58fn template_at(s: &[u8], at: usize) -> Option<(usize, usize)> {
61 const OPEN: &[u8] = b"${input.";
62 if !s[at..].starts_with(OPEN) {
63 return None;
64 }
65 let start = at + OPEN.len();
66 let mut i = start;
67 loop {
68 if !s.get(i).copied().is_some_and(is_ident_start) {
69 return None;
70 }
71 while s.get(i).copied().is_some_and(is_ident) {
72 i += 1;
73 }
74 if s.get(i) == Some(&b'.') && s.get(i + 1).copied().is_some_and(is_ident_start) {
75 i += 1;
76 continue;
77 }
78 break;
79 }
80 (s.get(i) == Some(&b'}')).then_some((i, i + 1))
81}
82
83fn string_templates(s: &str, out: &mut Vec<String>) {
84 let bytes = s.as_bytes();
85 let mut i = 0;
86 while i < bytes.len() {
87 if let Some((end, next)) = template_at(bytes, i) {
88 out.push(s[i + 8..end].to_string());
89 i = next;
90 } else {
91 i += 1;
92 }
93 }
94}
95
96pub fn expose_template_paths(value: &Value) -> Vec<String> {
99 fn walk(v: &Value, out: &mut Vec<String>) {
100 match v {
101 Value::String(s) => string_templates(s, out),
102 Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
103 Value::Object(map) => map.values().for_each(|i| walk(i, out)),
104 _ => {}
105 }
106 }
107 let mut out = Vec::new();
108 walk(value, &mut out);
109 out
110}
111
112fn words(name: &str) -> Vec<String> {
114 name.to_lowercase().split(|c: char| !c.is_ascii_lowercase()).filter(|w| !w.is_empty()).map(str::to_string).collect()
115}
116
117pub fn expose_action_access(action: &str) -> &'static str {
120 let last = words(action.rsplit('.').next().unwrap_or("")).pop();
121 if last.is_some_and(|w| EXPOSE_READ_OP_WORDS.contains(&w.as_str())) {
122 "read"
123 } else {
124 "write"
125 }
126}
127
128pub fn expose_name_destructive(name: &str) -> bool {
130 words(name).iter().any(|w| EXPOSE_DESTRUCTIVE_WORDS.contains(&w.as_str()))
131}
132
133fn is_expose_name(name: &str) -> bool {
134 let b = name.as_bytes();
135 b.first().is_some_and(u8::is_ascii_lowercase) && b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_')
136}
137
138fn is_action_name(name: &str) -> bool {
140 let parts: Vec<&str> = name.split('.').collect();
141 parts.len() >= 2 && parts.iter().all(|p| is_expose_name(p))
142}
143
144fn utf16_len(s: &str) -> usize {
145 s.encode_utf16().count()
146}
147
148pub fn validate_upstreams(upstreams: &Value) -> Vec<PackageIssue> {
151 let Some(list) = upstreams.as_array().filter(|l| l.iter().all(|u| u.as_str().is_some_and(|s| !s.is_empty()))) else {
152 return vec![PackageIssue { path: "/upstreams".into(), message: "upstreams is a list of MCP server names".into() }];
153 };
154 let mut issues = Vec::new();
155 for (i, name) in list.iter().enumerate() {
156 if list.iter().position(|u| u == name) != Some(i) {
157 issues.push(PackageIssue { path: format!("/upstreams/{i}"), message: format!("duplicate upstream {}", name.as_str().unwrap_or("")) });
158 }
159 }
160 issues
161}
162
163pub fn expose_issues(exposes: &Value, records: Option<&Value>, upstreams: Option<&Value>) -> Vec<ExposeIssue> {
166 let Some(list) = exposes.as_array() else {
167 return vec![ExposeIssue { index: None, code: "run".into(), path: "/exposes".into(), message: "exposes is a list of tools".into() }];
168 };
169 let mut issues = Vec::new();
170 if list.len() > EXPOSE_MAX_TOOLS {
171 issues.push(ExposeIssue { index: None, code: "tooMany".into(), path: "/exposes".into(), message: format!("exposes lists at most {EXPOSE_MAX_TOOLS} tools") });
172 }
173 let kinds: Vec<&String> = records.and_then(Value::as_object).map(|r| r.keys().collect()).unwrap_or_default();
174 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();
175 let mut seen: Vec<&str> = Vec::new();
176 let empty = Map::new();
177 for (index, expose) in list.iter().enumerate() {
178 let at = format!("/exposes/{index}");
179 let mut fail = |code: &str, path: &str, message: String| issues.push(ExposeIssue { index: Some(index), code: code.into(), path: format!("{at}{path}"), message });
180 let Some(expose) = expose.as_object() else {
181 fail("run", "", "a tool is {name, title, description, input, access, run}".into());
182 continue;
183 };
184 match expose.get("name").and_then(Value::as_str) {
185 Some(name) if is_expose_name(name) && name.len() <= EXPOSE_NAME_MAX && !name.contains(EXPOSE_SEPARATOR) => {
186 if seen.contains(&name) {
187 fail("duplicate", "/name", format!("duplicate tool {name}"));
188 } else {
189 seen.push(name);
190 }
191 }
192 _ => fail("name", "/name", format!("name is lowercase letters, digits and _ (at most {EXPOSE_NAME_MAX}, no {EXPOSE_SEPARATOR})")),
193 }
194 if expose.get("title").and_then(Value::as_str).is_none_or(|t| js_trim(t).is_empty() || utf16_len(t) > EXPOSE_TITLE_MAX) {
195 fail("title", "/title", format!("title is a non-blank string of at most {EXPOSE_TITLE_MAX} characters"));
196 }
197 if expose.get("description").and_then(Value::as_str).is_none_or(|t| js_trim(t).is_empty() || utf16_len(t) > EXPOSE_DESCRIPTION_MAX) {
198 fail("description", "/description", format!("description is a non-blank string of at most {EXPOSE_DESCRIPTION_MAX} characters"));
199 }
200 let input = expose.get("input").and_then(Value::as_object);
201 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));
202 if !input_ok {
203 fail("input", "/input", "input is a JSON Schema of type object".into());
204 }
205 let access = expose.get("access").and_then(Value::as_str);
206 if !matches!(access, Some("read" | "write")) {
207 fail("access", "/access", "access is read or write".into());
208 }
209 let properties = input.and_then(|i| i.get("properties")).and_then(Value::as_object).unwrap_or(&empty);
210 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 {
211 fail("run", "/run", "run holds exactly one of records, action, upstream, agent".into());
212 continue;
213 };
214 for path in expose_template_paths(&Value::Object(run.clone())) {
215 let first = path.split('.').next().unwrap_or("");
216 if !properties.contains_key(first) {
217 fail("template", "/run", format!("${{input.{path}}} names no input property"));
218 }
219 }
220 let level: &str;
221 if let Some(records) = run.get("records") {
222 let op = records.as_object().and_then(|r| r.get("op"));
223 let Some(op) = op.and_then(Value::as_str).filter(|o| EXPOSE_RECORD_OPS.contains(o)) else {
224 if op.and_then(Value::as_str) == Some("delete") {
225 fail("destructive", "/run/records/op", "a records delete is destructive: never exposed".into());
226 } else {
227 fail("run", "/run/records/op", format!("op is {}", EXPOSE_RECORD_OPS.join(", ")));
228 }
229 continue;
230 };
231 let kind = records.get("kind").and_then(Value::as_str);
232 if !kind.is_some_and(|k| kinds.iter().any(|d| d.as_str() == k)) {
233 fail("kind", "/run/records/kind", "kind names no declared record kind".into());
234 }
235 level = if EXPOSE_READ_RECORD_OPS.contains(&op) { "read" } else { "write" };
236 } else if let Some(action) = run.get("action") {
237 let Some(action) = action.as_str().filter(|a| is_action_name(a)) else {
238 fail("action", "/run/action", "action is a function name like namespace.function".into());
239 continue;
240 };
241 if expose_name_destructive(action) {
242 fail("destructive", "/run/action", "a destructive action is never exposed".into());
243 continue;
244 }
245 level = expose_action_access(action);
246 } else if run.contains_key("agent") {
247 if !run.get("agent").and_then(Value::as_str).is_some_and(agent_slug_ok) {
248 fail("agent", "/run/agent", "agent is an agent slug (lowercase words joined by -)".into());
249 }
250 if !run.get("prompt").and_then(Value::as_str).is_some_and(|p| !p.trim().is_empty() && p.encode_utf16().count() <= EXPOSE_AGENT_PROMPT_MAX) {
251 fail("prompt", "/run/prompt", format!("prompt is non-blank text of at most {EXPOSE_AGENT_PROMPT_MAX} characters"));
252 }
253 if run.get("device").is_some_and(|d| !d.as_str().is_some_and(|d| EXPOSE_AGENT_DEVICES.contains(&d))) {
254 fail("run", "/run/device", format!("device is {}", EXPOSE_AGENT_DEVICES.join(", ")));
255 }
256 level = "write";
257 } else {
258 if !run.get("upstream").and_then(Value::as_str).is_some_and(|u| servers.contains(&u)) {
259 fail("upstream", "/run/upstream", "upstream names none of the package's upstreams".into());
260 }
261 let Some(tool) = run.get("tool").and_then(Value::as_str).filter(|t| !t.is_empty()) else {
262 fail("run", "/run/tool", "tool is a non-empty string".into());
263 continue;
264 };
265 if expose_name_destructive(tool) {
266 fail("destructive", "/run/tool", "a destructive tool is never exposed".into());
267 }
268 continue;
270 }
271 if access == Some("read") && level == "write" {
272 fail("accessTooLow", "/access", "the run writes: access is write".into());
273 }
274 }
275 issues
276}
277
278pub fn validate_package_exposes(exposes: &Value, records: Option<&Value>, upstreams: Option<&Value>) -> Vec<PackageIssue> {
281 expose_issues(exposes, records, upstreams).into_iter().map(|i| PackageIssue { path: i.path, message: i.message }).collect()
282}