@smbcloud / cli / commits / 8ebee82

Add an MCP Apps inbox view and emit the legacy ui/resourceUri key

mail_message_list now links to ui://smbcloud/inbox.html. The view lists messages and opens one on click through mail_message_show; it renders only text_body, never html_body. Both mail tools return structured content next to the existing JSON. Tool _meta now carries the view URI under ui.resourceUri and the flat ui/resourceUri key, since older hosts read only the flat one. This may explain the deployments view not rendering in Claude Desktop; not yet confirmed there. Views are served from a single VIEWS table. List views and mail_message_list cap at the newest 50 rows to keep the text fallback under host size limits. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

keypair34 committed Oct 17, 2026 at 22:19 UTC 8ebee829de287199875f3a6a5732cb6df80c3522
3 files changed +549 -43
crates/cli/src/mcp.rs
+156 -35
@@ -50,7 +50,8 @@ use {
50 smbcloud_model::{
51 app_auth::{AuthAppCreate, AuthAppUpdate},
52 mail::{
53 - MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailTestEmailRequest,
53 + MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailMessage,
54 + MailTestEmailRequest,
55 },
56 project::{Deployment, ProjectCreate},
57 tenant::{TenantCreate, TenantUpdate},
@@ -112,24 +113,54 @@ fn invalid_request(message: impl Into<String>) -> ErrorData {
113 }
114
115 const DEPLOYMENTS_VIEW_URI: &str = "ui://smbcloud/deployments.html";
116 +const INBOX_VIEW_URI: &str = "ui://smbcloud/inbox.html";
117 const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app";
116 -const DEPLOYMENTS_VIEW_HTML: &str = include_str!("mcp_apps/deployments.html");
118
118 -/// `_meta` linking the `deployments` tool to its MCP Apps view (SEP-1865).
119 +/// Every MCP Apps view the server serves: (uri, name, description, html).
120 +/// Adding a view is one row here plus the tool that points at it.
121 +const VIEWS: [(&str, &str, &str, &str); 2] = [
122 + (
123 + DEPLOYMENTS_VIEW_URI,
124 + "deployments",
125 + "Interactive deployments dashboard for a project.",
126 + include_str!("mcp_apps/deployments.html"),
127 + ),
128 + (
129 + INBOX_VIEW_URI,
130 + "inbox",
131 + "Interactive inbox for a Mail inbox route.",
132 + include_str!("mcp_apps/inbox.html"),
133 + ),
134 +];
135 +
136 +/// List views show at most this many of the newest rows, which also keeps the
137 +/// text fallback under host size limits.
138 +const VIEW_ROW_CAP: usize = 50;
139 +
140 +/// `_meta` linking a tool to its MCP Apps view (SEP-1865).
141 ///
142 /// Sent unconditionally rather than gated on the client's
143 /// `io.modelcontextprotocol/ui` extension: hosts without MCP Apps support
144 /// ignore `_meta`, and gating would need per-session state in the tool router
145 /// for no user-visible gain.
124 -fn deployments_tool_meta() -> MetaObject {
146 +///
147 +/// The URI is emitted under both `_meta.ui.resourceUri` and the legacy flat
148 +/// `_meta["ui/resourceUri"]`, because older hosts read only the flat key.
149 +fn ui_tool_meta(uri: &str) -> MetaObject {
150 let mut meta = MetaObject::new();
126 - meta.insert(
127 - "ui".to_string(),
128 - serde_json::json!({ "resourceUri": DEPLOYMENTS_VIEW_URI }),
129 - );
151 + meta.insert("ui".to_string(), serde_json::json!({ "resourceUri": uri }));
152 + meta.insert("ui/resourceUri".to_string(), serde_json::json!(uri));
153 meta
154 }
155
156 +fn deployments_tool_meta() -> MetaObject {
157 + ui_tool_meta(DEPLOYMENTS_VIEW_URI)
158 +}
159 +
160 +fn inbox_tool_meta() -> MetaObject {
161 + ui_tool_meta(INBOX_VIEW_URI)
162 +}
163 +
164 /// The plain JSON content stays so hosts without MCP Apps support (and the
165 /// spec's requirement for a meaningful `content`) keep working; the view reads
166 /// `structured_content`, which must be an object rather than a bare array.
@@ -137,14 +168,40 @@ fn deployments_result(
168 project_id: i32,
169 deployments: &[Deployment],
170 ) -> Result<CallToolResult, ErrorData> {
140 - let mut result = CallToolResult::success(vec![ContentBlock::json(deployments)?]);
171 + let mut newest: Vec<&Deployment> = deployments.iter().collect();
172 + newest.sort_by_key(|deployment| std::cmp::Reverse(deployment.created_at));
173 + newest.truncate(VIEW_ROW_CAP);
174 + let mut result = CallToolResult::success(vec![ContentBlock::json(&newest)?]);
175 result.structured_content = Some(serde_json::json!({
176 "project_id": project_id,
143 - "deployments": deployments,
177 + "deployments": newest,
178 + }));
179 + Ok(result)
180 +}
181 +
182 +fn mail_messages_result(
183 + app_id: &str,
184 + inbox_id: &str,
185 + messages: &[MailMessage],
186 +) -> Result<CallToolResult, ErrorData> {
187 + let mut newest: Vec<&MailMessage> = messages.iter().collect();
188 + newest.sort_by_key(|message| std::cmp::Reverse(message.received_at));
189 + newest.truncate(VIEW_ROW_CAP);
190 + let mut result = CallToolResult::success(vec![ContentBlock::json(&newest)?]);
191 + result.structured_content = Some(serde_json::json!({
192 + "app_id": app_id,
193 + "inbox_id": inbox_id,
194 + "messages": newest,
195 }));
196 Ok(result)
197 }
198
199 +fn mail_message_result(message: &MailMessage) -> Result<CallToolResult, ErrorData> {
200 + let mut result = CallToolResult::success(vec![ContentBlock::json(message)?]);
201 + result.structured_content = Some(serde_json::json!({ "message": message }));
202 + Ok(result)
203 +}
204 +
205 #[derive(Debug, Deserialize, JsonSchema)]
206 struct ProjectShowArgs {
207 /// The project ID to show.
@@ -876,7 +933,10 @@ impl SmbMcpServer {
933 // ── Mail messages ────────────────────────────────────────────────────
934
935 #[tool(
879 - description = "List inbound mail messages for an inbox, most recent first, as a JSON array."
936 + description = "List inbound mail messages for an inbox, most recent first, as a JSON array \
937 + (at most the newest 50). Hosts that support MCP Apps render an \
938 + interactive inbox.",
939 + meta = inbox_tool_meta()
940 )]
941 async fn mail_message_list(
942 &self,
@@ -887,13 +947,13 @@ impl SmbMcpServer {
947 self.environment,
948 client(),
949 token,
890 - args.app_id,
891 - args.inbox_id,
892 - Some(args.limit),
950 + args.app_id.clone(),
951 + args.inbox_id.clone(),
952 + Some(args.limit.min(VIEW_ROW_CAP as u32)),
953 )
954 .await
955 .map_err(to_error_data)?;
896 - json_result(&messages)
956 + mail_messages_result(&args.app_id, &args.inbox_id, &messages)
957 }
958
959 #[tool(description = "Show a single inbound mail message, returned as JSON.")]
@@ -912,7 +972,7 @@ impl SmbMcpServer {
972 )
973 .await
974 .map_err(to_error_data)?;
915 - json_result(&message)
975 + mail_message_result(&message)
976 }
977
978 // ── Auth apps ────────────────────────────────────────────────────────
@@ -1052,10 +1112,15 @@ impl ServerHandler for SmbMcpServer {
1112 _request: Option<PaginatedRequestParams>,
1113 _context: RequestContext<RoleServer>,
1114 ) -> Result<ListResourcesResult, ErrorData> {
1055 - let view = Resource::new(DEPLOYMENTS_VIEW_URI, "deployments")
1056 - .with_description("Interactive deployments dashboard for a project.")
1057 - .with_mime_type(MCP_APP_MIME_TYPE);
1058 - Ok(ListResourcesResult::with_all_items(vec![view]))
1115 + let views = VIEWS
1116 + .iter()
1117 + .map(|(uri, name, description, _)| {
1118 + Resource::new(*uri, *name)
1119 + .with_description(*description)
1120 + .with_mime_type(MCP_APP_MIME_TYPE)
1121 + })
1122 + .collect();
1123 + Ok(ListResourcesResult::with_all_items(views))
1124 }
1125
1126 async fn read_resource(
@@ -1063,12 +1128,12 @@ impl ServerHandler for SmbMcpServer {
1128 request: ReadResourceRequestParams,
1129 _context: RequestContext<RoleServer>,
1130 ) -> Result<ReadResourceResponse, ErrorData> {
1066 - if request.uri != DEPLOYMENTS_VIEW_URI {
1131 + let Some((_, _, _, html)) = VIEWS.iter().find(|(uri, ..)| *uri == request.uri) else {
1132 return Err(ErrorData::resource_not_found(
1133 format!("Unknown resource: {}", request.uri),
1134 None,
1135 ));
1071 - }
1136 + };
1137 // No `csp` on purpose: the view loads nothing external, so the host's
1138 // restrictive default policy is the right one.
1139 let mut meta = MetaObject::new();
@@ -1076,7 +1141,7 @@ impl ServerHandler for SmbMcpServer {
1141 "ui".to_string(),
1142 serde_json::json!({ "prefersBorder": true }),
1143 );
1079 - let contents = ResourceContents::text(DEPLOYMENTS_VIEW_HTML, request.uri)
1144 + let contents = ResourceContents::text(*html, request.uri)
1145 .with_mime_type(MCP_APP_MIME_TYPE)
1146 .with_meta(meta);
1147 Ok(ReadResourceResult::new(vec![contents]).into())
@@ -1124,22 +1189,30 @@ mod tests {
1189 }
1190
1191 #[test]
1127 - fn deployments_tool_declares_ui_resource() {
1192 + fn view_tools_declare_ui_resource_under_both_keys() {
1193 let tools = SmbMcpServer::cloud_tool_router().list_all();
1129 - let tool = tools
1130 - .iter()
1131 - .find(|tool| tool.name == "deployments")
1132 - .expect("deployments tool");
1133 - let meta = tool.meta.as_ref().expect("deployments tool _meta");
1134 - assert_eq!(meta["ui"]["resourceUri"], DEPLOYMENTS_VIEW_URI);
1194 + for (tool_name, uri) in [
1195 + ("deployments", DEPLOYMENTS_VIEW_URI),
1196 + ("mail_message_list", INBOX_VIEW_URI),
1197 + ] {
1198 + let tool = tools
1199 + .iter()
1200 + .find(|tool| tool.name == tool_name)
1201 + .unwrap_or_else(|| panic!("{tool_name} tool"));
1202 + let meta = tool.meta.as_ref().expect("tool _meta");
1203 + assert_eq!(meta["ui"]["resourceUri"], uri, "{tool_name}");
1204 + assert_eq!(meta["ui/resourceUri"], uri, "{tool_name}");
1205 + }
1206 }
1207
1208 #[test]
1138 - fn deployments_view_is_self_contained() {
1139 - assert!(!DEPLOYMENTS_VIEW_HTML.is_empty());
1140 - assert!(DEPLOYMENTS_VIEW_HTML.contains("ui/initialize"));
1141 - assert!(!DEPLOYMENTS_VIEW_HTML.contains("http://"));
1142 - assert!(!DEPLOYMENTS_VIEW_HTML.contains("https://"));
1209 + fn views_are_self_contained() {
1210 + for (uri, _, _, html) in VIEWS {
1211 + assert!(!html.is_empty(), "{uri}");
1212 + assert!(html.contains("ui/initialize"), "{uri}");
1213 + assert!(!html.contains("http://"), "{uri}");
1214 + assert!(!html.contains("https://"), "{uri}");
1215 + }
1216 }
1217
1218 #[test]
@@ -1168,6 +1241,54 @@ mod tests {
1241 );
1242 }
1243
1244 + fn fixture_message(index: usize) -> MailMessage {
1245 + serde_json::from_value(serde_json::json!({
1246 + "id": format!("message-{index}"),
1247 + "mail_inbox_id": "inbox",
1248 + "provider_message_id": "provider",
1249 + "original_recipient_email": "to@example.com",
1250 + "from_email": "from@example.com",
1251 + "from_name": null,
1252 + "subject": "Hello",
1253 + "text_preview": "Preview",
1254 + "size_bytes": null,
1255 + "forwarded_at": null,
1256 + "forward_error": null,
1257 + "received_at": format!("2026-01-01T00:{:02}:00Z", index % 60),
1258 + "created_at": "2026-01-01T00:00:00Z",
1259 + "updated_at": "2026-01-01T00:00:00Z",
1260 + "status": 0,
1261 + "text_body": "Body",
1262 + "html_body": null,
1263 + "s3_bucket": null,
1264 + "s3_key": null,
1265 + }))
1266 + .expect("fixture message")
1267 + }
1268 +
1269 + #[test]
1270 + fn mail_messages_result_carries_content_and_structured_content() {
1271 + let messages: Vec<MailMessage> = (0..60).map(fixture_message).collect();
1272 +
1273 + let result = mail_messages_result("app", "inbox", &messages).expect("result");
1274 + assert!(!result.content.is_empty());
1275 + let structured = result.structured_content.expect("structured content");
1276 + assert_eq!(structured["app_id"], "app");
1277 + assert_eq!(structured["inbox_id"], "inbox");
1278 + assert_eq!(
1279 + structured["messages"].as_array().map(|items| items.len()),
1280 + Some(VIEW_ROW_CAP)
1281 + );
1282 + }
1283 +
1284 + #[test]
1285 + fn mail_message_result_carries_content_and_structured_content() {
1286 + let result = mail_message_result(&fixture_message(1)).expect("result");
1287 + assert!(!result.content.is_empty());
1288 + let structured = result.structured_content.expect("structured content");
1289 + assert_eq!(structured["message"]["id"], "message-1");
1290 + }
1291 +
1292 #[test]
1293 fn cloud_router_excludes_automation_tools() {
1294 let tools = SmbMcpServer::cloud_tool_router().list_all();
crates/cli/src/mcp_apps/inbox.html new
+377
@@ -0,0 +1,377 @@
1 +<!doctype html>
2 +<html lang="en">
3 +<head>
4 +<meta charset="utf-8">
5 +<meta name="viewport" content="width=device-width, initial-scale=1">
6 +<title>Inbox</title>
7 +<style>
8 + :root {
9 + color-scheme: light dark;
10 + --bg: #ffffff;
11 + --fg: #1a1a1a;
12 + --muted: #6b6b6b;
13 + --border: #e2e2e2;
14 + --accent: #2563eb;
15 + --ok: #15803d;
16 + --fail: #b91c1c;
17 + --busy: #b45309;
18 + }
19 + @media (prefers-color-scheme: dark) {
20 + :root {
21 + --bg: #1b1b1b;
22 + --fg: #ececec;
23 + --muted: #9a9a9a;
24 + --border: #333333;
25 + --accent: #60a5fa;
26 + --ok: #4ade80;
27 + --fail: #f87171;
28 + --busy: #fbbf24;
29 + }
30 + }
31 + :root[data-theme="light"] {
32 + color-scheme: light;
33 + --bg: #ffffff; --fg: #1a1a1a; --muted: #6b6b6b; --border: #e2e2e2;
34 + --accent: #2563eb; --ok: #15803d; --fail: #b91c1c; --busy: #b45309;
35 + }
36 + :root[data-theme="dark"] {
37 + color-scheme: dark;
38 + --bg: #1b1b1b; --fg: #ececec; --muted: #9a9a9a; --border: #333333;
39 + --accent: #60a5fa; --ok: #4ade80; --fail: #f87171; --busy: #fbbf24;
40 + }
41 + body {
42 + margin: 0;
43 + padding: 12px;
44 + background: var(--bg);
45 + color: var(--fg);
46 + font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
47 + }
48 + header {
49 + display: flex;
50 + align-items: center;
51 + justify-content: space-between;
52 + margin-bottom: 8px;
53 + }
54 + h1 { margin: 0; font-size: 15px; font-weight: 600; }
55 + button {
56 + font: inherit;
57 + color: var(--fg);
58 + background: transparent;
59 + border: 1px solid var(--border);
60 + border-radius: 6px;
61 + padding: 4px 10px;
62 + cursor: pointer;
63 + }
64 + button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
65 + button:disabled { opacity: 0.5; cursor: default; }
66 + table { width: 100%; border-collapse: collapse; }
67 + th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); }
68 + th { color: var(--muted); font-weight: 500; font-size: 12px; }
69 + code { font-family: ui-monospace, Menlo, Consolas, monospace; }
70 + tr.row { cursor: pointer; }
71 + tr.row:hover td { background: color-mix(in srgb, var(--accent) 8%, transparent); }
72 + .sub { display: block; color: var(--muted); font-size: 12px; }
73 + .unread { font-weight: 600; }
74 + .fail { color: var(--fail); }
75 + .ok { color: var(--ok); }
76 + .preview { color: var(--muted); }
77 + .actions { display: flex; gap: 6px; }
78 + .detail h2 { margin: 0 0 4px; font-size: 14px; }
79 + .detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
80 + .detail pre {
81 + margin: 0;
82 + white-space: pre-wrap;
83 + word-break: break-word;
84 + font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
85 + }
86 + .message { padding: 24px 8px; text-align: center; color: var(--muted); }
87 + .message.error { color: var(--fail); }
88 +</style>
89 +</head>
90 +<body>
91 +<header>
92 + <h1 id="title">Inbox</h1>
93 + <div class="actions">
94 + <button id="back" type="button" hidden>Back</button>
95 + <button id="refresh" type="button" disabled>Refresh</button>
96 + </div>
97 +</header>
98 +<div id="content" class="message">Waiting for data…</div>
99 +<script>
100 +(function () {
101 + "use strict";
102 +
103 + var LIST_TOOL = "mail_message_list";
104 + var SHOW_TOOL = "mail_message_show";
105 + var nextId = 1;
106 + var pending = {};
107 + var appId = null;
108 + var inboxId = null;
109 + var limit = null;
110 + var messages = [];
111 + var contentEl = document.getElementById("content");
112 + var titleEl = document.getElementById("title");
113 + var refreshButton = document.getElementById("refresh");
114 + var backButton = document.getElementById("back");
115 +
116 + function send(message) {
117 + window.parent.postMessage(Object.assign({ jsonrpc: "2.0" }, message), "*");
118 + }
119 +
120 + function request(method, params) {
121 + return new Promise(function (resolve, reject) {
122 + var id = nextId++;
123 + pending[id] = { resolve: resolve, reject: reject };
124 + send({ id: id, method: method, params: params });
125 + });
126 + }
127 +
128 + function notify(method, params) {
129 + send({ method: method, params: params });
130 + }
131 +
132 + function applyHostContext(context) {
133 + if (!context) return;
134 + if (context.theme === "light" || context.theme === "dark") {
135 + document.documentElement.setAttribute("data-theme", context.theme);
136 + }
137 + var variables = context.styles && context.styles.variables;
138 + if (variables) {
139 + Object.keys(variables).forEach(function (name) {
140 + if (variables[name]) document.documentElement.style.setProperty(name, variables[name]);
141 + });
142 + }
143 + }
144 +
145 + function showMessage(text, isError) {
146 + contentEl.className = "message" + (isError ? " error" : "");
147 + contentEl.textContent = text;
148 + }
149 +
150 + function relative(date) {
151 + var seconds = Math.round((Date.now() - date.getTime()) / 1000);
152 + var future = seconds < 0;
153 + seconds = Math.abs(seconds);
154 + var units = [["day", 86400], ["hour", 3600], ["minute", 60]];
155 + for (var index = 0; index < units.length; index++) {
156 + if (seconds >= units[index][1]) {
157 + var count = Math.floor(seconds / units[index][1]);
158 + var text = count + " " + units[index][0] + (count === 1 ? "" : "s");
159 + return future ? "in " + text : text + " ago";
160 + }
161 + }
162 + return "just now";
163 + }
164 +
165 + function timeCell(value) {
166 + var cell = document.createElement("td");
167 + var date = new Date(value);
168 + if (isNaN(date.getTime())) {
169 + cell.textContent = String(value || "");
170 + return cell;
171 + }
172 + cell.textContent = relative(date);
173 + var absolute = document.createElement("span");
174 + absolute.className = "sub";
175 + absolute.textContent = date.toLocaleString();
176 + cell.appendChild(absolute);
177 + return cell;
178 + }
179 +
180 + function textCell(text, className) {
181 + var cell = document.createElement("td");
182 + if (className) cell.className = className;
183 + cell.textContent = text;
184 + return cell;
185 + }
186 +
187 + function sender(message) {
188 + return message.from_name
189 + ? message.from_name + " <" + message.from_email + ">"
190 + : message.from_email || "";
191 + }
192 +
193 + // 0 received, 1 forwarded, 2 forward failed.
194 + function forwardCell(message) {
195 + if (message.status === 2) {
196 + var failed = textCell(message.forward_error || "Forward failed", "fail");
197 + return failed;
198 + }
199 + if (message.status === 1) return textCell("Forwarded", "ok");
200 + return textCell("—");
201 + }
202 +
203 + function setView(isDetail) {
204 + backButton.hidden = !isDetail;
205 + refreshButton.hidden = isDetail;
206 + titleEl.textContent = isDetail ? "Message" : "Inbox";
207 + }
208 +
209 + function renderList() {
210 + setView(false);
211 + if (!messages.length) {
212 + showMessage("No messages yet.", false);
213 + return;
214 + }
215 + var table = document.createElement("table");
216 + var head = document.createElement("thead");
217 + var headRow = document.createElement("tr");
218 + ["From", "Subject", "Received", "Forward"].forEach(function (label) {
219 + var th = document.createElement("th");
220 + th.textContent = label;
221 + headRow.appendChild(th);
222 + });
223 + head.appendChild(headRow);
224 + table.appendChild(head);
225 +
226 + var body = document.createElement("tbody");
227 + messages.forEach(function (message) {
228 + var row = document.createElement("tr");
229 + row.className = "row";
230 + row.appendChild(textCell(sender(message)));
231 + var subject = textCell(message.subject || "(no subject)");
232 + if (message.text_preview) {
233 + var preview = document.createElement("span");
234 + preview.className = "sub preview";
235 + preview.textContent = message.text_preview;
236 + subject.appendChild(preview);
237 + }
238 + row.appendChild(subject);
239 + row.appendChild(timeCell(message.received_at));
240 + row.appendChild(forwardCell(message));
241 + row.addEventListener("click", function () { openMessage(message.id); });
242 + body.appendChild(row);
243 + });
244 + table.appendChild(body);
245 + contentEl.className = "";
246 + contentEl.replaceChildren(table);
247 + }
248 +
249 + function renderDetail(message) {
250 + setView(true);
251 + var wrapper = document.createElement("div");
252 + wrapper.className = "detail";
253 + var subject = document.createElement("h2");
254 + subject.textContent = message.subject || "(no subject)";
255 + var meta = document.createElement("div");
256 + meta.className = "meta";
257 + var received = new Date(message.received_at);
258 + meta.textContent = "From " + sender(message) +
259 + (message.to_emails && message.to_emails.length ? " · To " + message.to_emails.join(", ") : "") +
260 + (isNaN(received.getTime()) ? "" : " · " + received.toLocaleString());
261 + // Only the plain-text body is shown; html_body is never rendered, so the
262 + // view never loads remote content.
263 + var body = document.createElement("pre");
264 + body.textContent = message.text_body || message.text_preview || "(no text body)";
265 + wrapper.appendChild(subject);
266 + wrapper.appendChild(meta);
267 + wrapper.appendChild(body);
268 + contentEl.className = "";
269 + contentEl.replaceChildren(wrapper);
270 + }
271 +
272 + function errorText(result, fallback) {
273 + var first = result && result.content && result.content[0];
274 + return (first && first.text) || fallback;
275 + }
276 +
277 + function renderResult(result) {
278 + if (!result) return;
279 + if (result.isError) {
280 + showMessage(errorText(result, "The inbox request failed."), true);
281 + return;
282 + }
283 + var data = result.structuredContent;
284 + if (data && data.message) {
285 + renderDetail(data.message);
286 + return;
287 + }
288 + if (!data || !Array.isArray(data.messages)) {
289 + showMessage("No inbox data in the tool result.", true);
290 + return;
291 + }
292 + if (data.app_id !== undefined) appId = data.app_id;
293 + if (data.inbox_id !== undefined) inboxId = data.inbox_id;
294 + refreshButton.disabled = appId === null || inboxId === null;
295 + messages = data.messages;
296 + renderList();
297 + }
298 +
299 + function openMessage(id) {
300 + if (appId === null || inboxId === null) return;
301 + showMessage("Loading…", false);
302 + request("tools/call", {
303 + name: SHOW_TOOL,
304 + arguments: { app_id: appId, inbox_id: inboxId, id: id }
305 + }).then(renderResult).catch(function (error) {
306 + showMessage((error && error.message) || "Could not load the message.", true);
307 + });
308 + }
309 +
310 + function refresh() {
311 + if (appId === null || inboxId === null) return;
312 + refreshButton.disabled = true;
313 + var args = { app_id: appId, inbox_id: inboxId };
314 + if (limit !== null) args.limit = limit;
315 + request("tools/call", { name: LIST_TOOL, arguments: args })
316 + .then(renderResult)
317 + .catch(function (error) {
318 + showMessage((error && error.message) || "Refresh failed.", true);
319 + })
320 + .then(function () {
321 + refreshButton.disabled = appId === null || inboxId === null;
322 + });
323 + }
324 +
325 + refreshButton.addEventListener("click", refresh);
326 + backButton.addEventListener("click", renderList);
327 +
328 + window.addEventListener("message", function (event) {
329 + if (event.source !== window.parent) return;
330 + var message = event.data;
331 + if (!message || message.jsonrpc !== "2.0") return;
332 +
333 + if (message.method === undefined && message.id !== undefined) {
334 + var entry = pending[message.id];
335 + if (!entry) return;
336 + delete pending[message.id];
337 + if (message.error) entry.reject(message.error);
338 + else entry.resolve(message.result);
339 + return;
340 + }
341 +
342 + var params = message.params || {};
343 + switch (message.method) {
344 + case "ui/notifications/tool-input":
345 + if (params.arguments) {
346 + if (params.arguments.app_id !== undefined) appId = params.arguments.app_id;
347 + if (params.arguments.inbox_id !== undefined) inboxId = params.arguments.inbox_id;
348 + if (params.arguments.limit !== undefined) limit = params.arguments.limit;
349 + refreshButton.disabled = appId === null || inboxId === null;
350 + }
351 + break;
352 + case "ui/notifications/tool-result":
353 + renderResult(params);
354 + break;
355 + case "ui/notifications/host-context-changed":
356 + applyHostContext(params);
357 + break;
358 + case "ui/resource-teardown":
359 + send({ id: message.id, result: {} });
360 + break;
361 + }
362 + });
363 +
364 + request("ui/initialize", {
365 + appInfo: { name: "smbcloud-inbox", version: "1.0.0" },
366 + appCapabilities: {},
367 + protocolVersion: "2026-01-26"
368 + }).then(function (result) {
369 + applyHostContext(result && result.hostContext);
370 + notify("ui/notifications/initialized", {});
371 + }).catch(function () {
372 + showMessage("Could not reach the host.", true);
373 + });
374 +})();
375 +</script>
376 +</body>
377 +</html>
docs/mcp.md
+16 -8
@@ -162,14 +162,22 @@ not a separate MCP-specific shape.
162
163 #### Interactive views (MCP Apps)
164
165 -`deployments` declares an [MCP Apps](https://modelcontextprotocol.io/extensions/apps) view
166 -(`ui://smbcloud/deployments.html`). Hosts that support the extension, such as Claude and
167 -ChatGPT, render it in a sandboxed frame next to the result: a table of deployments with
168 -status, app, commit, and created/updated times, newest first, plus a **Refresh** button.
169 -
170 -Other clients see the same JSON result as before. The view is a single self-contained
171 -HTML document that makes no network requests; Refresh goes back through the host with a
172 -`tools/call` to `deployments`.
165 +Two tools declare an [MCP Apps](https://modelcontextprotocol.io/extensions/apps) view.
166 +Hosts that support the extension, such as Claude and ChatGPT, render it in a sandboxed frame
167 +next to the result:
168 +
169 +- `deployments` → `ui://smbcloud/deployments.html`: a table of deployments with status, app,
170 + commit, and created/updated times, newest first, plus a **Refresh** button.
171 +- `mail_message_list` → `ui://smbcloud/inbox.html`: the inbox as a table (from, subject,
172 + preview, received, forward status). Click a row to open the message, which calls
173 + `mail_message_show`; **Back** returns to the list and **Refresh** reloads it. Only the
174 + plain-text body is shown, never `html_body`.
175 +
176 +List views show the newest 50 rows. Other clients see the same JSON result as before. Each
177 +view is a single self-contained HTML document that makes no network requests; Refresh and
178 +drill-in go back through the host with a `tools/call`. Tool `_meta` carries the view URI
179 +under both `ui.resourceUri` and the legacy `ui/resourceUri` key, since older hosts read only
180 +the flat one.
181
182 ### Tenants
183