Skip to main content

adk_server/rest/controllers/
ui.rs

1use crate::ui_protocol::{
2    TOOL_ENVELOPE_VERSION, UI_DEFAULT_PROTOCOL, UI_PROTOCOL_CAPABILITIES,
3    UiProtocolDeprecationSpec, UiProtocolImplementationTier, UiProtocolSpecTrack,
4};
5use crate::ui_types::{
6    McpAppsRenderOptions, McpUiBridgeSnapshot, McpUiHostCapabilities, McpUiHostInfo,
7    McpUiPermissions, McpUiResourceCsp, default_mcp_ui_host_capabilities, default_mcp_ui_host_info,
8    validate_mcp_apps_render_options,
9};
10use axum::Extension;
11use axum::{Json, extract::Query, http::StatusCode};
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14use std::collections::HashMap;
15use std::hash::Hash;
16use std::sync::{OnceLock, RwLock};
17use tracing::{info, warn};
18
19#[derive(Debug, Clone, Serialize)]
20pub struct UiProtocolCapability {
21    pub protocol: &'static str,
22    pub versions: Vec<&'static str>,
23    #[serde(rename = "implementationTier")]
24    pub implementation_tier: UiProtocolImplementationTier,
25    #[serde(rename = "specTrack")]
26    pub spec_track: UiProtocolSpecTrack,
27    pub summary: &'static str,
28    pub features: Vec<&'static str>,
29    pub limitations: Vec<&'static str>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub deprecation: Option<UiProtocolDeprecation>,
32}
33
34#[derive(Debug, Clone, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct UiProtocolDeprecation {
37    pub stage: &'static str,
38    pub announced_on: &'static str,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub sunset_target_on: Option<&'static str>,
41    pub replacement_protocols: Vec<&'static str>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub note: Option<&'static str>,
44}
45
46fn map_deprecation(
47    spec: Option<&'static UiProtocolDeprecationSpec>,
48) -> Option<UiProtocolDeprecation> {
49    let spec = spec?;
50    Some(UiProtocolDeprecation {
51        stage: spec.stage,
52        announced_on: spec.announced_on,
53        sunset_target_on: spec.sunset_target_on,
54        replacement_protocols: spec.replacement_protocols.to_vec(),
55        note: spec.note,
56    })
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct UiCapabilities {
61    pub default_protocol: &'static str,
62    pub protocols: Vec<UiProtocolCapability>,
63    pub tool_envelope_version: &'static str,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct UiResource {
69    pub uri: String,
70    pub name: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub description: Option<String>,
73    pub mime_type: String,
74    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
75    pub meta: Option<Value>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct UiResourceContent {
81    pub uri: String,
82    pub mime_type: String,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub text: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub blob: Option<String>,
87    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
88    pub meta: Option<Value>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct UiResourceListResponse {
93    pub resources: Vec<UiResource>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct UiResourceReadResponse {
98    pub contents: Vec<UiResourceContent>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct RegisterUiResourceRequest {
104    pub uri: String,
105    pub name: String,
106    #[serde(default)]
107    pub description: Option<String>,
108    pub mime_type: String,
109    pub text: String,
110    #[serde(rename = "_meta", default)]
111    pub meta: Option<Value>,
112}
113
114#[derive(Debug, Clone, Deserialize)]
115pub struct ReadUiResourceQuery {
116    pub uri: String,
117}
118
119#[derive(Debug, Clone)]
120struct UiResourceEntry {
121    resource: UiResource,
122    content: UiResourceContent,
123    /// The authenticated user that registered this resource, when the server has
124    /// authentication configured. `None` means the resource was registered by an
125    /// unauthenticated server and is visible to everyone, which preserves the
126    /// behaviour of a server that has no auth to enforce.
127    owner: Option<String>,
128}
129
130static UI_RESOURCE_REGISTRY: OnceLock<RwLock<HashMap<String, UiResourceEntry>>> = OnceLock::new();
131
132const MCP_UI_DEFAULT_PROTOCOL_VERSION: &str = "2025-11-25";
133
134fn resource_registry() -> &'static RwLock<HashMap<String, UiResourceEntry>> {
135    UI_RESOURCE_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Hash)]
139struct McpUiBridgeSessionKey {
140    app_name: String,
141    user_id: String,
142    session_id: String,
143}
144
145#[derive(Debug, Clone)]
146struct McpUiBridgeSessionEntry {
147    protocol_version: String,
148    initialized: bool,
149    app_info: Option<Value>,
150    app_capabilities: Option<Value>,
151    host_info: McpUiHostInfo,
152    host_capabilities: McpUiHostCapabilities,
153    host_context: Value,
154    message_count: u64,
155    last_message: Option<Value>,
156    model_context: Vec<Value>,
157    model_context_revision: u64,
158    resource_list_revision: u64,
159    tool_list_revision: u64,
160    notification_count: u64,
161    pending_notifications: Vec<McpUiBridgeNotification>,
162}
163
164static MCP_UI_BRIDGE_REGISTRY: OnceLock<
165    RwLock<HashMap<McpUiBridgeSessionKey, McpUiBridgeSessionEntry>>,
166> = OnceLock::new();
167
168fn bridge_registry() -> &'static RwLock<HashMap<McpUiBridgeSessionKey, McpUiBridgeSessionEntry>> {
169    MCP_UI_BRIDGE_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct McpUiBridgeNotification {
175    pub notification_id: u64,
176    pub method: String,
177    pub revision: u64,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub params: Option<Value>,
180}
181
182fn bridge_session_key(app_name: &str, user_id: &str, session_id: &str) -> McpUiBridgeSessionKey {
183    McpUiBridgeSessionKey {
184        app_name: app_name.to_string(),
185        user_id: user_id.to_string(),
186        session_id: session_id.to_string(),
187    }
188}
189
190fn default_host_context(app_name: &str, user_id: &str, session_id: &str) -> Value {
191    json!({
192        "appName": app_name,
193        "userId": user_id,
194        "sessionId": session_id,
195        "theme": "light",
196        "locale": "en-US",
197        "timeZone": "UTC",
198        "platform": "adk-server",
199        "displayMode": "inline",
200        "availableDisplayModes": ["inline"]
201    })
202}
203
204fn merge_host_context(
205    target: &mut Value,
206    patch: Option<Value>,
207) -> Result<(), (StatusCode, String)> {
208    let Some(patch) = patch else {
209        return Ok(());
210    };
211    let patch_object = patch.as_object().ok_or_else(|| {
212        (StatusCode::BAD_REQUEST, "hostContext must be a JSON object".to_string())
213    })?;
214    let target_object = target.as_object_mut().ok_or_else(|| {
215        (StatusCode::INTERNAL_SERVER_ERROR, "host context store invalid".to_string())
216    })?;
217    for (key, value) in patch_object {
218        target_object.insert(key.clone(), value.clone());
219    }
220    Ok(())
221}
222
223fn ensure_bridge_session<'a>(
224    registry: &'a mut HashMap<McpUiBridgeSessionKey, McpUiBridgeSessionEntry>,
225    app_name: &str,
226    user_id: &str,
227    session_id: &str,
228) -> &'a mut McpUiBridgeSessionEntry {
229    registry.entry(bridge_session_key(app_name, user_id, session_id)).or_insert_with(|| {
230        McpUiBridgeSessionEntry {
231            protocol_version: MCP_UI_DEFAULT_PROTOCOL_VERSION.to_string(),
232            initialized: false,
233            app_info: None,
234            app_capabilities: None,
235            host_info: default_mcp_ui_host_info(),
236            host_capabilities: default_mcp_ui_host_capabilities(),
237            host_context: default_host_context(app_name, user_id, session_id),
238            message_count: 0,
239            last_message: None,
240            model_context: vec![],
241            model_context_revision: 0,
242            resource_list_revision: 0,
243            tool_list_revision: 0,
244            notification_count: 0,
245            pending_notifications: vec![],
246        }
247    })
248}
249
250fn validate_ui_resource_uri(uri: &str) -> Result<(), (StatusCode, String)> {
251    if !uri.starts_with("ui://") {
252        return Err((
253            StatusCode::BAD_REQUEST,
254            "ui resource uri must start with 'ui://'".to_string(),
255        ));
256    }
257    Ok(())
258}
259
260fn validate_ui_resource_mime(mime_type: &str) -> Result<(), (StatusCode, String)> {
261    if mime_type != "text/html;profile=mcp-app" {
262        return Err((
263            StatusCode::BAD_REQUEST,
264            "mimeType must be 'text/html;profile=mcp-app'".to_string(),
265        ));
266    }
267    Ok(())
268}
269
270fn parse_ui_meta_options(
271    meta: &Option<Value>,
272) -> Result<McpAppsRenderOptions, (StatusCode, String)> {
273    let Some(meta_value) = meta else {
274        return Ok(McpAppsRenderOptions::default());
275    };
276    let meta_object = meta_value
277        .as_object()
278        .ok_or_else(|| (StatusCode::BAD_REQUEST, "_meta must be a JSON object".to_string()))?;
279    let Some(ui_value) = meta_object.get("ui") else {
280        return Ok(McpAppsRenderOptions::default());
281    };
282    let ui_object = ui_value
283        .as_object()
284        .ok_or_else(|| (StatusCode::BAD_REQUEST, "_meta.ui must be a JSON object".to_string()))?;
285
286    let domain = ui_object
287        .get("domain")
288        .map(|domain_value| {
289            domain_value.as_str().ok_or_else(|| {
290                (StatusCode::BAD_REQUEST, "_meta.ui.domain must be a string".to_string())
291            })
292        })
293        .transpose()?
294        .map(ToString::to_string);
295
296    let prefers_border = ui_object
297        .get("prefersBorder")
298        .map(|value| {
299            value.as_bool().ok_or_else(|| {
300                (StatusCode::BAD_REQUEST, "_meta.ui.prefersBorder must be a boolean".to_string())
301            })
302        })
303        .transpose()?;
304
305    let csp = ui_object
306        .get("csp")
307        .map(|value| {
308            serde_json::from_value::<McpUiResourceCsp>(value.clone()).map_err(|error| {
309                (
310                    StatusCode::BAD_REQUEST,
311                    format!("_meta.ui.csp must be an object with domain arrays: {}", error),
312                )
313            })
314        })
315        .transpose()?;
316
317    let permissions = ui_object
318        .get("permissions")
319        .map(|value| {
320            serde_json::from_value::<McpUiPermissions>(value.clone()).map_err(|error| {
321                (
322                    StatusCode::BAD_REQUEST,
323                    format!("_meta.ui.permissions must be an object: {}", error),
324                )
325            })
326        })
327        .transpose()?;
328
329    Ok(McpAppsRenderOptions { domain, prefers_border, csp, permissions })
330}
331
332fn validate_ui_meta(meta: &Option<Value>) -> Result<McpAppsRenderOptions, (StatusCode, String)> {
333    let options = parse_ui_meta_options(meta)?;
334    validate_mcp_apps_render_options(&options).map_err(|error| {
335        (StatusCode::BAD_REQUEST, format!("Invalid _meta.ui options for mcp_apps: {}", error))
336    })?;
337    Ok(options)
338}
339
340#[derive(Debug, Clone, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct McpUiInitializeParams {
343    pub app_name: String,
344    pub user_id: String,
345    pub session_id: String,
346    #[serde(default)]
347    pub protocol_version: Option<String>,
348    #[serde(default)]
349    pub app_info: Option<Value>,
350    #[serde(default)]
351    pub app_capabilities: Option<Value>,
352    #[serde(default)]
353    pub host_context: Option<Value>,
354    #[serde(default)]
355    pub host_info: Option<McpUiHostInfo>,
356    #[serde(default)]
357    pub host_capabilities: Option<McpUiHostCapabilities>,
358}
359
360#[derive(Debug, Clone, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct McpUiMessageParams {
363    pub app_name: String,
364    pub user_id: String,
365    pub session_id: String,
366    #[serde(default)]
367    pub role: Option<String>,
368    #[serde(default)]
369    pub content: Vec<Value>,
370    #[serde(default)]
371    pub metadata: Option<Value>,
372    #[serde(default)]
373    pub host_context: Option<Value>,
374}
375
376#[derive(Debug, Clone, Copy, Default, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum McpUiModelContextUpdateMode {
379    Append,
380    #[default]
381    Replace,
382}
383
384#[derive(Debug, Clone, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct McpUiUpdateModelContextParams {
387    pub app_name: String,
388    pub user_id: String,
389    pub session_id: String,
390    #[serde(default)]
391    pub content: Vec<Value>,
392    #[serde(default)]
393    pub structured_content: Option<Value>,
394    #[serde(default)]
395    pub host_context: Option<Value>,
396    #[serde(default)]
397    pub mode: McpUiModelContextUpdateMode,
398}
399
400#[derive(Debug, Clone, Serialize)]
401#[serde(rename_all = "camelCase")]
402pub struct McpUiInitializeResult {
403    pub initialized: bool,
404    pub protocol_version: String,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub app_info: Option<Value>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub app_capabilities: Option<Value>,
409    pub host_info: McpUiHostInfo,
410    pub host_capabilities: McpUiHostCapabilities,
411    pub host_context: Value,
412    pub message_count: u64,
413    pub model_context: Vec<Value>,
414    pub model_context_revision: u64,
415    pub resource_list_revision: u64,
416    pub tool_list_revision: u64,
417    pub notifications: Vec<McpUiBridgeNotification>,
418}
419
420#[derive(Debug, Clone, Serialize)]
421#[serde(rename_all = "camelCase")]
422pub struct McpUiMessageResult {
423    pub accepted: bool,
424    pub initialized: bool,
425    pub protocol_version: String,
426    pub message_count: u64,
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub last_message: Option<Value>,
429    pub resource_list_revision: u64,
430    pub tool_list_revision: u64,
431    pub notifications: Vec<McpUiBridgeNotification>,
432    pub host_info: McpUiHostInfo,
433    pub host_capabilities: McpUiHostCapabilities,
434    pub host_context: Value,
435}
436
437#[derive(Debug, Clone, Serialize)]
438#[serde(rename_all = "camelCase")]
439pub struct McpUiUpdateModelContextResult {
440    pub accepted: bool,
441    pub initialized: bool,
442    pub protocol_version: String,
443    pub model_context: Vec<Value>,
444    pub model_context_revision: u64,
445    pub resource_list_revision: u64,
446    pub tool_list_revision: u64,
447    pub notifications: Vec<McpUiBridgeNotification>,
448    pub host_info: McpUiHostInfo,
449    pub host_capabilities: McpUiHostCapabilities,
450    pub host_context: Value,
451}
452
453#[derive(Debug, Clone, Deserialize)]
454#[serde(rename_all = "camelCase")]
455pub struct McpUiPollNotificationsParams {
456    pub app_name: String,
457    pub user_id: String,
458    pub session_id: String,
459    #[serde(default = "default_true")]
460    pub drain: bool,
461}
462
463#[derive(Debug, Clone, Serialize)]
464#[serde(rename_all = "camelCase")]
465pub struct McpUiPollNotificationsResult {
466    pub initialized: bool,
467    pub protocol_version: String,
468    pub resource_list_revision: u64,
469    pub tool_list_revision: u64,
470    pub notifications: Vec<McpUiBridgeNotification>,
471    pub host_info: McpUiHostInfo,
472    pub host_capabilities: McpUiHostCapabilities,
473    pub host_context: Value,
474}
475
476#[derive(Debug, Clone, Deserialize)]
477#[serde(rename_all = "camelCase")]
478pub struct McpUiListChangedParams {
479    pub app_name: String,
480    pub user_id: String,
481    pub session_id: String,
482    #[serde(default)]
483    pub params: Option<Value>,
484}
485
486#[derive(Debug, Clone, Serialize)]
487#[serde(rename_all = "camelCase")]
488pub struct McpUiListChangedResult {
489    pub accepted: bool,
490    pub initialized: bool,
491    pub protocol_version: String,
492    pub method: String,
493    pub revision: u64,
494    pub pending_notification_count: usize,
495    pub resource_list_revision: u64,
496    pub tool_list_revision: u64,
497    pub host_info: McpUiHostInfo,
498    pub host_capabilities: McpUiHostCapabilities,
499    pub host_context: Value,
500}
501
502#[derive(Debug, Clone, Deserialize)]
503#[serde(untagged)]
504pub(crate) enum McpUiBridgeInput<T> {
505    Direct(T),
506    Rpc(McpUiBridgeRpcRequest<T>),
507}
508
509#[derive(Debug, Clone, Deserialize)]
510pub(crate) struct McpUiBridgeRpcRequest<T> {
511    #[serde(default)]
512    id: Option<Value>,
513    method: String,
514    params: T,
515}
516
517enum McpUiBridgeResponseMode {
518    Direct,
519    Rpc { id: Option<Value> },
520}
521
522fn parse_bridge_input<T>(
523    input: McpUiBridgeInput<T>,
524    expected_method: &str,
525) -> Result<(T, McpUiBridgeResponseMode), (StatusCode, String)> {
526    match input {
527        McpUiBridgeInput::Direct(params) => Ok((params, McpUiBridgeResponseMode::Direct)),
528        McpUiBridgeInput::Rpc(request) => {
529            if request.method != expected_method {
530                return Err((
531                    StatusCode::BAD_REQUEST,
532                    format!(
533                        "unexpected MCP Apps bridge method '{}', expected '{}'",
534                        request.method, expected_method
535                    ),
536                ));
537            }
538            Ok((request.params, McpUiBridgeResponseMode::Rpc { id: request.id }))
539        }
540    }
541}
542
543fn bridge_result_json<T: Serialize>(
544    mode: McpUiBridgeResponseMode,
545    result: T,
546) -> Result<Json<Value>, (StatusCode, String)> {
547    let value = match mode {
548        McpUiBridgeResponseMode::Direct => serde_json::to_value(result).map_err(|error| {
549            (
550                StatusCode::INTERNAL_SERVER_ERROR,
551                format!("failed to serialize bridge response: {}", error),
552            )
553        })?,
554        McpUiBridgeResponseMode::Rpc { id } => json!({
555            "jsonrpc": "2.0",
556            "id": id,
557            "result": result
558        }),
559    };
560    Ok(Json(value))
561}
562
563fn model_context_items(params: &McpUiUpdateModelContextParams) -> Vec<Value> {
564    let mut items = params.content.clone();
565    if let Some(structured_content) = params.structured_content.clone() {
566        items.push(json!({
567            "type": "structuredContent",
568            "structuredContent": structured_content
569        }));
570    }
571    items
572}
573
574fn default_true() -> bool {
575    true
576}
577
578fn bridge_snapshot(session: &McpUiBridgeSessionEntry) -> McpUiBridgeSnapshot {
579    McpUiBridgeSnapshot::new(
580        session.protocol_version.clone(),
581        session.initialized,
582        session.host_info.clone(),
583        session.host_capabilities.clone(),
584        session.host_context.clone(),
585    )
586    .with_optional_app_metadata(session.app_info.clone(), session.app_capabilities.clone())
587}
588
589fn queued_notifications(session: &McpUiBridgeSessionEntry) -> Vec<McpUiBridgeNotification> {
590    session.pending_notifications.clone()
591}
592
593fn queue_bridge_notification(
594    session: &mut McpUiBridgeSessionEntry,
595    method: &str,
596    revision: u64,
597    params: Option<Value>,
598) {
599    session.notification_count += 1;
600    session.pending_notifications.push(McpUiBridgeNotification {
601        notification_id: session.notification_count,
602        method: method.to_string(),
603        revision,
604        params,
605    });
606}
607
608pub(crate) fn initialize_mcp_ui_bridge(
609    params: McpUiInitializeParams,
610) -> Result<McpUiInitializeResult, (StatusCode, String)> {
611    let app_name = params.app_name.clone();
612    let user_id = params.user_id.clone();
613    let session_id = params.session_id.clone();
614    let mut registry = bridge_registry()
615        .write()
616        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bridge registry poisoned".to_string()))?;
617    let session = ensure_bridge_session(&mut registry, &app_name, &user_id, &session_id);
618
619    if let Some(protocol_version) = params.protocol_version.filter(|value| !value.trim().is_empty())
620    {
621        session.protocol_version = protocol_version;
622    }
623    if let Some(app_info) = params.app_info {
624        session.app_info = Some(app_info);
625    }
626    if let Some(app_capabilities) = params.app_capabilities {
627        session.app_capabilities = Some(app_capabilities);
628    }
629    if let Some(host_info) = params.host_info {
630        session.host_info = host_info;
631    }
632    if let Some(host_capabilities) = params.host_capabilities {
633        session.host_capabilities = host_capabilities;
634    }
635    merge_host_context(&mut session.host_context, params.host_context)?;
636    session.initialized = true;
637
638    info!(
639        app_name = %app_name,
640        user_id = %user_id,
641        session_id = %session_id,
642        protocol_version = %session.protocol_version,
643        "mcp ui initialize handled"
644    );
645
646    let snapshot = bridge_snapshot(session);
647    Ok(McpUiInitializeResult {
648        initialized: snapshot.initialized,
649        protocol_version: snapshot.protocol_version.clone(),
650        app_info: snapshot.app_info.clone(),
651        app_capabilities: snapshot.app_capabilities.clone(),
652        host_info: snapshot.host_info.clone(),
653        host_capabilities: snapshot.host_capabilities.clone(),
654        host_context: snapshot.host_context.clone(),
655        message_count: session.message_count,
656        model_context: session.model_context.clone(),
657        model_context_revision: session.model_context_revision,
658        resource_list_revision: session.resource_list_revision,
659        tool_list_revision: session.tool_list_revision,
660        notifications: queued_notifications(session),
661    })
662}
663
664pub(crate) fn message_mcp_ui_bridge(
665    params: McpUiMessageParams,
666) -> Result<McpUiMessageResult, (StatusCode, String)> {
667    let app_name = params.app_name.clone();
668    let user_id = params.user_id.clone();
669    let session_id = params.session_id.clone();
670    let host_context = params.host_context.clone();
671    let mut registry = bridge_registry()
672        .write()
673        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bridge registry poisoned".to_string()))?;
674    let session = ensure_bridge_session(&mut registry, &app_name, &user_id, &session_id);
675    merge_host_context(&mut session.host_context, host_context)?;
676
677    session.message_count += 1;
678    let mut message = json!({
679        "role": params.role.unwrap_or_else(|| "user".to_string()),
680        "content": params.content
681    });
682    if let Some(metadata) = params.metadata {
683        let object = message.as_object_mut().ok_or_else(|| {
684            (StatusCode::INTERNAL_SERVER_ERROR, "message payload must be an object".to_string())
685        })?;
686        object.insert("metadata".to_string(), metadata);
687    }
688    session.last_message = Some(message);
689
690    info!(
691        app_name = %app_name,
692        user_id = %user_id,
693        session_id = %session_id,
694        message_count = session.message_count,
695        initialized = session.initialized,
696        "mcp ui message handled"
697    );
698
699    let snapshot = bridge_snapshot(session);
700    Ok(McpUiMessageResult {
701        accepted: true,
702        initialized: snapshot.initialized,
703        protocol_version: snapshot.protocol_version,
704        message_count: session.message_count,
705        last_message: session.last_message.clone(),
706        resource_list_revision: session.resource_list_revision,
707        tool_list_revision: session.tool_list_revision,
708        notifications: queued_notifications(session),
709        host_info: snapshot.host_info,
710        host_capabilities: snapshot.host_capabilities,
711        host_context: snapshot.host_context,
712    })
713}
714
715pub(crate) fn update_mcp_ui_bridge_model_context(
716    params: McpUiUpdateModelContextParams,
717) -> Result<McpUiUpdateModelContextResult, (StatusCode, String)> {
718    let app_name = params.app_name.clone();
719    let user_id = params.user_id.clone();
720    let session_id = params.session_id.clone();
721    let host_context = params.host_context.clone();
722    let items = model_context_items(&params);
723    let mut registry = bridge_registry()
724        .write()
725        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bridge registry poisoned".to_string()))?;
726    let session = ensure_bridge_session(&mut registry, &app_name, &user_id, &session_id);
727    merge_host_context(&mut session.host_context, host_context)?;
728
729    match params.mode {
730        McpUiModelContextUpdateMode::Replace => session.model_context = items,
731        McpUiModelContextUpdateMode::Append => session.model_context.extend(items),
732    }
733    session.model_context_revision += 1;
734
735    info!(
736        app_name = %app_name,
737        user_id = %user_id,
738        session_id = %session_id,
739        model_context_revision = session.model_context_revision,
740        initialized = session.initialized,
741        "mcp ui model context updated"
742    );
743
744    let snapshot = bridge_snapshot(session);
745    Ok(McpUiUpdateModelContextResult {
746        accepted: true,
747        initialized: snapshot.initialized,
748        protocol_version: snapshot.protocol_version,
749        model_context: session.model_context.clone(),
750        model_context_revision: session.model_context_revision,
751        resource_list_revision: session.resource_list_revision,
752        tool_list_revision: session.tool_list_revision,
753        notifications: queued_notifications(session),
754        host_info: snapshot.host_info,
755        host_capabilities: snapshot.host_capabilities,
756        host_context: snapshot.host_context,
757    })
758}
759
760pub(crate) fn poll_mcp_ui_bridge_notifications(
761    params: McpUiPollNotificationsParams,
762) -> Result<McpUiPollNotificationsResult, (StatusCode, String)> {
763    let mut registry = bridge_registry()
764        .write()
765        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bridge registry poisoned".to_string()))?;
766    let session =
767        ensure_bridge_session(&mut registry, &params.app_name, &params.user_id, &params.session_id);
768    let notifications = queued_notifications(session);
769    if params.drain {
770        session.pending_notifications.clear();
771    }
772    let snapshot = bridge_snapshot(session);
773
774    Ok(McpUiPollNotificationsResult {
775        initialized: snapshot.initialized,
776        protocol_version: snapshot.protocol_version,
777        resource_list_revision: session.resource_list_revision,
778        tool_list_revision: session.tool_list_revision,
779        notifications,
780        host_info: snapshot.host_info,
781        host_capabilities: snapshot.host_capabilities,
782        host_context: snapshot.host_context,
783    })
784}
785
786fn notify_mcp_ui_bridge_list_changed(
787    params: McpUiListChangedParams,
788    method: &'static str,
789    revision_selector: impl Fn(&mut McpUiBridgeSessionEntry) -> &mut u64,
790) -> Result<McpUiListChangedResult, (StatusCode, String)> {
791    let mut registry = bridge_registry()
792        .write()
793        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bridge registry poisoned".to_string()))?;
794    let session =
795        ensure_bridge_session(&mut registry, &params.app_name, &params.user_id, &params.session_id);
796    let revision = {
797        let target = revision_selector(session);
798        *target += 1;
799        *target
800    };
801    queue_bridge_notification(session, method, revision, params.params);
802    let snapshot = bridge_snapshot(session);
803
804    Ok(McpUiListChangedResult {
805        accepted: true,
806        initialized: snapshot.initialized,
807        protocol_version: snapshot.protocol_version,
808        method: method.to_string(),
809        revision,
810        pending_notification_count: session.pending_notifications.len(),
811        resource_list_revision: session.resource_list_revision,
812        tool_list_revision: session.tool_list_revision,
813        host_info: snapshot.host_info,
814        host_capabilities: snapshot.host_capabilities,
815        host_context: snapshot.host_context,
816    })
817}
818
819pub(crate) fn notify_mcp_ui_resource_list_changed(
820    params: McpUiListChangedParams,
821) -> Result<McpUiListChangedResult, (StatusCode, String)> {
822    notify_mcp_ui_bridge_list_changed(
823        params,
824        "ui/notifications/resources/list_changed",
825        |session| &mut session.resource_list_revision,
826    )
827}
828
829pub(crate) fn notify_mcp_ui_tool_list_changed(
830    params: McpUiListChangedParams,
831) -> Result<McpUiListChangedResult, (StatusCode, String)> {
832    notify_mcp_ui_bridge_list_changed(params, "ui/notifications/tools/list_changed", |session| {
833        &mut session.tool_list_revision
834    })
835}
836
837pub(crate) fn mark_mcp_ui_initialized(
838    app_name: &str,
839    user_id: &str,
840    session_id: &str,
841) -> Result<(), (StatusCode, String)> {
842    let mut registry = bridge_registry()
843        .write()
844        .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bridge registry poisoned".to_string()))?;
845    let session = ensure_bridge_session(&mut registry, app_name, user_id, session_id);
846    session.initialized = true;
847    Ok(())
848}
849
850/// Whether `owner` may be accessed by `caller`.
851///
852/// A resource registered without an owner stays readable by anyone, so a server
853/// with no authentication behaves as before. Once a resource has an owner, only
854/// that user may read or replace it: the registry is process-global and keyed by
855/// URI alone, so without this check one authenticated caller could read or
856/// overwrite another's registered HTML.
857fn may_access_resource(owner: Option<&String>, caller: Option<&adk_core::RequestContext>) -> bool {
858    match (owner, caller) {
859        (None, _) => true,
860        (Some(_), None) => true,
861        (Some(owner), Some(caller)) => owner == &caller.user_id,
862    }
863}
864
865/// Bridge parameters that name the user whose bridge state is addressed.
866///
867/// Implemented for every bridge params type so one rule binds them all to the
868/// authenticated caller.
869trait BridgeUser {
870    fn user_id_mut(&mut self) -> &mut String;
871}
872
873/// Replaces the caller-supplied user with the authenticated one.
874///
875/// The bridge registry is keyed by `(app_name, user_id, session_id)` taken from the
876/// request body, so a body-supplied user would let one authenticated caller read and
877/// mutate another's bridge state. When authentication is configured, the body value
878/// is not trusted. When it is not configured, there is no authenticated identity to
879/// bind and the body value stands.
880fn bind_authenticated_user<P: BridgeUser>(
881    params: &mut P,
882    request_context: Option<&adk_core::RequestContext>,
883) {
884    if let Some(context) = request_context {
885        let supplied = params.user_id_mut();
886        if supplied != &context.user_id {
887            info!(
888                supplied.user_id = %supplied,
889                authenticated.user_id = %context.user_id,
890                "ui bridge request named a different user, using the authenticated user"
891            );
892            *supplied = context.user_id.clone();
893        }
894    }
895}
896
897impl BridgeUser for McpUiInitializeParams {
898    fn user_id_mut(&mut self) -> &mut String {
899        &mut self.user_id
900    }
901}
902
903impl BridgeUser for McpUiMessageParams {
904    fn user_id_mut(&mut self) -> &mut String {
905        &mut self.user_id
906    }
907}
908
909impl BridgeUser for McpUiUpdateModelContextParams {
910    fn user_id_mut(&mut self) -> &mut String {
911        &mut self.user_id
912    }
913}
914
915impl BridgeUser for McpUiPollNotificationsParams {
916    fn user_id_mut(&mut self) -> &mut String {
917        &mut self.user_id
918    }
919}
920
921impl BridgeUser for McpUiListChangedParams {
922    fn user_id_mut(&mut self) -> &mut String {
923        &mut self.user_id
924    }
925}
926
927/// GET /api/ui/capabilities
928pub async fn ui_capabilities() -> Json<UiCapabilities> {
929    Json(UiCapabilities {
930        default_protocol: UI_DEFAULT_PROTOCOL,
931        protocols: UI_PROTOCOL_CAPABILITIES
932            .iter()
933            .map(|spec| UiProtocolCapability {
934                protocol: spec.protocol,
935                versions: spec.versions.to_vec(),
936                implementation_tier: spec.implementation_tier,
937                spec_track: spec.spec_track,
938                summary: spec.summary,
939                features: spec.features.to_vec(),
940                limitations: spec.limitations.to_vec(),
941                deprecation: map_deprecation(spec.deprecation),
942            })
943            .collect(),
944        tool_envelope_version: TOOL_ENVELOPE_VERSION,
945    })
946}
947
948/// POST /api/ui/initialize
949pub(crate) async fn ui_initialize(
950    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
951    Json(input): Json<McpUiBridgeInput<McpUiInitializeParams>>,
952) -> Result<Json<Value>, (StatusCode, String)> {
953    let (mut params, response_mode) = parse_bridge_input(input, "ui/initialize")?;
954    bind_authenticated_user(&mut params, request_context.as_ref());
955    let result = initialize_mcp_ui_bridge(params)?;
956    bridge_result_json(response_mode, result)
957}
958
959/// POST /api/ui/message
960pub(crate) async fn ui_message(
961    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
962    Json(input): Json<McpUiBridgeInput<McpUiMessageParams>>,
963) -> Result<Json<Value>, (StatusCode, String)> {
964    let (mut params, response_mode) = parse_bridge_input(input, "ui/message")?;
965    bind_authenticated_user(&mut params, request_context.as_ref());
966    let result = message_mcp_ui_bridge(params)?;
967    bridge_result_json(response_mode, result)
968}
969
970/// POST /api/ui/update-model-context
971pub(crate) async fn ui_update_model_context(
972    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
973    Json(input): Json<McpUiBridgeInput<McpUiUpdateModelContextParams>>,
974) -> Result<Json<Value>, (StatusCode, String)> {
975    let (mut params, response_mode) = parse_bridge_input(input, "ui/update-model-context")?;
976    bind_authenticated_user(&mut params, request_context.as_ref());
977    let result = update_mcp_ui_bridge_model_context(params)?;
978    bridge_result_json(response_mode, result)
979}
980
981/// POST /api/ui/notifications/poll
982pub(crate) async fn ui_poll_notifications(
983    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
984    Json(input): Json<McpUiBridgeInput<McpUiPollNotificationsParams>>,
985) -> Result<Json<Value>, (StatusCode, String)> {
986    let (mut params, response_mode) = parse_bridge_input(input, "ui/notifications/poll")?;
987    bind_authenticated_user(&mut params, request_context.as_ref());
988    let result = poll_mcp_ui_bridge_notifications(params)?;
989    bridge_result_json(response_mode, result)
990}
991
992/// POST /api/ui/notifications/resources-list-changed
993pub(crate) async fn ui_notify_resources_list_changed(
994    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
995    Json(input): Json<McpUiBridgeInput<McpUiListChangedParams>>,
996) -> Result<Json<Value>, (StatusCode, String)> {
997    let (mut params, response_mode) =
998        parse_bridge_input(input, "ui/notifications/resources/list_changed")?;
999    bind_authenticated_user(&mut params, request_context.as_ref());
1000    let result = notify_mcp_ui_resource_list_changed(params)?;
1001    bridge_result_json(response_mode, result)
1002}
1003
1004/// POST /api/ui/notifications/tools-list-changed
1005pub(crate) async fn ui_notify_tools_list_changed(
1006    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
1007    Json(input): Json<McpUiBridgeInput<McpUiListChangedParams>>,
1008) -> Result<Json<Value>, (StatusCode, String)> {
1009    let (mut params, response_mode) =
1010        parse_bridge_input(input, "ui/notifications/tools/list_changed")?;
1011    bind_authenticated_user(&mut params, request_context.as_ref());
1012    let result = notify_mcp_ui_tool_list_changed(params)?;
1013    bridge_result_json(response_mode, result)
1014}
1015
1016/// GET /api/ui/resources
1017pub async fn list_ui_resources(
1018    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
1019) -> Json<UiResourceListResponse> {
1020    let resources: Vec<UiResource> = resource_registry()
1021        .read()
1022        .map(|registry| {
1023            registry
1024                .values()
1025                .filter(|entry| may_access_resource(entry.owner.as_ref(), request_context.as_ref()))
1026                .map(|entry| entry.resource.clone())
1027                .collect()
1028        })
1029        .unwrap_or_default();
1030    info!(resource_count = resources.len(), "ui resource list requested");
1031    Json(UiResourceListResponse { resources })
1032}
1033
1034/// GET /api/ui/resources/read?uri=ui://...
1035pub async fn read_ui_resource(
1036    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
1037    Query(query): Query<ReadUiResourceQuery>,
1038) -> Result<Json<UiResourceReadResponse>, (StatusCode, String)> {
1039    validate_ui_resource_uri(&query.uri)?;
1040    let guard = resource_registry().read().map_err(|_| {
1041        (StatusCode::INTERNAL_SERVER_ERROR, "resource registry poisoned".to_string())
1042    })?;
1043    let Some(entry) = guard.get(&query.uri) else {
1044        warn!(uri = %query.uri, "ui resource read failed: not found");
1045        return Err((StatusCode::NOT_FOUND, format!("resource not found: {}", query.uri)));
1046    };
1047    if !may_access_resource(entry.owner.as_ref(), request_context.as_ref()) {
1048        // Reported as not found so the URI's existence is not disclosed.
1049        warn!(uri = %query.uri, "ui resource read denied: owned by another user");
1050        return Err((StatusCode::NOT_FOUND, format!("resource not found: {}", query.uri)));
1051    }
1052    info!(uri = %query.uri, "ui resource read");
1053    Ok(Json(UiResourceReadResponse { contents: vec![entry.content.clone()] }))
1054}
1055
1056/// POST /api/ui/resources/register
1057pub async fn register_ui_resource(
1058    Extension(request_context): Extension<Option<adk_core::RequestContext>>,
1059    Json(req): Json<RegisterUiResourceRequest>,
1060) -> Result<StatusCode, (StatusCode, String)> {
1061    validate_ui_resource_uri(&req.uri)?;
1062    validate_ui_resource_mime(&req.mime_type)?;
1063    let ui_meta_options = validate_ui_meta(&req.meta)?;
1064
1065    let uri = req.uri.clone();
1066    let name = req.name.clone();
1067    let mime_type = req.mime_type.clone();
1068    let meta = req.meta.clone();
1069    let domain = ui_meta_options.domain.unwrap_or_else(|| "<none>".to_string());
1070
1071    let entry = UiResourceEntry {
1072        resource: UiResource {
1073            uri: uri.clone(),
1074            name: name.clone(),
1075            description: req.description.clone(),
1076            mime_type: mime_type.clone(),
1077            meta: meta.clone(),
1078        },
1079        content: UiResourceContent {
1080            uri: uri.clone(),
1081            mime_type: mime_type.clone(),
1082            text: Some(req.text),
1083            blob: None,
1084            meta,
1085        },
1086        owner: request_context.as_ref().map(|context| context.user_id.clone()),
1087    };
1088
1089    let mut registry = resource_registry().write().map_err(|_| {
1090        (StatusCode::INTERNAL_SERVER_ERROR, "resource registry poisoned".to_string())
1091    })?;
1092    // Registration replaces an existing URI, so an unauthorized overwrite would let
1093    // one caller substitute another's resource content.
1094    if let Some(existing) = registry.get(&uri)
1095        && !may_access_resource(existing.owner.as_ref(), request_context.as_ref())
1096    {
1097        warn!(uri = %uri, "ui resource registration denied: owned by another user");
1098        return Err((StatusCode::FORBIDDEN, format!("resource is owned by another user: {uri}")));
1099    }
1100    registry.insert(uri.clone(), entry);
1101    drop(registry);
1102    info!(
1103        uri = %uri,
1104        name = %name,
1105        mime_type = %mime_type,
1106        ui_domain = %domain,
1107        "ui resource registered"
1108    );
1109
1110    Ok(StatusCode::CREATED)
1111}