Skip to main content

MCP Resources

Expose read-only data to MCP clients: implement list_resources, list_resource_templates, and read_resource, returning text, blobs, or rendered UI artifacts.

MCP resources allow servers to expose data that clients can read. Unlike tools which perform actions, resources provide read-only access to content. This is useful for:

  • Exposing stored artifacts for display
  • Providing UI renderings of tool results
  • Sharing configuration or reference data
  • Listing available content

Resource Types

Static Resources

Resources with fixed URIs that always exist:

my-server://config
my-server://status

Dynamic Resources

Resources created at runtime (e.g., artifacts):

my-server://artifacts/abc123
my-server://content/blog-post-slug

Resource Templates

URI patterns with variables that clients can fill in:

ui://my-server/{artifact_id}
content://my-server/posts/{slug}

Enabling Resources

Enable resources in your server capabilities:

impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            protocol_version: ProtocolVersion::V_2025_06_18,
            capabilities: ServerCapabilities::builder()
                .enable_tools()
                .enable_resources()  // Enable resources
                .build(),
            // ...
        }
    }
}

Implementing Resource Methods

list_resources

Returns currently available resources:

use rmcp::model::{ListResourcesResult, PaginatedRequestParams, Resource};

async fn list_resources(
    &self,
    _request: Option<PaginatedRequestParams>,
    _ctx: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
    // Example: List all published blog posts as resources
    let posts = self.load_published_posts().await?;

    let resources: Vec<Resource> = posts
        .into_iter()
        .map(|post| {
            Resource::new(
                format!("content://my-server/posts/{}", post.slug),
                post.title,
            )
            .with_description(post.description)
            .with_mime_type("text/markdown")
            .with_size(post.content.len() as u64)
        })
        .collect();

    Ok(ListResourcesResult {
        resources,
        next_cursor: None,
        meta: None,
    })
}

list_resource_templates

Returns URI templates for dynamic resources:

use rmcp::model::{ListResourceTemplatesResult, ResourceTemplate};
use systemprompt::mcp::services::ui_renderer::MCP_APP_MIME_TYPE;

const SERVER_NAME: &str = "my-server";

async fn list_resource_templates(
    &self,
    _request: Option<PaginatedRequestParams>,
    _ctx: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
    let templates = vec![
        ResourceTemplate::new(
            format!("ui://{SERVER_NAME}/{{artifact_id}}"),
            "artifact-ui",
        )
        .with_title("Artifact UI")
        .with_description("Interactive UI for artifacts. Provide artifact_id.")
        .with_mime_type(MCP_APP_MIME_TYPE),
        ResourceTemplate::new(
            format!("content://{SERVER_NAME}/posts/{{slug}}"),
            "blog-post",
        )
        .with_title("Blog Post")
        .with_description("Read a blog post by slug")
        .with_mime_type("text/markdown"),
    ];

    Ok(ListResourceTemplatesResult {
        resource_templates: templates,
        next_cursor: None,
        meta: None,
    })
}

read_resource

Reads the content of a resource:

use rmcp::model::{
    ReadResourceRequestParams, ReadResourceResult, ResourceContents,
};

async fn read_resource(
    &self,
    request: ReadResourceRequestParams,
    _ctx: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
    let uri = &request.uri;

    // Route to appropriate handler based on URI prefix
    if let Some(artifact_id) = parse_ui_uri(uri) {
        self.read_artifact_ui(&artifact_id).await
    } else if let Some(slug) = parse_content_uri(uri) {
        self.read_blog_post(&slug).await
    } else {
        Err(McpError::invalid_params(
            format!("Unknown resource URI: {uri}"),
            None,
        ))
    }
}

URI Parsing

Create helpers to parse resource URIs:

const SERVER_NAME: &str = "my-server";

/// Parse ui://my-server/{artifact_id}
pub fn parse_ui_uri(uri: &str) -> Option<String> {
    let prefix = format!("ui://{SERVER_NAME}/");
    if uri.starts_with(&prefix) {
        Some(uri[prefix.len()..].to_string())
    } else {
        None
    }
}

/// Parse content://my-server/posts/{slug}
pub fn parse_content_uri(uri: &str) -> Option<String> {
    let prefix = format!("content://{SERVER_NAME}/posts/");
    if uri.starts_with(&prefix) {
        Some(uri[prefix.len()..].to_string())
    } else {
        None
    }
}

Returning Resource Content

Text Content

async fn read_blog_post(&self, slug: &str) -> Result<ReadResourceResult, McpError> {
    let post = self.load_post_by_slug(slug).await.map_err(|e| {
        McpError::internal_error(format!("Failed to load post: {e}"), None)
    })?;

    let contents = ResourceContents::TextResourceContents {
        uri: format!("content://{SERVER_NAME}/posts/{slug}"),
        mime_type: Some("text/markdown".to_string()),
        text: post.content,
        meta: None,
    };

    Ok(ReadResourceResult {
        contents: vec![contents],
    })
}

Binary Content

use base64::Engine;

async fn read_image(&self, id: &str) -> Result<ReadResourceResult, McpError> {
    let image = self.load_image(id).await?;

    let contents = ResourceContents::BlobResourceContents {
        uri: format!("images://{SERVER_NAME}/{id}"),
        mime_type: Some(image.mime_type),
        blob: base64::engine::general_purpose::STANDARD.encode(&image.data),
        meta: None,
    };

    Ok(ReadResourceResult {
        contents: vec![contents],
    })
}

UI Resources

UI resources render artifacts as HTML for display in clients that support it.

Setup UI Registry

use systemprompt::mcp::services::ui_renderer::{
    registry::create_default_registry,
    UiRendererRegistry,
    MCP_APP_MIME_TYPE,
};

#[derive(Clone)]
pub struct MyServer {
    db_pool: DbPool,
    service_id: McpServerId,
    ui_registry: Arc<UiRendererRegistry>,
}

impl MyServer {
    pub fn new(db_pool: DbPool, service_id: McpServerId) -> Self {
        Self {
            db_pool,
            service_id,
            ui_registry: Arc::new(create_default_registry()),
        }
    }
}

Read Artifact UI

async fn read_artifact_ui(&self, artifact_id: &str) -> Result<ReadResourceResult, McpError> {
    // Load artifact from database
    let artifact = self.load_artifact(artifact_id).await.map_err(|e| {
        McpError::internal_error(format!("Failed to load artifact: {e}"), None)
    })?;

    // Render to a UiResource (HTML + CSP policy) using the registry
    let ui = self.ui_registry
        .render(&artifact)
        .await
        .map_err(|e| {
            McpError::internal_error(format!("Failed to render: {e}"), None)
        })?;

    let contents = ResourceContents::TextResourceContents {
        uri: format!("ui://{SERVER_NAME}/{artifact_id}"),
        mime_type: Some(MCP_APP_MIME_TYPE.to_string()),
        text: ui.html,
        meta: None,
    };

    Ok(ReadResourceResult {
        contents: vec![contents],
    })
}

Custom Renderers

Extend the registry with custom renderers:

The UiRenderer trait is async and keyed by ArtifactType. A renderer declares which artifact type it handles and returns a UiResource (HTML plus a CSP policy):

use async_trait::async_trait;
use systemprompt::mcp::services::ui_renderer::{
    registry::create_default_registry, UiRenderer, UiResource,
};
use systemprompt::models::a2a::Artifact;
use systemprompt::models::artifacts::ArtifactType;

struct MyCustomRenderer;

#[async_trait]
impl UiRenderer for MyCustomRenderer {
    fn artifact_type(&self) -> ArtifactType {
        ArtifactType::Custom("my_custom_type".to_string())
    }

    async fn render(&self, artifact: &Artifact) -> McpDomainResult<UiResource> {
        // Custom HTML rendering logic
        let data = extract_data(artifact)?;
        Ok(UiResource::new(format!(
            r#"<div class="my-custom-artifact">
                <h2>{}</h2>
                <p>{}</p>
            </div>"#,
            data.title, data.content
        )))
    }
}

// Register the custom renderer (keyed by its artifact_type)
let mut registry = create_default_registry();
registry.register(MyCustomRenderer);

Resource MIME Types

MIME Type Use For
text/plain Plain text
text/markdown Markdown content
text/html HTML content
application/json JSON data
text/html;profile=mcp-app UI artifacts (MCP_APP_MIME_TYPE)
image/png, image/jpeg Images (as blob)

Pagination

For large resource lists, implement pagination:

async fn list_resources(
    &self,
    request: Option<PaginatedRequestParams>,
    _ctx: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
    let cursor = request
        .as_ref()
        .and_then(|r| r.cursor.as_ref())
        .and_then(|c| c.parse::<usize>().ok())
        .unwrap_or(0);

    let page_size = 50;

    let all_resources = self.load_all_resources().await?;
    let page: Vec<_> = all_resources
        .into_iter()
        .skip(cursor)
        .take(page_size)
        .collect();

    let next_cursor = if page.len() == page_size {
        Some((cursor + page_size).to_string())
    } else {
        None
    };

    Ok(ListResourcesResult {
        resources: page,
        next_cursor,
        meta: None,
    })
}

Complete Example

impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            capabilities: ServerCapabilities::builder()
                .enable_tools()
                .enable_resources()
                .build(),
            // ...
        }
    }

    async fn list_resources(
        &self,
        _request: Option<PaginatedRequestParams>,
        _ctx: RequestContext<RoleServer>,
    ) -> Result<ListResourcesResult, McpError> {
        Ok(ListResourcesResult {
            resources: vec![],  // No static resources
            next_cursor: None,
            meta: None,
        })
    }

    async fn list_resource_templates(
        &self,
        _request: Option<PaginatedRequestParams>,
        _ctx: RequestContext<RoleServer>,
    ) -> Result<ListResourceTemplatesResult, McpError> {
        let template = ResourceTemplate::new(
            format!("ui://{SERVER_NAME}/{{artifact_id}}"),
            "artifact-ui",
        )
        .with_title("Artifact UI")
        .with_description("Render artifact as HTML")
        .with_mime_type(MCP_APP_MIME_TYPE);

        Ok(ListResourceTemplatesResult {
            resource_templates: vec![template],
            next_cursor: None,
            meta: None,
        })
    }

    async fn read_resource(
        &self,
        request: ReadResourceRequestParams,
        _ctx: RequestContext<RoleServer>,
    ) -> Result<ReadResourceResult, McpError> {
        let uri = &request.uri;

        let artifact_id = parse_ui_uri(uri).ok_or_else(|| {
            McpError::invalid_params(format!("Invalid URI: {uri}"), None)
        })?;

        let html = render_artifact(&self.db_pool, &self.ui_registry, &artifact_id)
            .await
            .map_err(|e| McpError::internal_error(e.to_string(), None))?;

        Ok(ReadResourceResult {
            contents: vec![ResourceContents::TextResourceContents {
                uri: uri.clone(),
                mime_type: Some(MCP_APP_MIME_TYPE.to_string()),
                text: html,
                meta: None,
            }],
        })
    }
}