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.
~/.screenly is the login token file, so remember-by-name now uses ~/.screenly.d and still returns app_id if local bookkeeping fails after deploy.
The same display name on staging vs prod, or with a different API token, no longer reuses the wrong app id. Deleted apps are dropped from the local cache.
Rotate only explicit slideshow markers, leave dialogs closed, and set ready_signal only when screenly.js is actually loaded as a script.
mcpb-build is created when packing a .mcpb and holds a machine-local binary, so it should not be committed.
Honor MANIFEST_FILE_NAME when writing the temp app, reuse a remembered instance instead of the first listed one, and run the blocking deploy on spawn_blocking.
Wrap long lines and use format!("{}", e) so the fmt check and existing MCP error patterns stay consistent.
|
@sergey-borovkov Thanks for the review: the blockers and the rest of the list are addressed in this branch. Blocking1. Registry path vs token file 2. Bookkeeping failure after deploy Should fix3. Name cache scope / self-heal 4–7. Signage wrap heuristics 8. Minor9. Temp app files use 10. Instance reuse prefers the remembered 11. Would you mind taking another look? |
|
Re-reviewed at
Three things left before I approve — one is just CI, the other two are minor: 1. The 2. el.style.display = orig;
if (getComputedStyle(el).display === "none") el.style.display = "block";3. Nice cleanup overall — the tests around the registry scoping and the manifest env overrides are exactly the right ones to have. |
Satisfy cargo +nightly fmt import grouping, restore stylesheet display in showOnly, and disable ready_signal when <base> would break screenly.js.
|
@sergey-borovkov The three leftover items from your re-review at a6aab8a are in a5e34de.
|
sergey-borovkov
left a comment
There was a problem hiding this comment.
Reviewed a5e34de. All three items from the last round are fixed, and CI is green (format passes, clippy clean, cargo test mcp:: is 54/54 locally).
- rustfmt — imports collapsed and reordered;
cargo fmt --checkon nightly is clean. showOnly— nowel.style.display = origwithblockforced only when the computed value staysnone, so flex/grid slides keep their layout.ready_signal—ready_signal_for_htmlrequires a realscreenly.jsscript and no<base>tag, and the tag scanner skips<!-- -->regions. I probed it directly: uppercase<BASE HREF>is detected, a<base>inside a comment is ignored, a>inside an attribute value doesn't derail the scan, and an IE conditional comment is ignored (which matches what Chromium does anyway).
Approving. Three small things I noticed on this pass — all new, none blocking, happy for them to land in a follow-up:
-
src/mcp/tools/edge_app.rs:585— a document with no explicit<head>fails the whole publish.<head>is optional in HTML, so<html><body><h1>hi</h1></body></html>is valid input, buthas_html_shellis true and both injection attempts miss, givingErr("HTML document is missing a <head> element to inject screenly.js"). I confirmed this against the branch. An.or_else(|| inject_before_tag(&out, "<body", …))fallback puts the script in the implied head. -
src/mcp/tools/edge_app.rs:565—has_html_shellalso requires the closing</html>. A document that opens but never closes<html>(also legal) is treated as a fragment and nested inside a fresh shell — the output ends up with two<htmltags and the inner<title>in the body. Keying on the opening<htmlor on<body>is enough. -
src/mcp/tools/edge_app.rs:618— the scanner skips comments but not inline<script>bodies.<script>var s='<script src="screenly.js"></script>';</script>reads as a real tag, so injection is skipped,window.screenlynever exists, andready_signalis stilltrue→ blank screen. Same class if a</body>literal appears in JS before the real one, which would park the bootstrap inside a string. Narrow enough to leave as is, but the doc comment ("inline script text that merely mention the filename do not count") currently promises a bit more than the code delivers.
Thanks for working through all of these carefully — the registry scoping, the self-healing stale-id lookup, and the tests around the manifest env overrides are all solid.
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