Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 6 additions & 4 deletions mcpb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
57 changes: 56 additions & 1 deletion src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[schemars(description = "Optional description stored on the Edge App version")]
pub description: Option<String>,
}

// ============ SERVER STRUCT ============

/// MCP Server for Screenly API
Expand Down Expand Up @@ -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<EdgeAppPublishFromHtmlParam>,
) -> 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 ============
Expand All @@ -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.",
)
}
}
24 changes: 21 additions & 3 deletions src/mcp/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, " ", "<h1>Hi</h1>", 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() {
Expand Down Expand Up @@ -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!(
Expand Down
Loading
Loading