1use std::collections::HashSet;
21use std::fmt;
22
23use serde::Serialize;
24use serde_json::{Map, Value};
25use thiserror::Error;
26use url::Url;
27
28use crate::protocol::{
29 CallToolResult, Content, MetaValidationError, ReadResourceResult, ResourceContent,
30 validate_meta_object,
31};
32use crate::{
33 ExtensionDeclaration, McpClientBuilder, McpRouter, RequestContext, Resource, ResourceBuilder,
34 Tool,
35};
36
37pub const MCP_APPS_EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
39
40pub const MCP_APP_HTML_MIME_TYPE: &str = "text/html;profile=mcp-app";
42
43#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum McpAppError {
47 #[error("invalid MCP Apps UI URI {0:?}")]
49 InvalidUiUri(String),
50 #[error("MCP Apps content must be a complete HTML5 document")]
52 InvalidHtmlDocument,
53 #[error("invalid MCP Apps CSP origin {origin:?} for {directive}")]
55 InvalidCspOrigin {
56 origin: String,
58 directive: &'static str,
60 },
61 #[error("invalid MCP Apps dedicated domain {0:?}")]
63 InvalidDomain(String),
64 #[error("MCP Apps tool visibility must be non-empty and contain no duplicates")]
66 InvalidVisibility,
67 #[error("MCP Apps {0} must not be empty")]
69 EmptyField(&'static str),
70 #[error(transparent)]
72 Metadata(#[from] MetaValidationError),
73 #[error("failed to serialize MCP Apps metadata: {0}")]
75 Serialization(#[from] serde_json::Error),
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
80#[serde(transparent)]
81pub struct McpAppUri(String);
82
83impl McpAppUri {
84 pub fn new(uri: impl Into<String>) -> Result<Self, McpAppError> {
86 let uri = uri.into();
87 let parsed = Url::parse(&uri).map_err(|_| McpAppError::InvalidUiUri(uri.clone()))?;
88 if !uri.starts_with("ui://")
89 || parsed.scheme() != "ui"
90 || parsed.host_str().is_none()
91 || !parsed.username().is_empty()
92 || parsed.password().is_some()
93 || parsed.port().is_some()
94 || parsed.query().is_some()
95 || parsed.fragment().is_some()
96 {
97 return Err(McpAppError::InvalidUiUri(uri));
98 }
99 Ok(Self(uri))
100 }
101
102 pub fn as_str(&self) -> &str {
104 &self.0
105 }
106}
107
108impl fmt::Display for McpAppUri {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 self.0.fmt(f)
111 }
112}
113
114impl TryFrom<String> for McpAppUri {
115 type Error = McpAppError;
116
117 fn try_from(value: String) -> Result<Self, Self::Error> {
118 Self::new(value)
119 }
120}
121
122impl TryFrom<&str> for McpAppUri {
123 type Error = McpAppError;
124
125 fn try_from(value: &str) -> Result<Self, Self::Error> {
126 Self::new(value)
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct McpAppHtml(String);
133
134impl McpAppHtml {
135 pub fn new(html: impl Into<String>) -> Result<Self, McpAppError> {
141 let html = html.into();
142 let lower = html.trim_start().to_ascii_lowercase();
143 if html.contains('\0') || !lower.starts_with("<!doctype html>") || !lower.contains("<html")
144 {
145 return Err(McpAppError::InvalidHtmlDocument);
146 }
147 Ok(Self(html))
148 }
149
150 pub fn as_str(&self) -> &str {
152 &self.0
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
157#[serde(transparent)]
158struct CspOrigin(String);
159
160#[derive(Debug, Clone, Copy)]
161enum CspDirective {
162 Connect,
163 Resource,
164 Frame,
165 BaseUri,
166}
167
168impl CspDirective {
169 fn name(self) -> &'static str {
170 match self {
171 Self::Connect => "connectDomains",
172 Self::Resource => "resourceDomains",
173 Self::Frame => "frameDomains",
174 Self::BaseUri => "baseUriDomains",
175 }
176 }
177
178 fn allows_scheme(self, scheme: &str) -> bool {
179 match self {
180 Self::Connect => matches!(scheme, "http" | "https" | "ws" | "wss"),
181 Self::Resource | Self::Frame | Self::BaseUri => matches!(scheme, "http" | "https"),
182 }
183 }
184
185 fn allows_wildcard(self) -> bool {
186 matches!(self, Self::Resource)
187 }
188}
189
190impl CspOrigin {
191 fn parse(origin: impl Into<String>, directive: CspDirective) -> Result<Self, McpAppError> {
192 let origin = origin.into();
193 let invalid = || McpAppError::InvalidCspOrigin {
194 origin: origin.clone(),
195 directive: directive.name(),
196 };
197 if origin.trim() != origin || origin.ends_with("//") {
198 return Err(invalid());
199 }
200
201 let wildcard = origin.contains("://*.");
202 if wildcard && !directive.allows_wildcard() {
203 return Err(invalid());
204 }
205 if origin.contains('*') && !wildcard {
206 return Err(invalid());
207 }
208
209 let parseable = if wildcard {
210 origin.replacen("://*.", "://wildcard.", 1)
211 } else {
212 origin.clone()
213 };
214 let parsed = Url::parse(&parseable).map_err(|_| invalid())?;
215 if !directive.allows_scheme(parsed.scheme())
216 || parsed.host_str().is_none()
217 || !parsed.username().is_empty()
218 || parsed.password().is_some()
219 || !matches!(parsed.path(), "" | "/")
220 || parsed.query().is_some()
221 || parsed.fragment().is_some()
222 {
223 return Err(invalid());
224 }
225 if wildcard {
226 let host = parsed.host_str().ok_or_else(invalid)?;
227 let suffix = host.strip_prefix("wildcard.").ok_or_else(invalid)?;
228 if !suffix.contains('.') {
229 return Err(invalid());
230 }
231 }
232
233 Ok(Self(
234 origin.strip_suffix('/').unwrap_or(&origin).to_string(),
235 ))
236 }
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
245#[serde(rename_all = "camelCase")]
246pub struct McpUiResourceCsp {
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 connect_domains: Vec<CspOrigin>,
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 resource_domains: Vec<CspOrigin>,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 frame_domains: Vec<CspOrigin>,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 base_uri_domains: Vec<CspOrigin>,
255}
256
257impl McpUiResourceCsp {
258 pub fn allow_connect(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
260 push_unique(
261 &mut self.connect_domains,
262 CspOrigin::parse(origin, CspDirective::Connect)?,
263 );
264 Ok(self)
265 }
266
267 pub fn allow_resource(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
272 push_unique(
273 &mut self.resource_domains,
274 CspOrigin::parse(origin, CspDirective::Resource)?,
275 );
276 Ok(self)
277 }
278
279 pub fn allow_frame(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
281 push_unique(
282 &mut self.frame_domains,
283 CspOrigin::parse(origin, CspDirective::Frame)?,
284 );
285 Ok(self)
286 }
287
288 pub fn allow_base_uri(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
290 push_unique(
291 &mut self.base_uri_domains,
292 CspOrigin::parse(origin, CspDirective::BaseUri)?,
293 );
294 Ok(self)
295 }
296
297 pub fn connect_domains(&self) -> impl Iterator<Item = &str> {
299 self.connect_domains.iter().map(|origin| origin.0.as_str())
300 }
301
302 pub fn resource_domains(&self) -> impl Iterator<Item = &str> {
304 self.resource_domains.iter().map(|origin| origin.0.as_str())
305 }
306
307 pub fn frame_domains(&self) -> impl Iterator<Item = &str> {
309 self.frame_domains.iter().map(|origin| origin.0.as_str())
310 }
311
312 pub fn base_uri_domains(&self) -> impl Iterator<Item = &str> {
314 self.base_uri_domains.iter().map(|origin| origin.0.as_str())
315 }
316}
317
318fn push_unique(values: &mut Vec<CspOrigin>, value: CspOrigin) {
319 if !values.contains(&value) {
320 values.push(value);
321 }
322}
323
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
325struct EmptyPermission {}
326
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
332#[serde(rename_all = "camelCase")]
333pub struct McpUiPermissions {
334 #[serde(skip_serializing_if = "Option::is_none")]
335 camera: Option<EmptyPermission>,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 microphone: Option<EmptyPermission>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 geolocation: Option<EmptyPermission>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 clipboard_write: Option<EmptyPermission>,
342}
343
344impl McpUiPermissions {
345 pub fn camera(mut self) -> Self {
347 self.camera = Some(EmptyPermission {});
348 self
349 }
350
351 pub fn microphone(mut self) -> Self {
353 self.microphone = Some(EmptyPermission {});
354 self
355 }
356
357 pub fn geolocation(mut self) -> Self {
359 self.geolocation = Some(EmptyPermission {});
360 self
361 }
362
363 pub fn clipboard_write(mut self) -> Self {
365 self.clipboard_write = Some(EmptyPermission {});
366 self
367 }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
372#[serde(transparent)]
373pub struct McpAppDomain(String);
374
375impl McpAppDomain {
376 pub fn new(domain: impl Into<String>) -> Result<Self, McpAppError> {
378 let domain = domain.into();
379 let valid = !domain.is_empty()
380 && domain.len() <= 253
381 && domain.trim() == domain
382 && !domain.contains(['/', ':', '?', '#', '@'])
383 && domain.split('.').all(|label| {
384 let bytes = label.as_bytes();
385 !bytes.is_empty()
386 && bytes.len() <= 63
387 && bytes[0].is_ascii_alphanumeric()
388 && bytes[bytes.len() - 1].is_ascii_alphanumeric()
389 && bytes
390 .iter()
391 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
392 });
393 if !valid {
394 return Err(McpAppError::InvalidDomain(domain));
395 }
396 Ok(Self(domain))
397 }
398
399 pub fn as_str(&self) -> &str {
401 &self.0
402 }
403}
404
405#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
407#[serde(rename_all = "camelCase")]
408pub struct McpUiResourceMeta {
409 #[serde(skip_serializing_if = "Option::is_none")]
410 csp: Option<McpUiResourceCsp>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 permissions: Option<McpUiPermissions>,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 domain: Option<McpAppDomain>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 prefers_border: Option<bool>,
417}
418
419impl McpUiResourceMeta {
420 pub fn csp(mut self, csp: McpUiResourceCsp) -> Self {
422 self.csp = Some(csp);
423 self
424 }
425
426 pub fn permissions(mut self, permissions: McpUiPermissions) -> Self {
428 self.permissions = Some(permissions);
429 self
430 }
431
432 pub fn domain(mut self, domain: McpAppDomain) -> Self {
434 self.domain = Some(domain);
435 self
436 }
437
438 pub fn prefers_border(mut self, prefers_border: bool) -> Self {
440 self.prefers_border = Some(prefers_border);
441 self
442 }
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
447#[serde(rename_all = "lowercase")]
448#[non_exhaustive]
449pub enum McpUiToolVisibility {
450 Model,
452 App,
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
458#[serde(rename_all = "camelCase")]
459pub struct McpUiToolMeta {
460 resource_uri: McpAppUri,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 visibility: Option<Vec<McpUiToolVisibility>>,
463}
464
465impl McpUiToolMeta {
466 pub fn new(resource_uri: McpAppUri) -> Self {
470 Self {
471 resource_uri,
472 visibility: None,
473 }
474 }
475
476 pub fn app_only(resource_uri: McpAppUri) -> Self {
478 Self::new(resource_uri).visibility_unchecked(vec![McpUiToolVisibility::App])
479 }
480
481 pub fn model_only(resource_uri: McpAppUri) -> Self {
483 Self::new(resource_uri).visibility_unchecked(vec![McpUiToolVisibility::Model])
484 }
485
486 pub fn visibility(
488 mut self,
489 visibility: impl IntoIterator<Item = McpUiToolVisibility>,
490 ) -> Result<Self, McpAppError> {
491 let visibility: Vec<_> = visibility.into_iter().collect();
492 let unique: HashSet<_> = visibility.iter().copied().collect();
493 if visibility.is_empty() || unique.len() != visibility.len() {
494 return Err(McpAppError::InvalidVisibility);
495 }
496 self.visibility = Some(visibility);
497 Ok(self)
498 }
499
500 fn visibility_unchecked(mut self, visibility: Vec<McpUiToolVisibility>) -> Self {
501 self.visibility = Some(visibility);
502 self
503 }
504
505 pub fn resource_uri(&self) -> &McpAppUri {
507 &self.resource_uri
508 }
509
510 pub fn effective_visibility(&self) -> impl Iterator<Item = McpUiToolVisibility> + '_ {
512 self.visibility
513 .as_deref()
514 .unwrap_or(&[McpUiToolVisibility::Model, McpUiToolVisibility::App])
515 .iter()
516 .copied()
517 }
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
522#[serde(rename_all = "camelCase")]
523pub struct McpAppsCapabilitySettings {
524 mime_types: Vec<String>,
525}
526
527impl McpAppsCapabilitySettings {
528 pub fn html() -> Self {
530 Self {
531 mime_types: vec![MCP_APP_HTML_MIME_TYPE.to_string()],
532 }
533 }
534
535 pub fn mime_types(&self) -> impl Iterator<Item = &str> {
537 self.mime_types.iter().map(String::as_str)
538 }
539}
540
541pub fn mcp_apps_extension() -> ExtensionDeclaration {
543 ExtensionDeclaration::new(MCP_APPS_EXTENSION_ID, McpAppsCapabilitySettings::html())
544 .expect("the built-in MCP Apps extension declaration is valid")
545}
546
547impl McpRouter {
548 pub fn with_mcp_apps(self) -> Self {
550 self.with_protocol_extension(mcp_apps_extension())
551 }
552}
553
554impl McpClientBuilder {
555 pub fn with_mcp_apps(self) -> Self {
557 self.with_protocol_extension(mcp_apps_extension())
558 }
559}
560
561impl RequestContext {
562 pub fn supports_mcp_apps(&self) -> bool {
564 let Some(extension) = self
565 .negotiated_extensions()
566 .and_then(|extensions| extensions.get(MCP_APPS_EXTENSION_ID))
567 else {
568 return false;
569 };
570 settings_support_html(extension.client_settings())
571 && settings_support_html(extension.server_settings())
572 }
573}
574
575fn settings_support_html(settings: &Value) -> bool {
576 settings
577 .get("mimeTypes")
578 .and_then(Value::as_array)
579 .is_some_and(|mime_types| {
580 mime_types
581 .iter()
582 .any(|mime_type| mime_type.as_str() == Some(MCP_APP_HTML_MIME_TYPE))
583 })
584}
585
586impl Tool {
587 pub fn with_mcp_app(mut self, metadata: McpUiToolMeta) -> Result<Self, McpAppError> {
592 let ui = serde_json::to_value(metadata)?;
593 self.meta = Some(merge_ui_meta(self.meta.take(), ui)?);
594 Ok(self)
595 }
596}
597
598fn merge_ui_meta(existing: Option<Value>, ui: Value) -> Result<Value, McpAppError> {
599 let mut object = match existing {
600 Some(Value::Object(object)) => object,
601 Some(_) => return Err(McpAppError::Metadata(MetaValidationError::ExpectedObject)),
602 None => Map::new(),
603 };
604 object.insert("ui".to_string(), ui);
605 let meta = Value::Object(object);
606 validate_meta_object(&meta)?;
607 Ok(meta)
608}
609
610pub struct McpAppResourceBuilder {
612 uri: McpAppUri,
613 name: String,
614 description: Option<String>,
615 html: McpAppHtml,
616 metadata: McpUiResourceMeta,
617}
618
619impl McpAppResourceBuilder {
620 pub fn new(
622 uri: impl Into<String>,
623 name: impl Into<String>,
624 html: impl Into<String>,
625 ) -> Result<Self, McpAppError> {
626 let name = name.into();
627 if name.trim().is_empty() {
628 return Err(McpAppError::EmptyField("resource name"));
629 }
630 Ok(Self {
631 uri: McpAppUri::new(uri)?,
632 name,
633 description: None,
634 html: McpAppHtml::new(html)?,
635 metadata: McpUiResourceMeta::default(),
636 })
637 }
638
639 pub fn description(mut self, description: impl Into<String>) -> Self {
641 self.description = Some(description.into());
642 self
643 }
644
645 pub fn metadata(mut self, metadata: McpUiResourceMeta) -> Self {
647 self.metadata = metadata;
648 self
649 }
650
651 pub fn build(self) -> Result<Resource, McpAppError> {
654 let uri = self.uri.to_string();
655 let html = self.html.0;
656 let meta = merge_ui_meta(None, serde_json::to_value(self.metadata)?)?;
657 let content_meta = meta.clone();
658 let content_uri = uri.clone();
659
660 let mut builder = ResourceBuilder::new(uri)
661 .name(self.name)
662 .mime_type(MCP_APP_HTML_MIME_TYPE);
663 if let Some(description) = self.description {
664 builder = builder.description(description);
665 }
666 let resource = builder
667 .handler(move || {
668 let uri = content_uri.clone();
669 let html = html.clone();
670 let meta = content_meta.clone();
671 async move {
672 Ok(ReadResourceResult {
673 contents: vec![ResourceContent {
674 uri,
675 mime_type: Some(MCP_APP_HTML_MIME_TYPE.to_string()),
676 text: Some(html),
677 blob: None,
678 meta: Some(meta),
679 }],
680 ..ReadResourceResult::default()
681 })
682 }
683 })
684 .build()
685 .with_meta(meta)?;
686 Ok(resource)
687 }
688}
689
690pub fn mcp_app_tool_result(
695 fallback_text: impl Into<String>,
696 structured_content: impl Serialize,
697) -> Result<CallToolResult, serde_json::Error> {
698 Ok(CallToolResult {
699 content: vec![Content::text(fallback_text)],
700 is_error: false,
701 structured_content: Some(serde_json::to_value(structured_content)?),
702 meta: None,
703 })
704}
705
706#[cfg(test)]
707mod tests {
708 use std::collections::HashMap;
709
710 use serde_json::json;
711
712 use super::*;
713 use crate::protocol::{ClientCapabilities, ServerCapabilities};
714
715 const HTML: &str = "<!doctype html><html><body>weather</body></html>";
716
717 #[test]
718 fn ui_uri_rejects_non_ui_and_ambiguous_values() {
719 assert!(McpAppUri::new("ui://weather/dashboard").is_ok());
720 for invalid in [
721 "https://example.com/app",
722 "ui:///missing-authority",
723 "ui://user@example.com/app",
724 "ui://example.com/app?mode=wide",
725 "UI://example.com/app",
726 ] {
727 assert!(McpAppUri::new(invalid).is_err(), "{invalid}");
728 }
729 }
730
731 #[test]
732 fn html_requires_a_complete_document_shape() {
733 assert!(McpAppHtml::new(HTML).is_ok());
734 assert!(McpAppHtml::new("<div>fragment</div>").is_err());
735 assert!(McpAppHtml::new("<!doctype html><body>missing root</body>").is_err());
736 }
737
738 #[test]
739 fn csp_accepts_only_field_appropriate_origins() {
740 let csp = McpUiResourceCsp::default()
741 .allow_connect("wss://events.example.com")
742 .unwrap()
743 .allow_resource("https://*.cdn.example.com")
744 .unwrap()
745 .allow_frame("https://player.example.com")
746 .unwrap()
747 .allow_base_uri("https://assets.example.com/")
748 .unwrap();
749
750 assert_eq!(
751 serde_json::to_value(csp).unwrap(),
752 json!({
753 "connectDomains": ["wss://events.example.com"],
754 "resourceDomains": ["https://*.cdn.example.com"],
755 "frameDomains": ["https://player.example.com"],
756 "baseUriDomains": ["https://assets.example.com"]
757 })
758 );
759 assert!(
760 McpUiResourceCsp::default()
761 .allow_connect("https://example.com/api")
762 .is_err()
763 );
764 assert!(
765 McpUiResourceCsp::default()
766 .allow_frame("https://*.example.com")
767 .is_err()
768 );
769 assert!(
770 McpUiResourceCsp::default()
771 .allow_resource("data:text/javascript,alert(1)")
772 .is_err()
773 );
774 }
775
776 #[test]
777 fn permissions_serialize_as_empty_capability_objects() {
778 assert_eq!(
779 serde_json::to_value(
780 McpUiPermissions::default()
781 .camera()
782 .geolocation()
783 .clipboard_write()
784 )
785 .unwrap(),
786 json!({"camera": {}, "geolocation": {}, "clipboardWrite": {}})
787 );
788 }
789
790 #[test]
791 fn visibility_is_typed_and_validated() {
792 let uri = McpAppUri::new("ui://weather/dashboard").unwrap();
793 assert_eq!(
794 McpUiToolMeta::new(uri.clone())
795 .effective_visibility()
796 .collect::<Vec<_>>(),
797 vec![McpUiToolVisibility::Model, McpUiToolVisibility::App]
798 );
799 assert!(McpUiToolMeta::new(uri.clone()).visibility([]).is_err());
800 assert!(
801 McpUiToolMeta::new(uri)
802 .visibility([McpUiToolVisibility::App, McpUiToolVisibility::App])
803 .is_err()
804 );
805 }
806
807 #[tokio::test]
808 async fn app_resource_uses_exact_wire_shape_on_definition_and_content() {
809 let metadata = McpUiResourceMeta::default()
810 .csp(
811 McpUiResourceCsp::default()
812 .allow_connect("https://api.example.com")
813 .unwrap(),
814 )
815 .permissions(McpUiPermissions::default().geolocation())
816 .domain(McpAppDomain::new("app.example.com").unwrap())
817 .prefers_border(true);
818 let resource = McpAppResourceBuilder::new("ui://weather/dashboard", "Weather", HTML)
819 .unwrap()
820 .metadata(metadata)
821 .build()
822 .unwrap();
823
824 let definition = serde_json::to_value(resource.definition()).unwrap();
825 assert_eq!(definition["mimeType"], MCP_APP_HTML_MIME_TYPE);
826 assert_eq!(definition["_meta"]["ui"]["prefersBorder"], true);
827 assert_eq!(
828 definition["_meta"]["ui"]["csp"]["connectDomains"][0],
829 "https://api.example.com"
830 );
831
832 let result = resource.read().await;
833 assert_eq!(result.contents[0].uri, "ui://weather/dashboard");
834 assert_eq!(
835 result.contents[0].mime_type.as_deref(),
836 Some(MCP_APP_HTML_MIME_TYPE)
837 );
838 assert_eq!(
839 result.contents[0].meta.as_ref().unwrap()["ui"]["permissions"]["geolocation"],
840 json!({})
841 );
842 }
843
844 #[test]
845 fn tool_metadata_preserves_other_extension_keys() {
846 let tool = crate::ToolBuilder::new("weather")
847 .handler(|()| async { Ok(CallToolResult::text("sunny")) })
848 .build()
849 .with_meta(json!({"com.example/audit": {"level": "full"}}))
850 .unwrap()
851 .with_mcp_app(McpUiToolMeta::app_only(
852 McpAppUri::new("ui://weather/dashboard").unwrap(),
853 ))
854 .unwrap();
855
856 let definition = serde_json::to_value(tool.definition()).unwrap();
857 assert_eq!(definition["_meta"]["com.example/audit"]["level"], "full");
858 assert_eq!(
859 definition["_meta"]["ui"]["resourceUri"],
860 "ui://weather/dashboard"
861 );
862 assert_eq!(definition["_meta"]["ui"]["visibility"], json!(["app"]));
863 }
864
865 #[test]
866 fn runtime_support_requires_both_peers_and_the_html_mime() {
867 let client = ClientCapabilities {
868 extensions: Some(HashMap::from([(
869 MCP_APPS_EXTENSION_ID.to_string(),
870 serde_json::to_value(McpAppsCapabilitySettings::html()).unwrap(),
871 )])),
872 ..ClientCapabilities::default()
873 };
874 let server = ServerCapabilities {
875 extensions: Some(HashMap::from([(
876 MCP_APPS_EXTENSION_ID.to_string(),
877 serde_json::to_value(McpAppsCapabilitySettings::html()).unwrap(),
878 )])),
879 ..ServerCapabilities::default()
880 };
881 let mut context = RequestContext::new(crate::protocol::RequestId::Number(1));
882 context
883 .extensions_mut()
884 .insert(crate::NegotiatedExtensions::from_capabilities(
885 &client, &server,
886 ));
887 assert!(context.supports_mcp_apps());
888
889 let mismatched_server = ServerCapabilities {
890 extensions: Some(HashMap::from([(
891 MCP_APPS_EXTENSION_ID.to_string(),
892 json!({"mimeTypes": ["text/plain"]}),
893 )])),
894 ..ServerCapabilities::default()
895 };
896 context
897 .extensions_mut()
898 .insert(crate::NegotiatedExtensions::from_capabilities(
899 &client,
900 &mismatched_server,
901 ));
902 assert!(!context.supports_mcp_apps());
903 }
904
905 #[test]
906 fn opt_in_uses_the_exact_reserved_declaration() {
907 let extension = mcp_apps_extension();
908 let encoded = serde_json::to_value(extension.settings()).unwrap();
909 assert_eq!(encoded["mimeTypes"][0], MCP_APP_HTML_MIME_TYPE);
910 assert_eq!(extension.identifier(), MCP_APPS_EXTENSION_ID);
911
912 let _router = McpRouter::new().with_mcp_apps();
915 let _client = McpClientBuilder::new().with_mcp_apps();
916 }
917
918 #[test]
919 fn result_helper_always_carries_text_fallback() {
920 let result = mcp_app_tool_result("72 F and sunny", json!({"temperature": 72})).unwrap();
921 assert_eq!(result.first_text(), Some("72 F and sunny"));
922 assert_eq!(result.structured_content.unwrap()["temperature"], 72);
923 }
924}