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