Add an MCP Apps setup card for Auth apps
auth_app_show and auth_app_new link to ui://smbcloud/auth-app.html and return structured content next to the existing JSON. The card shows the client ID with a copy button, the client secret masked behind Reveal and Copy, the support email, and a Sign in with Apple checklist built from the fields the API already returns. The secret is still in the text content of both tools. Whether auth_app_show should stop returning it is left open. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
keypair34 committed
Oct 17, 2026 at 22:50 UTC
d59bbcb26fe0e785cb45dacaeee66034ac645143
3 files changed
+400
-7
crates/cli/src/mcp.rs
+54
-6
@@ -48,7 +48,7 @@ use {
48
mail_message::{get_mail_message, get_mail_messages},
49
},
50
smbcloud_model::{
51
- app_auth::{AuthAppCreate, AuthAppUpdate},
51
+ app_auth::{AuthApp, AuthAppCreate, AuthAppUpdate},
52
mail::{
53
MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailMessage,
54
MailTestEmailRequest,
@@ -114,11 +114,12 @@ fn invalid_request(message: impl Into<String>) -> ErrorData {
114
115
const DEPLOYMENTS_VIEW_URI: &str = "ui://smbcloud/deployments.html";
116
const INBOX_VIEW_URI: &str = "ui://smbcloud/inbox.html";
117
+const AUTH_APP_VIEW_URI: &str = "ui://smbcloud/auth-app.html";
118
const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app";
119
120
/// Every MCP Apps view the server serves: (uri, name, description, html).
121
/// Adding a view is one row here plus the tool that points at it.
121
-const VIEWS: [(&str, &str, &str, &str); 2] = [
122
+const VIEWS: [(&str, &str, &str, &str); 3] = [
123
(
124
DEPLOYMENTS_VIEW_URI,
125
"deployments",
@@ -131,6 +132,12 @@ const VIEWS: [(&str, &str, &str, &str); 2] = [
132
"Interactive inbox for a Mail inbox route.",
133
include_str!("mcp_apps/inbox.html"),
134
),
135
+ (
136
+ AUTH_APP_VIEW_URI,
137
+ "auth-app",
138
+ "Setup card for an Auth app: client ID, secret, and provider checklist.",
139
+ include_str!("mcp_apps/auth_app.html"),
140
+ ),
141
];
142
143
/// List views show at most this many of the newest rows, which also keeps the
@@ -161,6 +168,10 @@ fn inbox_tool_meta() -> MetaObject {
168
ui_tool_meta(INBOX_VIEW_URI)
169
}
170
171
+fn auth_app_tool_meta() -> MetaObject {
172
+ ui_tool_meta(AUTH_APP_VIEW_URI)
173
+}
174
+
175
/// The plain JSON content stays so hosts without MCP Apps support (and the
176
/// spec's requirement for a meaningful `content`) keep working; the view reads
177
/// `structured_content`, which must be an object rather than a bare array.
@@ -196,6 +207,12 @@ fn mail_messages_result(
207
Ok(result)
208
}
209
210
+fn auth_app_result(auth_app: &AuthApp) -> Result<CallToolResult, ErrorData> {
211
+ let mut result = CallToolResult::success(vec![ContentBlock::json(auth_app)?]);
212
+ result.structured_content = Some(serde_json::json!({ "auth_app": auth_app }));
213
+ Ok(result)
214
+}
215
+
216
fn mail_message_result(message: &MailMessage) -> Result<CallToolResult, ErrorData> {
217
let mut result = CallToolResult::success(vec![ContentBlock::json(message)?]);
218
result.structured_content = Some(serde_json::json!({ "message": message }));
@@ -996,7 +1013,11 @@ impl SmbMcpServer {
1013
json_result(&auth_apps)
1014
}
1015
999
- #[tool(description = "Show a single Auth app by ID, returned as JSON.")]
1016
+ #[tool(
1017
+ description = "Show a single Auth app by ID, returned as JSON. Hosts that support \
1018
+ MCP Apps render a setup card.",
1019
+ meta = auth_app_tool_meta()
1020
+ )]
1021
async fn auth_app_show(
1022
&self,
1023
Parameters(args): Parameters<AuthAppShowArgs>,
@@ -1006,13 +1027,15 @@ impl SmbMcpServer {
1027
crate::cloud_auth::auth_app::get_auth_app(self.environment, client(), token, args.id)
1028
.await
1029
.map_err(to_error_data)?;
1009
- json_result(&auth_app)
1030
+ auth_app_result(&auth_app)
1031
}
1032
1033
#[tool(
1034
description = "Create an Auth app under a project. Defaults to the currently \
1035
selected project (`project_use`) if project_id is omitted. Returns \
1015
- the created Auth app as JSON."
1036
+ the created Auth app as JSON. Hosts that support MCP Apps render a \
1037
+ setup card.",
1038
+ meta = auth_app_tool_meta()
1039
)]
1040
async fn auth_app_new(
1041
&self,
@@ -1033,7 +1056,7 @@ impl SmbMcpServer {
1056
)
1057
.await
1058
.map_err(to_error_data)?;
1036
- json_result(&auth_app)
1059
+ auth_app_result(&auth_app)
1060
}
1061
1062
#[tool(
@@ -1194,6 +1217,8 @@ mod tests {
1217
for (tool_name, uri) in [
1218
("deployments", DEPLOYMENTS_VIEW_URI),
1219
("mail_message_list", INBOX_VIEW_URI),
1220
+ ("auth_app_show", AUTH_APP_VIEW_URI),
1221
+ ("auth_app_new", AUTH_APP_VIEW_URI),
1222
] {
1223
let tool = tools
1224
.iter()
@@ -1281,6 +1306,29 @@ mod tests {
1306
);
1307
}
1308
1309
+ #[test]
1310
+ fn auth_app_result_carries_content_and_structured_content() {
1311
+ let auth_app: AuthApp = serde_json::from_value(serde_json::json!({
1312
+ "id": "client-id",
1313
+ "secret": "client-secret",
1314
+ "name": "My app",
1315
+ "project_id": 7,
1316
+ "support_email": null,
1317
+ "apple_oauth_enabled": true,
1318
+ "apple_oauth_client_id": null,
1319
+ "apple_oauth_configured": false,
1320
+ "apple_oauth_secret_present": false,
1321
+ "created_at": "2026-01-01T00:00:00.000+00:00",
1322
+ "updated_at": "2026-01-01T00:01:00.000+00:00",
1323
+ }))
1324
+ .expect("fixture auth app");
1325
+
1326
+ let result = auth_app_result(&auth_app).expect("result");
1327
+ assert!(!result.content.is_empty());
1328
+ let structured = result.structured_content.expect("structured content");
1329
+ assert_eq!(structured["auth_app"]["id"], "client-id");
1330
+ }
1331
+
1332
#[test]
1333
fn mail_message_result_carries_content_and_structured_content() {
1334
let result = mail_message_result(&fixture_message(1)).expect("result");
crates/cli/src/mcp_apps/auth_app.html
new
+341
@@ -0,0 +1,341 @@
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>Auth app</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
+ .card h2 { margin: 0 0 2px; font-size: 14px; }
71
+ .card .sub { display: block; color: var(--muted); font-size: 12px; margin-bottom: 12px; }
72
+ .field { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--border); }
73
+ .field .label { width: 110px; color: var(--muted); font-size: 12px; flex: none; }
74
+ .field .value { flex: 1; min-width: 0; word-break: break-all; }
75
+ .field .value.empty { color: var(--muted); }
76
+ code { font-family: ui-monospace, Menlo, Consolas, monospace; }
77
+ .checks { margin: 12px 0 0; padding: 0; list-style: none; }
78
+ .checks li { padding: 6px 0; border-top: 1px solid var(--border); }
79
+ .checks .state { font-weight: 500; }
80
+ .ok { color: var(--ok); }
81
+ .todo { color: var(--busy); }
82
+ .off { color: var(--muted); }
83
+ h3 { margin: 14px 0 0; font-size: 12px; font-weight: 500; color: var(--muted); }
84
+ .message { padding: 24px 8px; text-align: center; color: var(--muted); }
85
+ .message.error { color: var(--fail); }
86
+</style>
87
+</head>
88
+<body>
89
+<header>
90
+ <h1>Auth app</h1>
91
+</header>
92
+<div id="content" class="message">Waiting for data…</div>
93
+<script>
94
+(function () {
95
+ "use strict";
96
+
97
+ var nextId = 1;
98
+ var pending = {};
99
+ var contentEl = document.getElementById("content");
100
+
101
+ function send(message) {
102
+ window.parent.postMessage(Object.assign({ jsonrpc: "2.0" }, message), "*");
103
+ }
104
+
105
+ function request(method, params) {
106
+ return new Promise(function (resolve, reject) {
107
+ var id = nextId++;
108
+ pending[id] = { resolve: resolve, reject: reject };
109
+ send({ id: id, method: method, params: params });
110
+ });
111
+ }
112
+
113
+ function notify(method, params) {
114
+ send({ method: method, params: params });
115
+ }
116
+
117
+ function applyHostContext(context) {
118
+ if (!context) return;
119
+ if (context.theme === "light" || context.theme === "dark") {
120
+ document.documentElement.setAttribute("data-theme", context.theme);
121
+ }
122
+ var variables = context.styles && context.styles.variables;
123
+ if (variables) {
124
+ Object.keys(variables).forEach(function (name) {
125
+ if (variables[name]) document.documentElement.style.setProperty(name, variables[name]);
126
+ });
127
+ }
128
+ }
129
+
130
+ function showMessage(text, isError) {
131
+ contentEl.className = "message" + (isError ? " error" : "");
132
+ contentEl.textContent = text;
133
+ }
134
+
135
+ // The sandboxed frame may not be granted clipboard-write, so fall back to a
136
+ // transient textarea and execCommand.
137
+ function copyText(text) {
138
+ if (navigator.clipboard && navigator.clipboard.writeText) {
139
+ return navigator.clipboard.writeText(text).catch(function () { return fallbackCopy(text); });
140
+ }
141
+ return fallbackCopy(text);
142
+ }
143
+
144
+ function fallbackCopy(text) {
145
+ return new Promise(function (resolve, reject) {
146
+ var area = document.createElement("textarea");
147
+ area.value = text;
148
+ area.style.position = "fixed";
149
+ area.style.opacity = "0";
150
+ document.body.appendChild(area);
151
+ area.select();
152
+ var copied = false;
153
+ try { copied = document.execCommand("copy"); } catch (error) { copied = false; }
154
+ document.body.removeChild(area);
155
+ if (copied) resolve(); else reject(new Error("copy failed"));
156
+ });
157
+ }
158
+
159
+ function copyButton(getText) {
160
+ var button = document.createElement("button");
161
+ button.type = "button";
162
+ button.textContent = "Copy";
163
+ button.addEventListener("click", function () {
164
+ copyText(getText()).then(function () {
165
+ button.textContent = "Copied";
166
+ }, function () {
167
+ button.textContent = "Copy failed";
168
+ }).then(function () {
169
+ setTimeout(function () { button.textContent = "Copy"; }, 1500);
170
+ });
171
+ });
172
+ return button;
173
+ }
174
+
175
+ function field(label, valueNode, buttons) {
176
+ var row = document.createElement("div");
177
+ row.className = "field";
178
+ var labelEl = document.createElement("span");
179
+ labelEl.className = "label";
180
+ labelEl.textContent = label;
181
+ var valueEl = document.createElement("span");
182
+ valueEl.className = "value";
183
+ valueEl.appendChild(valueNode);
184
+ row.appendChild(labelEl);
185
+ row.appendChild(valueEl);
186
+ (buttons || []).forEach(function (button) { row.appendChild(button); });
187
+ return row;
188
+ }
189
+
190
+ function code(text) {
191
+ var el = document.createElement("code");
192
+ el.textContent = text;
193
+ return el;
194
+ }
195
+
196
+ function plain(text, empty) {
197
+ var el = document.createElement("span");
198
+ if (empty) el.className = "empty";
199
+ el.textContent = text;
200
+ return el;
201
+ }
202
+
203
+ function secretField(secret) {
204
+ if (!secret) return field("Client secret", plain("Not shown. The secret is only returned when the app is created.", true));
205
+ var shown = false;
206
+ var value = code("••••••••••••");
207
+ var reveal = document.createElement("button");
208
+ reveal.type = "button";
209
+ reveal.textContent = "Reveal";
210
+ reveal.addEventListener("click", function () {
211
+ shown = !shown;
212
+ value.textContent = shown ? secret : "••••••••••••";
213
+ reveal.textContent = shown ? "Hide" : "Reveal";
214
+ });
215
+ return field("Client secret", value, [reveal, copyButton(function () { return secret; })]);
216
+ }
217
+
218
+ function check(state, label, detail) {
219
+ var item = document.createElement("li");
220
+ var mark = document.createElement("span");
221
+ mark.className = "state " + state;
222
+ mark.textContent = state === "ok" ? "Done" : state === "todo" ? "To do" : "Off";
223
+ item.appendChild(mark);
224
+ item.appendChild(document.createTextNode(" " + label));
225
+ if (detail) {
226
+ var sub = document.createElement("span");
227
+ sub.className = "sub";
228
+ sub.style.marginBottom = "0";
229
+ sub.textContent = detail;
230
+ item.appendChild(sub);
231
+ }
232
+ return item;
233
+ }
234
+
235
+ function appleChecks(app) {
236
+ var list = document.createElement("ul");
237
+ list.className = "checks";
238
+ if (!app.apple_oauth_enabled) {
239
+ list.appendChild(check("off", "Sign in with Apple is not enabled"));
240
+ return list;
241
+ }
242
+ list.appendChild(check("ok", "Sign in with Apple enabled"));
243
+ list.appendChild(check(
244
+ app.apple_oauth_client_id ? "ok" : "todo",
245
+ "Services ID",
246
+ app.apple_oauth_client_id || "Not set"
247
+ ));
248
+ list.appendChild(check(
249
+ app.apple_oauth_secret_present ? "ok" : "todo",
250
+ "Client secret stored"
251
+ ));
252
+ list.appendChild(check(
253
+ app.apple_oauth_configured ? "ok" : "todo",
254
+ "Credentials complete"
255
+ ));
256
+ return list;
257
+ }
258
+
259
+ function render(app) {
260
+ var card = document.createElement("div");
261
+ card.className = "card";
262
+ var name = document.createElement("h2");
263
+ name.textContent = app.name || "Auth app";
264
+ card.appendChild(name);
265
+ if (app.project_id !== null && app.project_id !== undefined) {
266
+ var project = document.createElement("span");
267
+ project.className = "sub";
268
+ project.textContent = "Project " + app.project_id;
269
+ card.appendChild(project);
270
+ }
271
+ card.appendChild(field("Client ID", code(app.id), [copyButton(function () { return app.id; })]));
272
+ card.appendChild(secretField(app.secret));
273
+ card.appendChild(field(
274
+ "Support email",
275
+ app.support_email ? plain(app.support_email) : plain("Not set", true)
276
+ ));
277
+ var heading = document.createElement("h3");
278
+ heading.textContent = "Providers";
279
+ card.appendChild(heading);
280
+ card.appendChild(appleChecks(app));
281
+ contentEl.className = "";
282
+ contentEl.replaceChildren(card);
283
+ }
284
+
285
+ function renderResult(result) {
286
+ if (!result) return;
287
+ if (result.isError) {
288
+ var first = result.content && result.content[0];
289
+ showMessage((first && first.text) || "The Auth app request failed.", true);
290
+ return;
291
+ }
292
+ var data = result.structuredContent;
293
+ if (!data || !data.auth_app) {
294
+ showMessage("No Auth app data in the tool result.", true);
295
+ return;
296
+ }
297
+ render(data.auth_app);
298
+ }
299
+
300
+ window.addEventListener("message", function (event) {
301
+ if (event.source !== window.parent) return;
302
+ var message = event.data;
303
+ if (!message || message.jsonrpc !== "2.0") return;
304
+
305
+ if (message.method === undefined && message.id !== undefined) {
306
+ var entry = pending[message.id];
307
+ if (!entry) return;
308
+ delete pending[message.id];
309
+ if (message.error) entry.reject(message.error);
310
+ else entry.resolve(message.result);
311
+ return;
312
+ }
313
+
314
+ var params = message.params || {};
315
+ switch (message.method) {
316
+ case "ui/notifications/tool-result":
317
+ renderResult(params);
318
+ break;
319
+ case "ui/notifications/host-context-changed":
320
+ applyHostContext(params);
321
+ break;
322
+ case "ui/resource-teardown":
323
+ send({ id: message.id, result: {} });
324
+ break;
325
+ }
326
+ });
327
+
328
+ request("ui/initialize", {
329
+ appInfo: { name: "smbcloud-auth-app", version: "1.0.0" },
330
+ appCapabilities: {},
331
+ protocolVersion: "2026-01-26"
332
+ }).then(function (result) {
333
+ applyHostContext(result && result.hostContext);
334
+ notify("ui/notifications/initialized", {});
335
+ }).catch(function () {
336
+ showMessage("Could not reach the host.", true);
337
+ });
338
+})();
339
+</script>
340
+</body>
341
+</html>
docs/mcp.md
+5
-1
@@ -162,7 +162,7 @@ not a separate MCP-specific shape.
162
163
#### Interactive views (MCP Apps)
164
165
-Two tools declare an [MCP Apps](https://modelcontextprotocol.io/extensions/apps) view.
165
+Three 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
@@ -173,6 +173,10 @@ next to the result:
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
+- `auth_app_show` and `auth_app_new` → `ui://smbcloud/auth-app.html`: a setup card with the
177
+ client ID (copy), the client secret (masked, with Reveal and Copy), the support email, and a
178
+ Sign in with Apple checklist. The secret is only present when the API returns it.
179
+
180
List views show the newest 50 rows. Other clients see the same JSON result as before. Each
181
view is a single self-contained HTML document that makes no network requests; Refresh and
182
drill-in go back through the host with a `tools/call`. Tool `_meta` carries the view URI