adk_server/rest/controllers/
ui.rs1use 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
210pub 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
227pub 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
237pub 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
253pub 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}