Help:
{ "command": "core playbooks show build_mcp-artifacts" }
Patterns for creating artifacts, storing tool results, and exposing UI resources.
Prerequisites:
- Read MCP Tutorial
- Read MCP Tool Patterns
Reference Implementations:
extensions/mcp/content-manager/src/tools/research_blog/— Artifact creationextensions/mcp/systemprompt/src/server.rs— UI resourcesextensions/mcp/systemprompt/src/artifacts.rs— Artifact renderingsystemprompt-core/crates/domain/mcp/src/services/ui_renderer/— MCP UI renderers
MCP UI: Artifact → HTML Rendering
Artifacts are converted to MCP UI assets — interactive HTML components that can be displayed in MCP-compatible clients.
What is MCP UI?
MCP UI is a standard for rendering tool outputs as interactive HTML. When an MCP tool returns an artifact:
- Artifact stored with type (e.g.,
text,table,chart) - UI Renderer converts artifact data → HTML
- MCP App returned with MIME type
text/html;profile=mcp-app - Client displays the interactive HTML component
The Rendering Pipeline
Artifact (persisted)
│
▼
UiRendererRegistry.render(artifact)
│
▼
Type-specific renderer (TextRenderer, TableRenderer, etc.)
│
▼
UiResource { html, csp_policy }
│
▼
MCP Resource Response (MIME: text/html;profile=mcp-app)
Available UI Renderers
Core provides renderers for each artifact type:
| Artifact Type | Renderer | Output |
|---|---|---|
text |
TextRenderer |
Formatted text with copy button |
table |
TableRenderer |
Interactive sortable table |
chart |
ChartRenderer |
Chart.js visualization |
list |
ListRenderer |
Ordered/unordered lists |
image |
ImageRenderer |
Image display with zoom |
form |
FormRenderer |
Interactive form |
dashboard |
DashboardRenderer |
Multi-section dashboard |
Resource URI Pattern
Artifacts are exposed as MCP resources via URI:
ui://{server_name}/{artifact_id}
Example: ui://content-manager/550e8400-e29b-41d4-a716-446655440000
UiMetadata in Tool Responses
Include UI metadata in tool responses to enable client rendering:
use systemprompt::mcp::services::ui_renderer::UiMetadata;
// In tool response meta
let ui_meta = UiMetadata::for_artifact(artifact_id.as_str(), Some("my-server"));
meta.insert("ui".to_string(), ui_meta.to_json());
This adds to the response:
{
"ui": {
"resourceUri": "ui://my-server/{artifact_id}",
"visibility": ["model"]
}
}
Adding New Artifact Types
If you need a new artifact type, add it to systemprompt-core.
See Extension Checklist for the full process. Summary:
- Add artifact struct in
systemprompt-core/crates/shared/models/src/artifacts/ - Add to ArtifactType enum in
types.rs - Export from mod.rs
- Create UI renderer in
systemprompt-core/crates/domain/mcp/src/services/ui_renderer/templates/ - Register in default registry
Example: Adding a new artifact type
// 1. Define artifact struct (in core)
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MyNewArtifact {
#[serde(rename = "x-artifact-type")]
pub artifact_type: String,
pub content: String,
// ... fields
}
// 2. Create renderer (in core)
pub struct MyNewRenderer;
#[async_trait]
impl UiRenderer for MyNewRenderer {
fn artifact_type(&self) -> ArtifactType {
ArtifactType::Custom("my_new".to_string())
}
async fn render(&self, artifact: &Artifact) -> Result<UiResource> {
// Convert artifact.parts to HTML
let html = format!("<div>...</div>");
Ok(UiResource::new(html))
}
}
// 3. Register in create_default_registry()
registry.register(MyNewRenderer::new());
DO NOT create custom artifact types in MCP servers. They won't have UI renderers and won't be properly supported.
Artifact Pipeline Overview
CRITICAL: MCP tools do NOT save artifacts directly. The agent handles persistence.
MCP Tool Handler
│
▼
Return structured_content (artifact data)
│
▼
Agent receives response
│
▼
ArtifactBuilder transforms structured_content → Artifact
│
▼
ArtifactPublishingService.publish_from_a2a() saves to DB
│
▼
UI Resource (optional)
│
▼
HTML Rendering
Why This Architecture?
| Concern | Reason |
|---|---|
| Task ownership | Agent creates tasks in DB, owns valid task_ids |
| FK integrity | Agent's task_id exists in DB, MCP-generated IDs don't |
| Separation | MCP = business logic, Agent = persistence orchestration |
Creating Artifacts (Correct Pattern)
MCP tools return artifact data in structured_content. The agent handles persistence.
use systemprompt::identifiers::{ArtifactId, McpExecutionId};
use systemprompt::models::artifacts::{ResearchArtifact, SourceCitation, ToolResponse};
use systemprompt::models::ExecutionMetadata;
pub async fn handle(
ctx: RequestContext,
ai_service: &Arc<AiService>,
mcp_execution_id: &McpExecutionId,
// ... other params (NO artifact_repo for writing!)
) -> Result<CallToolResult, McpError> {
// Generate artifact_id (UUID) - this ID will be preserved by the agent
let artifact_id = uuid::Uuid::new_v4().to_string();
// Execute business logic (AI calls, etc.)
let research_result = ai_service.generate_with_google_search(...).await?;
// Build typed artifact for response
let sources: Vec<SourceCitation> = research_result.sources
.iter()
.map(|s| SourceCitation::new(&s.title, &s.uri, s.relevance))
.collect();
let artifact = ResearchArtifact::new(topic, card, sources)
.with_query_count(query_count as u32);
// Build metadata from request context
let metadata = ExecutionMetadata::with_request(&ctx)
.with_tool("research_blog")
.with_skill(skill_id, "Blog Research");
// Create typed response (agent will transform and persist this)
let response = ToolResponse::new(
ArtifactId::new(&artifact_id),
mcp_execution_id.clone(),
artifact,
metadata.clone(),
);
// Return artifact in structured_content - DO NOT save to database!
Ok(CallToolResult {
content: vec![Content::text(format!(
"Research complete. **Artifact ID: {artifact_id}**"
))],
structured_content: response.to_json().ok(), // Agent persists this
is_error: Some(false),
meta: metadata.to_meta(),
})
}
What Happens After Return
- Agent receives
CallToolResultwithstructured_content ArtifactBuilder.build_artifacts()transforms JSON →Artifact- Agent's
task_id(valid FK) is attached to artifact ArtifactPublishingService.publish_from_a2a()persists to database
Anti-Pattern (DO NOT DO)
// WRONG - MCP tools should NOT save artifacts directly
let task_id = TaskId::generate(); // This ID doesn't exist in DB!
artifact_repo
.create_artifact(&task_id, &context_id, &artifact) // FK violation!
.await?;
Artifact Structure
Artifact Fields
| Field | Type | Description |
|---|---|---|
id |
ArtifactId |
Unique identifier (UUID) |
name |
Option<String> |
Human-readable name |
description |
Option<String> |
Description of contents |
parts |
Vec<Part> |
Data parts (usually DataPart) |
metadata |
ArtifactMetadata |
Type, context, task, tool info |
extensions |
Vec<Value> |
Extension URIs for rendering |
ArtifactMetadata Fields
| Field | Type | Description |
|---|---|---|
artifact_type |
String |
Type identifier (e.g., "research", "blog") |
context_id |
ContextId |
Conversation context |
task_id |
TaskId |
Task that created this |
tool_name |
Option<String> |
Tool that created this |
skill_id |
Option<String> |
Skill used |
skill_name |
Option<String> |
Human-readable skill name |
Part Types
use systemprompt::models::a2a::{DataPart, FilePart, Part};
// Data part - JSON object
let data_part = Part::Data(DataPart {
data: serde_json::Map::from_iter([
("key".to_string(), json!("value")),
]),
});
// File part - binary data
let file_part = Part::File(FilePart {
file_data: FileBlobData {
data: base64_encoded_string,
mime_type: "image/png".to_string(),
},
});
Artifact Types (MUST Use Core Types)
You MUST use artifact types from systemprompt::models::artifacts. Do NOT create custom structs.
| Core Type | Import | Use For |
|---|---|---|
TextArtifact |
systemprompt::models::artifacts::TextArtifact |
Blog posts, articles, documents, any text content |
ResearchArtifact |
systemprompt::models::artifacts::ResearchArtifact |
Research with sources |
ImageArtifact |
systemprompt::models::artifacts::ImageArtifact |
Generated images |
TableArtifact |
systemprompt::models::artifacts::TableArtifact |
Tabular data |
ListArtifact |
systemprompt::models::artifacts::ListArtifact |
Lists |
ChartArtifact |
systemprompt::models::artifacts::ChartArtifact |
Charts and graphs |
CopyPasteTextArtifact |
systemprompt::models::artifacts::CopyPasteTextArtifact |
Social content, snippets |
AudioArtifact |
systemprompt::models::artifacts::AudioArtifact |
Audio files |
VideoArtifact |
systemprompt::models::artifacts::VideoArtifact |
Video files |
DashboardArtifact |
systemprompt::models::artifacts::DashboardArtifact |
Dashboard layouts |
PresentationCardArtifact |
systemprompt::models::artifacts::PresentationCardArtifact |
Card presentations |
Why Core Types Only?
- Schema Compatibility: Core's
ArtifactBuilderknows how to parse these types - UI Rendering: Renderers exist for core types
- Data Integrity: Core types include required fields (full content, not previews)
- Type Safety: Prevents data loss from missing fields
Structured Responses
Return both human-readable and machine-readable content:
Ok(CallToolResult {
// Human-readable text (markdown supported)
content: vec![Content::text(format!(
"## Research Complete\n\n\
**Topic:** {topic}\n\n\
**Sources Found:** {source_count}\n\n\
**Artifact ID:** `{artifact_id}`\n\n\
Use this artifact_id when calling create_blog_post."
))],
// Machine-readable structured data
structured_content: Some(json!({
"artifact_id": artifact_id.to_string(),
"topic": topic,
"source_count": source_count,
"research_summary": summary,
"sources": sources,
"status": "completed"
})),
// Success/error flag
is_error: Some(false),
// Optional metadata (artifact references)
meta: Some(create_result_meta(artifact_id.as_str())),
})
When to Use Each Field
| Field | Use When |
|---|---|
content |
Always — primary response for display |
structured_content |
Tool produces data clients may parse |
is_error |
Always — indicate success/failure |
meta |
Tool creates artifacts or has cross-references |
ToolResponse Pattern (Agent Framework)
When building tools that integrate with the agent framework, use the typed ToolResponse wrapper to ensure schema compatibility:
use systemprompt::models::ExecutionMetadata;
use systemprompt::models::artifacts::{ToolResponse, ResearchArtifact, SourceCitation};
use systemprompt::identifiers::McpExecutionId;
// Build typed artifact
let sources: Vec<SourceCitation> = search_response.sources
.iter()
.map(|s| SourceCitation::new(&s.title, &s.uri, s.relevance))
.collect();
let artifact = ResearchArtifact::new(topic, card, sources)
.with_query_count(query_count as u32);
// Build metadata from request context
let metadata = ExecutionMetadata::with_request(&ctx)
.tool("research_blog")
.skill(skill_id, "Blog Research");
// Create typed response
let response = ToolResponse::new(
&artifact_id,
mcp_execution_id.clone(),
artifact,
metadata.clone(),
);
Ok(CallToolResult {
content: vec![Content::text("Human readable...")],
structured_content: Some(response.to_json()),
is_error: Some(false),
meta: metadata.to_meta(),
})
ToolResponse Schema
The agent framework expects structured_content to follow this schema:
{
"artifact_id": "uuid-string",
"mcp_execution_id": "uuid-string",
"artifact": { ... typed artifact ... },
"_metadata": { ... execution metadata ... }
}
Key Pattern
| Field | Purpose |
|---|---|
content |
Text for LLMs (human-readable markdown) |
structured_content |
Typed ToolResponse<T> artifact |
Available Artifact Types
| Type | Import | Use Case |
|---|---|---|
ResearchArtifact |
systemprompt::models::artifacts |
Research results with sources |
TextArtifact |
systemprompt::models::artifacts |
Simple text content |
TableArtifact |
systemprompt::models::artifacts |
Tabular data |
ChartArtifact |
systemprompt::models::artifacts |
Charts/graphs |
DashboardArtifact |
systemprompt::models::artifacts |
Dashboard layouts |
PresentationCardArtifact |
systemprompt::models::artifacts |
Card presentations |
DO NOT Create Custom Artifact Structs
WRONG - This causes data loss:
// ❌ WRONG - Custom struct missing full content
#[derive(Serialize, Deserialize)]
pub struct BlogPostArtifact {
pub title: String,
pub content_preview: Option<String>, // Only 1000 chars - DATA LOSS!
}
CORRECT - Use TextArtifact which has full content:
// ✅ CORRECT - Use core type with full content
use systemprompt::models::artifacts::TextArtifact;
let artifact = TextArtifact::new(full_blog_content, &ctx)
.with_title(title)
.with_skill(skill_id, skill_name);
let response = ToolResponse::new(artifact_id, mcp_execution_id, artifact, metadata);
If you need a new artifact type, add it to systemprompt-core, not as a custom struct in your MCP server.
UI Resources
MCP servers can expose artifacts as UI resources for rendering:
Enable Resources in Server
impl ServerHandler for MyServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.enable_resources() // Enable resources
.build(),
// ...
}
}
}
Implement Resource Templates
use rmcp::model::{
ListResourceTemplatesResult, RawResourceTemplate, 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 template = ResourceTemplate {
raw: RawResourceTemplate {
uri_template: format!("ui://{SERVER_NAME}/{{artifact_id}}"),
name: "artifact-ui".to_string(),
title: Some("Artifact UI".to_string()),
description: Some(
"Interactive UI for artifacts. Use with artifact IDs.".to_string()
),
mime_type: Some(MCP_APP_MIME_TYPE.to_string()),
icons: None,
},
annotations: None,
};
Ok(ListResourceTemplatesResult {
resource_templates: vec![template],
next_cursor: None,
meta: None,
})
}
Implement Resource Reading
use rmcp::model::{
ReadResourceRequestParams, ReadResourceResult, ResourceContents,
};
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_ctx: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let uri = &request.uri;
// Parse artifact ID from URI
let artifact_id = parse_ui_uri(uri).ok_or_else(|| {
McpError::invalid_params(
format!("Invalid URI: {uri}. Expected: ui://{SERVER_NAME}/{{artifact_id}}"),
None,
)
})?;
// Render artifact to HTML
let html = render_artifact_ui(&self.db_pool, &self.ui_registry, &artifact_id)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to render: {e}"), None)
})?;
let contents = ResourceContents::TextResourceContents {
uri: uri.clone(),
mime_type: Some(MCP_APP_MIME_TYPE.to_string()),
text: html,
meta: None,
};
Ok(ReadResourceResult {
contents: vec![contents],
})
}
URI Parsing Helper
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
}
}
UI Renderer Registry
The UiRendererRegistry maps artifact types to HTML renderers:
use systemprompt::mcp::services::ui_renderer::{
registry::create_default_registry,
UiRendererRegistry,
};
#[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()),
}
}
// Or extend with custom renderers
pub fn with_extended_registry<F>(
db_pool: DbPool,
service_id: McpServerId,
extend_fn: F,
) -> Self
where
F: FnOnce(&mut UiRendererRegistry),
{
let mut registry = create_default_registry();
extend_fn(&mut registry);
Self {
db_pool,
service_id,
ui_registry: Arc::new(registry),
}
}
}
Default Renderers
The default registry includes renderers for:
| Artifact Type | Renderer | Output |
|---|---|---|
table |
Table renderer | HTML table |
list |
List renderer | Ordered/unordered list |
card |
Card renderer | Card layout |
chart |
Chart renderer | Chart visualization |
dashboard |
Dashboard renderer | Dashboard layout |
form |
Form renderer | Interactive form |
command_result |
CLI renderer | Command output |
Artifact Rendering
use systemprompt::mcp::services::ui_renderer::UiRendererRegistry;
pub async fn render_artifact_ui(
db_pool: &DbPool,
ui_registry: &UiRendererRegistry,
artifact_id: &str,
) -> Result<String> {
// Load artifact from database
let artifact = load_artifact(db_pool, artifact_id).await?;
// Get renderer for artifact type
let artifact_type = &artifact.metadata.artifact_type;
let renderer = ui_registry
.get(artifact_type)
.ok_or_else(|| anyhow!("No renderer for type: {artifact_type}"))?;
// Render to HTML
let html = renderer.render(&artifact)?;
Ok(html)
}
Result Meta Pattern
Include artifact references in response meta:
pub fn create_result_meta(artifact_id: &str) -> serde_json::Map<String, serde_json::Value> {
let mut meta = serde_json::Map::new();
meta.insert(
"artifact_id".to_string(),
json!(artifact_id),
);
meta.insert(
"ui_uri".to_string(),
json!(format!("ui://{SERVER_NAME}/{artifact_id}")),
);
meta
}
// In handler:
Ok(CallToolResult {
content: vec![Content::text("...")],
structured_content: Some(json!({...})),
is_error: Some(false),
meta: Some(create_result_meta(artifact_id.as_str())),
})
Loading Artifacts
Retrieve artifacts for subsequent tools:
pub async fn load_artifact_data(
artifact_repo: &ArtifactRepository,
artifact_id: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, McpError> {
let artifact_id = ArtifactId::parse(artifact_id)
.map_err(|_| McpError::invalid_params("Invalid artifact_id format", None))?;
let artifact = artifact_repo
.get_artifact(&artifact_id)
.await
.map_err(|e| McpError::internal_error(format!("Failed to load artifact: {e}"), None))?
.ok_or_else(|| McpError::invalid_params(
format!("Artifact not found: {artifact_id}"),
None,
))?;
// Extract data from first DataPart
let data = artifact
.parts
.iter()
.find_map(|p| match p {
Part::Data(d) => Some(d.data.clone()),
_ => None,
})
.unwrap_or_default();
Ok(data)
}
Checklist
- Use CORE artifact type (
TextArtifact,ResearchArtifact,ImageArtifact, etc.) - DO NOT create custom artifact structs - use core types or request new ones in core
- Include FULL content in artifact - never truncate or use previews
- Generate unique artifact_id (UUID) for tracking
- Create
ExecutionMetadatafrom request context with.with_tool()and.with_skill() - Wrap in
ToolResponse::new(artifact_id, mcp_execution_id, artifact, metadata) - Return artifact in
structured_content: response.to_json().ok() - Include
meta: metadata.to_meta() - DO NOT call
artifact_repo.create_artifact()for output artifacts - CAN use
artifact_repo.get_artifact_by_id()for input artifacts - For UI resources: implement
list_resource_templates - For UI resources: implement
read_resource
Quick Reference
| Task | Code |
|---|---|
| Generate ID | ArtifactId::generate() or uuid::Uuid::new_v4().to_string() |
| Text content | TextArtifact::new(full_content, &ctx).with_title(title) |
| Research | ResearchArtifact::new(topic, card, sources) |
| Build metadata | ExecutionMetadata::with_request(&ctx).with_tool(name).with_skill(id, name) |
| Wrap response | ToolResponse::new(artifact_id, mcp_execution_id, artifact, metadata) |
| Return artifact | structured_content: response.to_json().ok() |
| Return meta | meta: metadata.to_meta() |
| Load input artifact | artifact_repo.get_artifact_by_id(&artifact_id).await (READ is OK) |
Architecture Rules
| Rule | Reason |
|---|---|
| Use CORE artifact types only | Schema compatibility, UI rendering, data integrity |
| Include FULL content | Truncated content is permanently lost |
| MCP tools DO NOT save artifacts | Agent owns task_ids, handles FK integrity |
| MCP tools CAN read artifacts | Loading input data is valid |
Return data in structured_content |
Agent transforms and persists |
Use ToolResponse<T> wrapper |
Ensures schema compatibility |
Related Playbooks
- MCP Tutorial — Building your first MCP server
- MCP Tool Patterns — Modular tool organization
- MCP Checklist — Full requirements checklist