systemprompt-mcp 0.43.0

Native Model Context Protocol (MCP) implementation for systemprompt.io. Orchestration, per-server OAuth2, RBAC middleware, and tool-call governance — the core of the AI governance pipeline.
Documentation
//! MCP resource types and URI helpers.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use rmcp::ErrorData as McpError;
use rmcp::model::{
    CacheScope, Icon, ListResourceTemplatesResult, ListResourcesResult, MetaObject,
    ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents,
};

use crate::capabilities::WEBSITE_URL;
use crate::repository::McpArtifactRepository;
use crate::services::ui_renderer::{
    CspPolicy, MCP_APP_MIME_TYPE, RenderTarget, UiMetadata, artifact_ui_resource,
    parse_artifact_resource_uri,
};
use systemprompt_identifiers::{ArtifactId, ContextId};
use systemprompt_models::mcp::McpResourceUiMeta;

// Why: the viewer template only changes when the server binary is redeployed,
// so intermediaries may share it (SEP-2549 `public`); rendered artifacts are
// per-user data and must revalidate every read (`private`, ttl 0).
const STATIC_TEMPLATE_TTL_MS: u64 = 3_600_000;

#[derive(Debug)]
pub struct ArtifactViewerConfig<'a> {
    pub server_name: &'a str,
    pub title: &'a str,
    pub description: &'a str,
    pub template: &'a str,
    pub icons: Option<Vec<Icon>>,
}

#[must_use]
pub fn build_artifact_viewer_resource(config: &ArtifactViewerConfig<'_>) -> ListResourcesResult {
    let mut resource = Resource::new(
        format!("ui://{}/artifact-viewer", config.server_name),
        "Artifact Viewer",
    )
    .with_title(config.title.to_owned())
    .with_description(config.description.to_owned())
    .with_mime_type(MCP_APP_MIME_TYPE.to_owned())
    .with_size(u64::try_from(config.template.len()).unwrap_or(u64::MAX));
    resource.icons.clone_from(&config.icons);

    ListResourcesResult::with_all_items(vec![resource])
        .with_ttl_ms(STATIC_TEMPLATE_TTL_MS)
        .with_cache_scope(CacheScope::Public)
}

#[must_use]
pub fn build_resource_template_list_result() -> ListResourceTemplatesResult {
    ListResourceTemplatesResult::with_all_items(Vec::new())
        .with_ttl_ms(STATIC_TEMPLATE_TTL_MS)
        .with_cache_scope(CacheScope::Public)
}

pub fn read_artifact_viewer_resource(
    request: &ReadResourceRequestParams,
    server_name: &str,
    template: &str,
) -> Result<ReadResourceResult, McpError> {
    let uri = &request.uri;
    let expected_uri = format!("ui://{server_name}/artifact-viewer");

    if uri != &expected_uri {
        return Err(McpError::invalid_params(
            format!("Unknown resource URI: {uri}. Expected: {expected_uri}"),
            None,
        ));
    }

    let ui_meta = UiMetadata::for_static_template(server_name)
        .with_csp(CspPolicy::strict())
        .with_prefers_border(true);

    let resource_meta = ui_meta.to_resource_meta();
    let meta = MetaObject(resource_meta.to_meta_map());

    let contents = ResourceContents::TextResourceContents {
        uri: uri.clone(),
        mime_type: Some(MCP_APP_MIME_TYPE.to_owned()),
        text: template.to_owned(),
        meta: Some(meta),
    };

    Ok(ReadResourceResult::new(vec![contents])
        .with_ttl_ms(STATIC_TEMPLATE_TTL_MS)
        .with_cache_scope(CacheScope::Public))
}

pub async fn read_artifact_resource(
    request: &ReadResourceRequestParams,
    server_name: &str,
    repo: &McpArtifactRepository,
) -> Result<ReadResourceResult, McpError> {
    let uri = &request.uri;
    let (uri_server, artifact_id) = parse_artifact_resource_uri(uri).ok_or_else(|| {
        McpError::invalid_params(format!("Not an artifact resource URI: {uri}"), None)
    })?;

    if uri_server != server_name {
        return Err(McpError::invalid_params(
            format!("Artifact URI names server '{uri_server}', not '{server_name}'"),
            None,
        ));
    }

    let artifact_id = ArtifactId::new(artifact_id);
    let record = repo
        .find_by_id(&artifact_id)
        .await
        .map_err(|e| McpError::internal_error(format!("Failed to load artifact: {e}"), None))?
        .ok_or_else(|| {
            McpError::invalid_params(format!("Unknown artifact: {artifact_id}"), None)
        })?;

    let payload = record.data.get("artifact").ok_or_else(|| {
        McpError::internal_error(
            format!("Stored artifact {artifact_id} has no payload to render"),
            None,
        )
    })?;

    let target = RenderTarget {
        artifact_id: &artifact_id,
        artifact_type: &record.artifact_type,
        payload,
        context_id: record.context_id.clone().unwrap_or_else(ContextId::legacy),
        title: record.title.clone(),
    };

    let resource = artifact_ui_resource(&target)
        .await
        .map_err(|e| McpError::internal_error(format!("Failed to render artifact: {e}"), None))?;

    let ui_meta = McpResourceUiMeta::new()
        .with_prefers_border(true)
        .with_csp_opt(Some(resource.csp.to_mcp_domains()));

    let contents = ResourceContents::TextResourceContents {
        uri: uri.clone(),
        mime_type: Some(MCP_APP_MIME_TYPE.to_owned()),
        text: resource.html,
        meta: Some(MetaObject(ui_meta.to_meta_map())),
    };

    Ok(ReadResourceResult::new(vec![contents])
        .with_ttl_ms(0)
        .with_cache_scope(CacheScope::Private))
}

#[must_use]
pub fn default_server_icons() -> Vec<Icon> {
    vec![
        Icon::new(format!("{WEBSITE_URL}/files/images/favicon-32x32.png"))
            .with_mime_type("image/png")
            .with_sizes(vec!["32x32".to_owned()]),
        Icon::new(format!("{WEBSITE_URL}/files/images/favicon-96x96.png"))
            .with_mime_type("image/png")
            .with_sizes(vec!["96x96".to_owned()]),
    ]
}