1use serde_json::{json, Map, Value};
3
4pub const INSPECTION_PROTOCOL_VERSION: u64 = 1;
5
6#[derive(Debug, Clone)]
7pub struct PickerRequest {
8 pub action: String,
9 pub convenience: bool,
10 pub window_id: String,
11 pub request_key: Option<String>,
12 pub timeout_ms: u64,
13 pub wait_ms: u64,
14 pub picker_id: Option<String>,
15 pub capture_screenshot: bool,
16 pub screenshot_source: String,
17 pub include_image: bool,
18}
19
20fn invalid(message: impl Into<String>) -> Value {
21 json!({"code":"invalid_arguments","message":message.into()})
22}
23
24impl PickerRequest {
25 pub fn parse(value: &Value) -> Result<Self, Value> {
26 let fields = value
27 .as_object()
28 .ok_or_else(|| invalid("arguments must be an object"))?;
29 let convenience = !fields.contains_key("action");
30 let action = string(fields, "action")?.unwrap_or("start");
31 let allowed: &[&str] = match action {
32 "start" => &[
33 "action",
34 "authToken",
35 "windowId",
36 "requestKey",
37 "timeoutMs",
38 "timeout",
39 "waitMs",
40 "captureScreenshot",
41 "screenshotSource",
42 "includeImage",
43 ],
44 "get" => &["action", "authToken", "pickerId", "waitMs", "includeImage"],
45 "cancel" => &["action", "authToken", "pickerId"],
46 _ => return Err(invalid("action must be start, get, or cancel")),
47 };
48 for field in fields.keys() {
49 if !allowed.contains(&field.as_str()) {
50 return Err(invalid(format!("{field} is not allowed for {action}")));
51 }
52 }
53 let _ = string(fields, "authToken")?;
54 let window_id = string(fields, "windowId")?.unwrap_or("main");
55 if window_id.is_empty() {
56 return Err(invalid("windowId must not be empty"));
57 }
58 let request_key = string(fields, "requestKey")?;
59 if request_key.is_some_and(|key| key.is_empty() || key.len() > 128) {
60 return Err(invalid("requestKey must contain 1..128 UTF-8 bytes"));
61 }
62 let picker_id = string(fields, "pickerId")?;
63 if action != "start" && picker_id.is_none_or(str::is_empty) {
64 return Err(invalid("pickerId is required"));
65 }
66 let timeout_ms = integer(fields, "timeoutMs", 5000, 120000)?;
67 let timeout_alias = integer(fields, "timeout", 5000, 120000)?;
68 if timeout_ms.zip(timeout_alias).is_some_and(|(a, b)| a != b) {
69 return Err(invalid("timeout and timeoutMs must match"));
70 }
71 let capture_screenshot = boolean(fields, "captureScreenshot")?.unwrap_or(true);
72 let include_image = boolean(fields, "includeImage")?.unwrap_or(false);
73 let screenshot_source = string(fields, "screenshotSource")?.unwrap_or("auto");
74 if !["auto", "webview_native", "window_native", "dom_rendering"]
75 .contains(&screenshot_source)
76 {
77 return Err(invalid("unsupported screenshotSource"));
78 }
79 if action == "start"
80 && !capture_screenshot
81 && (include_image || fields.contains_key("screenshotSource"))
82 {
83 return Err(invalid(
84 "captureScreenshot:false conflicts with screenshotSource or includeImage:true",
85 ));
86 }
87 Ok(Self {
88 action: action.into(),
89 convenience,
90 window_id: window_id.into(),
91 request_key: request_key.map(str::to_owned),
92 timeout_ms: timeout_ms.or(timeout_alias).unwrap_or(60000),
93 wait_ms: integer(fields, "waitMs", 0, 10000)?.unwrap_or(if convenience {
94 10000
95 } else {
96 0
97 }),
98 picker_id: picker_id.map(str::to_owned),
99 capture_screenshot,
100 screenshot_source: screenshot_source.into(),
101 include_image,
102 })
103 }
104
105 pub fn fingerprint(&self) -> Value {
107 json!({"windowId":self.window_id,"timeoutMs":self.timeout_ms,"captureScreenshot":self.capture_screenshot,"screenshotSource":self.screenshot_source})
108 }
109}
110
111fn string<'a>(fields: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, Value> {
112 fields
113 .get(key)
114 .map(|v| {
115 v.as_str()
116 .ok_or_else(|| invalid(format!("{key} must be a string")))
117 })
118 .transpose()
119}
120fn boolean(fields: &Map<String, Value>, key: &str) -> Result<Option<bool>, Value> {
121 fields
122 .get(key)
123 .map(|v| {
124 v.as_bool()
125 .ok_or_else(|| invalid(format!("{key} must be a boolean")))
126 })
127 .transpose()
128}
129fn integer(
130 fields: &Map<String, Value>,
131 key: &str,
132 min: u64,
133 max: u64,
134) -> Result<Option<u64>, Value> {
135 fields
136 .get(key)
137 .map(|v| {
138 v.as_u64()
139 .filter(|v| (min..=max).contains(v))
140 .ok_or_else(|| invalid(format!("{key} must be an integer in {min}..{max}")))
141 })
142 .transpose()
143}
144
145pub fn picker_exit_code(report: &Value, cancel_request: bool) -> i32 {
146 if cancel_request
147 && report.get("code").is_none()
148 && matches!(
149 report.get("status").and_then(Value::as_str),
150 Some(
151 "created"
152 | "installing"
153 | "awaiting_selection"
154 | "selected"
155 | "cancelled"
156 | "expired"
157 | "target_changed"
158 | "failed"
159 )
160 )
161 {
162 return 0;
163 }
164 match report.get("status").and_then(Value::as_str) {
165 Some("selected") => 0,
166 Some("created" | "installing" | "awaiting_selection") => 2,
167 Some("cancelled") if cancel_request => 0,
168 _ => 1,
169 }
170}
171
172pub fn picker_mcp_content(mut report: Value, include_image: bool) -> Value {
174 let top_image = report.as_object_mut().and_then(|v| v.remove("image"));
175 let mut image = None;
176 if let Some(screenshot) = report.get_mut("screenshot").and_then(Value::as_object_mut) {
177 let payload = top_image.or_else(|| {
178 screenshot
179 .get("image")
180 .filter(|v| v.get("base64").is_some())
181 .cloned()
182 });
183 if let Some(metadata) = screenshot.get_mut("image").and_then(Value::as_object_mut) {
184 metadata.remove("base64");
185 }
186 let base64 = screenshot.remove("base64");
187 let redacted = screenshot
188 .get("redaction")
189 .and_then(|v| v.get("status"))
190 .and_then(Value::as_str)
191 == Some("applied");
192 if include_image && redacted {
193 let payload = payload
194 .unwrap_or_else(|| json!({"base64":base64,"mimeType":screenshot.get("mimeType")}));
195 if let (Some(data), Some(mime)) = (
196 payload.get("base64").and_then(Value::as_str),
197 payload.get("mimeType").and_then(Value::as_str),
198 ) {
199 if ["image/png", "image/jpeg", "image/webp"].contains(&mime)
200 && data.len() <= 4 * 1024 * 1024
201 {
202 image = Some(json!({"type":"image","data":data,"mimeType":mime}));
203 }
204 }
205 }
206 if include_image
207 && image.is_none()
208 && screenshot.get("status").and_then(Value::as_str) == Some("captured")
209 {
210 screenshot.insert(
211 "imageOmissionReason".into(),
212 json!("image_unavailable_or_output_budget"),
213 );
214 }
215 }
216 let mut content = vec![json!({"type":"text","text":report.to_string()})];
217 if let Some(image) = image {
218 content.push(image);
219 }
220 let is_error = report.get("status").and_then(Value::as_str) == Some("failed")
221 && report.get("cancellationAccepted") != Some(&json!(true))
222 || report.get("code").is_some();
223 json!({"content":content,"structuredContent":report,"isError":is_error})
224}
225
226pub fn picker_schema() -> Value {
227 let start = json!({"type":"object","additionalProperties":false,"properties":{
228 "action":{"const":"start"},"authToken":{"type":"string"},
229 "windowId":{"type":"string","minLength":1,"default":"main"},
230 "requestKey":{"type":"string","minLength":1,"maxLength":128,"description":"At most 128 UTF-8 bytes. Reuse only for the same logical selection within retainedUntil."},
231 "timeoutMs":{"type":"integer","minimum":5000,"maximum":120000,"default":60000},
232 "timeout":{"type":"integer","minimum":5000,"maximum":120000,"description":"Milliseconds alias; if both given values must match"},
233 "waitMs":{"type":"integer","minimum":0,"maximum":10000,"description":"Default 0 for explicit start, 10000 for omitted action; never renews the picker deadline"},
234 "captureScreenshot":{"type":"boolean","default":true},
235 "screenshotSource":{"enum":["auto","webview_native","window_native","dom_rendering"],"default":"auto"},
236 "includeImage":{"type":"boolean","default":false}
237 },"allOf":[{"if":{"properties":{"captureScreenshot":{"const":false}},"required":["captureScreenshot"]},"then":{"not":{"anyOf":[{"required":["screenshotSource"]},{"properties":{"includeImage":{"const":true}},"required":["includeImage"]}]}}}]});
238 let get = json!({"type":"object","additionalProperties":false,"required":["action","pickerId"],"properties":{
239 "action":{"const":"get"},"authToken":{"type":"string"},"pickerId":{"type":"string","minLength":1},
240 "waitMs":{"type":"integer","minimum":0,"maximum":10000,"default":0},"includeImage":{"type":"boolean","default":false}
241 }});
242 let cancel = json!({"type":"object","additionalProperties":false,"required":["action","pickerId"],"properties":{
243 "action":{"const":"cancel"},"authToken":{"type":"string"},"pickerId":{"type":"string","minLength":1}
244 }});
245 json!({"type":"object","oneOf":[start,get,cancel]})
246}
247
248pub fn new_request_key() -> String {
249 uuid::Uuid::new_v4().to_string()
250}
251
252pub fn protected_artifact_request(name: &str, args: &Value) -> bool {
253 name.starts_with("artifact_")
254 && (args.get("authToken").is_some()
255 || [
256 "artifactId",
257 "artifact",
258 "before",
259 "after",
260 "baselineId",
261 "currentId",
262 ]
263 .iter()
264 .any(|field| {
265 args.get(field).and_then(Value::as_str).is_some_and(|id| {
266 id.starts_with("artifact-")
267 || id.contains(":artifact:")
268 || id.contains(":picker:") && id.ends_with(":image")
269 })
270 }))
271}
272pub fn protected_artifact_args(args: &Value) -> Result<Value, Value> {
273 let mut args = args.clone();
274 if let Some(fields) = args.as_object_mut() {
275 for (legacy, current) in [
276 ("artifact", "artifactId"),
277 ("before", "baselineId"),
278 ("after", "currentId"),
279 ] {
280 if let Some(value) = fields.remove(legacy) {
281 if fields.get(current).is_some_and(|other| other != &value) {
282 return Err(invalid("conflicting artifact identity aliases"));
283 }
284 fields.entry(current).or_insert(value);
285 }
286 }
287 }
288 Ok(args)
289}
290
291pub fn image_mcp_content(mut report: Value, require_redaction: bool) -> Value {
292 let bytes = report.as_object_mut().and_then(|v| v.remove("base64"));
293 let allowed = !require_redaction
294 || report
295 .pointer("/redaction/status")
296 .or_else(|| report.pointer("/artifact/redaction/status"))
297 .and_then(Value::as_str)
298 == Some("applied");
299 let image = bytes
300 .as_ref()
301 .and_then(Value::as_str)
302 .filter(|data| allowed && data.len() <= 4 * 1024 * 1024)
303 .zip(report.get("mimeType").and_then(Value::as_str))
304 .filter(|(_, mime)| ["image/png", "image/jpeg", "image/webp"].contains(mime))
305 .map(|(data, mime)| json!({"type":"image","data":data,"mimeType":mime}));
306 if bytes.is_some() && image.is_none() {
307 report["imageOmissionReason"] = json!("redaction_or_output_budget");
308 }
309 let mut content = vec![json!({"type":"text","text":report.to_string()})];
310 if let Some(image) = image {
311 content.push(image);
312 }
313 json!({"content":content,"structuredContent":report})
314}
315
316pub fn validate_static_locator(value: &Value) -> Result<(), Value> {
318 fn visit(value: &Value, depth: usize) -> Result<(), Value> {
319 if depth > 8 {
320 return Err(invalid("locator scope exceeds depth budget"));
321 }
322 let object = value
323 .as_object()
324 .ok_or_else(|| invalid("target must be a locator object"))?;
325 if object
326 .keys()
327 .any(|key| !["by", "value", "name", "scope", "entity"].contains(&key.as_str()))
328 {
329 return Err(invalid("unknown locator field"));
330 }
331 if !object
332 .get("by")
333 .and_then(Value::as_str)
334 .is_some_and(|by| ["role", "label", "testId", "css"].contains(&by))
335 {
336 return Err(invalid("unsupported locator kind"));
337 }
338 for key in ["value", "name"] {
339 if (key == "value" || object.contains_key(key))
340 && !object
341 .get(key)
342 .and_then(Value::as_str)
343 .is_some_and(|v| !v.is_empty() && v.len() <= 4096)
344 {
345 return Err(invalid(
346 "locator identifiers must be nonempty bounded strings",
347 ));
348 }
349 }
350 if let Some(scope) = object.get("scope") {
351 visit(scope, depth + 1)?;
352 }
353 if let Some(entity) = object.get("entity") {
354 let entity = entity
355 .as_object()
356 .ok_or_else(|| invalid("entity must be an object"))?;
357 if entity.len() != 2
358 || !["attribute", "value"].iter().all(|key| {
359 entity
360 .get(*key)
361 .and_then(Value::as_str)
362 .is_some_and(|v| !v.is_empty() && v.len() <= 4096)
363 })
364 {
365 return Err(invalid("entity requires bounded attribute/value strings"));
366 }
367 }
368 Ok(())
369 }
370 visit(value, 0)
371}
372
373pub fn shape_mcp_result(mut result: Value, protocol_version: Option<&str>) -> Value {
376 if let Some(fields) = result
377 .as_object_mut()
378 .filter(|_| !matches!(protocol_version, Some("2025-06-18" | "2025-11-25")))
379 {
380 fields.remove("structuredContent");
381 }
382 result
383}
384
385pub const RICH_SCREENSHOT_FIELDS: &[&str] = &[
386 "source",
387 "target",
388 "redaction",
389 "allowWindowPreparation",
390 "includeImage",
391 "timeoutMs",
392 "authToken",
393];
394pub fn is_rich_screenshot(args: &Value) -> bool {
395 RICH_SCREENSHOT_FIELDS
396 .iter()
397 .any(|field| args.get(field).is_some())
398}