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