#!/usr/bin/env node
/**
* Roku MCP Server — Full Development Toolkit
*
* 23 tools for deploy, test, screenshot, log, SG inspection,
* resolution check, video streaming, server health, DRM,
* accessibility, and RAF verification.
*
* All tools include inline Roku API documentation so AI agents
* can use them without looking up external docs.
*
* Transport: stdio (for use with Gemini, Claude, Copilot, etc.)
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import * as dotenv from 'dotenv';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { RokuClient } from './roku-client.js';
import { LogClient } from './log-client.js';
import { WebDriverClient } from './webdriver-client.js';
import { TestRunner, type TestStep } from './test-runner.js';
// ─── Config ──────────────────────────────────────────────
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..', '..');
dotenv.config({ path: path.join(__dirname, '..', '.env') });
const ROKU_HOST = process.env.ROKU_DEV_HOST || '';
const ROKU_PASSWORD = process.env.ROKU_DEV_PASSWORD || '';
const APP_SERVER_URL = process.env.ROKU_APP_SERVER_URL || 'https://casualgame.megafuncreative.com/server';
const PROJECT_ROOT = process.env.ROKU_PROJECT_ROOT || projectRoot;
// ─── Clients ─────────────────────────────────────────────
const roku = new RokuClient({
host: ROKU_HOST,
password: ROKU_PASSWORD,
projectRoot: PROJECT_ROOT,
appServerUrl: APP_SERVER_URL,
});
const logClient = new LogClient(ROKU_HOST);
const webdriver = new WebDriverClient(ROKU_HOST);
const testRunner = new TestRunner(roku, logClient, webdriver);
// ─── MCP Server ──────────────────────────────────────────
const server = new McpServer({
name: 'roku-dev',
version: '1.0.0',
});
// ═══════════════════════════════════════════════════════════
// 📦 DEPLOY / BUILD
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_deploy',
`Deploy (sideload) the Roku app to the device.
Roku API: POST http://{host}/plugin_install (Basic Auth: rokudev:{password})
Uses roku-deploy npm to zip and upload the channel.
Requires: Developer Mode enabled, device on same network.
Ref: developer.roku.com/docs/developer-program/getting-started/developer-setup.md`,
{ rootDir: z.string().optional().describe('Project root directory override') },
async ({ rootDir }) => {
try {
const result = await roku.deploy(rootDir);
return { content: [{ type: 'text', text: `✅ ${result}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ Deploy failed: ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_screenshot',
`Capture a screenshot of the current Roku screen.
Roku API: POST http://{host}/plugin_inspect (Basic Auth)
Returns: base64-encoded image (JPG/PNG).
Only works for sideloaded (dev) apps, not published channels.
Ref: developer.roku.com/docs/developer-program/getting-started/developer-setup.md`,
{ savePath: z.string().optional().describe('Optional file path to save screenshot') },
async ({ savePath }) => {
try {
const result = await roku.screenshot();
const base64 = result.imageData.toString('base64');
if (savePath) {
const fs = await import('node:fs');
fs.writeFileSync(savePath, result.imageData);
}
return {
content: [
{ type: 'image', data: base64, mimeType: result.contentType },
{ type: 'text', text: `Screenshot captured (${result.imageData.length} bytes, ${result.contentType})${savePath ? ` — saved to ${savePath}` : ''}` },
],
};
} catch (err) {
return { content: [{ type: 'text', text: `❌ Screenshot failed: ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 🖥️ DISPLAY / RESOLUTION
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_device_info',
`Query comprehensive device information.
Roku API: GET http://{host}:8060/query/device-info
Returns: model-name, model-number, ui-resolution (720p/1080p/2160p),
display-type, firmware-version, serial-number, country, language,
wifi-mac, ethernet-mac, captions-mode, audio-guide-enabled, etc.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{},
async () => {
try {
const info = await roku.getDeviceInfo();
const text = Object.entries(info)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_check_resolution',
`Verify HD/FHD resolution support.
Checks:
1. Device ui-resolution (720p/1080p/2160p) via GET :8060/query/device-info
2. Manifest ui_resolutions setting (hd, fhd)
3. images/hd/ and images/fhd/ resource directories
Reports any mismatches or missing resources.
Ref: developer.roku.com/docs/developer-program/core-concepts/resolution.md`,
{},
async () => {
try {
const result = await roku.checkResolution();
let text = `Device Resolution: ${result.deviceResolution}\n`;
text += `Manifest Resolutions: ${result.manifestResolutions.join(', ')}\n`;
text += `HD Resources (images/hd/): ${result.hdResourcesExist ? '✅ exists' : '❌ missing'}\n`;
text += `FHD Resources (images/fhd/): ${result.fhdResourcesExist ? '✅ exists' : '❌ missing'}\n`;
if (result.issues.length > 0) {
text += `\n⚠️ Issues:\n${result.issues.map(i => ` - ${i}`).join('\n')}`;
} else {
text += '\n✅ Resolution configuration looks correct.';
}
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// ⌨️ INPUT
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_keypress',
`Send a remote control key press to the Roku device.
Roku API: POST http://{host}:8060/keypress/{key}
Available keys: Home, Rev, Fwd, Play, Select, Left, Right, Down, Up,
Back, InstantReplay, Info, Backspace, Search, Enter, VolumeDown,
VolumeMute, VolumeUp, PowerOff, ChannelUp, ChannelDown,
Lit_{character} (for text input, e.g., Lit_a, Lit_1)
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{
key: z.string().describe('Key to press (e.g., Home, Select, Up, Down, Left, Right, Back, Play)'),
count: z.number().optional().describe('Number of times to press (default: 1)'),
delay: z.number().optional().describe('Delay between presses in ms (default: 300)'),
},
async ({ key, count, delay }) => {
try {
const n = count || 1;
const d = delay || 300;
for (let i = 0; i < n; i++) {
await roku.keypress(key);
if (i < n - 1 && d > 0) await new Promise(r => setTimeout(r, d));
}
return { content: [{ type: 'text', text: `✅ Pressed ${key}${n > 1 ? ` × ${n}` : ''}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_keypress_sequence',
`Send a sequence of key presses to the Roku device.
Roku API: POST http://{host}:8060/keypress/{key} (sequential)
Useful for navigation: e.g., ["Down", "Down", "Select"] to navigate menus.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{
keys: z.array(z.string()).describe('Array of keys to press in order'),
delay: z.number().optional().describe('Delay between keys in ms (default: 500)'),
},
async ({ keys, delay }) => {
try {
await roku.keypressSequence(keys, delay || 500);
return { content: [{ type: 'text', text: `✅ Pressed sequence: ${keys.join(' → ')}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_input',
`Send custom input/deep-link parameters to the running app.
Roku API: POST http://{host}:8060/input?{params}
Sends name-value pairs via roInput to the active channel.
Use for testing deep linking into a running app.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{
params: z.record(z.string(), z.string()).describe('Key-value pairs to send'),
},
async ({ params }) => {
try {
await roku.input(params as Record<string, string>);
return { content: [{ type: 'text' as const, text: `✅ Input sent: ${JSON.stringify(params)}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 📱 APP / INFO
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_apps',
`List all installed apps on the Roku device.
Roku API: GET http://{host}:8060/query/apps
Returns: id, name, version, type for each installed channel.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{},
async () => {
try {
const apps = await roku.getApps();
const text = apps.map(a => `[${a.id}] ${a.name} v${a.version} (${a.type || 'app'})`).join('\n');
return { content: [{ type: 'text', text: text || 'No apps found' }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_active_app',
`Query the currently running/active app.
Roku API: GET http://{host}:8060/query/active-app
Returns: id, name, version of the foreground app.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{},
async () => {
try {
const app = await roku.getActiveApp();
if (!app) return { content: [{ type: 'text', text: 'No active app (home screen)' }] };
return { content: [{ type: 'text', text: `Active: [${app.id}] ${app.name} v${app.version}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_launch',
`Launch or restart an app on the Roku device.
Roku API: POST http://{host}:8060/launch/{appId}?{params}
Use appId='dev' for sideloaded app.
Add params for deep linking: contentId, mediaType.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{
appId: z.string().optional().describe("App ID (default: 'dev' for sideloaded)"),
params: z.record(z.string(), z.string()).optional().describe('Launch parameters (e.g., contentId, mediaType)'),
},
async ({ appId, params }) => {
try {
await roku.launch(appId || 'dev', params as Record<string, string> | undefined);
return { content: [{ type: 'text' as const, text: `✅ Launched app ${appId || 'dev'}${params ? ` with params: ${JSON.stringify(params)}` : ''}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_deep_link',
`Test deep linking (Roku certification requirement).
Roku API: POST http://{host}:8060/launch/dev?contentId={id}&mediaType={type}
Deep linking is MANDATORY for Roku certification.
mediaType examples: movie, episode, series, short-form, live, game
Ref: developer.roku.com/docs/developer-program/discovery/deep-linking.md`,
{
contentId: z.string().describe('Content ID to deep link to'),
mediaType: z.string().describe('Media type (movie, episode, series, game, etc.)'),
appId: z.string().optional().describe("App ID (default: 'dev')"),
},
async ({ contentId, mediaType, appId }) => {
try {
await roku.deepLink(contentId, mediaType, appId);
return { content: [{ type: 'text', text: `✅ Deep link sent: contentId=${contentId}, mediaType=${mediaType}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_registry',
`Read app registry (stored settings/data).
Roku API: GET http://{host}:8060/query/registry/{appId}
Returns: sections and key-value pairs stored in the device registry.
Use appId='dev' for sideloaded app.
Requires: Developer Mode. Registry data is per-app persistent storage.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{
appId: z.string().optional().describe("App ID (default: 'dev')"),
},
async ({ appId }) => {
try {
const result = await roku.getRegistry(appId);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_app_state',
`Track app/media lifecycle events.
Roku API: GET http://{host}:8060/query/app-state/{appId}
Returns: current app state and media lifecycle information.
Requires: Developer Mode.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{
appId: z.string().optional().describe("App ID (default: 'dev')"),
},
async ({ appId }) => {
try {
const result = await roku.getAppState(appId);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 📋 LOGGING
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_log',
`Read BrightScript debug console logs.
Roku API: Telnet connection to {host}:8085
The BrightScript console shows runtime output, print statements,
crash logs, stack traces, and compilation errors.
Use pattern parameter to filter logs (regex).
Ref: developer.roku.com/docs/developer-program/debugging/debugging-channels.md`,
{
lines: z.number().optional().describe('Number of recent lines to return (default: 50)'),
pattern: z.string().optional().describe('Regex pattern to filter logs'),
},
async ({ lines, pattern }) => {
try {
if (!logClient.isConnected()) {
await logClient.connect();
// Wait a moment for initial data
await new Promise(r => setTimeout(r, 1000));
}
let logs: string[];
if (pattern) {
logs = logClient.getFilteredLogs(pattern, lines || 50);
} else {
logs = logClient.getRecentLogs(lines || 50);
}
return { content: [{ type: 'text', text: logs.length > 0 ? logs.join('\n') : '(no logs matching criteria)' }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ Log connection failed: ${err instanceof Error ? err.message : err}\nMake sure the Roku device is on and an app is sideloaded.` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 🔍 SCENEGRAPH / UI INSPECTION
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_sg_nodes',
`Inspect SceneGraph node tree of the running app.
Roku API: GET http://{host}:8060/query/sgnodes/{mode}
Modes:
- all: All existing nodes (osref + bscref counts)
- roots: Un-parented nodes only (leak detection)
- nodes: Specific node by ID (use nodeId param)
Options: sizes=true (memory in KB), count_only=true
Ref: developer.roku.com/docs/developer-program/debugging/debugging-channels.md`,
{
mode: z.enum(['all', 'roots', 'nodes']).optional().describe("Query mode: 'all', 'roots', or 'nodes' (default: 'all')"),
nodeId: z.string().optional().describe("Node ID (required when mode='nodes')"),
sizes: z.boolean().optional().describe('Include memory sizes in KB'),
countOnly: z.boolean().optional().describe('Return only node count'),
},
async ({ mode, nodeId, sizes, countOnly }) => {
try {
const result = await roku.getSgNodes(mode || 'all', { sizes, countOnly: countOnly, nodeId });
let text = '';
if (result.nodeCount !== undefined) text += `Node count: ${result.nodeCount}\n\n`;
// Truncate XML if too long
text += result.xml.length > 5000 ? result.xml.substring(0, 5000) + '\n... (truncated)' : result.xml;
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_perf',
`Query app performance metrics (CPU/memory).
Roku API: GET http://{host}:8060/query/chanperf
Returns: CPU utilization, memory usage, and other perf metrics.
Requires: Developer Mode.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{},
async () => {
try {
const result = await roku.getPerformance();
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_graphics_fps',
`Query graphics frame rate (FPS).
Roku API: GET http://{host}:8060/query/graphics-frame-rate
Returns: current rendering FPS.
Useful for detecting UI performance issues and frame drops.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{},
async () => {
try {
const result = await roku.getGraphicsFps();
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_element',
`Search for a UI element on screen (Roku WebDriver).
Roku API: POST http://{host}:9000/v1/session/{id}/element
Search strategies:
- text: visible text content (e.g., "Start Game")
- tag: SceneGraph node type (e.g., "Label", "Button", "Poster")
- attr: attribute value (set attr parameter, e.g., attr="visible")
Requires: Roku WebDriver server running (Roku Automated Testing).
Ref: developer.roku.com/docs/developer-program/dev-tools/automated-channel-testing/web-driver.md`,
{
using: z.enum(['text', 'tag', 'attr']).describe('Search strategy'),
value: z.string().describe('Value to search for'),
attr: z.string().optional().describe('Attribute name (required when using=attr)'),
},
async ({ using, value, attr }) => {
try {
if (!(await webdriver.isAvailable())) {
return { content: [{ type: 'text' as const, text: '⚠️ WebDriver not available. Start Roku WebDriver server first.' }] };
}
const el = await webdriver.findElement(using, value, attr);
if (!el) {
return { content: [{ type: 'text', text: `Element not found: ${using}="${value}"` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(el, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_focused_element',
`Get the currently focused UI element.
Roku API: GET http://{host}:9000/v1/session/{id}/element/active
Returns: tag, text, attrs of the element with current focus.
Requires: Roku WebDriver server running.
Ref: developer.roku.com/docs/developer-program/dev-tools/automated-channel-testing/web-driver.md`,
{},
async () => {
try {
if (!(await webdriver.isAvailable())) {
return { content: [{ type: 'text' as const, text: '⚠️ WebDriver not available.' }] };
}
const el = await webdriver.getFocusedElement();
if (!el) return { content: [{ type: 'text', text: 'No focused element found' }] };
return { content: [{ type: 'text', text: JSON.stringify(el, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 🎥 VIDEO / STREAMING / SERVER
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_media_player',
`Query current video/audio streaming state.
Roku API: GET http://{host}:8060/query/media-player
Returns: state (play/pause/buffer/stop/close), position (ms),
duration (ms), audio format, video format, container,
DRM type (widevine/playready/none), bandwidth, buffering progress.
Ref: developer.roku.com/docs/developer-program/debugging/external-control-api.md`,
{},
async () => {
try {
const state = await roku.getMediaPlayer();
return { content: [{ type: 'text', text: JSON.stringify(state, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_server_check',
`Check app server health and response time.
Makes HTTP GET request to the app server endpoint.
Default: casualgame.megafuncreative.com/server/version_check.php
Returns: status code, response time (ms), response body preview.
Checks the same endpoints used by VersionCheckTask.brs.`,
{
url: z.string().optional().describe('Server URL to check (default: app server version_check endpoint)'),
},
async ({ url }) => {
try {
const result = await roku.serverCheck(url);
let text = `URL: ${result.url}\n`;
text += `Status: ${result.status} ${result.ok ? '✅' : '❌'}\n`;
text += `Response Time: ${result.responseTimeMs}ms\n`;
text += `Body: ${result.body}`;
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 🛡️ CERTIFICATION / COMPLIANCE
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_check_drm',
`Verify DRM content protection status.
Roku API: GET http://{host}:8060/query/media-player (drm field in format)
DRM values: "widevine" | "playready" | "none"
Roku supports:
- Widevine (recommended, royalty-free, Roku OS 9.4+)
- PlayReady (deprecated for US/CA/LATAM June 2025)
Configure via drmParams: keySystem, licenseServerURL, cert.
Ref: developer.roku.com/docs/developer-program/getting-started/architecture/content-protection.md`,
{},
async () => {
try {
const result = await roku.checkDrm();
let text = `DRM Status: ${result.drmActive}\n`;
text += `Player State: ${result.state}\n`;
text += `Format: ${JSON.stringify(result.format)}\n`;
text += `\n${result.guidance}`;
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_check_accessibility',
`Verify accessibility compliance (Audio Guide / Captions).
Checks:
1. Device settings: captions-mode, audio-guide-enabled
(via GET :8060/query/device-info)
2. SceneGraph nodes: checks interactive nodes (Button, Label, etc.)
for 'description' attribute (required for Screen Reader)
3. Log monitoring: roTextToSpeech, roAudioGuide events
Roku requires:
- All interactive elements accessible via Audio Guide
- Captions support: On, Off, On instant replay, On mute
- Supported formats: SMPTE-TT, EIA-608/708, WebVTT
Ref: developer.roku.com/docs/developer-program/core-concepts/accessibility.md`,
{},
async () => {
try {
const result = await roku.checkAccessibility();
// Also get accessibility logs if connected
let accLogs: string[] = [];
if (logClient.isConnected()) {
accLogs = logClient.getAccessibilityLogs();
}
let text = `=== Device Accessibility Settings ===\n`;
text += `Captions Mode: ${result.deviceSettings.captionsMode}\n`;
text += `Audio Guide: ${result.deviceSettings.audioGuideEnabled}\n\n`;
text += `=== SceneGraph Node Analysis ===\n`;
text += `Total Nodes: ${result.sgNodeAnalysis.totalNodes}\n`;
text += `Nodes with description: ${result.sgNodeAnalysis.nodesWithDescription}\n`;
if (result.sgNodeAnalysis.nodesWithoutDescription.length > 0) {
text += `⚠️ Missing description: ${result.sgNodeAnalysis.nodesWithoutDescription.join(', ')}\n`;
}
if (result.issues.length > 0) {
text += `\n⚠️ Issues:\n${result.issues.map(i => ` - ${i}`).join('\n')}\n`;
}
text += `\n💡 Guidance:\n${result.guidance.map(g => ` - ${g}`).join('\n')}`;
if (accLogs.length > 0) {
text += `\n\n=== Accessibility Logs ===\n${accLogs.join('\n')}`;
}
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
server.tool(
'roku_check_raf',
`Verify Roku Ad Framework (RAF) integration.
Checks:
1. Manifest: bs_const=RAF_ENABLED=True, bs_libs_required=roku_ads_lib
2. Log analysis: [AdManager] output, ad beacon/impression events,
adCompleted/adSkipped/adError states
Roku certification requires:
- All ads served via RAF (no custom ad implementations)
- All ad beacons fired client-side by RAF
- Roku Advertising Watermark applied
- enableAdMeasurements() called for US store
- setContentGenre/setContentId/setContentLength provided
Ref: developer.roku.com/docs/developer-program/advertising/roku-advertising-framework.md`,
{},
async () => {
try {
const manifest = roku.checkRafManifest();
// Get RAF logs if connected
let rafLogs = { adManagerLogs: [] as string[], beaconLogs: [] as string[], impressionLogs: [] as string[], errorLogs: [] as string[], summary: '' };
if (logClient.isConnected()) {
rafLogs = logClient.getRafLogs();
}
let text = `=== Manifest Configuration ===\n`;
text += `RAF_ENABLED: ${manifest.rafEnabled ? '✅ True' : '❌ False (test mode)'}\n`;
text += `bs_libs_required: ${manifest.bsLibsRequired ? '✅ set' : '❌ missing'}\n`;
text += `bs_libs_required commented out: ${manifest.bsLibsCommented ? '⚠️ Yes' : 'No'}\n`;
if (manifest.issues.length > 0) {
text += `\n⚠️ Issues:\n${manifest.issues.map(i => ` - ${i}`).join('\n')}\n`;
}
if (manifest.guidance.length > 0) {
text += `\n💡 Guidance:\n${manifest.guidance.map(g => ` - ${g}`).join('\n')}\n`;
}
if (rafLogs.summary) {
text += `\n=== Runtime Log Analysis ===\n${rafLogs.summary}\n`;
if (rafLogs.adManagerLogs.length > 0) {
text += `\nRecent AdManager logs:\n${rafLogs.adManagerLogs.slice(-5).join('\n')}\n`;
}
if (rafLogs.errorLogs.length > 0) {
text += `\n❌ Error logs:\n${rafLogs.errorLogs.join('\n')}\n`;
}
}
return { content: [{ type: 'text', text }] };
} catch (err) {
return { content: [{ type: 'text', text: `❌ ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 🧪 INTEGRATION TEST RUNNER
// ═══════════════════════════════════════════════════════════
server.tool(
'roku_run_test',
`Run an automated integration test sequence.
Orchestrates multiple actions in order: deploy, wait, screenshot,
keypress, check_log, sg_nodes, element, perf, media_player,
check_resolution, server_check, deep_link, check_drm, check_raf,
check_accessibility.
Each step result is collected into a test report with pass/fail status.
Screenshots are captured as base64 images.
Example steps:
[{"action":"deploy"}, {"action":"wait","ms":5000},
{"action":"screenshot","label":"splash"},
{"action":"keypress","key":"Select"},
{"action":"check_log","pattern":"Game started","timeout":5000},
{"action":"perf"}, {"action":"check_resolution"}]`,
{
testName: z.string().describe('Name of the test'),
steps: z.array(z.object({
action: z.string(),
label: z.string().optional(),
key: z.string().optional(),
keys: z.array(z.string()).optional(),
ms: z.number().optional(),
delay: z.number().optional(),
using: z.string().optional(),
value: z.string().optional(),
attr: z.string().optional(),
pattern: z.string().optional(),
timeout: z.number().optional(),
mode: z.string().optional(),
sizes: z.boolean().optional(),
contentId: z.string().optional(),
mediaType: z.string().optional(),
url: z.string().optional(),
})).describe('Array of test steps to execute'),
},
async ({ testName, steps }) => {
try {
// Ensure log client is connected for log-related steps
if (steps.some(s => ['check_log', 'check_raf', 'check_accessibility'].includes(s.action))) {
if (!logClient.isConnected()) {
try { await logClient.connect(); } catch { /* continue without logs */ }
}
}
const report = await testRunner.run(testName, steps as TestStep[]);
let text = `═══ Test Report: ${report.testName} ═══\n`;
text += `Started: ${report.startedAt}\n`;
text += `Duration: ${report.totalDurationMs}ms\n`;
text += `Result: ${report.failedSteps === 0 ? '✅ ALL PASSED' : `❌ ${report.failedSteps} FAILED`}\n`;
text += `Steps: ${report.passedSteps}/${report.totalSteps} passed\n\n`;
for (const r of report.results) {
const icon = r.success ? '✅' : '❌';
text += `${icon} Step ${r.step}: ${r.action}${r.label ? ` (${r.label})` : ''} — ${r.durationMs}ms\n`;
if (r.error) text += ` Error: ${r.error}\n`;
}
const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [{ type: 'text' as const, text }];
// Include screenshots in response
for (const ss of report.screenshots) {
content.push({ type: 'image' as const, data: ss.base64, mimeType: ss.contentType });
content.push({ type: 'text' as const, text: `📸 Screenshot: ${ss.label}` });
}
return { content };
} catch (err) {
return { content: [{ type: 'text', text: `❌ Test failed: ${err instanceof Error ? err.message : err}` }], isError: true };
}
}
);
// ═══════════════════════════════════════════════════════════
// 📚 RESOURCES
// ═══════════════════════════════════════════════════════════
server.resource(
'device-info',
'roku://device/info',
{ description: 'Roku device information (model, resolution, firmware)' },
async () => {
try {
const info = await roku.getDeviceInfo();
return {
contents: [{
uri: 'roku://device/info',
text: JSON.stringify(info, null, 2),
mimeType: 'application/json',
}],
};
} catch (err) {
return {
contents: [{
uri: 'roku://device/info',
text: JSON.stringify({ error: String(err) }),
mimeType: 'application/json',
}],
};
}
}
);
server.resource(
'log-latest',
'roku://log/latest',
{ description: 'Latest BrightScript debug console logs' },
async () => {
try {
if (!logClient.isConnected()) {
await logClient.connect();
await new Promise(r => setTimeout(r, 1000));
}
const logs = logClient.getRecentLogs(100);
return {
contents: [{
uri: 'roku://log/latest',
text: logs.join('\n'),
mimeType: 'text/plain',
}],
};
} catch (err) {
return {
contents: [{
uri: 'roku://log/latest',
text: `Error: ${err}`,
mimeType: 'text/plain',
}],
};
}
}
);
// ═══════════════════════════════════════════════════════════
// 🚀 START SERVER
// ═══════════════════════════════════════════════════════════
async function main(): Promise<void> {
if (!ROKU_HOST) {
console.error('⚠️ ROKU_DEV_HOST not set. Set it in mcp/.env or as environment variable.');
console.error(' The server will start but tools requiring device connection will fail.');
}
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});