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"];
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"];
27pub const EXPOSE_ACTION_PATTERN: &str = "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$";
29
30#[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
48fn 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
86pub 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
102fn 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
107pub 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
118pub 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
128fn 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
138pub 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
153pub 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 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
257pub 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}