From 72fea172725e9074a7ac40be66524949ad68e2b6 Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Thu, 20 Aug 2026 00:10:49 +0530 Subject: [PATCH 1/7] Add an MCP tool to publish Claude Artifact HTML as a Screenly Edge App. 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. --- README.md | 2 +- mcpb/README.md | 10 +- mcpb/manifest.json | 4 + src/mcp/server.rs | 53 ++++++++- src/mcp/tests.rs | 24 +++- src/mcp/tools/edge_app.rs | 239 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 323 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c100baca..d6e8d876 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ The server communicates over stdio and exposes the full Screenly API as tools. | **Playlist Items** | `playlist_item_list`, `playlist_item_create`, `playlist_item_update`, `playlist_item_delete` | | **Labels** | `label_list`, `label_create`, `label_update`, `label_delete`, `label_link_screen`, `label_unlink_screen`, `label_link_playlist`, `label_unlink_playlist` | | **Shared Playlists** | `shared_playlist_list`, `shared_playlist_create`, `shared_playlist_delete` | -| **Edge Apps** | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances` | +| **Edge Apps** | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances`, `edge_app_publish_from_html` | Every tool is annotated with behaviour hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`), so MCP clients can tell read-only tools apart from ones that modify or delete data and prompt for confirmation before destructive actions. diff --git a/mcpb/README.md b/mcpb/README.md index d4b45e36..f36e8513 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -49,10 +49,11 @@ Once installed, you can ask Claude to: - Organise content with asset groups and labels - Share a playlist with another team - Inspect Edge Apps, their settings, and their instances +- Publish a Claude Artifact or HTML page as an Edge App, and push HTML updates as new revisions ## Capabilities -The bundle exposes 33 tools. Every tool is annotated so Claude knows whether it only reads +The bundle exposes 34 tools. Every tool is annotated so Claude knows whether it only reads data or modifies your account, which means Claude will ask for confirmation before doing anything destructive. @@ -65,10 +66,11 @@ anything destructive. | Playlist Items | `playlist_item_list`, `playlist_item_create`, `playlist_item_update`, `playlist_item_delete` | | Labels | `label_list`, `label_create`, `label_update`, `label_delete`, `label_link_screen`, `label_unlink_screen`, `label_link_playlist`, `label_unlink_playlist` | | Shared Playlists | `shared_playlist_list`, `shared_playlist_create`, `shared_playlist_delete` | -| Edge Apps | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances` | +| Edge Apps | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances`, `edge_app_publish_from_html` | -Twelve of these tools are read-only. Thirteen are marked destructive (deletes, unlinks, -and updates that overwrite existing fields) so clients can prompt before running them. +Twelve of these tools are read-only. Fourteen are marked destructive (deletes, unlinks, +updates that overwrite existing fields, and Edge App publishes) so clients can prompt +before running them. ## Authentication diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 8d17caab..abdaa379 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -188,6 +188,10 @@ { "name": "edge_app_list_instances", "description": "List instances of an Edge App." + }, + { + "name": "edge_app_publish_from_html", + "description": "Publish HTML as an Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision." } ], "compatibility": { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4613ed31..1a7a5135 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -184,6 +184,22 @@ pub struct AppUuidParam { pub app_uuid: String, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EdgeAppPublishFromHtmlParam { + #[schemars(description = "Name of the Edge App")] + pub name: String, + #[schemars( + description = "Full HTML source of the page or Claude Artifact. Fragments are wrapped into a complete document." + )] + pub html: String, + #[schemars( + description = "Existing Edge App UUID. Omit to create a new app. Pass this to publish an HTML update as a new revision (same as screenly edge-app deploy)." + )] + pub app_id: Option, + #[schemars(description = "Optional description stored on the Edge App version")] + pub description: Option, +} + // ============ SERVER STRUCT ============ /// MCP Server for Screenly API @@ -853,6 +869,37 @@ impl ScreenlyMcpServer { Err(e) => json!({"error": e}).to_string(), } } + + #[tool( + description = "Publish HTML as an Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision.", + annotations( + title = "Publish Edge App from HTML", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + fn edge_app_publish_from_html( + &self, + Parameters(EdgeAppPublishFromHtmlParam { + name, + html, + app_id, + description, + }): Parameters, + ) -> String { + match EdgeAppTools::publish_from_html( + &self.auth, + &name, + &html, + app_id.as_deref(), + description.as_deref(), + ) { + Ok(result) => result, + Err(e) => json!({"error": e}).to_string(), + } + } } // ============ SERVER HANDLER ============ @@ -870,7 +917,11 @@ impl rmcp::ServerHandler for ScreenlyMcpServer { Examples: 'TRUE' (always show), '$WEEKDAY IN {1,2,3,4,5}' (weekdays only), \ '$TIME BETWEEN {32400000, 61200000}' (9AM-5PM), \ '$TIME >= 32400000 AND $TIME <= 61200000 AND NOT $WEEKDAY IN {0, 6}' (business hours). \ - Time reference: 32400000=9AM, 43200000=12PM, 61200000=5PM, 72000000=8PM.", + Time reference: 32400000=9AM, 43200000=12PM, 61200000=5PM, 72000000=8PM.\n\n\ + EDGE APPS FROM HTML: To turn a Claude Artifact or webpage into a Screenly Edge App, \ + call edge_app_publish_from_html with the full HTML source. Omit app_id to create. \ + To update later, pass the same HTML (revised) plus the app_id from the first response \ + so Screenly deploys a new revision.", ) } } diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 35614e32..b7be7d94 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -636,7 +636,25 @@ fn test_edge_app_list_instances() { assert!(result.is_ok()); } -/// Guard against the 33-tool catalog drifting between the MCPB manifest and +#[test] +fn test_edge_app_publish_from_html_rejects_empty_name() { + let mock_server = MockServer::start(); + let auth = setup_auth(&mock_server); + let result = EdgeAppTools::publish_from_html(&auth, " ", "

Hi

", None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name is required")); +} + +#[test] +fn test_edge_app_publish_from_html_rejects_empty_html() { + let mock_server = MockServer::start(); + let auth = setup_auth(&mock_server); + let result = EdgeAppTools::publish_from_html(&auth, "Lobby Board", " ", None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("html is empty")); +} + +/// Guard against the tool catalog drifting between the MCPB manifest and /// the `#[tool]` definitions in `server.rs` (names + descriptions). #[test] fn test_mcpb_manifest_tools_match_server() { @@ -692,8 +710,8 @@ fn test_mcpb_manifest_tools_match_server() { assert_eq!( server_tools.len(), - 33, - "expected 33 #[tool] handlers in server.rs, found {}", + 34, + "expected 34 #[tool] handlers in server.rs, found {}", server_tools.len() ); assert_eq!( diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index 57253da1..0b84ffb0 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -1,7 +1,44 @@ //! Edge App MCP tools. +use std::fs; + +use serde_json::json; + use crate::authentication::Authentication; use crate::commands; +use crate::commands::edge_app::manifest::{ + EdgeAppManifest, Entrypoint, EntrypointType, MANIFEST_VERSION, +}; +use crate::commands::edge_app::EdgeAppCommand; + +/// Marker attribute so wrap is idempotent when HTML is published again. +pub(crate) const THEME_BOOTSTRAP_MARKER: &str = "data-screenly-mcp-theme"; + +const SCREENLY_JS_SRC: &str = "screenly.js?version=1"; + +/// Theme bootstrap aligned with `@screenly/edge-apps` `setupTheme()`: +/// map Screenly branding settings onto CSS custom properties, then signal ready. +const THEME_BOOTSTRAP_SCRIPT: &str = r#""#; /// Edge App tools for the MCP server. pub struct EdgeAppTools; @@ -41,4 +78,206 @@ impl EdgeAppTools { serde_json::to_string_pretty(&result) .map_err(|e| format!("Failed to serialize response: {}", e)) } + + /// Create or update an Edge App from HTML (Claude Artifact / webpage). + /// + /// Omitting `app_id` creates a new app. Passing `app_id` deploys a new revision, + /// the same as `screenly edge-app deploy`. + pub fn publish_from_html( + auth: &Authentication, + name: &str, + html: &str, + app_id: Option<&str>, + description: Option<&str>, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("name is required".to_string()); + } + + let wrapped = wrap_html_for_edge_app(html)?; + let dir = tempfile::tempdir() + .map_err(|e| format!("Failed to create temporary Edge App directory: {e}"))?; + let dir_path = dir.path(); + + fs::write(dir_path.join("index.html"), &wrapped) + .map_err(|e| format!("Failed to write index.html: {e}"))?; + + let existing_id = app_id + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned); + + let created = existing_id.is_none(); + let manifest = EdgeAppManifest { + syntax: MANIFEST_VERSION.to_owned(), + id: existing_id.clone(), + description: description + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(ToOwned::to_owned), + ready_signal: Some(true), + entrypoint: Some(Entrypoint { + entrypoint_type: EntrypointType::File, + uri: None, + }), + ..Default::default() + }; + + let manifest_path = dir_path.join("screenly.yml"); + EdgeAppManifest::save_to_file(&manifest, &manifest_path) + .map_err(|e| format!("Failed to write screenly.yml: {e}"))?; + + let command = edge_app_command(auth); + if created { + command + .create_in_place(name, &manifest_path) + .map_err(|e| format!("Failed to create Edge App: {e}"))?; + } + + let app_id = EdgeAppManifest::new(&manifest_path) + .map_err(|e| format!("Failed to read Edge App id: {e}"))? + .id + .ok_or_else(|| "Edge App id missing after create".to_string())?; + + let path = dir_path + .to_str() + .ok_or_else(|| "Edge App path is not valid UTF-8".to_string())? + .to_string(); + + let revision = command + .deploy(Some(path), Some(false)) + .map_err(|e| format!("Failed to deploy Edge App: {e}"))?; + + serde_json::to_string_pretty(&json!({ + "app_id": app_id, + "name": name, + "revision": revision, + "created": created, + "message": "Reuse app_id with this tool to publish an HTML update as a new Edge App revision.", + })) + .map_err(|e| format!("Failed to serialize response: {e}")) + } +} + +fn edge_app_command(auth: &Authentication) -> EdgeAppCommand { + EdgeAppCommand::new(Authentication { + config: crate::authentication::Config { + url: auth.config.url.clone(), + }, + token: auth.token.clone(), + }) +} + +/// Turn a Claude Artifact / HTML fragment into a player-ready Edge App document. +pub(crate) fn wrap_html_for_edge_app(html: &str) -> Result { + let html = html.trim(); + if html.is_empty() { + return Err("html is empty".to_string()); + } + + let screenly_script = format!(r#""#); + let lower = html.to_ascii_lowercase(); + let has_html_shell = lower.contains(""); + + if !has_html_shell { + return Ok(format!( + "\n\n\n\n\n{screenly_script}\n\n\n{html}\n{THEME_BOOTSTRAP_SCRIPT}\n\n\n" + )); + } + + let mut out = html.to_string(); + + if !lower.contains("screenly.js") { + out = inject_before_tag(&out, "", &format!("{screenly_script}\n")) + .or_else(|| inject_after_tag(&out, "", &format!("\n{screenly_script}\n"))) + .ok_or_else(|| { + "HTML document is missing a element to inject screenly.js".to_string() + })?; + } + + if !out.contains(THEME_BOOTSTRAP_MARKER) { + out = inject_before_tag(&out, "", &format!("{THEME_BOOTSTRAP_SCRIPT}\n")) + .or_else(|| inject_before_tag(&out, "", &format!("{THEME_BOOTSTRAP_SCRIPT}\n"))) + .ok_or_else(|| { + "HTML document is missing a or tag to inject theme bootstrap" + .to_string() + })?; + } + + if !out.to_ascii_lowercase().contains("\n{out}"); + } + + Ok(out) +} + +fn inject_before_tag(html: &str, tag: &str, snippet: &str) -> Option { + let idx = find_ascii_ignore_case(html, tag)?; + let mut out = String::with_capacity(html.len() + snippet.len()); + out.push_str(&html[..idx]); + out.push_str(snippet); + out.push_str(&html[idx..]); + Some(out) +} + +fn inject_after_tag(html: &str, tag: &str, snippet: &str) -> Option { + let idx = find_ascii_ignore_case(html, tag)?; + let insert_at = idx + tag.len(); + let mut out = String::with_capacity(html.len() + snippet.len()); + out.push_str(&html[..insert_at]); + out.push_str(snippet); + out.push_str(&html[insert_at..]); + Some(out) +} + +fn find_ascii_ignore_case(haystack: &str, needle: &str) -> Option { + haystack + .as_bytes() + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +#[cfg(test)] +mod wrap_tests { + use super::*; + + #[test] + fn wrap_fragment_adds_shell_screenly_js_and_theme() { + let wrapped = wrap_html_for_edge_app("

Hello

").unwrap(); + assert!(wrapped.contains("")); + assert!(wrapped.contains(SCREENLY_JS_SRC)); + assert!(wrapped.contains(THEME_BOOTSTRAP_MARKER)); + assert!(wrapped.contains("

Hello

")); + assert!(wrapped.contains("--screenly-color-accent")); + } + + #[test] + fn wrap_full_document_injects_into_existing_head_and_body() { + let html = r#" + +Board +

News

+"#; + let wrapped = wrap_html_for_edge_app(html).unwrap(); + assert!(wrapped.contains(SCREENLY_JS_SRC)); + assert!(wrapped.contains("Board")); + assert!(wrapped.contains("

News

")); + let js_pos = wrapped.find(SCREENLY_JS_SRC).unwrap(); + let head_close = wrapped.to_ascii_lowercase().find("").unwrap(); + assert!(js_pos < head_close); + } + + #[test] + fn wrap_is_idempotent_for_screenly_js_and_theme() { + let once = wrap_html_for_edge_app("
Hi
").unwrap(); + let twice = wrap_html_for_edge_app(&once).unwrap(); + assert_eq!(twice.matches(SCREENLY_JS_SRC).count(), 1); + assert_eq!(twice.matches(THEME_BOOTSTRAP_MARKER).count(), 1); + } + + #[test] + fn wrap_rejects_empty_html() { + assert!(wrap_html_for_edge_app(" ").is_err()); + } } From a4e8bf218110c4b2ed735f52d7c072b53ae0fbad Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Thu, 20 Aug 2026 00:16:41 +0530 Subject: [PATCH 2/7] Treat 'upload as a Screenly app' as Edge App HTML publish. Claude is more likely to call edge_app_publish_from_html for Artifact HTML instead of asset_create. --- mcpb/README.md | 2 +- mcpb/manifest.json | 2 +- src/mcp/server.rs | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mcpb/README.md b/mcpb/README.md index f36e8513..07c65d0b 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -49,7 +49,7 @@ Once installed, you can ask Claude to: - Organise content with asset groups and labels - Share a playlist with another team - Inspect Edge Apps, their settings, and their instances -- Publish a Claude Artifact or HTML page as an Edge App, and push HTML updates as new revisions +- Publish a Claude Artifact or HTML page as a Screenly app (Edge App), and push HTML updates as new revisions ## Capabilities diff --git a/mcpb/manifest.json b/mcpb/manifest.json index abdaa379..503e62a9 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -191,7 +191,7 @@ }, { "name": "edge_app_publish_from_html", - "description": "Publish HTML as an Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision." + "description": "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision." } ], "compatibility": { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 1a7a5135..4135b38c 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -189,7 +189,7 @@ pub struct EdgeAppPublishFromHtmlParam { #[schemars(description = "Name of the Edge App")] pub name: String, #[schemars( - description = "Full HTML source of the page or Claude Artifact. Fragments are wrapped into a complete document." + description = "Full HTML source of the page or Claude Artifact. Use this when uploading HTML as a Screenly app. Fragments are wrapped into a complete document." )] pub html: String, #[schemars( @@ -871,9 +871,9 @@ impl ScreenlyMcpServer { } #[tool( - description = "Publish HTML as an Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision.", + description = "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision.", annotations( - title = "Publish Edge App from HTML", + title = "Publish Screenly App from HTML", read_only_hint = false, destructive_hint = true, idempotent_hint = false, @@ -918,9 +918,10 @@ impl rmcp::ServerHandler for ScreenlyMcpServer { '$TIME BETWEEN {32400000, 61200000}' (9AM-5PM), \ '$TIME >= 32400000 AND $TIME <= 61200000 AND NOT $WEEKDAY IN {0, 6}' (business hours). \ Time reference: 32400000=9AM, 43200000=12PM, 61200000=5PM, 72000000=8PM.\n\n\ - EDGE APPS FROM HTML: To turn a Claude Artifact or webpage into a Screenly Edge App, \ - call edge_app_publish_from_html with the full HTML source. Omit app_id to create. \ - To update later, pass the same HTML (revised) plus the app_id from the first response \ + SCREENLY APPS FROM HTML: If the user asks to upload HTML or a Claude Artifact as a \ + Screenly app, app, or Edge App, call edge_app_publish_from_html with the full HTML \ + source (not asset_create, which needs a public URL). Omit app_id to create. \ + To update later, pass the revised HTML plus the app_id from the first response \ so Screenly deploys a new revision.", ) } From 2e5ba3ecc8b5453524432acabd2e276307220f06 Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Thu, 20 Aug 2026 00:19:50 +0530 Subject: [PATCH 3/7] Fix Edge App HTML publish so release builds and MCP style checks pass. tempfile was only a dev-dependency, so cargo build failed; also match existing MCP format-string style. --- Cargo.toml | 2 +- src/mcp/tools/edge_app.rs | 31 +++++++++++++++++++++---------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1f3d21f6..1efaba61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,10 +56,10 @@ warp = "0.3" # MCP server dependencies rmcp = { version = "1.4", default-features = false, features = ["macros", "server", "schemars", "transport-io"] } schemars = "1" +tempfile = "3.8" [dev-dependencies] envtestkit = "1.1.2" httpmock = "0.8" swc_common = { version = "18", default-features = false, features = [] } swc_ecma_parser = { version = "32", default-features = false, features = ["typescript"] } -tempfile = "3.8" diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index 0b84ffb0..e8c2a144 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -12,7 +12,7 @@ use crate::commands::edge_app::manifest::{ use crate::commands::edge_app::EdgeAppCommand; /// Marker attribute so wrap is idempotent when HTML is published again. -pub(crate) const THEME_BOOTSTRAP_MARKER: &str = "data-screenly-mcp-theme"; +const THEME_BOOTSTRAP_MARKER: &str = "data-screenly-mcp-theme"; const SCREENLY_JS_SRC: &str = "screenly.js?version=1"; @@ -97,11 +97,11 @@ impl EdgeAppTools { let wrapped = wrap_html_for_edge_app(html)?; let dir = tempfile::tempdir() - .map_err(|e| format!("Failed to create temporary Edge App directory: {e}"))?; + .map_err(|e| format!("Failed to create temporary Edge App directory: {}", e))?; let dir_path = dir.path(); fs::write(dir_path.join("index.html"), &wrapped) - .map_err(|e| format!("Failed to write index.html: {e}"))?; + .map_err(|e| format!("Failed to write index.html: {}", e))?; let existing_id = app_id .map(str::trim) @@ -111,7 +111,7 @@ impl EdgeAppTools { let created = existing_id.is_none(); let manifest = EdgeAppManifest { syntax: MANIFEST_VERSION.to_owned(), - id: existing_id.clone(), + id: existing_id, description: description .map(str::trim) .filter(|d| !d.is_empty()) @@ -126,17 +126,17 @@ impl EdgeAppTools { let manifest_path = dir_path.join("screenly.yml"); EdgeAppManifest::save_to_file(&manifest, &manifest_path) - .map_err(|e| format!("Failed to write screenly.yml: {e}"))?; + .map_err(|e| format!("Failed to write screenly.yml: {}", e))?; let command = edge_app_command(auth); if created { command .create_in_place(name, &manifest_path) - .map_err(|e| format!("Failed to create Edge App: {e}"))?; + .map_err(|e| format!("Failed to create Edge App: {}", e))?; } let app_id = EdgeAppManifest::new(&manifest_path) - .map_err(|e| format!("Failed to read Edge App id: {e}"))? + .map_err(|e| format!("Failed to read Edge App id: {}", e))? .id .ok_or_else(|| "Edge App id missing after create".to_string())?; @@ -147,7 +147,7 @@ impl EdgeAppTools { let revision = command .deploy(Some(path), Some(false)) - .map_err(|e| format!("Failed to deploy Edge App: {e}"))?; + .map_err(|e| format!("Failed to deploy Edge App: {}", e))?; serde_json::to_string_pretty(&json!({ "app_id": app_id, @@ -156,7 +156,7 @@ impl EdgeAppTools { "created": created, "message": "Reuse app_id with this tool to publish an HTML update as a new Edge App revision.", })) - .map_err(|e| format!("Failed to serialize response: {e}")) + .map_err(|e| format!("Failed to serialize response: {}", e)) } } @@ -182,7 +182,18 @@ pub(crate) fn wrap_html_for_edge_app(html: &str) -> Result { if !has_html_shell { return Ok(format!( - "\n\n\n\n\n{screenly_script}\n\n\n{html}\n{THEME_BOOTSTRAP_SCRIPT}\n\n\n" + "\n\ + \n\ + \n\ + \n\ + \n\ + {screenly_script}\n\ + \n\ + \n\ + {html}\n\ + {THEME_BOOTSTRAP_SCRIPT}\n\ + \n\ + \n" )); } From c00a42de874e582d131d8e618af17e524ca01d2c Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Thu, 20 Aug 2026 01:31:49 +0530 Subject: [PATCH 4/7] Install an Edge App instance on publish and auto-play unattended UIs. 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. --- mcpb/README.md | 2 +- mcpb/manifest.json | 2 +- src/mcp/server.rs | 9 ++- src/mcp/tools/edge_app.rs | 163 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 11 deletions(-) diff --git a/mcpb/README.md b/mcpb/README.md index 07c65d0b..634d251d 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -49,7 +49,7 @@ Once installed, you can ask Claude to: - Organise content with asset groups and labels - Share a playlist with another team - Inspect Edge Apps, their settings, and their instances -- Publish a Claude Artifact or HTML page as a Screenly app (Edge App), and push HTML updates as new revisions +- Publish a Claude Artifact or HTML page as a Screenly app (Edge App), install an instance in Content, and push HTML updates as new revisions ## Capabilities diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 503e62a9..9d37e9e0 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -191,7 +191,7 @@ }, { "name": "edge_app_publish_from_html", - "description": "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision." + "description": "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for unattended digital signage (no mouse/keyboard; auto-rotates tiles/pages), creates an instance so it appears in Content, and deploys. Omit app_id to create; pass app_id to update." } ], "compatibility": { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4135b38c..4ededb9a 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -871,7 +871,7 @@ impl ScreenlyMcpServer { } #[tool( - description = "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision.", + description = "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for unattended digital signage (no mouse/keyboard; auto-rotates tiles/pages), creates an instance so it appears in Content, and deploys. Omit app_id to create; pass app_id to update.", annotations( title = "Publish Screenly App from HTML", read_only_hint = false, @@ -920,9 +920,10 @@ impl rmcp::ServerHandler for ScreenlyMcpServer { Time reference: 32400000=9AM, 43200000=12PM, 61200000=5PM, 72000000=8PM.\n\n\ SCREENLY APPS FROM HTML: If the user asks to upload HTML or a Claude Artifact as a \ Screenly app, app, or Edge App, call edge_app_publish_from_html with the full HTML \ - source (not asset_create, which needs a public URL). Omit app_id to create. \ - To update later, pass the revised HTML plus the app_id from the first response \ - so Screenly deploys a new revision.", + source (not asset_create, which needs a public URL). This creates the app, deploys it, \ + and creates an instance so it appears in Content and can be scheduled on a screen. \ + Omit app_id to create. To update later, pass the revised HTML plus the app_id from \ + the first response so Screenly deploys a new revision; existing instances update automatically.", ) } } diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index e8c2a144..044c447c 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -1,6 +1,7 @@ //! Edge App MCP tools. use std::fs; +use std::path::Path; use serde_json::json; @@ -13,11 +14,18 @@ use crate::commands::edge_app::EdgeAppCommand; /// Marker attribute so wrap is idempotent when HTML is published again. const THEME_BOOTSTRAP_MARKER: &str = "data-screenly-mcp-theme"; +const SIGNAGE_STYLE_MARKER: &str = "data-screenly-mcp-signage"; const SCREENLY_JS_SRC: &str = "screenly.js?version=1"; -/// Theme bootstrap aligned with `@screenly/edge-apps` `setupTheme()`: -/// map Screenly branding settings onto CSS custom properties, then signal ready. +const SIGNAGE_STYLE: &str = r#""#; + +/// Theme + unattended signage: branding CSS variables, then auto-show tiles/pages +/// that would otherwise need a mouse or keyboard. const THEME_BOOTSTRAP_SCRIPT: &str = r#""#; @@ -149,17 +259,47 @@ impl EdgeAppTools { .deploy(Some(path), Some(false)) .map_err(|e| format!("Failed to deploy Edge App: {}", e))?; + let (instance_id, instance_created) = + ensure_instance(auth, &app_id, name, &dir_path.join("instance.yml"))?; + serde_json::to_string_pretty(&json!({ "app_id": app_id, + "instance_id": instance_id, + "instance_created": instance_created, "name": name, "revision": revision, "created": created, - "message": "Reuse app_id with this tool to publish an HTML update as a new Edge App revision.", + "message": "The instance appears in Screenly Content and can be scheduled on a screen. Reuse app_id to publish an HTML update as a new revision.", })) .map_err(|e| format!("Failed to serialize response: {}", e)) } } +fn ensure_instance( + auth: &Authentication, + app_id: &str, + name: &str, + instance_manifest_path: &Path, +) -> Result<(Option, bool), String> { + let command = edge_app_command(auth); + let listed = command + .list_instances(app_id) + .map_err(|e| format!("Failed to list Edge App instances: {}", e))?; + + if let Some(id) = listed.value.as_array().and_then(|rows| { + rows.iter() + .find_map(|row| row.get("id").and_then(|v| v.as_str())) + .map(ToOwned::to_owned) + }) { + return Ok((Some(id), false)); + } + + let instance_id = command + .create_instance(instance_manifest_path, app_id, name) + .map_err(|e| format!("Failed to create Edge App instance: {}", e))?; + Ok((Some(instance_id), true)) +} + fn edge_app_command(auth: &Authentication) -> EdgeAppCommand { EdgeAppCommand::new(Authentication { config: crate::authentication::Config { @@ -188,6 +328,7 @@ pub(crate) fn wrap_html_for_edge_app(html: &str) -> Result { \n\ \n\ {screenly_script}\n\ + {SIGNAGE_STYLE}\n\ \n\ \n\ {html}\n\ @@ -207,6 +348,14 @@ pub(crate) fn wrap_html_for_edge_app(html: &str) -> Result { })?; } + if !out.contains(SIGNAGE_STYLE_MARKER) { + out = inject_before_tag(&out, "", &format!("{SIGNAGE_STYLE}\n")) + .or_else(|| inject_after_tag(&out, "", &format!("\n{SIGNAGE_STYLE}\n"))) + .ok_or_else(|| { + "HTML document is missing a element to inject signage styles".to_string() + })?; + } + if !out.contains(THEME_BOOTSTRAP_MARKER) { out = inject_before_tag(&out, "", &format!("{THEME_BOOTSTRAP_SCRIPT}\n")) .or_else(|| inject_before_tag(&out, "", &format!("{THEME_BOOTSTRAP_SCRIPT}\n"))) @@ -261,6 +410,9 @@ mod wrap_tests { assert!(wrapped.contains(THEME_BOOTSTRAP_MARKER)); assert!(wrapped.contains("

Hello

")); assert!(wrapped.contains("--screenly-color-accent")); + assert!(wrapped.contains(SIGNAGE_STYLE_MARKER)); + assert!(wrapped.contains("[role=\"tab\"]")); + assert!(wrapped.contains("DWELL_MS")); } #[test] @@ -285,6 +437,7 @@ mod wrap_tests { let twice = wrap_html_for_edge_app(&once).unwrap(); assert_eq!(twice.matches(SCREENLY_JS_SRC).count(), 1); assert_eq!(twice.matches(THEME_BOOTSTRAP_MARKER).count(), 1); + assert_eq!(twice.matches(SIGNAGE_STYLE_MARKER).count(), 1); } #[test] From 19e35aa114c333814557f2998b5a4ffc21527cc0 Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Thu, 20 Aug 2026 01:48:18 +0530 Subject: [PATCH 5/7] Call screenly.signalReadyForRendering after wrapping HTML. ready_signal is already true on published apps; the player ignores signalReady and would never show the content. --- src/mcp/tools/edge_app.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index 044c447c..7c9c85d0 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -143,7 +143,10 @@ const THEME_BOOTSTRAP_SCRIPT: &str = r#"