Skip to main content

vtcode_utility_tool_specs/
mcp_tool.rs

1use rmcp::model::Tool;
2use serde_json::Value;
3
4/// MCP Apps extension identifier (`io.modelcontextprotocol/ui`).
5pub const MCP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
6
7/// MIME type a `ui://` app resource must serve.
8pub const MCP_UI_RESOURCE_MIME_TYPE: &str = "text/html;profile=mcp-app";
9
10/// URI scheme identifying MCP Apps UI resources.
11pub const MCP_UI_RESOURCE_SCHEME: &str = "ui://";
12
13/// Deprecated `_meta` key for the app resource URI. Tolerated on read, never emitted.
14const DEPRECATED_UI_RESOURCE_URI_KEY: &str = "ui/resourceUri";
15
16/// Permission-policy features a UI resource may request (SEP-1865 `UIResourceMeta.permissions`).
17const UI_PERMISSION_KEYS: [&str; 4] = ["camera", "microphone", "geolocation", "clipboardWrite"];
18
19/// Linkage from an MCP tool to its MCP Apps UI resource.
20///
21/// Invariant: `resource_uri` is non-empty and starts with [`MCP_UI_RESOURCE_SCHEME`].
22/// The only constructor site is [`parse_mcp_tool`], which enforces this fail-closed:
23/// any missing, mistyped, or non-`ui://` value yields `None` instead of a link.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct McpUiToolMeta {
26    pub resource_uri: String,
27}
28
29#[derive(Clone, Debug, PartialEq)]
30pub struct ParsedMcpTool {
31    pub name: String,
32    pub description: String,
33    pub input_schema: Value,
34    pub output_schema: Option<Value>,
35    pub ui: Option<McpUiToolMeta>,
36    pub visibility: McpToolVisibility,
37}
38
39/// Tool accessibility between model and app (SEP-1865 `_meta.ui.visibility`).
40///
41/// The default is visible to both, matching the spec's omitted-means-both rule.
42/// Hosts must hide tools without model visibility from the agent's tool list.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct McpToolVisibility {
45    pub model: bool,
46    pub app: bool,
47}
48
49impl Default for McpToolVisibility {
50    fn default() -> Self {
51        Self { model: true, app: true }
52    }
53}
54
55#[must_use]
56pub fn parse_mcp_tool(tool: &Tool) -> ParsedMcpTool {
57    let meta = tool.meta.as_ref().map(|meta| &meta.0);
58    ParsedMcpTool {
59        name: tool.name.to_string(),
60        description: tool.description.clone().unwrap_or_default().to_string(),
61        input_schema: serde_json::to_value(&tool.input_schema).unwrap_or(Value::Null),
62        output_schema: tool.output_schema.as_ref().and_then(|schema| serde_json::to_value(schema).ok()),
63        ui: parse_ui_meta(meta),
64        visibility: parse_ui_tool_visibility(meta),
65    }
66}
67
68/// Parse `_meta.ui.visibility` into model/app accessibility.
69///
70/// Omitted (or an absent `ui` object) means visible to both. A present array
71/// enables exactly the named audiences; unknown entries are ignored for
72/// forward compatibility. A present-but-malformed value hides the tool from
73/// the model (fail-closed: a mistyped audience list must not leak an
74/// app-only tool into the agent's tool list).
75#[must_use]
76pub fn parse_ui_tool_visibility(meta: Option<&serde_json::Map<String, Value>>) -> McpToolVisibility {
77    let Some(ui) = meta.and_then(|meta| meta.get("ui")).and_then(Value::as_object) else {
78        return McpToolVisibility::default();
79    };
80    let Some(visibility) = ui.get("visibility") else {
81        return McpToolVisibility::default();
82    };
83    let Value::Array(entries) = visibility else {
84        return McpToolVisibility { model: false, app: true };
85    };
86    McpToolVisibility {
87        model: entries.iter().any(|entry| entry == "model"),
88        app: entries.iter().any(|entry| entry == "app"),
89    }
90}
91
92/// Declared sandbox posture of an MCP Apps UI resource (SEP-1865 `UIResourceMeta`).
93///
94/// Disclosure data only: hosts may restrict further and must never loosen.
95/// All lists preserve server order; mistyped entries are skipped.
96///
97/// Serializes with the spec wire names (`connectDomains`, …) for disclosure payloads.
98#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct McpUiResourceMeta {
101    /// Origins for network requests (`csp.connectDomains`).
102    pub connect_domains: Vec<String>,
103    /// Origins for static resources (`csp.resourceDomains`).
104    pub resource_domains: Vec<String>,
105    /// Origins for nested iframes (`csp.frameDomains`).
106    pub frame_domains: Vec<String>,
107    /// Allowed base URIs (`csp.baseUriDomains`).
108    pub base_uri_domains: Vec<String>,
109    /// Requested permission-policy features, in spec order (`permissions`).
110    pub permissions: Vec<String>,
111    /// Dedicated sandbox origin for the view (`domain`).
112    pub domain: Option<String>,
113}
114
115/// Extract the UI sandbox posture from a resource's `_meta` map.
116///
117/// Returns `None` when `_meta.ui` is absent or not an object. A present object
118/// parses fail-soft: unknown permission keys are ignored for forward
119/// compatibility, and non-string domain entries are skipped.
120#[must_use]
121pub fn parse_ui_resource_meta(meta: Option<&serde_json::Map<String, Value>>) -> Option<McpUiResourceMeta> {
122    let ui = meta?.get("ui")?.as_object()?;
123    let csp = ui.get("csp").and_then(Value::as_object);
124    let permissions = ui.get("permissions").and_then(Value::as_object);
125    Some(McpUiResourceMeta {
126        connect_domains: string_list(csp, "connectDomains"),
127        resource_domains: string_list(csp, "resourceDomains"),
128        frame_domains: string_list(csp, "frameDomains"),
129        base_uri_domains: string_list(csp, "baseUriDomains"),
130        permissions: UI_PERMISSION_KEYS
131            .into_iter()
132            .filter(|key| {
133                permissions.is_some_and(
134                    |map| matches!(map.get(*key), Some(value) if !value.is_null() && *value != Value::Bool(false)),
135                )
136            })
137            .map(str::to_owned)
138            .collect(),
139        domain: ui.get("domain").and_then(Value::as_str).map(str::to_owned),
140    })
141}
142
143/// Collect the string entries of `key` from an optional JSON object.
144///
145/// Non-array values and non-string entries yield no items rather than an error.
146fn string_list(obj: Option<&serde_json::Map<String, Value>>, key: &str) -> Vec<String> {
147    obj.and_then(|map| map.get(key))
148        .and_then(Value::as_array)
149        .map(|items| items.iter().filter_map(Value::as_str).map(str::to_owned).collect())
150        .unwrap_or_default()
151}
152
153/// Extract the MCP Apps linkage from a tool's `_meta` map.
154///
155/// Reads `_meta.ui.resourceUri`, falling back to the deprecated `_meta["ui/resourceUri"]`
156/// only when the canonical value is absent. A present-but-invalid canonical value links
157/// nothing rather than falling through. Returns `None` unless the winning value is a
158/// string with a non-empty `ui://` URI.
159fn parse_ui_meta(meta: Option<&serde_json::Map<String, Value>>) -> Option<McpUiToolMeta> {
160    let meta = meta?;
161    let canonical = meta
162        .get("ui")
163        .and_then(Value::as_object)
164        .and_then(|ui| ui.get("resourceUri"))
165        .and_then(Value::as_str);
166    let deprecated = meta.get(DEPRECATED_UI_RESOURCE_URI_KEY).and_then(Value::as_str);
167    let uri = canonical.or(deprecated)?;
168    if uri.len() > MCP_UI_RESOURCE_SCHEME.len() && uri.starts_with(MCP_UI_RESOURCE_SCHEME) {
169        Some(McpUiToolMeta { resource_uri: uri.to_owned() })
170    } else {
171        None
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::{McpToolVisibility, McpUiToolMeta, ParsedMcpTool, parse_mcp_tool};
178    use rmcp::model::{MetaObject, Tool};
179    use serde_json::json;
180    use std::sync::Arc;
181
182    fn empty_schema() -> Arc<serde_json::Map<String, serde_json::Value>> {
183        Arc::new(serde_json::from_value(json!({})).expect("json object"))
184    }
185
186    fn tool_with_meta(meta: serde_json::Value) -> Tool {
187        let mut tool = Tool::new_with_raw("app-tool", None, empty_schema());
188        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_value(meta).expect("meta object");
189        tool.meta = Some(MetaObject(map));
190        tool
191    }
192
193    #[test]
194    fn parse_mcp_tool_visibility_matrix() {
195        use super::McpToolVisibility as Visibility;
196
197        // Omitted everywhere means both.
198        let tool = Tool::new_with_raw("plain", None, empty_schema());
199        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility::default());
200        let tool = tool_with_meta(json!({"ui": {"resourceUri": "ui://demo/app.html"}}));
201        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility::default());
202
203        // Explicit audiences enable exactly what they name.
204        let tool = tool_with_meta(json!({"ui": {"visibility": ["model", "app"]}}));
205        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: true, app: true });
206        let tool = tool_with_meta(json!({"ui": {"visibility": ["app"]}}));
207        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: false, app: true });
208        let tool = tool_with_meta(json!({"ui": {"visibility": ["model"]}}));
209        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: true, app: false });
210        let tool = tool_with_meta(json!({"ui": {"visibility": []}}));
211        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: false, app: false });
212
213        // Unknown entries are ignored; malformed values hide from the model.
214        let tool = tool_with_meta(json!({"ui": {"visibility": ["model", "hologram"]}}));
215        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: true, app: false });
216        let tool = tool_with_meta(json!({"ui": {"visibility": "model"}}));
217        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: false, app: true });
218        let tool = tool_with_meta(json!({"ui": {"visibility": {"model": true}}}));
219        assert_eq!(parse_mcp_tool(&tool).visibility, Visibility { model: false, app: true });
220    }
221
222    #[test]
223    fn parse_mcp_tool_preserves_name_description_and_input_schema() {
224        let input_schema = Arc::new(
225            serde_json::from_value(json!({
226                "type": "object",
227                "properties": {
228                    "query": {"type": "string"}
229                }
230            }))
231            .expect("json object"),
232        );
233        let tool = Tool::new("search-docs", "Search documentation", input_schema);
234
235        let parsed = parse_mcp_tool(&tool);
236        assert_eq!(
237            parsed,
238            ParsedMcpTool {
239                name: "search-docs".to_string(),
240                description: "Search documentation".to_string(),
241                input_schema: json!({
242                    "type": "object",
243                    "properties": {
244                        "query": {"type": "string"}
245                    }
246                }),
247                output_schema: None,
248                ui: None,
249                visibility: McpToolVisibility::default(),
250            }
251        );
252    }
253
254    #[test]
255    fn parse_mcp_tool_defaults_missing_description() {
256        let tool = Tool::new_with_raw("search-docs", None, empty_schema());
257
258        let parsed = parse_mcp_tool(&tool);
259        assert_eq!(parsed.description, "");
260        assert_eq!(parsed.input_schema, json!({}));
261        assert_eq!(parsed.output_schema, None);
262        assert_eq!(parsed.ui, None);
263    }
264
265    #[test]
266    fn parse_mcp_tool_links_canonical_ui_resource_uri() {
267        let tool = tool_with_meta(json!({"ui": {"resourceUri": "ui://get-time/mcp-app.html"}}));
268
269        let parsed = parse_mcp_tool(&tool);
270        assert_eq!(
271            parsed.ui,
272            Some(McpUiToolMeta {
273                resource_uri: "ui://get-time/mcp-app.html".to_string(),
274            })
275        );
276    }
277
278    #[test]
279    fn parse_mcp_tool_tolerates_deprecated_ui_resource_uri_key() {
280        let tool = tool_with_meta(json!({"ui/resourceUri": "ui://legacy/app.html"}));
281
282        let parsed = parse_mcp_tool(&tool);
283        assert_eq!(parsed.ui, Some(McpUiToolMeta { resource_uri: "ui://legacy/app.html".to_string() }));
284    }
285
286    #[test]
287    fn parse_mcp_tool_prefers_canonical_over_deprecated_key() {
288        let tool = tool_with_meta(json!({
289            "ui": {"resourceUri": "ui://canonical/app.html"},
290            "ui/resourceUri": "ui://deprecated/app.html",
291        }));
292
293        let parsed = parse_mcp_tool(&tool);
294        assert_eq!(
295            parsed.ui,
296            Some(McpUiToolMeta {
297                resource_uri: "ui://canonical/app.html".to_string(),
298            })
299        );
300    }
301
302    #[test]
303    fn parse_mcp_tool_rejects_non_ui_scheme_without_fallback() {
304        let tool = tool_with_meta(json!({
305            "ui": {"resourceUri": "https://example.com/app.html"},
306            "ui/resourceUri": "ui://fallback/app.html",
307        }));
308
309        // A present-but-invalid canonical value does not fall through to the
310        // deprecated key; mixed-shape metadata links nothing.
311        let parsed = parse_mcp_tool(&tool);
312        assert_eq!(parsed.ui, None);
313    }
314
315    #[test]
316    fn parse_ui_resource_meta_reads_csp_permissions_and_domain() {
317        let meta: serde_json::Map<String, serde_json::Value> = serde_json::from_value(json!({
318            "ui": {
319                "csp": {
320                    "connectDomains": ["https://api.example.com", "wss://realtime.example.com"],
321                    "resourceDomains": ["https://cdn.jsdelivr.net"],
322                    "frameDomains": [],
323                    "baseUriDomains": ["https://cdn.example.com"],
324                },
325                "permissions": {"camera": {}, "clipboardWrite": {}},
326                "domain": "app.example.com",
327            }
328        }))
329        .expect("meta object");
330
331        let parsed = super::parse_ui_resource_meta(Some(&meta)).expect("ui meta");
332        assert_eq!(parsed.connect_domains, ["https://api.example.com", "wss://realtime.example.com"]);
333        assert_eq!(parsed.resource_domains, ["https://cdn.jsdelivr.net"]);
334        assert!(parsed.frame_domains.is_empty());
335        assert_eq!(parsed.base_uri_domains, ["https://cdn.example.com"]);
336        assert_eq!(parsed.permissions, ["camera", "clipboardWrite"]);
337        assert_eq!(parsed.domain.as_deref(), Some("app.example.com"));
338    }
339
340    #[test]
341    fn ui_resource_meta_serializes_with_spec_wire_names() {
342        let meta = super::McpUiResourceMeta {
343            connect_domains: vec!["https://api.example.com".to_string()],
344            permissions: vec!["camera".to_string()],
345            domain: Some("app.example.com".to_string()),
346            ..Default::default()
347        };
348        let value = serde_json::to_value(&meta).expect("serialize ui meta");
349        assert_eq!(value["connectDomains"], json!(["https://api.example.com"]));
350        assert_eq!(value["resourceDomains"], json!([]));
351        assert_eq!(value["permissions"], json!(["camera"]));
352        assert_eq!(value["domain"], json!("app.example.com"));
353    }
354
355    #[test]
356    fn parse_ui_resource_meta_ignores_unknown_permission_keys() {
357        let meta: serde_json::Map<String, serde_json::Value> = serde_json::from_value(json!({
358            "ui": {"permissions": {"camera": {}, "future-feature": {}}}
359        }))
360        .expect("meta object");
361
362        let parsed = super::parse_ui_resource_meta(Some(&meta)).expect("ui meta");
363        assert_eq!(parsed.permissions, ["camera"]);
364    }
365
366    #[test]
367    fn parse_ui_resource_meta_treats_null_and_false_permissions_as_absent() {
368        let meta: serde_json::Map<String, serde_json::Value> = serde_json::from_value(json!({
369            "ui": {"permissions": {"camera": false, "microphone": null, "geolocation": {}}}
370        }))
371        .expect("meta object");
372
373        let parsed = super::parse_ui_resource_meta(Some(&meta)).expect("ui meta");
374        assert_eq!(parsed.permissions, ["geolocation"]);
375    }
376
377    #[test]
378    fn parse_ui_resource_meta_skips_mistyped_entries_fail_soft() {
379        let meta: serde_json::Map<String, serde_json::Value> = serde_json::from_value(json!({
380            "ui": {
381                "csp": {
382                    "connectDomains": ["https://api.example.com", 42, null],
383                    "resourceDomains": "https://cdn.example.com",
384                },
385                "domain": 7,
386            }
387        }))
388        .expect("meta object");
389
390        let parsed = super::parse_ui_resource_meta(Some(&meta)).expect("ui meta");
391        assert_eq!(parsed.connect_domains, ["https://api.example.com"]);
392        assert!(parsed.resource_domains.is_empty());
393        assert_eq!(parsed.domain, None);
394    }
395
396    #[test]
397    fn parse_ui_resource_meta_returns_none_without_ui_object() {
398        assert_eq!(super::parse_ui_resource_meta(None), None);
399        for meta in [
400            json!({}),
401            json!({"ui": "ui://not-an-object"}),
402            json!({"ui": []}),
403            json!({"traceparent": "00-abc-def-01"}),
404        ] {
405            let map: serde_json::Map<String, serde_json::Value> = serde_json::from_value(meta).expect("meta object");
406            assert_eq!(super::parse_ui_resource_meta(Some(&map)), None);
407        }
408    }
409
410    #[test]
411    fn parse_mcp_tool_rejects_malformed_ui_meta() {
412        for meta in [
413            json!({}),
414            json!({"ui": "ui://not-an-object/app.html"}),
415            json!({"ui": {}}),
416            json!({"ui": {"resourceUri": 42}}),
417            json!({"ui": {"resourceUri": ""}}),
418            json!({"ui": {"resourceUri": "ui://"}}),
419            json!({"ui": {"resourceUri": "file:///local/app.html"}}),
420            json!({"ui/resourceUri": "https://example.com/app.html"}),
421        ] {
422            let parsed = parse_mcp_tool(&tool_with_meta(meta));
423            assert_eq!(parsed.ui, None);
424        }
425    }
426
427    #[test]
428    fn parse_mcp_tool_captures_output_schema() {
429        let input_schema = Arc::new(serde_json::from_value(json!({})).expect("json object"));
430        let output_schema = Arc::new(
431            serde_json::from_value(json!({
432                "type": "object",
433                "properties": {
434                    "answer": {"type": "string"}
435                },
436                "required": ["answer"]
437            }))
438            .expect("json object"),
439        );
440        let mut tool = Tool::new_with_raw("ask_question", None, input_schema);
441        tool.output_schema = Some(output_schema);
442
443        let parsed = parse_mcp_tool(&tool);
444        assert_eq!(
445            parsed.output_schema,
446            Some(json!({
447                "type": "object",
448                "properties": {
449                    "answer": {"type": "string"}
450                },
451                "required": ["answer"]
452            }))
453        );
454    }
455}