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