Skip to main content

systemprompt_api/routes/agent/
artifacts.rs

1//! Artifact retrieval routes for the agent surface.
2//!
3//! Handlers list artifacts by context, task, or user, fetch a single artifact,
4//! and render an artifact as MCP App UI. Every accessor enforces ownership
5//! against the authenticated [`RequestContext`] before returning data.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use axum::extract::{Path, Query, State};
11use axum::http::{StatusCode, header};
12use axum::response::{IntoResponse, Response};
13use axum::{Extension, Json};
14use serde::Deserialize;
15
16use systemprompt_identifiers::{ArtifactId, TaskId};
17use systemprompt_mcp::McpDomainError;
18use systemprompt_mcp::services::ui_renderer::MCP_APP_MIME_TYPE;
19use systemprompt_mcp::services::ui_renderer::registry::{
20    create_default_registry, resolve_artifact_type,
21};
22use systemprompt_models::RequestContext;
23use systemprompt_models::api::ApiError;
24use systemprompt_runtime::AppContext;
25
26use crate::error::ApiHttpError;
27
28#[derive(Debug, Clone, Copy, Deserialize)]
29pub struct ArtifactQueryParams {
30    pub limit: Option<u32>,
31}
32
33pub async fn list_artifacts_by_context(
34    Extension(req_ctx): Extension<RequestContext>,
35    State(app_context): State<AppContext>,
36    Path(context_id): Path<String>,
37) -> Result<impl IntoResponse, ApiHttpError> {
38    tracing::debug!(context_id = %context_id, "Listing artifacts by context");
39
40    let context_id_typed = super::parse_context_id(&context_id)?;
41
42    let context_repo = app_context.a2a_repositories().contexts.clone();
43    context_repo
44        .validate_context_ownership(&context_id_typed, req_ctx.user_id())
45        .await?;
46
47    let artifact_repo = app_context.a2a_repositories().artifacts.clone();
48    let artifacts = artifact_repo
49        .get_artifacts_by_context(&context_id_typed)
50        .await?;
51
52    tracing::debug!(
53        context_id = %context_id,
54        count = artifacts.len(),
55        "Artifacts listed"
56    );
57    Ok((StatusCode::OK, Json(artifacts)))
58}
59
60pub async fn list_artifacts_by_task(
61    Extension(req_ctx): Extension<RequestContext>,
62    State(app_context): State<AppContext>,
63    Path(task_id): Path<String>,
64) -> Result<impl IntoResponse, ApiHttpError> {
65    tracing::debug!(task_id = %task_id, "Listing artifacts by task");
66
67    let task_id_typed = TaskId::try_new(&task_id).map_err(ApiError::from)?;
68
69    let task_repo = app_context.a2a_repositories().tasks.clone();
70    task_repo
71        .validate_task_ownership(&task_id_typed, req_ctx.user_id())
72        .await?;
73
74    let artifact_repo = app_context.a2a_repositories().artifacts.clone();
75    let artifacts = artifact_repo.get_artifacts_by_task(&task_id_typed).await?;
76
77    tracing::debug!(
78        task_id = %task_id,
79        count = artifacts.len(),
80        "Artifacts listed"
81    );
82    Ok((StatusCode::OK, Json(artifacts)))
83}
84
85pub async fn get_artifact(
86    Extension(req_ctx): Extension<RequestContext>,
87    State(app_context): State<AppContext>,
88    Path(artifact_id): Path<String>,
89) -> Result<impl IntoResponse, ApiHttpError> {
90    tracing::debug!(artifact_id = %artifact_id, "Retrieving artifact");
91
92    let artifact_repo = app_context.a2a_repositories().artifacts.clone();
93
94    let artifact_id_typed = ArtifactId::try_new(&artifact_id).map_err(ApiError::from)?;
95    artifact_repo
96        .validate_artifact_ownership(&artifact_id_typed, req_ctx.user_id())
97        .await?;
98
99    let artifact = artifact_repo
100        .find_artifact_by_id(&artifact_id_typed)
101        .await?
102        .ok_or_else(|| ApiHttpError::not_found(format!("Artifact '{artifact_id}' not found")))?;
103
104    tracing::debug!("Artifact retrieved successfully");
105    Ok((StatusCode::OK, Json(artifact)))
106}
107
108pub async fn list_artifacts_by_user(
109    Extension(req_ctx): Extension<RequestContext>,
110    State(app_context): State<AppContext>,
111    Query(params): Query<ArtifactQueryParams>,
112) -> Result<impl IntoResponse, ApiHttpError> {
113    let user_id = req_ctx.user_id();
114
115    tracing::debug!(user_id = %user_id, "Listing artifacts by user");
116
117    let artifact_repo = app_context.a2a_repositories().artifacts.clone();
118
119    let limit = params.limit.map(|l| i32::try_from(l).unwrap_or(i32::MAX));
120    let artifacts = artifact_repo
121        .get_artifacts_by_user_id(user_id, limit)
122        .await?;
123
124    tracing::debug!(
125        user_id = %user_id,
126        count = artifacts.len(),
127        "Artifacts listed"
128    );
129    Ok((StatusCode::OK, Json(artifacts)))
130}
131
132pub async fn get_artifact_ui(
133    Extension(req_ctx): Extension<RequestContext>,
134    State(app_context): State<AppContext>,
135    Path(artifact_id): Path<String>,
136) -> Result<Response, ApiHttpError> {
137    tracing::debug!(artifact_id = %artifact_id, "Rendering artifact as MCP App UI");
138
139    let artifact_repo = app_context.a2a_repositories().artifacts.clone();
140    let artifact_id_typed = ArtifactId::try_new(&artifact_id).map_err(ApiError::from)?;
141
142    artifact_repo
143        .validate_artifact_ownership(&artifact_id_typed, req_ctx.user_id())
144        .await?;
145
146    let artifact = artifact_repo
147        .find_artifact_by_id(&artifact_id_typed)
148        .await?
149        .ok_or_else(|| ApiHttpError::not_found(format!("Artifact '{artifact_id}' not found")))?;
150
151    let registry = create_default_registry();
152    let artifact_type = resolve_artifact_type(&artifact);
153
154    if !registry.supports(artifact_type) {
155        tracing::warn!(artifact_type = %artifact_type, "No UI renderer for artifact type");
156        return Err(ApiHttpError::bad_request(format!(
157            "No UI renderer available for artifact type '{artifact_type}'"
158        )));
159    }
160
161    let ui_resource: systemprompt_mcp::services::ui_renderer::UiResource = registry
162        .render(&artifact)
163        .map_err(ArtifactUiError::Render)?;
164
165    tracing::debug!(artifact_id = %artifact_id, "Artifact UI rendered successfully");
166
167    Response::builder()
168        .status(StatusCode::OK)
169        .header(header::CONTENT_TYPE, MCP_APP_MIME_TYPE)
170        .header(
171            header::CONTENT_SECURITY_POLICY,
172            ui_resource.csp.to_header_value(),
173        )
174        .header(header::X_FRAME_OPTIONS, "SAMEORIGIN")
175        .body(axum::body::Body::from(ui_resource.html))
176        .map_err(|e| ArtifactUiError::Response(e).into())
177}
178
179#[derive(Debug, thiserror::Error)]
180enum ArtifactUiError {
181    #[error("failed to render artifact UI")]
182    Render(#[source] McpDomainError),
183    #[error("failed to build artifact UI response")]
184    Response(#[source] http::Error),
185}
186
187impl From<ArtifactUiError> for ApiHttpError {
188    fn from(err: ArtifactUiError) -> Self {
189        let context = match &err {
190            ArtifactUiError::Render(_) => "Failed to render artifact UI",
191            ArtifactUiError::Response(_) => "Failed to build response",
192        };
193        ApiError::internal(context, err).into()
194    }
195}