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 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(¶ms);
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, ¶ms.app_name, ¶ms.user_id, ¶ms.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, ¶ms.app_name, ¶ms.user_id, ¶ms.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
850fn 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
865trait BridgeUser {
870 fn user_id_mut(&mut self) -> &mut String;
871}
872
873fn 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
927pub 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
948pub(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
959pub(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
970pub(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
981pub(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
992pub(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
1004pub(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
1016pub 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
1034pub 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 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
1056pub 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 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}