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/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..3494ecdc 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 a Screenly app (Edge App), install an instance in Content, remember app/instance ids by name for later updates, 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..8df8068c 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 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. Remembers app_id/instance_id by name on this machine. Omit app_id to create or to update a remembered name; pass app_id to target a specific app." } ], "compatibility": { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4613ed31..6188eb71 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. Use this when uploading HTML as a Screenly app. Fragments are wrapped into a complete document." + )] + pub html: String, + #[schemars( + description = "Existing Edge App UUID. Prefer this when known. If omitted, the tool reuses the app_id saved locally for this exact name from a previous publish on this machine, or creates a new app." + )] + 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 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. Remembers app_id/instance_id by name on this machine. Omit app_id to create or to update a remembered name; pass app_id to target a specific app.", + annotations( + title = "Publish Screenly 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,15 @@ 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\ + 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). This creates the app, deploys it, \ + and creates an instance so it appears in Content and can be scheduled on a screen. \ + The tool saves app_id and instance_id locally by name (~/.screenly/mcp-edge-apps.json). \ + To update later, call again with the same name and revised HTML; pass app_id when known, \ + otherwise the remembered name is enough. Keep the returned app_id and instance_id in mind \ + for the rest of the conversation.", ) } } 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..48bcefa4 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -1,7 +1,181 @@ //! Edge App MCP tools. +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +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; + +/// Override path for the local name → app/instance cache (used in tests). +const MCP_EDGE_APPS_PATH_ENV: &str = "SCREENLY_MCP_EDGE_APPS_PATH"; + +/// Default Edge App icon for Claude Artifact publishes (screenly.yml `icon`). +const DEFAULT_CLAUDE_APP_ICON: &str = + "https://playground.srly.io/edge-apps/icons/claude-app-default.svg"; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +struct McpEdgeAppRecord { + app_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + instance_id: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +struct McpEdgeAppRegistry { + /// Exact display name → last published Edge App / instance. + #[serde(default)] + apps: BTreeMap, +} + +/// 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"; + +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#""#; /// Edge App tools for the MCP server. pub struct EdgeAppTools; @@ -41,4 +215,359 @@ 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 unless the same `name` was published + /// before on this machine (`~/.screenly/mcp-edge-apps.json`). Passing + /// `app_id` always deploys a new revision for that app. + 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 explicit_id = app_id + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned); + let remembered = if explicit_id.is_none() { + lookup_remembered_app(name) + } else { + None + }; + let from_memory = remembered.is_some(); + let existing_id = explicit_id.or_else(|| remembered.map(|r| r.app_id)); + + let created = existing_id.is_none(); + let manifest = EdgeAppManifest { + syntax: MANIFEST_VERSION.to_owned(), + id: existing_id, + description: description + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(ToOwned::to_owned), + icon: Some(DEFAULT_CLAUDE_APP_ICON.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))?; + + let (instance_id, instance_created) = + ensure_instance(auth, &app_id, name, &dir_path.join("instance.yml"))?; + + remember_published_app(name, &app_id, instance_id.as_deref())?; + + serde_json::to_string_pretty(&json!({ + "app_id": app_id, + "instance_id": instance_id, + "instance_created": instance_created, + "name": name, + "revision": revision, + "created": created, + "resolved_from_memory": from_memory, + "message": "IDs are saved locally under ~/.screenly/mcp-edge-apps.json for this name. Later, call this tool again with the same name (and updated HTML) to deploy a new revision; app_id is optional when the name is remembered.", + })) + .map_err(|e| format!("Failed to serialize response: {}", e)) + } +} + +fn registry_path() -> Result { + if let Ok(path) = env::var(MCP_EDGE_APPS_PATH_ENV) { + let path = path.trim(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + + let home = dirs::home_dir().ok_or_else(|| "Home directory not found".to_string())?; + Ok(home.join(".screenly").join("mcp-edge-apps.json")) +} + +fn load_registry() -> Result { + let path = registry_path()?; + if !path.exists() { + return Ok(McpEdgeAppRegistry::default()); + } + + let data = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + if data.trim().is_empty() { + return Ok(McpEdgeAppRegistry::default()); + } + + serde_json::from_str(&data).map_err(|e| format!("Failed to parse {}: {}", path.display(), e)) +} + +fn save_registry(registry: &McpEdgeAppRegistry) -> Result<(), String> { + let path = registry_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?; + } + + let data = serde_json::to_string_pretty(registry) + .map_err(|e| format!("Failed to serialize Edge App memory: {}", e))?; + fs::write(&path, format!("{data}\n")) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e)) +} + +fn lookup_remembered_app(name: &str) -> Option { + load_registry().ok()?.apps.get(name).cloned() +} + +fn remember_published_app( + name: &str, + app_id: &str, + instance_id: Option<&str>, +) -> Result<(), String> { + let mut registry = load_registry()?; + registry.apps.insert( + name.to_string(), + McpEdgeAppRecord { + app_id: app_id.to_string(), + instance_id: instance_id.map(ToOwned::to_owned), + }, + ); + save_registry(®istry) +} + +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 { + 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\ + {SIGNAGE_STYLE}\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(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"))) + .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")); + assert!(wrapped.contains(SIGNAGE_STYLE_MARKER)); + assert!(wrapped.contains("[role=\"tab\"]")); + assert!(wrapped.contains("DWELL_MS")); + assert!(wrapped.contains("signalReadyForRendering")); + } + + #[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); + assert_eq!(twice.matches(SIGNAGE_STYLE_MARKER).count(), 1); + } + + #[test] + fn wrap_rejects_empty_html() { + assert!(wrap_html_for_edge_app(" ").is_err()); + } +} + +#[cfg(test)] +mod registry_tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn remember_and_lookup_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("mcp-edge-apps.json"); + let path_str = path.to_str().unwrap(); + + temp_env::with_var(MCP_EDGE_APPS_PATH_ENV, Some(path_str), || { + assert!(lookup_remembered_app("Lobby Board").is_none()); + remember_published_app("Lobby Board", "app-1", Some("inst-1")).unwrap(); + + let remembered = lookup_remembered_app("Lobby Board").unwrap(); + assert_eq!(remembered.app_id, "app-1"); + assert_eq!(remembered.instance_id.as_deref(), Some("inst-1")); + + remember_published_app("Lobby Board", "app-1", Some("inst-2")).unwrap(); + let updated = lookup_remembered_app("Lobby Board").unwrap(); + assert_eq!(updated.instance_id.as_deref(), Some("inst-2")); + }); + } }