Skip to main content

connector_client/
inspection.rs

1//! Shared inspection protocol contracts. This module holds no application state.
2use 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    /// Canonical logical selection request. Transport wait/image preferences do not alter it.
106    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
172/// Render only at the MCP boundary. Image bytes never replace selection metadata.
173pub 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
316/// Inspection locators are already-resolved data; workflow expressions are not evaluated here.
317pub 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
373/// Apply negotiated MCP features only at the transport boundary.
374/// Uninitialized/legacy clients retain the complete text and optional image representations.
375pub 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}