diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index c233f295..c70af714 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -5,6 +5,10 @@ on: jobs: test-github-action-workflow: + # Secrets are not available to pull_request runs from forks, so the + # SCREENLY_API_TOKEN would be empty and `screen list` would fail auth. + # Only run this integration check for same-repository pull requests. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest name: List screens steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02cea212..0bb1aa0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,14 +50,20 @@ jobs: os: macos-15 rust: stable target: x86_64-apple-darwin + mcpb_platform: darwin + mcpb_alias: macos-x64 - build: macos-aarch64 os: macos-15 rust: stable target: aarch64-apple-darwin + mcpb_platform: darwin + mcpb_alias: macos-arm64 - build: windows os: ubuntu-22.04 rust: stable target: x86_64-pc-windows-gnu + mcpb_platform: win32 + mcpb_alias: windows-x64 - build: windows-32 os: ubuntu-22.04 rust: stable @@ -105,6 +111,42 @@ jobs: fi cd - + # Only desktop targets are bundled: Claude Desktop runs on macOS and Windows. + - name: Package MCP Bundle + if: matrix.mcpb_platform != '' + shell: bash + run: | + set -euo pipefail + mkdir -p mcpb-build/server + cp mcpb/README.md mcpb/icon.png mcpb-build/ + if [[ "${{ matrix.build }}" == windows* ]]; then + cp "target/${{ matrix.target }}/release/screenly.exe" mcpb-build/server/ + else + cp "target/${{ matrix.target }}/release/screenly" mcpb-build/server/ + fi + # The committed manifest carries a 0.0.0 placeholder so the version cannot + # drift from Cargo.toml; the tag is the single source of truth. + # Windows packs screenly.exe, so entry_point/command must match the artifact. + if [[ "${{ matrix.build }}" == windows* ]]; then + entry_point="server/screenly.exe" + else + entry_point="server/screenly" + fi + jq --arg version "${GITHUB_REF_NAME#v}" \ + --arg platform "${{ matrix.mcpb_platform }}" \ + --arg entry_point "$entry_point" \ + '.version = $version + | .compatibility.platforms = [$platform] + | .server.entry_point = $entry_point + | .server.mcp_config.command = ("${__dirname}/" + $entry_point) + | del(.server.mcp_config.platform_overrides)' \ + mcpb/manifest.json > mcpb-build/manifest.json + npx --yes @anthropic-ai/mcpb@2.1.2 pack \ + mcpb-build "screenly-cli-${{ matrix.target }}.mcpb" + # Friendlier alias next to the rustc-target name (both are published). + cp "screenly-cli-${{ matrix.target }}.mcpb" \ + "screenly-${{ matrix.mcpb_alias }}.mcpb" + - name: Publish uses: softprops/action-gh-release@v1 # TODO: if any of the build step fails, the release should be deleted. diff --git a/.gitignore b/.gitignore index 6d5bfc95..61b20296 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ # Build output for nix result* + +# Local MCP Bundle builds (release artifacts) +*.mcpb diff --git a/Cargo.toml b/Cargo.toml index e334b29b..1efaba61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,9 @@ serde_with = "3.8.3" serde_yaml = "0.9.17" sha1 = "0.10.5" sha2 = "0.10.7" -simple_logger = { version = "5", features = ["colors"] } +# The "stderr" feature keeps stdout free of log output, which the `mcp` +# subcommand needs for its JSON-RPC stream. +simple_logger = { version = "5", features = ["colors", "stderr"] } strum = "0.27" strum_macros = "0.27" temp-env = "0.3.6" @@ -54,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 983b48e9..d6e8d876 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,8 @@ $ screenly --output json screen list > screens.json ``` > [!NOTE] -> In debug builds, the CLI outputs log messages to stdout. Use `RUST_LOG=off` to suppress them -> when redirecting output to a file: -> ```bash -> $ RUST_LOG=off screenly --output csv screen list > screens.csv -> $ RUST_LOG=off screenly --output json screen list > screens.json -> ``` +> Log messages go to stderr, so redirecting stdout to a file captures only command output. +> Use `RUST_LOG` to change the log level, or `RUST_LOG=off` to silence logging entirely. ## MCP Server (AI Assistant Integration) @@ -122,11 +118,28 @@ 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. ### Configuration Examples -#### Cursor / Claude Desktop +#### Claude Desktop Extension (`.mcpb`) + +For [Claude Desktop](https://claude.ai/download), the expected install path is +**Desktop Extensions** (Settings → Extensions) — the same idea as installing +the CLI with Homebrew. Once Screenly is listed, install it there and paste your +API token when prompted. No manual JSON editing required. + +For testing before the listing is live, you can sideload a `.mcpb` from the +[latest release](https://github.com/Screenly/cli/releases/latest). macOS release bundles are +not Developer ID–signed yet (same as the CLI `.tar.gz` artifacts); a browser download may be +blocked by Gatekeeper. If that happens, use **System Settings → Privacy & Security → Open Anyway**. +Details: [`mcpb/README.md`](mcpb/README.md). + +The token is stored in your operating system's keychain rather than a plaintext config file. + +#### Cursor / other clients Add to your MCP configuration file: diff --git a/mcpb/README.md b/mcpb/README.md new file mode 100644 index 00000000..07c65d0b --- /dev/null +++ b/mcpb/README.md @@ -0,0 +1,130 @@ +# Screenly for Claude Desktop + +Manage your [Screenly](https://www.screenly.io) digital signage network from Claude. + +This is the packaging directory for the Screenly MCP Bundle (`.mcpb`). The bundle wraps +the MCP server that ships inside the [Screenly CLI](https://github.com/Screenly/cli) so it +can be installed into Claude Desktop with a single click, with no terminal setup required. + +## Installation + +### Recommended: Claude Desktop Extensions + +Once listed, install Screenly from **Desktop Extensions** in Claude Desktop +(Settings → Extensions) — the same idea as installing the CLI with Homebrew. +Claude handles download and setup; you only need to paste your Screenly API token +when prompted. + +You can generate a token at `https://[your-workspace].screenlyapp.com` under +**Settings → Security → API tokens**. + +### Sideload from a GitHub release (testing / pre-listing) + +1. Download the bundle for your machine from the + [latest release](https://github.com/Screenly/cli/releases/latest), for example + `screenly-macos-arm64.mcpb` on an Apple Silicon Mac (aliases: + `screenly-macos-x64.mcpb`, `screenly-windows-x64.mcpb`). +2. Open the file. Claude Desktop shows an installation dialog. +3. Paste your Screenly API token when prompted. + +#### macOS Gatekeeper note + +Release `.mcpb` bundles currently ship the same unsigned macOS binary as the CLI +`.tar.gz` artifacts. A bundle downloaded in a browser may be blocked by Gatekeeper. + +If Claude Desktop cannot start the extension after a sideload install, open +**System Settings → Privacy & Security**, look for the blocked `screenly` +message, and click **Open Anyway**. Then try the extension again. + +Prefer the Desktop Extensions install once it is available. Developer ID signing and +notarization for release binaries is tracked separately and is not unique to the MCP bundle. + +## What you can do + +Once installed, you can ask Claude to: + +- Review your screens and check which ones are offline or out of sync +- Add a web page, image, or video as an asset +- Build a playlist and schedule it, for example "only during business hours on weekdays" +- 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 + +## Capabilities + +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. + +| Category | Tools | +| --- | --- | +| Screens | `screen_list`, `screen_get` | +| Assets | `asset_list`, `asset_get`, `asset_create`, `asset_update`, `asset_delete` | +| Asset Groups | `asset_group_list`, `asset_group_create`, `asset_group_update`, `asset_group_delete` | +| Playlists | `playlist_list`, `playlist_create`, `playlist_update`, `playlist_delete` | +| 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_app_publish_from_html` | + +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 + +The bundle authenticates with a Screenly API token, which you provide during installation. +The token is marked as sensitive in the bundle manifest, so Claude Desktop stores it in your +operating system's keychain rather than in a plaintext configuration file. + +Tokens are scoped to a single Screenly team. To limit what the extension can reach, use a +token for a team that contains only the screens you want Claude to manage. You can revoke a +token at any time from the Screenly console. + +## Privacy Policy + +Screenly's privacy policy is available at +. + +This extension connects to the Screenly API at `api.screenlyapp.com` over HTTPS (or another +Screenly API host if configured via `API_BASE_URL`). Requests are made directly from your +machine using the API token you supply. + +The data returned to Claude is the data you ask for: your screens, assets, playlists, +labels, and Edge Apps, along with their metadata. Your API token is sent only to Screenly, +as the credential for those API requests. + +Separately, the Screenly CLI initializes [Sentry](https://sentry.io) crash reporting on +startup (including when run as this extension). If the process panics, diagnostic details +such as the stack trace and device/OS context may be sent to Sentry's ingest endpoint +(`*.ingest.sentry.io`). This is used for reliability debugging, not product analytics. +Screenly's handling of that data is covered by the privacy policy linked above. + +## Support + +- Documentation: +- Issues: + +## Building the bundle + +Bundles are built automatically for each tagged release by +[`.github/workflows/release.yml`](../.github/workflows/release.yml), which injects the +release version into `manifest.json` and packs it together with the compiled `screenly` +binary. + +To build one locally: + +```bash +npm install -g @anthropic-ai/mcpb + +cargo build --release + +mkdir -p build/server +cp mcpb/manifest.json mcpb/README.md mcpb/icon.png build/ +cp target/release/screenly build/server/ +mcpb pack build screenly.mcpb +``` + +The `version` field in the committed `manifest.json` is a `0.0.0` placeholder. The release +workflow replaces it with the Git tag so it cannot drift from `Cargo.toml`. diff --git a/mcpb/icon.png b/mcpb/icon.png new file mode 100644 index 00000000..eb6e040f Binary files /dev/null and b/mcpb/icon.png differ diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 00000000..503e62a9 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,203 @@ +{ + "manifest_version": "0.3", + "name": "screenly", + "display_name": "Screenly", + "version": "0.0.0", + "description": "Manage your Screenly digital signage network: screens, playlists, assets, labels, and Edge Apps.", + "long_description": "Screenly is a digital signage platform for managing screens at scale. This extension exposes your Screenly account to Claude so you can manage your signage network in plain language.\n\nYou can list and inspect screens along with their sync state and hardware details, create assets from web pages, images, or videos, build and schedule playlists, organise content with asset groups and labels, share playlists with other teams, and inspect Edge Apps and their settings.\n\nPlaylists support Screenly's scheduling predicate language, so you can ask for content that only appears during business hours or on weekdays and it will be translated into the correct predicate for you.\n\nThe extension runs the MCP server bundled in the official Screenly CLI, talking directly to the Screenly API over HTTPS using an API token you supply.", + "author": { + "name": "Screenly, Inc.", + "url": "https://www.screenly.io" + }, + "repository": { + "type": "git", + "url": "https://github.com/Screenly/cli.git" + }, + "homepage": "https://www.screenly.io", + "documentation": "https://developer.screenly.io/mcp", + "support": "https://github.com/Screenly/cli/issues", + "license": "MIT", + "icon": "icon.png", + "keywords": [ + "digital-signage", + "screenly", + "screens", + "playlists", + "edge-apps" + ], + "privacy_policies": [ + "https://www.screenly.io/privacy-policy/" + ], + "server": { + "type": "binary", + "entry_point": "server/screenly", + "mcp_config": { + "command": "${__dirname}/server/screenly", + "args": [ + "mcp" + ], + "env": { + "API_TOKEN": "${user_config.api_token}" + }, + "platform_overrides": { + "win32": { + "command": "${__dirname}/server/screenly.exe" + } + } + } + }, + "user_config": { + "api_token": { + "type": "string", + "title": "Screenly API Token", + "description": "API token for your Screenly team. Generate one at https://[your-workspace].screenlyapp.com under Settings > Security > API tokens.", + "sensitive": true, + "required": true + } + }, + "tools_generated": false, + "tools": [ + { + "name": "screen_list", + "description": "List all screens with their status, hardware info, and sync state." + }, + { + "name": "screen_get", + "description": "Get a screen by UUID." + }, + { + "name": "asset_list", + "description": "List all assets with their type, status, and metadata." + }, + { + "name": "asset_get", + "description": "Get an asset by UUID." + }, + { + "name": "asset_create", + "description": "Create a new asset from a URL. Supports web pages, images, and videos." + }, + { + "name": "asset_update", + "description": "Update an asset's properties (title, js_injection, headers)." + }, + { + "name": "asset_delete", + "description": "Delete an asset by UUID." + }, + { + "name": "asset_group_list", + "description": "List all asset groups (folders for organizing assets)." + }, + { + "name": "asset_group_create", + "description": "Create a new asset group." + }, + { + "name": "asset_group_update", + "description": "Update an asset group." + }, + { + "name": "asset_group_delete", + "description": "Delete an asset group. WARNING: Also deletes all assets in the group." + }, + { + "name": "playlist_list", + "description": "List all playlists." + }, + { + "name": "playlist_create", + "description": "Create a new playlist." + }, + { + "name": "playlist_update", + "description": "Update a playlist." + }, + { + "name": "playlist_delete", + "description": "Delete a playlist by UUID." + }, + { + "name": "playlist_item_list", + "description": "List all items in a playlist." + }, + { + "name": "playlist_item_create", + "description": "Add an asset to a playlist." + }, + { + "name": "playlist_item_update", + "description": "Update a playlist item (duration, position)." + }, + { + "name": "playlist_item_delete", + "description": "Remove an item from a playlist." + }, + { + "name": "label_list", + "description": "List all labels. Labels group screens and target playlists." + }, + { + "name": "label_create", + "description": "Create a new label." + }, + { + "name": "label_update", + "description": "Update a label." + }, + { + "name": "label_delete", + "description": "Delete a label." + }, + { + "name": "label_link_screen", + "description": "Attach a label to a screen." + }, + { + "name": "label_unlink_screen", + "description": "Remove a label from a screen." + }, + { + "name": "label_link_playlist", + "description": "Attach a label to a playlist." + }, + { + "name": "label_unlink_playlist", + "description": "Remove a label from a playlist." + }, + { + "name": "shared_playlist_list", + "description": "List shared playlists." + }, + { + "name": "shared_playlist_create", + "description": "Share a playlist with another team." + }, + { + "name": "shared_playlist_delete", + "description": "Unshare a playlist from a team." + }, + { + "name": "edge_app_list", + "description": "List all Edge Apps." + }, + { + "name": "edge_app_list_settings", + "description": "List settings for an Edge App." + }, + { + "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 digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision." + } + ], + "compatibility": { + "platforms": [ + "darwin", + "win32" + ] + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 29f9bb33..4135b38c 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. 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 @@ -220,7 +236,10 @@ impl ScreenlyMcpServer { impl ScreenlyMcpServer { // ============ SCREEN TOOLS ============ - #[tool(description = "List all screens with their status, hardware info, and sync state.")] + #[tool( + description = "List all screens with their status, hardware info, and sync state.", + annotations(title = "List Screens", read_only_hint = true, open_world_hint = false) + )] fn screen_list(&self) -> String { match ScreenTools::list(&self.auth) { Ok(result) => result, @@ -228,7 +247,10 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Get a screen by UUID.")] + #[tool( + description = "Get a screen by UUID.", + annotations(title = "Get Screen", read_only_hint = true, open_world_hint = false) + )] fn screen_get(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match ScreenTools::get(&self.auth, &uuid) { Ok(result) => result, @@ -238,7 +260,10 @@ impl ScreenlyMcpServer { // ============ ASSET TOOLS ============ - #[tool(description = "List all assets with their type, status, and metadata.")] + #[tool( + description = "List all assets with their type, status, and metadata.", + annotations(title = "List Assets", read_only_hint = true, open_world_hint = false) + )] fn asset_list(&self) -> String { match AssetTools::list(&self.auth) { Ok(result) => result, @@ -246,7 +271,10 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Get an asset by UUID.")] + #[tool( + description = "Get an asset by UUID.", + annotations(title = "Get Asset", read_only_hint = true, open_world_hint = false) + )] fn asset_get(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match AssetTools::get(&self.auth, &uuid) { Ok(result) => result, @@ -254,7 +282,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Create a new asset from a URL. Supports web pages, images, and videos.")] + #[tool( + description = "Create a new asset from a URL. Supports web pages, images, and videos.", + annotations( + title = "Create Asset", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] fn asset_create( &self, Parameters(AssetCreateParam { title, source_url }): Parameters, @@ -265,7 +302,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Update an asset's properties (title, js_injection, headers).")] + #[tool( + description = "Update an asset's properties (title, js_injection, headers).", + annotations( + title = "Update Asset", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn asset_update( &self, Parameters(AssetUpdateParam { @@ -281,7 +327,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Delete an asset by UUID.")] + #[tool( + description = "Delete an asset by UUID.", + annotations( + title = "Delete Asset", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn asset_delete(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match AssetTools::delete(&self.auth, &uuid) { Ok(result) => result, @@ -291,7 +346,14 @@ impl ScreenlyMcpServer { // ============ ASSET GROUP TOOLS ============ - #[tool(description = "List all asset groups (folders for organizing assets).")] + #[tool( + description = "List all asset groups (folders for organizing assets).", + annotations( + title = "List Asset Groups", + read_only_hint = true, + open_world_hint = false + ) + )] fn asset_group_list(&self) -> String { match AssetGroupTools::list(&self.auth) { Ok(result) => result, @@ -299,7 +361,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Create a new asset group.")] + #[tool( + description = "Create a new asset group.", + annotations( + title = "Create Asset Group", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) + )] fn asset_group_create( &self, Parameters(TitleParam { title }): Parameters, @@ -310,7 +381,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Update an asset group.")] + #[tool( + description = "Update an asset group.", + annotations( + title = "Update Asset Group", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn asset_group_update( &self, Parameters(AssetGroupUpdateParam { uuid, title }): Parameters, @@ -321,7 +401,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Delete an asset group. WARNING: Also deletes all assets in the group.")] + #[tool( + description = "Delete an asset group. WARNING: Also deletes all assets in the group.", + annotations( + title = "Delete Asset Group", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn asset_group_delete(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match AssetGroupTools::delete(&self.auth, &uuid) { Ok(result) => result, @@ -331,7 +420,14 @@ impl ScreenlyMcpServer { // ============ PLAYLIST TOOLS ============ - #[tool(description = "List all playlists.")] + #[tool( + description = "List all playlists.", + annotations( + title = "List Playlists", + read_only_hint = true, + open_world_hint = false + ) + )] fn playlist_list(&self) -> String { match PlaylistTools::list(&self.auth) { Ok(result) => result, @@ -339,7 +435,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Create a new playlist.")] + #[tool( + description = "Create a new playlist.", + annotations( + title = "Create Playlist", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) + )] fn playlist_create( &self, Parameters(PlaylistCreateParam { @@ -355,7 +460,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Update a playlist.")] + #[tool( + description = "Update a playlist.", + annotations( + title = "Update Playlist", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn playlist_update( &self, Parameters(PlaylistUpdateParam { @@ -372,7 +486,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Delete a playlist by UUID.")] + #[tool( + description = "Delete a playlist by UUID.", + annotations( + title = "Delete Playlist", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn playlist_delete(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match PlaylistTools::delete(&self.auth, &uuid) { Ok(result) => result, @@ -382,7 +505,14 @@ impl ScreenlyMcpServer { // ============ PLAYLIST ITEM TOOLS ============ - #[tool(description = "List all items in a playlist.")] + #[tool( + description = "List all items in a playlist.", + annotations( + title = "List Playlist Items", + read_only_hint = true, + open_world_hint = false + ) + )] fn playlist_item_list(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match PlaylistItemTools::list(&self.auth, &uuid) { Ok(result) => result, @@ -390,7 +520,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Add an asset to a playlist.")] + #[tool( + description = "Add an asset to a playlist.", + annotations( + title = "Add Asset to Playlist", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) + )] fn playlist_item_create( &self, Parameters(PlaylistItemCreateParam { @@ -407,7 +546,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Update a playlist item (duration, position).")] + #[tool( + description = "Update a playlist item (duration, position).", + annotations( + title = "Update Playlist Item", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn playlist_item_update( &self, Parameters(PlaylistItemUpdateParam { @@ -424,7 +572,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Remove an item from a playlist.")] + #[tool( + description = "Remove an item from a playlist.", + annotations( + title = "Remove Playlist Item", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn playlist_item_delete( &self, Parameters(PlaylistItemDeleteParam { @@ -440,7 +597,10 @@ impl ScreenlyMcpServer { // ============ LABEL TOOLS ============ - #[tool(description = "List all labels. Labels group screens and target playlists.")] + #[tool( + description = "List all labels. Labels group screens and target playlists.", + annotations(title = "List Labels", read_only_hint = true, open_world_hint = false) + )] fn label_list(&self) -> String { match LabelTools::list(&self.auth) { Ok(result) => result, @@ -448,7 +608,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Create a new label.")] + #[tool( + description = "Create a new label.", + annotations( + title = "Create Label", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) + )] fn label_create(&self, Parameters(NameParam { name }): Parameters) -> String { match LabelTools::create(&self.auth, &name) { Ok(result) => result, @@ -456,7 +625,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Update a label.")] + #[tool( + description = "Update a label.", + annotations( + title = "Update Label", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn label_update( &self, Parameters(LabelUpdateParam { uuid, name }): Parameters, @@ -467,7 +645,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Delete a label.")] + #[tool( + description = "Delete a label.", + annotations( + title = "Delete Label", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn label_delete(&self, Parameters(UuidParam { uuid }): Parameters) -> String { match LabelTools::delete(&self.auth, &uuid) { Ok(result) => result, @@ -475,7 +662,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Attach a label to a screen.")] + #[tool( + description = "Attach a label to a screen.", + annotations( + title = "Attach Label to Screen", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] fn label_link_screen( &self, Parameters(LabelScreenParam { @@ -489,7 +685,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Remove a label from a screen.")] + #[tool( + description = "Remove a label from a screen.", + annotations( + title = "Detach Label from Screen", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn label_unlink_screen( &self, Parameters(LabelScreenParam { @@ -503,7 +708,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Attach a label to a playlist.")] + #[tool( + description = "Attach a label to a playlist.", + annotations( + title = "Attach Label to Playlist", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] fn label_link_playlist( &self, Parameters(LabelPlaylistParam { @@ -517,7 +731,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Remove a label from a playlist.")] + #[tool( + description = "Remove a label from a playlist.", + annotations( + title = "Detach Label from Playlist", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn label_unlink_playlist( &self, Parameters(LabelPlaylistParam { @@ -533,7 +756,14 @@ impl ScreenlyMcpServer { // ============ SHARED PLAYLIST TOOLS ============ - #[tool(description = "List shared playlists.")] + #[tool( + description = "List shared playlists.", + annotations( + title = "List Shared Playlists", + read_only_hint = true, + open_world_hint = false + ) + )] fn shared_playlist_list(&self) -> String { match SharedPlaylistTools::list(&self.auth) { Ok(result) => result, @@ -541,7 +771,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Share a playlist with another team.")] + #[tool( + description = "Share a playlist with another team.", + annotations( + title = "Share Playlist with Team", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] fn shared_playlist_create( &self, Parameters(SharedPlaylistParam { @@ -555,7 +794,16 @@ impl ScreenlyMcpServer { } } - #[tool(description = "Unshare a playlist from a team.")] + #[tool( + description = "Unshare a playlist from a team.", + annotations( + title = "Unshare Playlist from Team", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] fn shared_playlist_delete( &self, Parameters(SharedPlaylistParam { @@ -571,7 +819,14 @@ impl ScreenlyMcpServer { // ============ EDGE APP TOOLS ============ - #[tool(description = "List all Edge Apps.")] + #[tool( + description = "List all Edge Apps.", + annotations( + title = "List Edge Apps", + read_only_hint = true, + open_world_hint = false + ) + )] fn edge_app_list(&self) -> String { match EdgeAppTools::list(&self.auth) { Ok(result) => result, @@ -579,7 +834,14 @@ impl ScreenlyMcpServer { } } - #[tool(description = "List settings for an Edge App.")] + #[tool( + description = "List settings for an Edge App.", + annotations( + title = "List Edge App Settings", + read_only_hint = true, + open_world_hint = false + ) + )] fn edge_app_list_settings( &self, Parameters(AppUuidParam { app_uuid }): Parameters, @@ -590,7 +852,14 @@ impl ScreenlyMcpServer { } } - #[tool(description = "List instances of an Edge App.")] + #[tool( + description = "List instances of an Edge App.", + annotations( + title = "List Edge App Instances", + read_only_hint = true, + open_world_hint = false + ) + )] fn edge_app_list_instances( &self, Parameters(AppUuidParam { app_uuid }): Parameters, @@ -600,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 digital signage (screenly.js + theme CSS variables). Omit app_id to create; pass app_id to deploy a new revision.", + 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 ============ @@ -617,7 +917,12 @@ 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). 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.", ) } } diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 86a4a0b3..b7be7d94 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -635,3 +635,96 @@ fn test_edge_app_list_instances() { let result = EdgeAppTools::list_instances(&auth, "app-uuid"); assert!(result.is_ok()); } + +#[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() { + use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; + + use regex::Regex; + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(manifest_dir.join("mcpb/manifest.json")) + .expect("mcpb/manifest.json should exist"), + ) + .expect("mcpb/manifest.json should be valid JSON"); + + assert_eq!( + manifest["tools_generated"], false, + "tools_generated must stay false while the tools array is the source of truth" + ); + + let mut manifest_tools = BTreeMap::new(); + for tool in manifest["tools"] + .as_array() + .expect("manifest tools must be an array") + { + let name = tool["name"].as_str().expect("tool name").to_string(); + let description = tool["description"] + .as_str() + .expect("tool description") + .to_string(); + assert!( + manifest_tools.insert(name.clone(), description).is_none(), + "duplicate tool in manifest: {name}" + ); + } + + let server_src = fs::read_to_string(manifest_dir.join("src/mcp/server.rs")) + .expect("src/mcp/server.rs should exist"); + let tool_re = + Regex::new(r#"(?s)#\[tool\(\s*description\s*=\s*"([^"]+)"[\s\S]*?\)\]\s*fn\s+(\w+)"#) + .unwrap(); + + let mut server_tools = BTreeMap::new(); + for caps in tool_re.captures_iter(&server_src) { + let description = caps[1].to_string(); + let name = caps[2].to_string(); + assert!( + server_tools.insert(name.clone(), description).is_none(), + "duplicate tool in server.rs: {name}" + ); + } + + assert_eq!( + server_tools.len(), + 34, + "expected 34 #[tool] handlers in server.rs, found {}", + server_tools.len() + ); + assert_eq!( + manifest_tools.keys().collect::>(), + server_tools.keys().collect::>(), + "tool names in mcpb/manifest.json must match src/mcp/server.rs" + ); + + for (name, server_description) in &server_tools { + assert_eq!( + manifest_tools.get(name).map(String::as_str), + Some(server_description.as_str()), + "description drift for tool `{name}`" + ); + } +} diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index 57253da1..e8c2a144 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. +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,217 @@ 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, + 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()); + } }