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..1f3d21f6 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"
diff --git a/README.md b/README.md
index 983b48e9..c100baca 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)
@@ -124,9 +120,26 @@ The server communicates over stdio and exposes the full Screenly API as tools.
| **Shared Playlists** | `shared_playlist_list`, `shared_playlist_create`, `shared_playlist_delete` |
| **Edge Apps** | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances` |
+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..d4b45e36
--- /dev/null
+++ b/mcpb/README.md
@@ -0,0 +1,128 @@
+# 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
+
+## Capabilities
+
+The bundle exposes 33 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` |
+
+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.
+
+## 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..8d17caab
--- /dev/null
+++ b/mcpb/manifest.json
@@ -0,0 +1,199 @@
+{
+ "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."
+ }
+ ],
+ "compatibility": {
+ "platforms": [
+ "darwin",
+ "win32"
+ ]
+ }
+}
diff --git a/src/mcp/server.rs b/src/mcp/server.rs
index 29f9bb33..4613ed31 100644
--- a/src/mcp/server.rs
+++ b/src/mcp/server.rs
@@ -220,7 +220,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 +231,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 +244,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 +255,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 +266,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 +286,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 +311,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 +330,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 +345,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 +365,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 +385,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 +404,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 +419,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 +444,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 +470,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 +489,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 +504,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 +530,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 +556,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 +581,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 +592,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 +609,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 +629,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 +646,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 +669,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 +692,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 +715,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 +740,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 +755,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 +778,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 +803,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 +818,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 +836,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,
diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs
index 86a4a0b3..35614e32 100644
--- a/src/mcp/tests.rs
+++ b/src/mcp/tests.rs
@@ -635,3 +635,78 @@ fn test_edge_app_list_instances() {
let result = EdgeAppTools::list_instances(&auth, "app-uuid");
assert!(result.is_ok());
}
+
+/// Guard against the 33-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(),
+ 33,
+ "expected 33 #[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}`"
+ );
+ }
+}