Skip to main content

systemprompt_models/mcp/
apps.rs

1//! Typed bindings for the MCP Apps extension (SEP-1865, `2026-01-26`).
2//!
3//! Everything the extension puts on the wire is modelled here so no call site
4//! hand-writes a method name or a `_meta` key: [`UiMethod`] enumerates the
5//! app-to-host protocol, [`McpUiToolMeta`] and
6//! [`McpResourceUiMeta`](super::McpResourceUiMeta) carry the metadata, and the
7//! `*Params` types carry the message bodies. Field names and string literals
8//! track the normative schema at
9//! <https://github.com/modelcontextprotocol/ext-apps>.
10//!
11//! Two shapes are easy to get wrong and are pinned by wire-shape tests:
12//! [`UiMessageParams::content`] is an array of content blocks rather than a
13//! bare block, and [`McpUiToolMeta`] omits `csp` and `permissions` because the
14//! schema types them as `never` on tools — they belong on the UI resource.
15//! [`ui_method_js_constants`] projects [`UiMethod`] into the browser so app
16//! templates cannot drift from this enum.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use super::capabilities::ToolVisibility;
22use rmcp::model::{ContentBlock, Implementation};
23use serde::{Deserialize, Serialize};
24use std::fmt;
25
26pub const EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
27
28pub const RESOURCE_MIME_TYPE: &str = "text/html;profile=mcp-app";
29
30pub const LATEST_PROTOCOL_VERSION: &str = "2026-01-26";
31
32pub const UI_META_KEY: &str = "ui";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub enum UiMethod {
36    #[serde(rename = "ui/initialize")]
37    Initialize,
38    #[serde(rename = "ui/notifications/initialized")]
39    Initialized,
40    #[serde(rename = "ui/notifications/tool-input")]
41    ToolInput,
42    #[serde(rename = "ui/notifications/tool-input-partial")]
43    ToolInputPartial,
44    #[serde(rename = "ui/notifications/tool-result")]
45    ToolResult,
46    #[serde(rename = "ui/notifications/tool-cancelled")]
47    ToolCancelled,
48    #[serde(rename = "ui/notifications/size-changed")]
49    SizeChanged,
50    #[serde(rename = "ui/notifications/host-context-changed")]
51    HostContextChanged,
52    #[serde(rename = "ui/request-display-mode")]
53    RequestDisplayMode,
54    #[serde(rename = "ui/message")]
55    Message,
56    #[serde(rename = "ui/update-model-context")]
57    UpdateModelContext,
58    #[serde(rename = "ui/open-link")]
59    OpenLink,
60    #[serde(rename = "ui/download-file")]
61    DownloadFile,
62    #[serde(rename = "ui/resource-teardown")]
63    ResourceTeardown,
64    #[serde(rename = "ui/notifications/request-teardown")]
65    RequestTeardown,
66}
67
68impl UiMethod {
69    #[must_use]
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::Initialize => "ui/initialize",
73            Self::Initialized => "ui/notifications/initialized",
74            Self::ToolInput => "ui/notifications/tool-input",
75            Self::ToolInputPartial => "ui/notifications/tool-input-partial",
76            Self::ToolResult => "ui/notifications/tool-result",
77            Self::ToolCancelled => "ui/notifications/tool-cancelled",
78            Self::SizeChanged => "ui/notifications/size-changed",
79            Self::HostContextChanged => "ui/notifications/host-context-changed",
80            Self::RequestDisplayMode => "ui/request-display-mode",
81            Self::Message => "ui/message",
82            Self::UpdateModelContext => "ui/update-model-context",
83            Self::OpenLink => "ui/open-link",
84            Self::DownloadFile => "ui/download-file",
85            Self::ResourceTeardown => "ui/resource-teardown",
86            Self::RequestTeardown => "ui/notifications/request-teardown",
87        }
88    }
89
90    #[must_use]
91    pub const fn js_const(self) -> &'static str {
92        match self {
93            Self::Initialize => "INITIALIZE",
94            Self::Initialized => "INITIALIZED",
95            Self::ToolInput => "TOOL_INPUT",
96            Self::ToolInputPartial => "TOOL_INPUT_PARTIAL",
97            Self::ToolResult => "TOOL_RESULT",
98            Self::ToolCancelled => "TOOL_CANCELLED",
99            Self::SizeChanged => "SIZE_CHANGED",
100            Self::HostContextChanged => "HOST_CONTEXT_CHANGED",
101            Self::RequestDisplayMode => "REQUEST_DISPLAY_MODE",
102            Self::Message => "MESSAGE",
103            Self::UpdateModelContext => "UPDATE_MODEL_CONTEXT",
104            Self::OpenLink => "OPEN_LINK",
105            Self::DownloadFile => "DOWNLOAD_FILE",
106            Self::ResourceTeardown => "RESOURCE_TEARDOWN",
107            Self::RequestTeardown => "REQUEST_TEARDOWN",
108        }
109    }
110
111    #[must_use]
112    pub const fn all() -> &'static [Self] {
113        &[
114            Self::Initialize,
115            Self::Initialized,
116            Self::ToolInput,
117            Self::ToolInputPartial,
118            Self::ToolResult,
119            Self::ToolCancelled,
120            Self::SizeChanged,
121            Self::HostContextChanged,
122            Self::RequestDisplayMode,
123            Self::Message,
124            Self::UpdateModelContext,
125            Self::OpenLink,
126            Self::DownloadFile,
127            Self::ResourceTeardown,
128            Self::RequestTeardown,
129        ]
130    }
131}
132
133impl fmt::Display for UiMethod {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct McpUiToolMeta {
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub resource_uri: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub visibility: Option<Vec<ToolVisibility>>,
146}
147
148impl McpUiToolMeta {
149    #[must_use]
150    pub fn new(resource_uri: impl Into<String>) -> Self {
151        Self {
152            resource_uri: Some(resource_uri.into()),
153            visibility: None,
154        }
155    }
156
157    #[must_use]
158    pub fn with_visibility(mut self, visibility: Vec<ToolVisibility>) -> Self {
159        self.visibility = Some(visibility);
160        self
161    }
162}
163
164#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
165pub struct SizeChangedParams {
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub width: Option<f64>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub height: Option<f64>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct UiMessageParams {
174    pub role: UiMessageRole,
175    pub content: Vec<ContentBlock>,
176}
177
178impl UiMessageParams {
179    #[must_use]
180    pub fn user_text(text: impl Into<String>) -> Self {
181        Self {
182            role: UiMessageRole::User,
183            content: vec![ContentBlock::text(text.into())],
184        }
185    }
186}
187
188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum UiMessageRole {
191    #[default]
192    User,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct UiInitializeParams {
198    pub app_info: Implementation,
199    // JSON: protocol boundary — McpUiAppCapabilities is an open object hosts
200    // extend, so the schema fixes no field set to type against.
201    pub app_capabilities: serde_json::Value,
202    pub protocol_version: String,
203}
204
205impl UiInitializeParams {
206    #[must_use]
207    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
208        Self {
209            app_info: Implementation::new(name.into(), version.into()),
210            app_capabilities: serde_json::json!({}),
211            protocol_version: LATEST_PROTOCOL_VERSION.to_owned(),
212        }
213    }
214}
215
216#[must_use]
217pub fn ui_method_js_constants() -> String {
218    let entries = UiMethod::all()
219        .iter()
220        .map(|m| format!("    {}: '{}'", m.js_const(), m.as_str()))
221        .collect::<Vec<_>>()
222        .join(",\n");
223
224    format!(
225        "const MCP_UI = Object.freeze({{\n{entries},\n    PROTOCOL_VERSION: \
226         '{LATEST_PROTOCOL_VERSION}'\n}});\n"
227    )
228}