use std::fmt;
use fastmcp_core::{McpContext, McpResult};
use fastmcp_protocol::common_types::AbsoluteUri;
use fastmcp_protocol::{
FinalResource, MCP_APPS_HTML_MIME_TYPE, McpAppsMetadataError, McpAppsResourceMetadata,
Resource, ResourceContent,
};
use crate::ResourceHandler;
#[derive(Clone, Debug)]
pub struct McpAppsUiResource {
uri: AbsoluteUri,
name: String,
description: Option<String>,
html: String,
apps_metadata: Option<McpAppsResourceMetadata>,
}
impl McpAppsUiResource {
pub fn try_new(
uri: AbsoluteUri,
name: impl Into<String>,
html: impl Into<String>,
) -> Result<Self, McpAppsUiResourceError> {
if !uri.as_str().starts_with("ui://") {
return Err(McpAppsUiResourceError::UriMustUseUiPrefix);
}
let name = name.into();
if name.is_empty() {
return Err(McpAppsUiResourceError::EmptyName);
}
Ok(Self {
uri,
name,
description: None,
html: html.into(),
apps_metadata: None,
})
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_apps_metadata(
mut self,
metadata: McpAppsResourceMetadata,
) -> Result<Self, McpAppsUiResourceError> {
let metadata = validated_apps_metadata(metadata)?;
metadata.to_open_metadata().map_err(|_| {
McpAppsUiResourceError::InvalidMetadata(McpAppsMetadataError::InvalidResourceMetadata)
})?;
self.apps_metadata = Some(metadata);
Ok(self)
}
#[must_use]
pub fn final_catalog_entry(&self) -> FinalResource {
FinalResource {
uri: self.uri.clone(),
name: self.name.clone(),
title: None,
description: self.description.clone(),
icons: None,
mime_type: Some(MCP_APPS_HTML_MIME_TYPE.to_owned()),
size: None,
annotations: None,
meta: self.apps_metadata.as_ref().map(|metadata| {
metadata
.to_open_metadata()
.expect("validated MCP Apps resource metadata serializes")
}),
}
}
#[must_use]
pub const fn uri(&self) -> &AbsoluteUri {
&self.uri
}
}
impl ResourceHandler for McpAppsUiResource {
fn definition(&self) -> Resource {
Resource {
uri: self.uri.as_str().to_owned(),
name: self.name.clone(),
description: self.description.clone(),
mime_type: Some(MCP_APPS_HTML_MIME_TYPE.to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn final_definition(&self) -> Option<FinalResource> {
Some(self.final_catalog_entry())
}
fn read(&self, context: &McpContext) -> McpResult<Vec<ResourceContent>> {
context.checkpoint()?;
Ok(vec![ResourceContent {
uri: self.uri.as_str().to_owned(),
mime_type: Some(MCP_APPS_HTML_MIME_TYPE.to_owned()),
text: Some(self.html.clone()),
blob: None,
}])
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum McpAppsUiResourceError {
UriMustUseUiPrefix,
EmptyName,
InvalidMetadata(McpAppsMetadataError),
}
impl fmt::Display for McpAppsUiResourceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UriMustUseUiPrefix => {
formatter.write_str("MCP Apps UI resource URI must start with ui://")
}
Self::EmptyName => formatter.write_str("MCP Apps UI resource name must not be empty"),
Self::InvalidMetadata(error) => {
write!(formatter, "MCP Apps UI resource metadata: {error}")
}
}
}
}
impl std::error::Error for McpAppsUiResourceError {}
fn validated_apps_metadata(
metadata: McpAppsResourceMetadata,
) -> Result<McpAppsResourceMetadata, McpAppsUiResourceError> {
McpAppsResourceMetadata::try_new(
metadata.csp,
metadata.permissions,
metadata.domain,
metadata.prefers_border,
)
.map_err(McpAppsUiResourceError::InvalidMetadata)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use fastmcp_core::{Cx, McpContext, McpErrorCode, SessionState};
use fastmcp_protocol::common_types::AbsoluteUri;
use fastmcp_protocol::{
McpAppsResourceCsp, McpAppsResourceMetadata, McpAppsResourcePermission,
McpAppsResourcePermissions, ReadResourceParams,
};
use super::*;
use crate::{DuplicateBehavior, Router};
#[test]
fn ui_resource_publishes_the_exact_html_catalog_shape_and_body() {
let resource = McpAppsUiResource::try_new(
AbsoluteUri::parse("ui://weather/dashboard").expect("valid ui URI"),
"weather-dashboard",
"<main>weather</main>",
)
.expect("ui resource configuration is valid")
.with_description("Current weather");
let catalog = resource.final_catalog_entry();
assert_eq!(catalog.uri.as_str(), "ui://weather/dashboard");
assert_eq!(catalog.mime_type.as_deref(), Some(MCP_APPS_HTML_MIME_TYPE));
let context = McpContext::new(Cx::for_testing(), 1);
let contents = resource.read(&context).expect("fixed UI document reads");
assert_eq!(contents.len(), 1);
assert_eq!(contents[0].text.as_deref(), Some("<main>weather</main>"));
assert_eq!(
contents[0].mime_type.as_deref(),
Some(MCP_APPS_HTML_MIME_TYPE)
);
}
#[test]
fn changing_only_the_ui_authority_delimiter_rejects_an_opaque_ui_uri() {
assert!(matches!(
McpAppsUiResource::try_new(
AbsoluteUri::parse("ui:opaque").expect("valid opaque ui URI"),
"weather-dashboard",
"<main>weather</main>",
),
Err(McpAppsUiResourceError::UriMustUseUiPrefix)
));
}
#[test]
fn ui_resource_publishes_typed_apps_csp_permissions_and_domain_metadata() {
let metadata = McpAppsResourceMetadata::try_new(
Some(
McpAppsResourceCsp::try_new(
Some(vec!["https://api.weather.example".to_owned()]),
None,
None,
None,
)
.expect("bounded CSP is valid"),
),
Some(McpAppsResourcePermissions {
geolocation: Some(McpAppsResourcePermission::default()),
..McpAppsResourcePermissions::default()
}),
Some("weather-view.host.example".to_owned()),
Some(true),
)
.expect("typed Apps metadata is valid");
let resource = McpAppsUiResource::try_new(
AbsoluteUri::parse("ui://weather/dashboard").expect("valid ui URI"),
"weather-dashboard",
"<main>weather</main>",
)
.expect("ui resource configuration is valid")
.with_apps_metadata(metadata)
.expect("typed Apps metadata is accepted");
let metadata = resource
.final_catalog_entry()
.mcp_apps_metadata()
.expect("published metadata remains closed and typed")
.expect("Apps resource publishes its presentation metadata");
assert_eq!(
metadata.domain.as_deref(),
Some("weather-view.host.example")
);
assert_eq!(metadata.prefers_border, Some(true));
assert!(
metadata
.permissions
.as_ref()
.and_then(|permissions| permissions.geolocation.as_ref())
.is_some()
);
assert_eq!(
metadata
.csp
.as_ref()
.and_then(|csp| csp.connect_domains.as_deref()),
Some(["https://api.weather.example".to_owned()].as_slice())
);
}
#[test]
fn changing_only_the_apps_domain_to_empty_rejects_metadata_without_catalog_mutation() {
let accepted = McpAppsResourceMetadata::try_new(
None,
None,
Some("weather-view.host.example".to_owned()),
None,
)
.expect("non-empty domain is valid");
let planted = McpAppsResourceMetadata {
domain: Some(String::new()),
..accepted.clone()
};
let resource = McpAppsUiResource::try_new(
AbsoluteUri::parse("ui://weather/dashboard").expect("valid ui URI"),
"weather-dashboard",
"<main>weather</main>",
)
.expect("ui resource configuration is valid");
let catalog_before = resource.final_catalog_entry();
assert_eq!(
resource
.clone()
.with_apps_metadata(planted)
.expect_err("only an empty domain makes the typed metadata invalid"),
McpAppsUiResourceError::InvalidMetadata(McpAppsMetadataError::InvalidDomain)
);
assert_eq!(
resource.final_catalog_entry().meta,
catalog_before.meta,
"rejected metadata cannot alter the published final catalog entry"
);
}
struct CountingUiResource {
inner: McpAppsUiResource,
reads: Arc<AtomicUsize>,
}
impl ResourceHandler for CountingUiResource {
fn definition(&self) -> Resource {
self.inner.definition()
}
fn final_definition(&self) -> Option<FinalResource> {
self.inner.final_definition()
}
fn read(&self, context: &McpContext) -> McpResult<Vec<ResourceContent>> {
self.reads.fetch_add(1, Ordering::SeqCst);
self.inner.read(context)
}
}
#[test]
fn final_only_ui_resource_has_exact_2024_zero_read_invocation() {
let reads = Arc::new(AtomicUsize::new(0));
let resource = CountingUiResource {
inner: McpAppsUiResource::try_new(
AbsoluteUri::parse("ui://weather/dashboard").expect("valid ui URI"),
"weather-dashboard",
"<main>weather</main>",
)
.expect("ui resource configuration is valid"),
reads: Arc::clone(&reads),
};
let mut router = Router::new();
router
.add_mcp_apps_ui_resource_with_behavior(resource, DuplicateBehavior::Error)
.expect("final-only UI resource registers");
let state = SessionState::new();
let context = McpContext::with_state(Cx::for_testing(), 2, state.clone());
let error = router
.handle_resources_read(
&context,
&ReadResourceParams {
uri: "ui://weather/dashboard".to_owned(),
meta: None,
},
state,
None,
None,
)
.expect_err("final-only Apps documents are not exact-2024 resources");
assert_eq!(error.code, McpErrorCode::ResourceNotFound);
assert_eq!(
reads.load(Ordering::SeqCst),
0,
"exact 2024 rejection must not invoke the Apps document reader"
);
}
}