tv-debug-mcp
MCP server for semi-manual QA test execution on real Smart TVs (Tizen/webOS) and local Chrome via Chrome DevTools Protocol, enabling remote control, state inspection, and profiling.
README
tv-debug-mcp
MCP-сервер для полуручного прогона QA-кейсов на реальных Smart TV (Tizen / webOS) и на локальном Chrome — через Chrome DevTools Protocol. Агент управляет приложением: навигация пультом, лонгтап с точными таймингами, переходы по меню, чтение консоли и состояния плеера. Человек подтверждает то, что можно проверить только глазами.
Закрывает боль ручного тестирования сложных кейсов (лонгтап, перемещения, меню) на всём парке устройств, включая старые.
Быстрый старт
git clone https://github.com/Ediand11/tv-debug-mcp.git
cd tv-debug-mcp
npm install # за корп-прокси: env -u HTTP_PROXY -u HTTPS_PROXY npm install
cp devices.example.json devices.json # devices.json в .gitignore — ваш парк остаётся локальным
npm run check:browser # зелёный прогон без ТВ: свой Chrome + встроенная фикстура
Дальше — зарегистрировать сервер в Claude Code:
claude mcp add tv-debug --scope user -- node "$PWD/src/server.js"
Тулы появятся как mcp__tv-debug__*. Проверить, что MCP видит парк: попросить агента вызвать tv_devices.
Чтобы гонять своё приложение, а не фикстуру:
- в
devices.jsonописать устройство (platform,appId,hostдля ТВ илиurlдля браузера) — поля и их проверки описаны в «Парк устройств»; - завести
apps/<id>.jsonс селекторами приложения и сослаться на него полем"app"— см. «App-профиль», готовый пример лежит вapps/fixture.json; - для ТВ — Developer Mode на устройстве и подключённый
sdb/ares.
Node ≥ 18. Зависимости: @modelcontextprotocol/sdk, ws, source-map-js (чистый JS-порт source-map 0.6, без wasm — важно для офлайн-запуска).
Зачем не Appium / не playwriter
- Appium TV-драйверы тянут chromedriver, который мёртв на Tizen с Chrome ≤ 57 и держится на хаке подмены UA на webOS 3. Тяжёлая инфра, два разных драйвера.
- playwriter / Playwright connectOverCDP требует свежий Chromium — не заведётся на webOS 3/4 (Chrome 38/53).
- Этот MCP говорит с инспектором по «голому» CDP. Один кодовый путь от Chrome 38 до 120+, ноль зависимостей на устройстве. Тот же набор тулов работает и против браузера на ноуте.
Инструменты (14)
| Тул | Что делает |
|---|---|
tv_devices |
Парк из devices.json: доступность и реальные capabilities каждого устройства |
tv_install |
Установка билда (.wgt / .ipk). uninstallFirst:true лечит «Author certificate not match» |
tv_launch |
Debug-запуск + attach по CDP. Режимы: свежий старт / reload / relaunch / attach |
tv_press |
Клавиша пульта. durationMs = лонгтап; repeat+intervalMs = серия. Возвращает фокус до/после и inputMode |
tv_state |
Структурный снимок: url, заголовок, видимые сцены, фокус (текст, класс, путь, индекс/всего), попапы, счётчики |
tv_wait_for |
Ожидание условия вместо sleep: focusText / selector / selectorGone / scene / text / expression / videoAdvancing |
tv_goto |
Жать направление, пока сфокусированный элемент не совпадёт с целью. Ограничен maxSteps, дедлайном и детектом «фокус встал» / «обернулись по кругу» |
tv_menu |
Войти в меню приложения и выбрать раздел по имени; без имени — открыть и вернуть список разделов |
tv_sequence |
Весь кейс одним вызовом: вердикт, время и результат по каждому шагу, под device-lock |
tv_screenshot |
PNG кадра. В браузере работает всегда; на Tizen деградирует с пометкой (secure/overlay plane) |
tv_console |
Консоль / исключения / упавшие запросы с момента launch. Все уровни, фильтр, счётчик отброшенного буфером |
tv_video_state |
Программный снимок <video>: тикает ли currentTime (два замера), readyState, размеры, MediaError |
tv_evaluate |
Произвольный JS в странице (escape hatch). На старых ТВ — только ES5 |
tv_profile |
Запись JS CPU-профиля (start → действия → stop): файл .cpuprofile для DevTools + топ функций и файлов по self time. sourceMap деминифицирует топ на прод-сборке. Плюс метрики Performance.getMetrics (heap, DOM-узлы, слушатели, layout) — снимок на start и на stop, в ответе diff; action:"metrics" снимает их отдельно, без записи профиля |
tv_sequence — шаги
{"launch": {"relaunch": true}} // привести апп в известное состояние
{"press": "RIGHT", "repeat": 2}
{"longpress": "ENTER", "durationMs": 1600}
{"goto": {"direction": "DOWN", "text": "Library"}}
{"menu": "Settings"}
{"wait": {"scene": "player"}, "timeoutMs": 30000}
{"expect": {"selector": "[class*=context-menu]"}}
{"eval": "document.title"}
{"sleep": 1500}
{"videoState": true, "expectAdvancing": true}
{"state": true}
{"profileStart": {"samplingIntervalUs": 1000}}
{"profileStop": {"path": "/tmp/scroll.cpuprofile", "sourceMap": "…/app.js.map"}}
{"metrics": true} // снимок Performance.getMetrics; {"collectGarbage": true} — с GC
expect — то же, что wait, но невыполнение валит шаг. stopOnFail по умолчанию true.
tv_press — клавиши
UP DOWN LEFT RIGHT ENTER BACK MENU INFO GUIDE SEARCH TOOLS CAPTION RED GREEN YELLOW BLUE PLAY PAUSE PLAY_PAUSE STOP REWIND FAST_FORWARD TRACK_NEXT TRACK_PREV RECORD CHANNEL_UP CHANNEL_DOWN PAGE_UP PAGE_DOWN VOLUME_UP VOLUME_DOWN VOLUME_MUTE EXIT DIGIT_0..9 (регистр не важен, можно сырой числовой keyCode). Коды взяты из платформенных input-слоёв Tizen (TvKeyCode) и webOS.
Лонгтап: {"key":"ENTER","durationMs":1600} — keydown, hold, keyup. Механика LongPressService: таймер стартует на keydown, keyup решает «клик или лонгтап». LG SSAP-пульт hold не выражает — поэтому синтетика, а не пульт.
tv_profile — CPU-профиль и метрики
CPU-профиль — единственный перф-домен, который жив на всём парке: Profiler.start/stop есть и в Chromium 69 (tizen55), и в Chrome 38 (webos3) — в отличие от Tracing. Метрики Performance.getMetrics требуют Chromium 60+, поэтому они едут прицепом и никогда не ценой профиля (см. «Метрики» ниже).
tv_profile {"action": "start"} # опц. samplingIntervalUs, по умолчанию 1000
tv_goto {"direction": "DOWN", …} # то, что меряем
tv_profile {"action": "stop", "sourceMap": "…/app.js.map", "topN": 20}
stop отдаёт:
path— файл.cpuprofile. Открывается в Chrome DevTools → Performance → Load profile (кнопка ⤒). Сырой профиль в ответ тула не кладётся никогда — это сотни килобайт JSON;summary.topFunctions— self time и % по функциям (аггрегат по одинаковым фреймам; total time рекурсивной функции считается один раз, а не на каждом уровне);summary.topFiles— то же по файлам;summary.special—(program)/(garbage collector)/(idle)отдельно, в топ функций они не лезут;metrics— diffPerformance.getMetricsза окно записи (илиnullна движке без домена);warning— если карта не прочиталась, если ни один топовый фрейм в ней не нашёлся, если формат легаси или если метрик на этом движке нет.
Self time = hitCount × средний интервал семплинга, где интервал выводится из самой записи (длительность / число хитов), а не из запрошенного samplingIntervalUs — старый движок вправе его проигнорировать.
Прод-сборка без sourceMap — это топ вида Xy/abc. Карту брать из той же сборки, что стоит на ТВ (<каталог сорсмапов сборки>/app.js.map); деминифицируются только топ-N фреймов, остальное DevTools разберёт сам по файлу.
Внутри tv_sequence — шагами profileStart/profileStop: сценарий держит операционный лок, отдельный tv_profile в него не влезет.
Форматы профиля различаются между поколениями движков и нормализуются оба: современный (nodes[], 0-based строки, микросекунды) и легаси Chrome 38 (head-дерево, 1-based строки, секунды). Строки в саммари всегда 1-based, как показывает DevTools. Файл легаси-формата современный DevTools может не открыть — об этом приходит warning, саммари при этом валидное.
tv_profile — метрики (heap, DOM, layout)
CPU-профиль показывает, где горит JS, и не видит ни память, ни layout. Performance.getMetrics — один дешёвый вызов, который отдаёт JSHeapUsedSize, JSHeapTotalSize, Nodes, Documents, JSEventListeners, LayoutCount, RecalcStyleCount и кумулятивные счётчики времени (LayoutDuration, RecalcStyleDuration, ScriptDuration, TaskDuration).
tv_profile {"action": "metrics"} # снимок здесь и сейчас
tv_profile {"action": "metrics", "collectGarbage": true}
start и stop снимают метрики сами, поэтому охота на утечку — это обычная запись:
tv_profile {"action": "start"}
tv_press {"key": "DOWN", "repeat": 20}
tv_profile {"action": "stop", "collectGarbage": true}
stop вернёт
"metrics": {
"windowSec": 12.4,
"collectedGarbage": true,
"values": {
"Nodes": {"before": 1200, "after": 1650, "diff": 450},
"JSEventListeners": {"before": 340, "after": 352, "diff": 12},
"JSHeapUsedSize": {"before": 20000000, "after": 24500000, "diff": 4500000},
"LayoutDuration": {"before": 0.1, "after": 0.4, "diff": 0.3}
}
}
Читать так: Nodes вырос на 450 после того, как навигация вернулась туда же — сцена не разбирает свой DOM. LayoutDuration — секунды layout-времени именно за окно записи.
Детали:
- Отдаётся весь список метрик, какой прислал движок, без белых списков: набор в Chromium 69 и в свежем Chrome разный, а фильтр молча съел бы то, чего мы не ждали. Метрика, которую знает только один из двух снимков, остаётся в diff со стороной
null— это тоже информация. Нечисловые значения проходят насквозь сdiff: null; windowSec— изTimestamp(монотонные часы движка), не из часов хоста: раунд-трипы CDP в окно не входят;- кумулятивные
*Durationсчитаются с момента старта движка — смысл имеет только diff, не абсолют; collectGarbageпо умолчанию выключен. Форсированный GC — это пауза: внутри записи она искажает и профиль, и поведение слабого ТВ. Включать под охоту за утечкой, где несобранный мусор как раз и подделывает рост heap. Метод, которого на движке нет, даётwarning, а не ошибку;- снимок на
startберётся доProfiler.start, наstop— послеProfiler.disable, чтобы сами вызовы метрик не попали в запись, которую они описывают.
Платформы: tizen55 (Chromium 69) ✓, pc ✓, webos3 (Chrome 38) ✗ — домена Performance там нет. action:"metrics" на webos3 честно падает с сообщением про Chromium 60+; start/stop при этом работают как раньше и возвращают metrics: null плюс warning — потерять CPU-профиль из-за отсутствующих метрик нельзя. Фолбэка на performance.memory нет намеренно: на webOS значения квантованы и дают стабильную ложь вместо честного отказа.
В tv_sequence — шаг {"metrics": true}: им можно обрамить любой кусок сценария, не только тот, что покрыт записью профиля. Diff между двумя такими шагами считает вызывающий.
Парк устройств
devices.json (или путь в TV_DEBUG_CONFIG) — он в .gitignore, заводится копией devices.example.json. Файл перечитывается по mtime — правка подхватывается без рестарта MCP; дубли id и портов отвергаются с внятной ошибкой.
{
"defaultDevice": "tizen",
"devices": [
{"id": "tizen", "platform": "tizen", "app": "myapp", "appId": "AbCdEfGhIj.myapp",
"host": "192.168.1.10", "sdbPort": 26101, "localPort": 9955},
{"id": "webos", "platform": "webos", "app": "myapp", "appId": "com.example.myapp", "device": "webos7"},
{"id": "pc-dev", "platform": "pc", "app": "myapp", "url": "http://localhost:1337"},
{"id": "pc-dev-parity", "platform": "pc", "app": "myapp", "url": "http://localhost:1337",
"inputMode": "synthetic"}
]
}
cliTarget (Tizen) можно не указывать — выводится из третьей колонки sdb devices; он нужен, чтобы tizen install -t попал в нужный ТВ на парке.
App-профиль
apps/<id>.json, привязка полем "app". Здесь живёт всё знание о приложении — чем помечен фокус, как выглядит сцена, где меню. Это то, что делает MCP переносимым: для другого приложения заводится второй файл, а не форк. Рабочий пример — apps/fixture.json (профиль встроенной фикстуры).
{
"focus": ["._active"],
"scene": {"container": "._scene", "strip": "layer__container|fullscreen"},
"popup": ["[class*=popup]", "[class*=context-menu]"],
"menu": {"openKey": "LEFT", "exitKey": "BACK",
"root": ".menu__primary", "item": ".menu__primary .menu-cell",
"title": ".menu-cell__title"},
"tile": ".video-tile, .media-tile",
"bootReady": {"selector": ".video-tile", "timeoutMs": 40000},
"checks": {"homeSection": "Main", "popup": ".context-menu"}
}
Два неочевидных момента, ради которых профиль вообще существует:
- Фреймворк может вешать класс фокуса на всю цепочку scene → container → list → tile, поэтому сфокусированный виджет — это самый глубокий match, а не первый. Первый — это сцена, и по нему навигация выглядит неподвижной.
root/itemпришивайте к первому уровню меню. Вложенный раздел легко рисует свои строки теми же классами, и одна из них может называться как раздел верхнего уровня — тогда матч по всему меню выбирает вложенную строку и рапортует успех, пока апп никуда не уходил. По той же причине естьexitKey: внутри раздела клавиша открытия меню может не возвращать в сайдбар, надо сначала выйти по BACK.
Необязательный блок checks читают приёмочные скрипты (test/phase1-check.mjs), чтобы не быть прибитыми к одному приложению: homeSection — раздел, в который возвращаемся после захода в меню, popup — как выглядит контекстное меню тайла.
Браузерный режим (platform: "pc")
Тот же набор тулов против локального Chrome. Быстро, и скриншоты реально работают — на Tizen они виснут.
- Chrome — наш: свой временный
--user-data-dir,--remote-debugging-port=0(порт читается изDevToolsActivePort, а не прибит к 9333), гасится и подчищается на dispose. К обычному браузеру пользователя MCP не цепляется. - Dev-сервер — ваш: MCP проверяет, что
urlотвечает, и не запускает и не гасит его. Запускатьnpm startв проекте приложения. --disable-web-securityобязателен: приложение, чей бутстрап ходит за токеном на другой origin, без него умирает на CORS и не стартует.Network.setCacheDisabled(true)обязателен: dev-сервер отдаёт ES-модули, и переиспользованный браузер молча гоняет вчерашний код.
Trusted vs synthetic — почему это два разных эксперимента
| ТВ | Браузер по умолчанию | Браузер inputMode: "synthetic" |
|
|---|---|---|---|
| Механизм | page-side KeyboardEvent |
Input.dispatchKeyEvent |
page-side KeyboardEvent |
isTrusted |
нет | да | нет |
| Куда летит | document |
реально сфокусированный элемент | document |
| Дефолтные действия браузера | нет | да | нет |
Кейс может быть зелёным в браузере и красным на ТВ (ветка TV-keyCode не задействована) — и наоборот (Backspace уводит браузер назад). Поэтому: режим пишется в каждый вердикт, тихого фолбэка между режимами нет, а навигационные кейсы прогоняются ещё и на pc-dev-parity перед выводом «на ТВ будет так же».
Ключевые находки on-device (Tizen 5.5, sdb 4.2.36)
- Debug-запуск:
sdb -s <serial> shell 0 debug <appId>без аргумента-таймаута. С таймаутом launchpad отвечаетclosed. Инспектор на device-порту переживает закрытие sdb-канала, поэтому канал закрывается сразу после разбора порта. - Надёжный kill —
sdb shell 0 was_kill <appId>.kill_appна retail-шелле молча no-op. attachработает только через живой инспектор: второйdebugпо уже отлаживаемому аппу отвечаетclosed. Порт берётся из памяти сессии или из правилаsdb forward --list, которое переживает рестарт MCP; поэтому forward намеренно не снимается на dispose.- Скриншот
Page.captureScreenshotвиснет (secure/overlay plane, HDCP) — тул отдаётok:falseс пометкой. Для плейбека —tv_video_state+ взгляд на ТВ. - localStorage переживает debug-релонч на 5.5 (проверено: маркер на месте после
was_kill+ свежегоdebug). - Загрузка каталога — 3.6–6.1 с, а не «22 секунды на всякий случай»:
tv_wait_forбыстрее и детерминированнее слепой паузы. relaunchв браузерном режиме переиспользует ту же throwaway-профиль-директорию, а Chrome оставляет в нейDevToolsActivePortот прошлого запуска. Файл сносится перед спавном — иначе адаптер отдаёт порт, на котором уже никто не слушает (no inspectable page at http://127.0.0.1:…).- Весь page-side JS — строго ES5:
Array.prototype.findпоявился в Chrome 45, а webOS 3 — это Chrome 38, и одна такая строчка ронялаtv_video_stateровно на самом старом устройстве парка.
Как это устроено
Claude Code ── stdio ── server.js
├── config.js devices.json (перечитка по mtime + валидация)
├── appprofile.js apps/<app>.json — знания о приложении
├── adapters/
│ tizen.js sdb -s: install/was_kill/debug/forward
│ webos.js ares: close→launch→inspect
│ pc.js свой Chrome + navigate + setCacheDisabled
│ spawn-until-match.js общий супервизор CLI-детей
├── input/
│ synthetic.js page-side KeyboardEvent (ТВ + parity)
│ trusted.js Input.dispatchKeyEvent (браузер)
├── cdp.js CDP по WebSocket, единый путь дисконнекта
├── keymaps.js KeySpec {code, key, domCode} по платформам
├── inject.js page-side ES5: key dispatch, focus, video-state
├── state.js page-side ES5: снимок состояния и фокуса
├── wait.js поллинг условий (общий для wait/goto/sequence)
├── profile.js CPU-профиль: оба формата, саммари, sourcemap
├── ports.js свободный локальный порт под forward
└── session.js живая сессия: два лока, авто-реконнект, навигация
Устойчивость: упавший ТВ, выдернутый сокет или отсутствующий sdb валят один вызов тула, а не процесс MCP. ensureConnected сериализован — параллельные вызовы не запускают апп дважды.
Проверено
| Прогон | Что |
|---|---|
npm run check:offline |
53/53 — честный статус офлайн-устройства, перечитка конфига без рестарта, отказ при дублях id, выживание без sdb; парсер CPU-профиля на фикстурах обоих форматов (совпадающие числа, спец-узлы отдельно, рекурсия не удваивается) и деминификация топа с деградацией до warning |
node test/phase0-check.mjs |
18/18 на Samsung UE50TU8510 — launch, движение фокуса, ES5-проба видео, limit:1, attach из другого процесса с сохранением состояния, выживание при обрыве сокета |
node test/phase1-check.mjs |
12/12 на ТВ — wait_for вместо сна, структурный фокус, goto до цели и его границы, заход в раздел меню и возврат обратно, кейс лонгтапа целиком. Селекторы берутся из app-профиля устройства, поэтому прогон не привязан к конкретному приложению |
npm run check:browser |
44/44 в Chrome — capabilities, отказ tv_install, свой Chrome на порту 0, навигация, реальный скриншот, кейс лонгтапа в trusted и synthetic, CPU-профиль (именованная busy-функция видна в топе, двойной start и сиротский stop отвергнуты, профилирование шагами сценария), уборка за собой |
webOS-адаптер переписан (close → launch → inspect, честный freshLaunch), но on-device не прогонялся: LG из ares-setup-device --list сейчас недоступны (connection timed out).
test/smoke.mjs — ad-hoc прогон произвольного списка вызовов; test/harness.mjs — общий stdio-клиент для всех проверок и хелпер appTargets, который вытаскивает селекторы из app-профиля.
check:browser дополнительно прогоняется против вашего живого dev-сервера, если задать обе переменные:
TV_DEV_URL=http://localhost:1337 TV_DEV_APP=myapp npm run check:browser
Демо-кейсы
cases/fixture-smoke.md — кейс против встроенной фикстуры, исполним сразу после клона, без ТВ и без dev-сервера. Формат и правила, выведенные из реальных прогонов, — в cases/README.md.
Дальше
- webOS on-device прогон (в т.ч. webOS 3 = Chrome 38: ES5-инъекция, работоспособность скриншота и легаси-формат CPU-профиля — парсер написан по спецификации Chrome 38 и проверен на фикстуре, но не на живом LG).
- Прогон
tv_profileна ТВ сsourceMapот прод-сборки (карта Closure парсится и позиции разрешаются — проверено офлайн). - Остальные перф-инструменты (FPS,
Performance.getMetrics,Tracing) — отдельным заходом, они не покрывают весь парк. - Авто-повтор удержанной d-pad-клавиши (
holdRepeatMs): сейчасdurationMsшлёт одинkeydown, что верно для лонгтапа, но не воспроизводит скролл ленты зажатой стрелкой. Обход списков закрываетtv_goto. - Параллельный прогон одного кейса на N ТВ (адресация
-sдля этого уже есть). - Allure TestOps (чтение кейсов) +
allurectl(заливка результатов). - WS-пульт (SSAP / Samsung remote) для системных кейсов HOME/suspend, которые page-level синтетика не покрывает.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.