use crate::repository::{CreateMcpArtifact, McpArtifactRepository};
use crate::schema::McpOutputSchema;
use crate::services::ui_renderer::{
RenderTarget, UiResource, artifact_resource_uri, artifact_ui_resource,
};
use rmcp::ErrorData as McpError;
use rmcp::model::{CallToolResult, ContentBlock, MetaObject, ResourceContents};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value as JsonValue;
use systemprompt_identifiers::{ArtifactId, McpExecutionId};
use systemprompt_models::RequestContext;
use systemprompt_models::artifacts::{EXECUTION_META_KEY, ExecutionMetadata, ToolResponse};
use systemprompt_models::mcp::{ClientProfile, McpResourceUiMeta};
pub const UI_RESOURCE_URI_META_KEY: &str = "io.systemprompt/ui-resource-uri";
#[derive(Clone, Debug)]
pub struct ToolIdentity {
server_name: String,
tool_name: String,
}
impl ToolIdentity {
pub fn new(server_name: impl Into<String>, tool_name: impl Into<String>) -> Self {
Self {
server_name: server_name.into(),
tool_name: tool_name.into(),
}
}
}
pub struct McpResponseBuilder<T: Serialize + JsonSchema> {
output: T,
identity: ToolIdentity,
ctx: RequestContext,
mcp_execution_id: McpExecutionId,
client: ClientProfile,
}
impl<T: Serialize + JsonSchema> std::fmt::Debug for McpResponseBuilder<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpResponseBuilder")
.field("identity", &self.identity)
.field("mcp_execution_id", &self.mcp_execution_id)
.field("client", &self.client)
.finish_non_exhaustive()
}
}
impl<T: Serialize + JsonSchema + McpOutputSchema> McpResponseBuilder<T> {
pub fn new(
output: T,
identity: ToolIdentity,
ctx: &RequestContext,
exec_id: &McpExecutionId,
client: &ClientProfile,
) -> Self {
Self {
output,
identity,
ctx: ctx.clone(),
mcp_execution_id: exec_id.clone(),
client: client.clone(),
}
}
pub async fn build(
self,
summary: impl Into<String>,
repo: &McpArtifactRepository,
artifact_type: impl Into<String>,
title: Option<String>,
) -> Result<CallToolResult, McpError> {
let artifact_id = ArtifactId::generate();
let summary_str = summary.into();
let artifact_type_str = artifact_type.into();
let ToolIdentity {
server_name,
tool_name,
} = self.identity;
let exec_id = self.mcp_execution_id;
let metadata = ExecutionMetadata::builder(&self.ctx)
.with_tool(tool_name.clone())
.with_execution(exec_id.to_string())
.build();
let structured_output = serde_json::to_value(&self.output).map_err(|e| {
tracing::error!(error = %e, tool = %tool_name, "Failed to serialize tool output");
McpError::internal_error(format!("Serialization error: {e}"), None)
})?;
let text_body = self.output.text_body();
let stored_envelope = ToolResponse::new(
artifact_id.clone(),
exec_id.clone(),
self.output,
metadata.clone(),
)
.to_json()
.map_err(|e| {
tracing::error!(error = %e, tool = %tool_name, "Failed to serialize tool response");
McpError::internal_error(format!("Serialization error: {e}"), None)
})?;
let create_artifact = CreateMcpArtifact {
artifact_id: artifact_id.clone(),
mcp_execution_id: exec_id.clone(),
context_id: Some(self.ctx.context_id().clone()),
user_id: (!self.ctx.is_anonymous()).then(|| self.ctx.user_id().clone()),
server_name,
artifact_type: artifact_type_str,
title,
data: stored_envelope,
metadata: metadata.to_object().map(JsonValue::Object),
expires_at: None,
};
repo.save(&create_artifact).await.map_err(|e| {
tracing::error!(error = %e, artifact_id = %artifact_id, "Failed to persist artifact");
McpError::internal_error(format!("Failed to persist artifact: {e}"), None)
})?;
tracing::info!(artifact_id = %artifact_id, server = %create_artifact.server_name, "Artifact persisted");
let shape = WireShape {
client: &self.client,
summary: summary_str,
text_body,
structured_output,
metadata: &metadata,
};
Ok(shape.into_result(&create_artifact, &self.ctx).await)
}
}
struct WireShape<'a> {
client: &'a ClientProfile,
summary: String,
text_body: Option<String>,
structured_output: JsonValue,
metadata: &'a ExecutionMetadata,
}
impl WireShape<'_> {
async fn into_result(
self,
artifact: &CreateMcpArtifact,
ctx: &RequestContext,
) -> CallToolResult {
let include_ui = self.client.supports_ui();
let include_structured = self.client.supports_structured_content();
let uri = artifact_resource_uri(&artifact.server_name, &artifact.artifact_id);
let mut content = vec![ContentBlock::text(
self.text_block(include_ui, include_structured),
)];
if include_ui && let Some(block) = ui_resource_block(artifact, ctx, &uri).await {
content.push(block);
}
let mut result = CallToolResult::success(content);
if include_structured {
result.structured_content = Some(self.structured_output);
}
if include_ui || include_structured {
result = result.with_meta(wire_meta(
self.metadata,
&artifact.artifact_id,
&artifact.mcp_execution_id,
&uri,
));
}
result
}
fn text_block(&self, include_ui: bool, include_structured: bool) -> String {
let summary = &self.summary;
if include_ui || (include_structured && self.text_body.is_none()) {
return summary.clone();
}
if let Some(body) = &self.text_body {
return format!("{summary}\n\n{body}");
}
let pretty = serde_json::to_string_pretty(&self.structured_output)
.unwrap_or_else(|_| self.structured_output.to_string());
format!("{summary}\n\n```json\n{pretty}\n```")
}
}
fn wire_meta(
metadata: &ExecutionMetadata,
artifact_id: &ArtifactId,
exec_id: &McpExecutionId,
ui_resource_uri: &str,
) -> Option<MetaObject> {
let mut fields = metadata.to_object()?;
fields.insert(
"artifact_id".to_owned(),
JsonValue::String(artifact_id.to_string()),
);
fields.insert(
"mcp_execution_id".to_owned(),
JsonValue::String(exec_id.to_string()),
);
let mut meta = serde_json::Map::new();
meta.insert(EXECUTION_META_KEY.to_owned(), JsonValue::Object(fields));
meta.insert(
UI_RESOURCE_URI_META_KEY.to_owned(),
JsonValue::String(ui_resource_uri.to_owned()),
);
Some(MetaObject(meta))
}
async fn ui_resource_block(
artifact: &CreateMcpArtifact,
ctx: &RequestContext,
uri: &str,
) -> Option<ContentBlock> {
let payload = artifact.data.get("artifact")?;
let target = RenderTarget {
artifact_id: &artifact.artifact_id,
artifact_type: &artifact.artifact_type,
payload,
context_id: ctx.context_id().clone(),
title: artifact.title.clone(),
};
let resource = match artifact_ui_resource(&target).await {
Ok(resource) => resource,
Err(e) => {
tracing::warn!(
error = %e,
artifact_id = %artifact.artifact_id,
artifact_type = %artifact.artifact_type,
"Artifact UI rendering failed; returning result without embedded resource"
);
return None;
},
};
let ui_meta = McpResourceUiMeta::new()
.with_prefers_border(true)
.with_csp_opt(Some(resource.csp.to_mcp_domains()));
Some(ContentBlock::resource(
ResourceContents::TextResourceContents {
uri: uri.to_owned(),
mime_type: Some(UiResource::mime_type().to_owned()),
text: resource.html,
meta: Some(MetaObject(ui_meta.to_meta_map())),
},
))
}
impl<T: Serialize + JsonSchema> McpResponseBuilder<T> {
pub fn build_error(error_message: impl Into<String>) -> CallToolResult {
let error_text = error_message.into();
CallToolResult::error(vec![ContentBlock::text(error_text)])
}
}