@smbcloud / cli / commits / daa9c32

Add an MCP Apps deployments view to the MCP server

The deployments tool now declares a ui:// resource (SEP-1865). Hosts that support MCP Apps render a table of deployments with a Refresh button; other clients get the same JSON content as before, plus a structuredContent object with the project ID and deployments. The view is one hand-written HTML file embedded with include_str!. It loads nothing external, so the resource sets no CSP. Refresh calls the deployments tool through the host. Refs #40 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

keypair34 committed Oct 17, 2026 at 16:50 UTC daa9c329c160bfdcef9d47501dab76306fcafe77
3 files changed +435 -12
crates/cli/src/mcp.rs
+140 -11
@@ -29,8 +29,13 @@ use {
29 anyhow::{anyhow, Result},
30 rmcp::{
31 handler::server::{wrapper::Parameters, ServerHandler},
32 - model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerConfig},
33 - tool, tool_handler, tool_router, ErrorData, ServiceExt,
32 + model::{
33 + CallToolResult, ContentBlock, Implementation, ListResourcesResult, MetaObject,
34 + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse,
35 + ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig,
36 + },
37 + service::RequestContext,
38 + tool, tool_handler, tool_router, ErrorData, RoleServer, ServiceExt,
39 },
40 schemars::JsonSchema,
41 serde::Deserialize,
@@ -47,7 +52,7 @@ use {
52 mail::{
53 MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailTestEmailRequest,
54 },
50 - project::ProjectCreate,
55 + project::{Deployment, ProjectCreate},
56 tenant::{TenantCreate, TenantUpdate},
57 },
58 smbcloud_network::environment::Environment,
@@ -106,6 +111,40 @@ fn invalid_request(message: impl Into<String>) -> ErrorData {
111 ErrorData::invalid_request(message.into(), None)
112 }
113
114 +const DEPLOYMENTS_VIEW_URI: &str = "ui://smbcloud/deployments.html";
115 +const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app";
116 +const DEPLOYMENTS_VIEW_HTML: &str = include_str!("mcp_apps/deployments.html");
117 +
118 +/// `_meta` linking the `deployments` tool to its MCP Apps view (SEP-1865).
119 +///
120 +/// Sent unconditionally rather than gated on the client's
121 +/// `io.modelcontextprotocol/ui` extension: hosts without MCP Apps support
122 +/// ignore `_meta`, and gating would need per-session state in the tool router
123 +/// for no user-visible gain.
124 +fn deployments_tool_meta() -> MetaObject {
125 + let mut meta = MetaObject::new();
126 + meta.insert(
127 + "ui".to_string(),
128 + serde_json::json!({ "resourceUri": DEPLOYMENTS_VIEW_URI }),
129 + );
130 + meta
131 +}
132 +
133 +/// The plain JSON content stays so hosts without MCP Apps support (and the
134 +/// spec's requirement for a meaningful `content`) keep working; the view reads
135 +/// `structured_content`, which must be an object rather than a bare array.
136 +fn deployments_result(
137 + project_id: i32,
138 + deployments: &[Deployment],
139 +) -> Result<CallToolResult, ErrorData> {
140 + let mut result = CallToolResult::success(vec![ContentBlock::json(deployments)?]);
141 + result.structured_content = Some(serde_json::json!({
142 + "project_id": project_id,
143 + "deployments": deployments,
144 + }));
145 + Ok(result)
146 +}
147 +
148 #[derive(Debug, Deserialize, JsonSchema)]
149 struct ProjectShowArgs {
150 /// The project ID to show.
@@ -429,7 +468,11 @@ impl SmbMcpServer {
468 json_result(&project)
469 }
470
432 - #[tool(description = "List deployments for a project by project ID, returned as a JSON array.")]
471 + #[tool(
472 + description = "List deployments for a project by project ID, returned as a JSON array. \
473 + Hosts that support MCP Apps render an interactive dashboard.",
474 + meta = deployments_tool_meta()
475 + )]
476 async fn deployments(
477 &self,
478 Parameters(args): Parameters<DeploymentsArgs>,
@@ -438,7 +481,7 @@ impl SmbMcpServer {
481 let deployments = get_deployments(self.environment, client(), token, args.project_id)
482 .await
483 .map_err(to_error_data)?;
441 - json_result(&deployments)
484 + deployments_result(args.project_id, &deployments)
485 }
486
487 #[tool(
@@ -987,15 +1030,56 @@ impl ServerHandler for SmbMcpServer {
1030 server_info.name = "smb".to_string();
1031 server_info.version = env!("CARGO_PKG_VERSION").to_string();
1032
990 - ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
991 - .with_server_info(server_info)
992 - .with_instructions(
993 - "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
1033 + ServerConfig::new(
1034 + ServerCapabilities::builder()
1035 + .enable_tools()
1036 + .enable_resources()
1037 + .build(),
1038 + )
1039 + .with_server_info(server_info)
1040 + .with_instructions(
1041 + "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
1042 `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \
1043 the tenant/project context other tools default to, shared with the CLI's own \
1044 `smb tenant use` / `smb project use`. Use `smb --mcp --scope automation` \
997 - instead for mobile and TV app automation tools.",
998 - )
1045 + instead for mobile and TV app automation tools. `deployments` renders an \
1046 + interactive dashboard on MCP Apps hosts.",
1047 + )
1048 + }
1049 +
1050 + async fn list_resources(
1051 + &self,
1052 + _request: Option<PaginatedRequestParams>,
1053 + _context: RequestContext<RoleServer>,
1054 + ) -> 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]))
1059 + }
1060 +
1061 + async fn read_resource(
1062 + &self,
1063 + request: ReadResourceRequestParams,
1064 + _context: RequestContext<RoleServer>,
1065 + ) -> Result<ReadResourceResponse, ErrorData> {
1066 + if request.uri != DEPLOYMENTS_VIEW_URI {
1067 + return Err(ErrorData::resource_not_found(
1068 + format!("Unknown resource: {}", request.uri),
1069 + None,
1070 + ));
1071 + }
1072 + // No `csp` on purpose: the view loads nothing external, so the host's
1073 + // restrictive default policy is the right one.
1074 + let mut meta = MetaObject::new();
1075 + meta.insert(
1076 + "ui".to_string(),
1077 + serde_json::json!({ "prefersBorder": true }),
1078 + );
1079 + let contents = ResourceContents::text(DEPLOYMENTS_VIEW_HTML, request.uri)
1080 + .with_mime_type(MCP_APP_MIME_TYPE)
1081 + .with_meta(meta);
1082 + Ok(ReadResourceResult::new(vec![contents]).into())
1083 }
1084 }
1085
@@ -1039,6 +1123,51 @@ mod tests {
1123 }
1124 }
1125
1126 + #[test]
1127 + fn deployments_tool_declares_ui_resource() {
1128 + 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);
1135 + }
1136 +
1137 + #[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://"));
1143 + }
1144 +
1145 + #[test]
1146 + fn deployments_result_carries_content_and_structured_content() {
1147 + let deployments: Vec<Deployment> = serde_json::from_value(serde_json::json!([{
1148 + "id": 1,
1149 + "project_id": 7,
1150 + "frontend_app_id": null,
1151 + "frontend_app_name": "web",
1152 + "commit_hash": "abcdef1234567",
1153 + "status": 2,
1154 + "created_at": "2026-01-01T00:00:00.000+00:00",
1155 + "updated_at": "2026-01-01T00:01:00.000+00:00",
1156 + }]))
1157 + .expect("fixture deployment");
1158 +
1159 + let result = deployments_result(7, &deployments).expect("result");
1160 + assert!(!result.content.is_empty());
1161 + let structured = result.structured_content.expect("structured content");
1162 + assert_eq!(structured["project_id"], 7);
1163 + assert_eq!(
1164 + structured["deployments"]
1165 + .as_array()
1166 + .map(|items| items.len()),
1167 + Some(1)
1168 + );
1169 + }
1170 +
1171 #[test]
1172 fn cloud_router_excludes_automation_tools() {
1173 let tools = SmbMcpServer::cloud_tool_router().list_all();
crates/cli/src/mcp_apps/deployments.html new
+283
@@ -0,0 +1,283 @@
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>Deployments</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 + .status { font-weight: 500; }
71 + .status-0 { color: var(--busy); }
72 + .status-1 { color: var(--fail); }
73 + .status-2 { color: var(--ok); }
74 + .sub { display: block; color: var(--muted); font-size: 12px; }
75 + .message { padding: 24px 8px; text-align: center; color: var(--muted); }
76 + .message.error { color: var(--fail); }
77 +</style>
78 +</head>
79 +<body>
80 +<header>
81 + <h1>Deployments</h1>
82 + <button id="refresh" type="button" disabled>Refresh</button>
83 +</header>
84 +<div id="content" class="message">Waiting for data…</div>
85 +<script>
86 +(function () {
87 + "use strict";
88 +
89 + var STATUS_LABELS = ["Started", "Failed", "Done"];
90 + var nextId = 1;
91 + var pending = {};
92 + var projectId = null;
93 + var contentEl = document.getElementById("content");
94 + var refreshButton = document.getElementById("refresh");
95 +
96 + function send(message) {
97 + window.parent.postMessage(Object.assign({ jsonrpc: "2.0" }, message), "*");
98 + }
99 +
100 + function request(method, params) {
101 + return new Promise(function (resolve, reject) {
102 + var id = nextId++;
103 + pending[id] = { resolve: resolve, reject: reject };
104 + send({ id: id, method: method, params: params });
105 + });
106 + }
107 +
108 + function notify(method, params) {
109 + send({ method: method, params: params });
110 + }
111 +
112 + function applyHostContext(context) {
113 + if (!context) return;
114 + if (context.theme === "light" || context.theme === "dark") {
115 + document.documentElement.setAttribute("data-theme", context.theme);
116 + }
117 + var variables = context.styles && context.styles.variables;
118 + if (variables) {
119 + Object.keys(variables).forEach(function (name) {
120 + if (variables[name]) document.documentElement.style.setProperty(name, variables[name]);
121 + });
122 + }
123 + }
124 +
125 + function showMessage(text, isError) {
126 + contentEl.className = "message" + (isError ? " error" : "");
127 + contentEl.textContent = text;
128 + }
129 +
130 + function relative(date) {
131 + var seconds = Math.round((Date.now() - date.getTime()) / 1000);
132 + var future = seconds < 0;
133 + seconds = Math.abs(seconds);
134 + var units = [["day", 86400], ["hour", 3600], ["minute", 60]];
135 + for (var index = 0; index < units.length; index++) {
136 + if (seconds >= units[index][1]) {
137 + var count = Math.floor(seconds / units[index][1]);
138 + var text = count + " " + units[index][0] + (count === 1 ? "" : "s");
139 + return future ? "in " + text : text + " ago";
140 + }
141 + }
142 + return "just now";
143 + }
144 +
145 + function timeCell(value) {
146 + var cell = document.createElement("td");
147 + var date = new Date(value);
148 + if (isNaN(date.getTime())) {
149 + cell.textContent = String(value || "");
150 + return cell;
151 + }
152 + cell.textContent = relative(date);
153 + var absolute = document.createElement("span");
154 + absolute.className = "sub";
155 + absolute.textContent = date.toLocaleString();
156 + cell.appendChild(absolute);
157 + return cell;
158 + }
159 +
160 + function textCell(text, className) {
161 + var cell = document.createElement("td");
162 + if (className) cell.className = className;
163 + cell.textContent = text;
164 + return cell;
165 + }
166 +
167 + function render(deployments) {
168 + if (!deployments.length) {
169 + showMessage("No deployments yet.", false);
170 + return;
171 + }
172 + var sorted = deployments.slice().sort(function (a, b) {
173 + return new Date(b.created_at) - new Date(a.created_at);
174 + });
175 + var table = document.createElement("table");
176 + var head = document.createElement("thead");
177 + var headRow = document.createElement("tr");
178 + ["Status", "App", "Commit", "Created", "Updated"].forEach(function (label) {
179 + var th = document.createElement("th");
180 + th.textContent = label;
181 + headRow.appendChild(th);
182 + });
183 + head.appendChild(headRow);
184 + table.appendChild(head);
185 +
186 + var body = document.createElement("tbody");
187 + sorted.forEach(function (deployment) {
188 + var row = document.createElement("tr");
189 + var status = deployment.status;
190 + row.appendChild(textCell(STATUS_LABELS[status] || String(status), "status status-" + status));
191 + row.appendChild(textCell(deployment.frontend_app_name || deployment.frontend_app_id || "—"));
192 + var commit = document.createElement("td");
193 + var code = document.createElement("code");
194 + code.textContent = String(deployment.commit_hash || "").slice(0, 7);
195 + commit.appendChild(code);
196 + row.appendChild(commit);
197 + row.appendChild(timeCell(deployment.created_at));
198 + row.appendChild(timeCell(deployment.updated_at));
199 + body.appendChild(row);
200 + });
201 + table.appendChild(body);
202 + contentEl.className = "";
203 + contentEl.replaceChildren(table);
204 + }
205 +
206 + function renderResult(result) {
207 + if (!result) return;
208 + if (result.isError) {
209 + var first = result.content && result.content[0];
210 + showMessage((first && first.text) || "The deployments request failed.", true);
211 + return;
212 + }
213 + var data = result.structuredContent;
214 + if (!data || !Array.isArray(data.deployments)) {
215 + showMessage("No deployment data in the tool result.", true);
216 + return;
217 + }
218 + if (data.project_id !== undefined) projectId = data.project_id;
219 + refreshButton.disabled = projectId === null;
220 + render(data.deployments);
221 + }
222 +
223 + refreshButton.addEventListener("click", function () {
224 + if (projectId === null) return;
225 + refreshButton.disabled = true;
226 + request("tools/call", { name: "deployments", arguments: { project_id: projectId } })
227 + .then(renderResult)
228 + .catch(function (error) {
229 + showMessage((error && error.message) || "Refresh failed.", true);
230 + })
231 + .then(function () {
232 + refreshButton.disabled = projectId === null;
233 + });
234 + });
235 +
236 + window.addEventListener("message", function (event) {
237 + if (event.source !== window.parent) return;
238 + var message = event.data;
239 + if (!message || message.jsonrpc !== "2.0") return;
240 +
241 + if (message.method === undefined && message.id !== undefined) {
242 + var entry = pending[message.id];
243 + if (!entry) return;
244 + delete pending[message.id];
245 + if (message.error) entry.reject(message.error);
246 + else entry.resolve(message.result);
247 + return;
248 + }
249 +
250 + var params = message.params || {};
251 + switch (message.method) {
252 + case "ui/notifications/tool-input":
253 + if (params.arguments && params.arguments.project_id !== undefined) {
254 + projectId = params.arguments.project_id;
255 + refreshButton.disabled = false;
256 + }
257 + break;
258 + case "ui/notifications/tool-result":
259 + renderResult(params);
260 + break;
261 + case "ui/notifications/host-context-changed":
262 + applyHostContext(params);
263 + break;
264 + case "ui/resource-teardown":
265 + send({ id: message.id, result: {} });
266 + break;
267 + }
268 + });
269 +
270 + request("ui/initialize", {
271 + appInfo: { name: "smbcloud-deployments", version: "1.0.0" },
272 + appCapabilities: {},
273 + protocolVersion: "2026-01-26"
274 + }).then(function (result) {
275 + applyHostContext(result && result.hostContext);
276 + notify("ui/notifications/initialized", {});
277 + }).catch(function () {
278 + showMessage("Could not reach the host.", true);
279 + });
280 +})();
281 +</script>
282 +</body>
283 +</html>
docs/mcp.md
+12 -1
@@ -158,7 +158,18 @@ not a separate MCP-specific shape.
158 | `project_create` | `name`, `description` (optional) | The created project. Targets the selected tenant, if any. |
159 | `project_update` | `id`, `description` | The updated project (runner preserved). |
160 | `project_delete` | `id` | Confirmation. **Destructive and irreversible.** |
161 -| `deployments` | `project_id` | A project's deployments. |
161 +| `deployments` | `project_id` | A project's deployments. On MCP Apps hosts it also renders a dashboard (see below). |
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`.
173
174 ### Tenants
175