@smbcloud / cli / commits / d59bbcb

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