Publish Claude Artifact HTML as a Screenly Edge App - #307
Conversation
Claude can now create an app from HTML and reuse the app id to deploy updates as new revisions, with screenly.js and theme CSS variables injected for the player.
Claude is more likely to call edge_app_publish_from_html for Artifact HTML instead of asset_create.
tempfile was only a dev-dependency, so cargo build failed; also match existing MCP format-string style.
Create/reuse an instance so the app appears in Content, and wrap Artifact HTML so tabs, tiles, and pages rotate without a mouse or keyboard.
ready_signal is already true on published apps; the player ignores signalReady and would never show the content.
Store app_id and instance_id in ~/.screenly/mcp-edge-apps.json so Claude can update the same app with only the name across chats.
Use the Screenly playground SVG so published apps show a consistent icon in the console.
sergey-borovkov
left a comment
There was a problem hiding this comment.
Nice feature, but there are two blockers that make edge_app_publish_from_html fail on essentially every real invocation, plus a set of heuristics that will misrender ordinary pages. Line numbers are for 4974e1e.
Blocking
1. src/mcp/tools/edge_app.rs:320 — the registry path collides with the token file.
registry_path() returns ~/.screenly/mcp-edge-apps.json, but ~/.screenly is a regular file holding the API token (src/authentication.rs:124 does fs::write(home.join(".screenly"), token), and read_token reads it back). For anyone who has run screenly login, save_registry's fs::create_dir_all(parent) at line 342 fails — I reproduced it: Err(Os { code: 17, kind: AlreadyExists, message: "File exists" }).
So remember_published_app fails on every call and the tool returns {"error":"Failed to create /home/<user>/.screenly: File exists"} even though the app was created, deployed and instanced. Symmetrically, load_registry sees path.exists() == false and always returns an empty registry, so the "remember by name" feature never works at all. Please pick a different location (~/.screenly.d/, ~/.config/screenly/, or dirs::data_dir()); the README, manifest and tool descriptions advertise the old path too.
2. src/mcp/tools/edge_app.rs:296 — local bookkeeping failure is fatal after irreversible remote work.
ensure_instance(...)? (line 294) and remember_published_app(...)? (line 296) run after create_in_place + deploy have already created the app and published a revision. Any error there discards the whole response, including app_id. Together with #1 this fires on every call: the caller sees a failure, retries with the same name, lookup_remembered_app returns None, and a brand-new Edge App is created — one orphan app plus one published revision per retry. These post-deploy steps should be best-effort (surface a warning in the payload) and the response should always carry app_id.
Should fix before merge
3. src/mcp/tools/edge_app.rs:352 — the name→app_id registry is not scoped to account or API host, and never self-heals. McpEdgeAppRegistry.apps is keyed by display name only. The same machine used against staging and prod (API_BASE_URL), or with two different API_TOKENs, resolves "Lobby Board" to an app id in the other account; deploy then fails with 404/permission and there's no way to clear the entry from the tool. Same for a name whose app was deleted server-side. Suggest keying on (api url, account, name) and falling back to creating a new app when the remembered id no longer resolves.
4. src/mcp/tools/edge_app.rs:91 — the auto-rotate selectors .page, .tile, .view are too generic. collectPages() returns the first selector match with length > 1, then showOnly hides all but one. A stat dashboard built from <div class="tile"> KPI cards — exactly the artifact shape in the demo — is detected as a slideshow: 5 of 6 tiles get hidden + display:none and the grid becomes a one-card-at-a-time carousel every 8s. Restrict to unambiguous markers ([role="tabpanel"], .carousel-item, [data-slide]) or require an explicit opt-in attribute.
5. src/mcp/tools/edge_app.rs:98 — the last-resort body * heuristic misfires on any page with a single hidden child. The loop returns kids for the first element having ≥1 hidden and ≥1 visible element child. A <template>, a hidden modal, an sr-only span or a display:none legend anywhere under <body> qualifies — so a page with <header>, <main>, <footer> and one hidden dialog gets its three real sections rotated one at a time, and the hidden dialog is shown as one of the "pages" (showOnly removes its hidden attribute at line 78). I'd drop this fallback.
6. src/mcp/tools/edge_app.rs:80 — showOnly clears inline display on the element it's trying to show. el.style.display = on ? "" : "none" resets the active item to its stylesheet value. With the common .carousel-item { display: none } pattern where the page's own JS sets inline display:block, the active item resolves back to display:none — every item hidden, blank screen. Cache and restore the original inline value instead of assigning "".
7. src/mcp/tools/edge_app.rs:117 — every <dialog> is force-opened. querySelectorAll("dialog").forEach(el => el.show()) opens confirmation/error modals the page deliberately keeps closed; on a screen they become permanent overlays with nobody there to dismiss them. Milder version of the same concern for details.open = true on line 116.
8. src/mcp/tools/edge_app.rs:437 (with :260) — the screenly.js substring check plus hardcoded ready_signal: true can leave the player permanently blank. Injection is skipped whenever the document merely contains the string screenly.js anywhere — a comment, a code sample, a CSP note — or on a <base href> page where the relative screenly.js?version=1 resolves off-origin. When that happens window.screenly is undefined, the bootstrap hits if (!window.screenly) return; at line 166 and never calls signalReadyForRendering(). Per docs/EdgeApps.md:360, if ready_signal is true and the function is never called, the content is not displayed. Either detect an actual <script src=…screenly.js…> tag, or only set ready_signal: true when the bootstrap was injected.
Minor
9. src/mcp/tools/edge_app.rs:268 — the manifest is hardcoded to screenly.yml, but deploy resolves it through transform_edge_app_path_to_manifest (src/commands/edge_app/utils.rs:75), which honours MANIFEST_FILE_NAME. A user with that generically-named env var exported gets InvalidManifest on every publish.
10. src/mcp/tools/edge_app.rs:383 — ensure_instance does rows.iter().find_map(|row| row.get("id")), taking whichever instance the API lists first, with no name match and no use of McpEdgeAppRecord.instance_id (stored but never read back). For an app with several instances the reported instance_id changes between runs and overwrites the remembered one.
11. src/mcp/server.rs:880 — edge_app_publish_from_html is a synchronous #[tool] fn running the full blocking deploy, including ensure_assets_processing_finished (src/commands/edge_app/app.rs:396), which thread::sleeps in a poll loop for up to MAX_WAIT_TIME = 1000 seconds. That parks a tokio worker for the whole duration; the existing read-only tools block only for a single request. tokio::task::spawn_blocking would keep the stdio server responsive.
cargo check --all-targets is clean — everything above is behavioural.
Summary
feat/mcp-annotations-and-mcpb-bundle). This PR only adds the HTML → Edge App publish path.edge_app_publish_from_htmlso Claude can turn Artifact / webpage HTML into a Screenly Edge App (screenly.js+ theme CSS variables).app_idto create; passapp_idto deploy a new revision (same asscreenly edge-app deploy). Phrasing like “upload this as a Screenly app” maps to the same tool.Test plan
cargo checkandcargo clippy -- -D warningscargo test --bin screenly mcp::Demo
3.1 Edge App Installation is ready to be added to the playlist.


3.2 Apps running on Screenly anywhere