Skip to main content

adk_server/rest/controllers/
ui.rs

1use crate::ui_protocol::{
2    TOOL_ENVELOPE_VERSION, UI_DEFAULT_PROTOCOL, UI_PROTOCOL_CAPABILITIES, UiProtocolDeprecationSpec,
3};
4use crate::ui_types::{
5    McpAppsRenderOptions, McpUiPermissions, McpUiResourceCsp, validate_mcp_apps_render_options,
6};
7use axum::{Json, extract::Query, http::StatusCode};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::sync::{OnceLock, RwLock};
12use tracing::{info, warn};
13
14#[derive(Debug, Clone, Serialize)]
15pub struct UiProtocolCapability {
16    pub protocol: &'static str,
17    pub versions: Vec<&'static str>,
18    pub features: Vec<&'static str>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub deprecation: Option<UiProtocolDeprecation>,
21}
22
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct UiProtocolDeprecation {
26    pub stage: &'static str,
27    pub announced_on: &'static str,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub sunset_target_on: Option<&'static str>,
30    pub replacement_protocols: Vec<&'static str>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub note: Option<&'static str>,
33}
34
35fn map_deprecation(
36    spec: Option<&'static UiProtocolDeprecationSpec>,
37) -> Option<UiProtocolDeprecation> {
38    let spec = spec?;
39    Some(UiProtocolDeprecation {
40        stage: spec.stage,
41        announced_on: spec.announced_on,
42        sunset_target_on: spec.sunset_target_on,
43        replacement_protocols: spec.replacement_protocols.to_vec(),
44        note: spec.note,
45    })
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct UiCapabilities {
50    pub default_protocol: &'static str,
51    pub protocols: Vec<UiProtocolCapability>,
52    pub tool_envelope_version: &'static str,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct UiResource {
58    pub uri: String,
59    pub name: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub description: Option<String>,
62    pub mime_type: String,
63    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
64    pub meta: Option<Value>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct UiResourceContent {
70    pub uri: String,
71    pub mime_type: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub text: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub blob: Option<String>,
76    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
77    pub meta: Option<Value>,
78}
79
80#[derive(Debug, Clone, Serialize)]
81pub struct UiResourceListResponse {
82    pub resources: Vec<UiResource>,
83}
84
85#[derive(Debug, Clone, Serialize)]
86pub struct UiResourceReadResponse {
87    pub contents: Vec<UiResourceContent>,
88}
89
90#[derive(Debug, Clone, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct RegisterUiResourceRequest {
93    pub uri: String,
94    pub name: String,
95    #[serde(default)]
96    pub description: Option<String>,
97    pub mime_type: String,
98    pub text: String,
99    #[serde(rename = "_meta", default)]
100    pub meta: Option<Value>,
101}
102
103#[derive(Debug, Clone, Deserialize)]
104pub struct ReadUiResourceQuery {
105    pub uri: String,
106}
107
108#[derive(Debug, Clone)]
109struct UiResourceEntry {
110    resource: UiResource,
111    content: UiResourceContent,
112}
113
114static UI_RESOURCE_REGISTRY: OnceLock<RwLock<HashMap<String, UiResourceEntry>>> = OnceLock::new();
115
116fn resource_registry() -> &'static RwLock<HashMap<String, UiResourceEntry>> {
117    UI_RESOURCE_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
118}
119
120fn validate_ui_resource_uri(uri: &str) -> Result<(), (StatusCode, String)> {
121    if !uri.starts_with("ui://") {
122        return Err((
123            StatusCode::BAD_REQUEST,
124            "ui resource uri must start with 'ui://'".to_string(),
125        ));
126    }
127    Ok(())
128}
129
130fn validate_ui_resource_mime(mime_type: &str) -> Result<(), (StatusCode, String)> {
131    if mime_type != "text/html;profile=mcp-app" {
132        return Err((
133            StatusCode::BAD_REQUEST,
134            "mimeType must be 'text/html;profile=mcp-app'".to_string(),
135        ));
136    }
137    Ok(())
138}
139
140fn parse_ui_meta_options(
141    meta: &Option<Value>,
142) -> Result<McpAppsRenderOptions, (StatusCode, String)> {
143    let Some(meta_value) = meta else {
144        return Ok(McpAppsRenderOptions::default());
145    };
146    let meta_object = meta_value
147        .as_object()
148        .ok_or_else(|| (StatusCode::BAD_REQUEST, "_meta must be a JSON object".to_string()))?;
149    let Some(ui_value) = meta_object.get("ui") else {
150        return Ok(McpAppsRenderOptions::default());
151    };
152    let ui_object = ui_value
153        .as_object()
154        .ok_or_else(|| (StatusCode::BAD_REQUEST, "_meta.ui must be a JSON object".to_string()))?;
155
156    let domain = ui_object
157        .get("domain")
158        .map(|domain_value| {
159            domain_value.as_str().ok_or_else(|| {
160                (StatusCode::BAD_REQUEST, "_meta.ui.domain must be a string".to_string())
161            })
162        })
163        .transpose()?
164        .map(ToString::to_string);
165
166    let prefers_border = ui_object
167        .get("prefersBorder")
168        .map(|value| {
169            value.as_bool().ok_or_else(|| {
170                (StatusCode::BAD_REQUEST, "_meta.ui.prefersBorder must be a boolean".to_string())
171            })
172        })
173        .transpose()?;
174
175    let csp = ui_object
176        .get("csp")
177        .map(|value| {
178            serde_json::from_value::<McpUiResourceCsp>(value.clone()).map_err(|error| {
179                (
180                    StatusCode::BAD_REQUEST,
181                    format!("_meta.ui.csp must be an object with domain arrays: {}", error),
182                )
183            })
184        })
185        .transpose()?;
186
187    let permissions = ui_object
188        .get("permissions")
189        .map(|value| {
190            serde_json::from_value::<McpUiPermissions>(value.clone()).map_err(|error| {
191                (
192                    StatusCode::BAD_REQUEST,
193                    format!("_meta.ui.permissions must be an object: {}", error),
194                )
195            })
196        })
197        .transpose()?;
198
199    Ok(McpAppsRenderOptions { domain, prefers_border, csp, permissions })
200}
201
202fn validate_ui_meta(meta: &Option<Value>) -> Result<McpAppsRenderOptions, (StatusCode, String)> {
203    let options = parse_ui_meta_options(meta)?;
204    validate_mcp_apps_render_options(&options).map_err(|error| {
205        (StatusCode::BAD_REQUEST, format!("Invalid _meta.ui options for mcp_apps: {}", error))
206    })?;
207    Ok(options)
208}
209
210/// GET /api/ui/capabilities
211pub async fn ui_capabilities() -> Json<UiCapabilities> {
212    Json(UiCapabilities {
213        default_protocol: UI_DEFAULT_PROTOCOL,
214        protocols: UI_PROTOCOL_CAPABILITIES
215            .iter()
216            .map(|spec| UiProtocolCapability {
217                protocol: spec.protocol,
218                versions: spec.versions.to_vec(),
219                features: spec.features.to_vec(),
220                deprecation: map_deprecation(spec.deprecation),
221            })
222            .collect(),
223        tool_envelope_version: TOOL_ENVELOPE_VERSION,
224    })
225}
226
227/// GET /api/ui/resources
228pub async fn list_ui_resources() -> Json<UiResourceListResponse> {
229    let resources: Vec<UiResource> = resource_registry()
230        .read()
231        .map(|registry| registry.values().map(|entry| entry.resource.clone()).collect())
232        .unwrap_or_default();
233    info!(resource_count = resources.len(), "ui resource list requested");
234    Json(UiResourceListResponse { resources })
235}
236
237/// GET /api/ui/resources/read?uri=ui://...
238pub async fn read_ui_resource(
239    Query(query): Query<ReadUiResourceQuery>,
240) -> Result<Json<UiResourceReadResponse>, (StatusCode, String)> {
241    validate_ui_resource_uri(&query.uri)?;
242    let guard = resource_registry().read().map_err(|_| {
243        (StatusCode::INTERNAL_SERVER_ERROR, "resource registry poisoned".to_string())
244    })?;
245    let Some(entry) = guard.get(&query.uri) else {
246        warn!(uri = %query.uri, "ui resource read failed: not found");
247        return Err((StatusCode::NOT_FOUND, format!("resource not found: {}", query.uri)));
248    };
249    info!(uri = %query.uri, "ui resource read");
250    Ok(Json(UiResourceReadResponse { contents: vec![entry.content.clone()] }))
251}
252
253/// POST /api/ui/resources/register
254pub async fn register_ui_resource(
255    Json(req): Json<RegisterUiResourceRequest>,
256) -> Result<StatusCode, (StatusCode, String)> {
257    validate_ui_resource_uri(&req.uri)?;
258    validate_ui_resource_mime(&req.mime_type)?;
259    let ui_meta_options = validate_ui_meta(&req.meta)?;
260
261    let uri = req.uri.clone();
262    let name = req.name.clone();
263    let mime_type = req.mime_type.clone();
264    let meta = req.meta.clone();
265    let domain = ui_meta_options.domain.unwrap_or_else(|| "<none>".to_string());
266
267    let entry = UiResourceEntry {
268        resource: UiResource {
269            uri: uri.clone(),
270            name: name.clone(),
271            description: req.description.clone(),
272            mime_type: mime_type.clone(),
273            meta: meta.clone(),
274        },
275        content: UiResourceContent {
276            uri: uri.clone(),
277            mime_type: mime_type.clone(),
278            text: Some(req.text),
279            blob: None,
280            meta,
281        },
282    };
283
284    resource_registry()
285        .write()
286        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "resource registry poisoned".to_string()))?
287        .insert(uri.clone(), entry);
288    info!(
289        uri = %uri,
290        name = %name,
291        mime_type = %mime_type,
292        ui_domain = %domain,
293        "ui resource registered"
294    );
295
296    Ok(StatusCode::CREATED)
297}