1use rmcp::model::Tool;
2use serde_json::Value;
3
4pub const MCP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
6
7pub const MCP_UI_RESOURCE_MIME_TYPE: &str = "text/html;profile=mcp-app";
9
10pub const MCP_UI_RESOURCE_SCHEME: &str = "ui://";
12
13const DEPRECATED_UI_RESOURCE_URI_KEY: &str = "ui/resourceUri";
15
16const UI_PERMISSION_KEYS: [&str; 4] = ["camera", "microphone", "geolocation", "clipboardWrite"];
18
19#[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#[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct McpUiResourceMeta {
101 pub connect_domains: Vec<String>,
103 pub resource_domains: Vec<String>,
105 pub frame_domains: Vec<String>,
107 pub base_uri_domains: Vec<String>,
109 pub permissions: Vec<String>,
111 pub domain: Option<String>,
113}
114
115#[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
143fn 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
153fn 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 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 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 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 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}