Skip to main content
Glama
README.md
# brs-mcp

> An MCP server that scaffolds runnable Roku channels (BrightScript + SceneGraph) from a validated `AppSpec`, zips them, and optionally sideloads them to a Roku in developer mode.

[![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![MCP](https://img.shields.io/badge/MCP-stdio-purple)](https://modelcontextprotocol.io)

## What it does

An AI assistant (or any MCP client) passes a strict, versioned `AppSpec`. The server returns a complete project tree, produces a sideload-ready zip, and optionally installs it on a Roku device. Every generated file comes from a curated, hand-authored, device-tested template; the server never asks an LLM to write BrightScript.

Same spec in, same bytes out, every time. Zips are byte-reproducible across hosts (sorted entries, fixed mtime). Re-running on a machine in a different time zone produces identical output.

For a fuller styled reference with per-tool I/O examples, see [`docs/index.html`](docs/index.html).

## Install

For a styled walkthrough with prereqs, four numbered steps, and update guidance on one page, see [`docs/install.html`](docs/install.html).

```bash
npm install -g brs-mcp
```

Wire it into your MCP client (e.g., Claude Desktop):

```json
{
  "mcpServers": {
    "brs-mcp": {
      "command": "brs-mcp"
    }
  }
}
```

The server speaks MCP over stdio. Logs go to stderr; stdout is reserved for JSON-RPC.

### Verify

One-liner that cold-fetches the published package and exercises a real MCP `initialize` handshake. No global install required:

```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}' | npx -y brs-mcp
```

Expected response on stdout (one JSON object, formatted here for readability):

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "brs-mcp", "version": "0.1.0" }
  }
}
```

Exit code `0`, stderr silent. If you see any stdout output that isn't a single JSON-RPC object, something in your shell is polluting the transport; verify with `npx -y brs-mcp <<< '{}' 2>/dev/null | head -c 200`.

## Tools

### `list_templates`

List every template bundled with this server.

- **Input:** `{}`
- **Output:** `{ "templates": [{ "id", "category", "version", "description" }] }`

### `get_template_schema`

Return the JSON Schema (Draft 7) for a template's `AppSpec`, plus a minimal example.

- **Input:** `{ "id": "video_grid_channel" }`
- **Output:** `{ "schema": <JSON Schema>, "example_spec": <AppSpec> }`

### `generate_app`

Render a Roku channel project from a validated `AppSpec`. Optionally zip and sideload.

- **Input:** `{ "spec", "output_dir", "assets_root"?, "overwrite"?, "zip"?, "sideload"? }`
- **Output (success):** `{ "ok": true, "project_dir", "files_written", "zip_path"?, "sideload"? }`
- `sideload` implies `zip: true` (enforced by schema).

### `package_app`

Zip an already-generated project directory into a sideload-ready archive. Validates a top-level `manifest`. Output is byte-reproducible.

- **Input:** `{ "project_dir", "output_zip"? }`
- **Output:** `{ "ok": true, "zip_path", "size_bytes", "entry_count" }`

### `sideload_app`

Install a zip on a Roku in developer mode via HTTP Digest-authenticated multipart `POST` to `/plugin_install`. **`dev_password` is never logged or echoed.**

- **Input:** `{ "zip_path", "device_ip", "dev_password" }`
- **Output:** `{ "ok": true, "status": "installed" | "identical", "message", "duration_ms", "raw_html"? }`

## Templates

### `screensaver`

Roku screensaver channel. Three styles:

- `slideshow`: crossfade through bundled images.
- `animated`: bouncing colored shapes (no images required).
- `quadrant`: 4-up grid of bundled images with rotating cells.

```json
{
  "template": "screensaver",
  "spec_version": 1,
  "app": { "name": "My Screensaver", "major_version": 1, "minor_version": 0, "build_version": 0 },
  "style": "animated"
}
```

> **Roku menu label note:** When sideloaded via the dev web server, screensavers always appear in `Settings -> Theme -> Screensavers` as the literal string `(dev)`, not as the `app.name` value. This is a Roku dev-build UX quirk and applies to every sideloaded screensaver including Roku's own canonical samples. The `screensaver_title` manifest field IS still required (and is presumably used once the screensaver is published through the channel store), but for sideload-based testing, look for the `(dev)` entry.

### `video_grid_channel`

VOD grid: home (`RowList`) → detail → video player. Consumes mRSS, Roku Direct Publisher JSON, or a custom JSON feed.

```json
{
  "template": "video_grid_channel",
  "spec_version": 1,
  "app": { "name": "Example", "major_version": 1, "minor_version": 0, "build_version": 0 },
  "branding": {
    "primary_color": "#E50914",
    "background_color": "#141414",
    "text_color": "#FFFFFF",
    "splash": { "hd": "./splash_hd.png", "fhd": "./splash_fhd.png" },
    "icon": { "hd": "./icon_hd.png", "fhd": "./icon_fhd.png" }
  },
  "content": {
    "feed_url": "https://example.com/feed.json",
    "feed_format": "roku_direct_publisher_json"
  }
}
```

Highlights: deep-link aware (`Main(args)` + `roInput` runtime listener); centralized screen-stack with focus restoration; canonical Roku `Overhang` brand bar; `BusySpinner` during feed load; async feed fetch via `roUrlTransfer.asyncGetToString`.

## Error taxonomy

| Code                       | Meaning                                                             |
| -------------------------- | ------------------------------------------------------------------- |
| `SCHEMA_MISMATCH`          | Tool input or `AppSpec` failed validation.                          |
| `UNKNOWN_TEMPLATE`         | `spec.template` is not a registered id.                             |
| `UNSUPPORTED_SPEC_VERSION` | `spec.spec_version` is not supported by this server.                |
| `PATH_REFUSED`             | Path is on the blocklist or outside an allowed directory.           |
| `OUTPUT_DIR_NOT_EMPTY`     | Target directory exists and is non-empty (overwrite not requested). |
| `ASSET_NOT_FOUND`          | A spec-referenced asset (icon/splash/etc.) is missing.              |
| `PROJECT_DIR_INVALID`      | `package_app` input is not a valid Roku project directory.          |
| `RENDER_FAILED`            | EJS render error inside a template.                                 |
| `WRITE_FAILED`             | Writer failed during atomic project write.                          |
| `ZIP_FAILED`               | Packager failed.                                                    |
| `ZIP_NOT_FOUND`            | `sideload_app` could not find the supplied zip.                     |
| `DEVICE_UNREACHABLE`       | Network failure reaching the Roku.                                  |
| `DEVICE_NOT_DEV_MODE`      | Roku is not in developer mode.                                      |
| `DEVICE_AUTH_FAILED`       | HTTP Digest auth was rejected.                                      |
| `SIDELOAD_REJECTED`        | Roku returned a failure body (e.g., bad archive).                   |
| `SIDELOAD_TIMEOUT`         | Operation timed out before the device responded.                    |

Every failure response carries `{ ok: false, stage, code, message, details? }`. `stage` is one of `validate`, `render`, `write`, `package`, `sideload`.

## Architecture

Strict one-way dependency flow under `src/`:

- `tools/`: MCP handlers. Composition root.
- `templates/`: Static template registry + EJS engine + render helpers. No network.
- `build/`: Atomic writer + deterministic zip packager (yazl, STORED, forced DOS timestamps). No network.
- `device/`: The ONLY module that imports a network client (`undici`). RFC 2617 Digest auth, multipart streaming via `fs.openAsBlob`, parsed Roku response markers.
- `spec/`: Shared zod schemas + error factory.

## Real-device fixtures

`test/fixtures/roku-responses/` contains the HTML response bodies the parser is tested against. They are captured against a live Roku via `scripts/smoke.ts`. Provenance and capture date live in `test/fixtures/roku-responses/README.md`.

## Determinism

No `Date.now()`, no `Math.random()`, no clock skew leak into template output or the zip. Same `AppSpec` produces the same bytes on any host.

## Contributing

Run `npm install` once after cloning. This installs the husky pre-commit hook via the `prepare` lifecycle script. Without it, your first commit bypasses the lint / format check.

The full local quality gate is:

```bash
npm run format:check && npm run lint && npm run typecheck && npm run build && npm test
```

## License

MIT; see [`LICENSE`](LICENSE).

TDQS

A4/5.0

Scored across 5 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: generating a project, fetching schema, listing templates, packaging, and sideloading. No overlap exists.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern in lowercase snake_case (generate_app, get_template_schema, list_templates, package_app, sideload_app), making the set predictable.

Tool Count5/5

With 5 tools covering the core workflow of generating, packaging, and deploying Roku channels, the count is well-scoped and appropriate for the domain.

Completeness4/5

The tool surface covers the main lifecycle: template discovery, schema retrieval, project generation, packaging, and sideloading. A minor gap is the lack of a standalone validation tool, but generate_app implicitly validates the AppSpec.

Maintenance

ActivitySlowing
ResponsivenessSyncing