From 9ad5a018a2fa778a713754180f486068f29e1590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B5=D1=82=D1=80=D0=BE=D0=B2?= Date: Wed, 16 Sep 2026 20:43:50 +0300 Subject: [PATCH] Add shared-session MCP source debugger adapter --- docs/TODO.md | 27 ++++++ docs/libc-reference.md | 4 +- docs/mame-source-debug-status.md | 36 +++++-- docs/mame-source-debug.md | 18 ++++ docs/sdbg-log-macros.md | 4 +- docs/sdbg-mcp.md | 98 +++++++++++++++++++ docs/vscode-sprinter-debug.md | 10 +- tests/sdbg/run_mcp_sdk_probe.py | 67 +++++++++++++ tests/sdbg/run_vscode_dap_probe.py | 73 +++++++++++++- tests/sdbg/test_mcp_adapter.py | 52 ++++++++++ tests/sdbg/test_server.py | 44 ++++++++- tests/sdbg/test_session.py | 19 ++++ toolchain/mcp/sdbgbridge/init.lua | 3 +- toolchain/requirements-mcp.txt | 2 + toolchain/sdbg/mcp_adapter.py | 77 +++++++++++++++ toolchain/sdbg/server.py | 52 +++++++++- toolchain/sdbg/session.py | 9 +- toolchain/sdbg_launcher.py | 20 +++- toolchain/sdbg_mcp.py | 147 +++++++++++++++++++++++++++++ 19 files changed, 740 insertions(+), 22 deletions(-) create mode 100644 docs/sdbg-mcp.md create mode 100644 tests/sdbg/run_mcp_sdk_probe.py create mode 100644 tests/sdbg/test_mcp_adapter.py create mode 100644 toolchain/requirements-mcp.txt create mode 100644 toolchain/sdbg/mcp_adapter.py create mode 100644 toolchain/sdbg_mcp.py diff --git a/docs/TODO.md b/docs/TODO.md index 520829c..4fc40c7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -279,12 +279,39 @@ Quick wins: ## Прочий backlog +- [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый + stdio-адаптер с 16 инструментами использует общую sdbg-сессию и + owner ID личных точек. Следующее: эксклюзивный control lease для + DAP/MCP, heartbeat/очистка точек при аварийном выходе MCP, безопасные + input/screenshot/console API через session server, затем доказанные + watchpoints, запись typed values, disassemble_src и batch-загрузка + точек. Raw `mame_mcp.py` с `-plugin mamebridge` или старым + `mame_bridge.lua` не подключать к тому же MAME в обход общей сессии. + Отдельно мигрировать raw `mame_mcp.py` с FastMCP SDK 1.x на MCPServer + SDK 2.x, если этот standalone-интерфейс сохраняется; сейчас окружения + raw и C-адаптера разделены. + Проверять конкуренцию, ошибки и lifecycle живым DAP/MCP + пробником; см. [sdbg-mcp.md](sdbg-mcp.md). - [ ] **Несколько одновременных экземпляров MAME и MCP bridge (отдельная задача, позже).** Проверить два изолированных процесса с разными дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать команду с PID/session ID, не допускать ответа от чужого процесса, проверить сброс/exit и одновременные команды. До успешного end-to-end теста параллельная работа MAME через MCP не гарантируется. +- [ ] **Финальная полировка: оградить ввод до запуска отлаживаемой программы.** + Проверить, можно ли не передавать в гостевой Sprinter физические + клавиатуру/мышь, пока MAME грузит DSS и launcher вводит команду запуска: + MAME может захватить фокус, а пользователь в это время печатает в другой + программе. По возможности до запуска EXE сохранить фокус на прежнем + приложении, чтобы оно продолжало получать ввод. Автоматический ввод + команды launcher должен оставаться рабочим. При переходе к исполнению + EXE сразу разрешить прямой ввод в MAME, в том числе для `getchar()`; + при Stop, ошибке запуска и restart корректно сбрасывать состояние. + Если MAME не умеет различать эти два + источника ввода, исследовать управление фокусом/захватом окна либо + узкий patch MAME; не блокировать ввод ценой поломки запуска. + Проверить ручное переключение окон, сохранение ввода в другом приложении + и ранние нажатия живым сценарием. - [ ] factoring parse_argv из crt0/crt0_banked в общий argv.s - [ ] `restore SP on EXIT` (паттерн z88dk +pps) — проверить нужность - [x] ~~CI: MAME с -aviwrite для screenshot-сравнения без человека~~ — diff --git a/docs/libc-reference.md b/docs/libc-reference.md index 4ceceeb..cd107c3 100644 --- a/docs/libc-reference.md +++ b/docs/libc-reference.md @@ -484,7 +484,9 @@ global/static переменной, ненулевое значение озна [руководство по SDBG_LOG](sdbg-log-macros.md). Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console -при подключённой source-debug сессии; без неё макрос сам ничего не печатает. +при подключённой source-debug сессии, а MCP-клиент читает тот же журнал +через `recent_events` ([руководство по MCP](sdbg-mcp.md)); без сессии макрос +сам ничего не печатает. ## — платформа diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index be58844..75969ca 100644 --- a/docs/mame-source-debug-status.md +++ b/docs/mame-source-debug-status.md @@ -2,7 +2,8 @@ Дата: 2026-09-16. План: [mame-source-debug.md](mame-source-debug.md). Реализованы сборка/карта, проверенный транспорт, базовая C-сессия, -постоянный session server, DAP MVP, VS Code launch и build task. MCP C-уровня, +постоянный session server, DAP MVP, VS Code launch/build task и первый +stdio MCP-адаптер C-уровня. Эксклюзивное владение CPU между клиентами, безопасный restart и расширенная отладка ещё не готовы. Этапы плана не считаются завершёнными целиком по одному успешному репро. @@ -118,7 +119,21 @@ копию системного HDD и отдельные state-каталоги, показывает окно Sprinter, ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`, проверяет сигнатуру entry и запускает session server. Закрытие DAP - завершает только созданные им server/MAME и удаляет временный каталог. + завершает только созданные им server/MAME, удаляет временный каталог и + свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс). +- `sdbg_mcp.py` предоставляет 16 инструментов через официальный MCP SDK + 2.x поверх того же session server: статус, C-позиция, регистры, logical + memory, переменные, события/логи, личные точки и команды исполнения. + MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном + закрытии MCP-клиента они очищаются. Живой совместный прогон проверил + структурированные ответы, отказ на удаление чужой точки и последующее + срабатывание точки DAP с provider `sdbg` и `osx`. Отдельный WAITKEY-прогон + подтвердил удаление личной точки при running CPU и дальнейшее завершение + шага после клавиши. + Повторный запуск на том же фиксированном socket после прежнего stale-файла + прошёл; после Stop путь удалён. + Контракт запуска и ограничения — в + [руководстве по MCP](sdbg-mcp.md). ## Использование карты @@ -224,6 +239,8 @@ pyenv exec python tests/sdbg/run_vscode_dap_probe.py pyenv exec python tests/sdbg/run_vscode_dap_probe.py --exit-while-stopped pyenv exec python tests/sdbg/run_vscode_dap_probe.py --stop-while-stopped pyenv exec python tests/sdbg/run_vscode_dap_probe.py --term-while-stopped +pyenv exec python tests/sdbg/run_vscode_dap_probe.py --mcp-python /tmp/sprinter-sdbg-mcp-venv/bin/python +pyenv exec python tests/sdbg/run_vscode_dap_probe.py --waitkey --emulated-key --clear-while-running pyenv exec python tests/sdbg/run_vscode_dap_probe.py --waitkey --manual-key ``` @@ -267,7 +284,7 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: | Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует | | Владение и generation | Второй FileBridge и чтение со stale generation отклоняются | -Полный host-набор: 39 тестов (38 прошли, один Unix-socket тест пропущен только +Полный host-набор: 44 теста (43 прошли, один Unix-socket тест пропущен только из-за запрета `bind` в sandbox). Тот же socket path проверен живым DAP-запуском. После разделения репозиториев установленный VSIX в изолированном профиле VS Code запустил сборку SprPoP и остановился в `src/sprpop.c:264` (`main`) @@ -328,8 +345,9 @@ host-моста пока не готова: FileBridge использует `fcn Также startup debugscript с `g` может продолжить первое реальное попадание; живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot -на reset защищена от дублирования callback. Старый mamebridge параллельно -с sdbgbridge не загружать: общая арбитрирующая сессия ещё не реализована. +на reset защищена от дублирования callback. Raw `-plugin mamebridge` +параллельно с `sdbgbridge` не загружать: общая арбитрирующая сессия для +двух Lua-мостов ещё не реализована. Два одновременно запущенных процесса MAME через MCP bridge не проверялись; корректная маршрутизация команд между ними не гарантируется. Это отдельная отложенная задача в [TODO.md](TODO.md). @@ -353,8 +371,12 @@ Crash reports в 19:22/19:23 относились к неподдержанно строит проверенное условие через I/O port. После штатных прогонов в 20:43 и позже новых `mame.arm-*.ips` не появилось. -Далее по плану: сделать MCP-адаптер, безопасный restart и расширенные -выражения. VSIX уже упакован; +Далее по плану: арбитраж управления CPU и lease/cleanup для MCP-точек при +аварийном выходе клиента, затем безопасный restart и расширенные выражения. +Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с новым +`-plugin mamebridge` либо старым `mame_bridge.lua`) нельзя просто перенести: они должны +проходить через общий session server и получить проверенный контракт для +ввода, скриншотов, записи/watchpoints. VSIX уже упакован; для IDE ещё нужны Run-команда, выбор профиля сборки/данных и расширенная диагностика assembler/linker. Базовый attach уже проверяет принадлежность resident/current-bank к build, diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md index 22f17c9..c3a76c6 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -534,6 +534,13 @@ console_log и загрузка набора точек. Возвращать bu Неподдержанное — явная ошибка. Старые низкоуровневые команды интегрируются в арбитраж, а не обходят его. +Первый stdio MCP-адаптер уже работает через общий session server и отдаёт +16 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой +DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния, +точки и последующее срабатывание DAP-точки. Эксклюзивный control lease, +cleanup при аварийном выходе MCP и остальные интерфейсы этого раздела ещё +не реализованы. Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md). + ### 8.2 DAP MVP и развитие MVP: initialize, attach, configurationDone, disconnect, setBreakpoints, @@ -751,6 +758,17 @@ SDCC. Добавить launch/restart/stopOnMain и упаковку VSIX. До без side effects, после проверки размера, доступного банка, границ и image identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный pointee дают явный `unavailable`/truncated, а не неверное значение. +- Исследовать временную блокировку физического ввода в MAME до запуска + отлаживаемого EXE: окно может получить фокус, пока пользователь вводит + текст в другой программе. По возможности сохранить фокус и ввод за этой + программой до запуска EXE. Автоматический ввод команды launcher в DSS + должен работать и при блокировке. В момент запуска EXE сразу открыть + прямой ввод, включая ожидание `getchar()`; восстановить исходное состояние + при Stop, ошибке и restart. Если источники ввода нельзя разделить штатно, + проверить управление фокусом или узкий patch MAME. Приёмка — живой тест + ранних нажатий, ввода в другое приложение, переключения окон и ввода после + запуска. Подробная задача + сохранена в [TODO.md](TODO.md). **Выход:** доказательства и ограничения каждой функции опубликованы. Эвристика не выдаётся за стек, anchors — за гарантированную идентичность, diff --git a/docs/sdbg-log-macros.md b/docs/sdbg-log-macros.md index 3db4e94..eb0934a 100644 --- a/docs/sdbg-log-macros.md +++ b/docs/sdbg-log-macros.md @@ -5,7 +5,9 @@ кода печати в приложении. Сообщение попадает в Debug Console VS Code и в журнал debugger MAME. Отдельное окно MAME debugger видно при launch с `"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME, -но не открывает штатное debugger-окно. +но не открывает штатное debugger-окно. Подключённый +[MCP-адаптер C-уровня](sdbg-mcp.md) читает те же записи `output` через +`recent_events(after=...)`; он не ставит второй набор макросных точек. ## Быстрый пример diff --git a/docs/sdbg-mcp.md b/docs/sdbg-mcp.md new file mode 100644 index 0000000..d897e4c --- /dev/null +++ b/docs/sdbg-mcp.md @@ -0,0 +1,98 @@ +# MCP-доступ к source debugger Sprinter + +`toolchain/sdbg_mcp.py` подключается к уже работающей `sdbg_server.py` через +Unix socket. Это отдельный stdio MCP-сервер для C-уровня: он использует ту же +сессию, карту сборки и журнал, что VS Code/DAP. В репозитории MAME есть две +Lua-реализации **другого**, raw MCP-моста: `src/mame_bridge.lua` +(`-autoboot_script`) и более новый `-plugin mamebridge`. Оба обслуживают +один `src/mame_mcp.py` и общий файловый протокол; plugin использует +`register_periodic` и отвечает даже при остановленном CPU. Ни один из этих +вариантов не должен одновременно управлять тем же MAME в обход sdbg-сессии. +`src/mame_mcp.py` всё ещё импортирует `FastMCP` по пути SDK 1.x; +наш адаптер использует `MCPServer` SDK 2.x. Пока raw frontend не мигрирован, +держите для него отдельное Python-окружение. Это различие касается Python +обвязки, а не Lua-плагина `mamebridge`. + +Для MCP нужен Python 3.12 с официальным SDK 2.x. Он не требуется для сборки +приложений и работы VS Code. Например, создайте отдельное окружение и +установите `toolchain/requirements-mcp.txt`: + +```sh +pyenv exec python -m venv /tmp/sprinter-sdbg-mcp-venv +/tmp/sprinter-sdbg-mcp-venv/bin/python -m pip install -r toolchain/requirements-mcp.txt +``` + +В профиле `launch` типа `sprinter-mame` укажите фиксированный socket, +например `"socket": "/tmp/sprinter-sdbg-hello.sock"`. После F5 дождитесь +остановки в `main`. Путь должен быть коротким (у Unix socket есть лимит длины), +уникальным для этого сеанса и доступным только вашему пользователю. Затем +зарегистрируйте в MCP-клиенте stdio-сервер: + +```json +{ + "command": "/tmp/sprinter-sdbg-mcp-venv/bin/python", + "args": [ + "/ABS/PATH/C-Compiler/toolchain/sdbg_mcp.py", + "--socket", "/tmp/sprinter-sdbg-hello.sock" + ] +} +``` + +Замените `/ABS/PATH/C-Compiler` абсолютным путём к toolkit. MCP-сервер при +старте проверяет подключение; если VS Code ещё не запустил MAME или socket +устарел, он завершается с понятной ошибкой в stderr. После Stop запустите +MCP-сервер заново вместе с новой DAP-сессией. Альтернатива параметру +`--socket` — переменная окружения `SDBG_SOCKET`. + +Доступные инструменты: `session_status`, `where`, `read_registers`, +`read_memory`, `list_variables`, `read_variable`, `recent_events`, +`mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`, +`clear_breakpoint`, `clear_my_breakpoints`, `continue_execution`, +`pause_execution`, `step_instruction`, `step_source`. Ответы SDK 2.x содержат +`structuredContent`. `step_source` принимает `into`, `over` или `out` и +возвращает принятие команды; итоговую остановку получите через +`recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события +`output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`. + +Число 16 не означает удаление возможностей более нового `-plugin mamebridge`: +его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на +машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а +некоторые функции (C-позиция, typed global/static, журнал с generation) +в raw MCP вообще отсутствуют. Оставшиеся группы требуют отдельного контракта: + +| Группа raw MCP | Что требуется перед переносом в общую сессию | +|---|---| +| `setmem`, raw `debugger_command`, watchpoints | Проверка прав, банка, диапазона и согласование с DAP-точками; произвольная debugger-команда может нарушить состояние сессии | +| VRAM/shares, screen pixels, screenshot | Явные адресные пространства, лимиты, формат ответа и изолированный каталог снимков | +| Клавиатура, мышь, type/press | Один владелец ввода, корректное отпускание клавиш, ожидание running CPU и проверка прямого ввода с клавиатуры | +| Raw disassembly | Связать адрес/банк с проверенной C-картой; не выдавать физический адрес за logical | + +Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить +`mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять +CPU и точки без общего owner ID и журнала. + +Чтение памяти принимает десятичный адрес или `0xHEX`, 1–256 байт logical +Z80 memory, только при остановленном CPU, без side effects. Ответ содержит +generation и страницы банков; диапазон не может пересекать границу 64 КБ. +`read_variable` работает с проверенными global/static размером 1, 2 или 4 +байта, только когда нужный банк отображён. Локальные, стек, watchpoints, +запись переменных и произвольные выражения пока не поддерживаются. + +У каждой MCP-копии свой owner ID. Она может удалить только свои точки; при +обычном закрытии stdio они очищаются. Живой тест подтвердил, что попытка +удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP. +Удаление личной точки при running CPU отдельно проверено на ожидании +`getchar()`; оно не ставит CPU на паузу и не блокирует ввод. +Общий session server сериализует команды, но эксклюзивного владельца +управления CPU пока нет: не посылайте `continue`/`step` одновременно из MCP +и VS Code. При аварийном завершении MCP его точки могут остаться до конца +сессии MAME; нужна отдельная lease/cleanup-механика. Несколько одновременно +запущенных MAME с MCP не проверялись и не гарантируются; это отложенный тест. + +Проверка совместной работы с DAP и реальным MAME: + +```sh +MAME_HOME=/path/to/MAME/runtime pyenv exec python tests/sdbg/run_vscode_dap_probe.py \ + --socket /tmp/sprinter-sdbg-mcp-probe.sock \ + --mcp-python /tmp/sprinter-sdbg-mcp-venv/bin/python +``` diff --git a/docs/vscode-sprinter-debug.md b/docs/vscode-sprinter-debug.md index 4abaa5d..b590d21 100644 --- a/docs/vscode-sprinter-debug.md +++ b/docs/vscode-sprinter-debug.md @@ -151,7 +151,8 @@ Launcher монтирует временную копию CHD как `-hard2`, Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу Sprinter Build. После успешной сборки должно появиться окно Sprinter; launcher дождётся prompt DSS, введёт - `A:\\HELLO.EXE` и VS Code остановится в `main`. + `A:\\HELLO.EXE` и VS Code остановится в `main`. До этой остановки + не вводите символы в MAME: они смешаются с командой launcher в DSS. 4. Проверьте Call Stack, scope Registers и Debug Console. После Continue должна сработать подтверждённая точка строки 31. 5. На остановке проверьте F11 и F10. Курсор должен переходить только после @@ -168,6 +169,13 @@ launcher задаёт этому процессу `SDL_NO_SIGNAL_HANDLERS=1`, е не была задана вручную. Прямой `SIGTERM` и Stop проверены живыми пробниками на остановке в `main`; они не требуют отдельного патча MAME. +Для одновременного чтения C-сессии из MCP задайте в launch-профиле короткий +фиксированный `"socket": "/tmp/sprinter-sdbg-hello.sock"` и подключите +`toolchain/sdbg_mcp.py` после остановки в `main`. MCP использует тот же +session server; его личные точки не заменяют точки VS Code. Порядок запуска, +16 доступных инструментов и ограничения совместного управления описаны в +[руководстве по MCP](sdbg-mcp.md). + Команда палитры `Sprinter: Build Active Project` собирает приложение по Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в `Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию, diff --git a/tests/sdbg/run_mcp_sdk_probe.py b/tests/sdbg/run_mcp_sdk_probe.py new file mode 100644 index 0000000..0636c97 --- /dev/null +++ b/tests/sdbg/run_mcp_sdk_probe.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Живой MCP 2.x клиент: stdio → sdbg MCP → общая DAP-сессия.""" +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path +import sys + +from mcp import Client, StdioServerParameters + +ROOT = Path(__file__).resolve().parents[2] + + +async def probe(socket: str, source: str, foreign_id: int): + parameters = StdioServerParameters( + command=sys.executable, + args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--socket', socket]) + async with Client(parameters) as client: + listed = await client.list_tools() + names = {tool.name for tool in listed.tools} + expected = {'session_status', 'where', 'read_registers', 'read_memory', + 'recent_events', 'set_line_breakpoint', 'clear_breakpoint'} + if not expected <= names: + raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names)) + + async def call(name: str, arguments: dict | None = None): + result = await client.call_tool(name, arguments or {}) + if result.is_error: + raise RuntimeError(name + ': ' + str(result.content)) + if result.structured_content is None: + raise RuntimeError(name + ': нет structuredContent: '+repr(result.content)) + return result.structured_content + + status = await call('session_status') + location = await call('where') + registers = await call('read_registers') + memory = await call('read_memory', {'address': hex(location['pc']), 'length': 4}) + events = await call('recent_events', {'after': 0}) + if not status['build_id'] or location['status'] != 'mapped' or \ + registers['registers']['PC'] != location['pc'] or \ + len(memory['hex']) != 8 or not events['events']: + raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory))) + + own = await call('set_line_breakpoint', {'file': source, 'line': 62}) + foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id}) + if not foreign.is_error or 'не принадлежит' not in str(foreign.content): + raise RuntimeError('MCP не подтвердил защиту чужой точки VS Code') + print(json.dumps({'event': 'mcp_shared_session_verified', + 'build_id': status['build_id'], 'pc': location['pc'], + 'tools': len(names), 'foreign_point_rejected': True, + 'owned_point_for_cleanup': own['id']}, + ensure_ascii=False), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--socket', required=True) + parser.add_argument('--source', required=True) + parser.add_argument('--foreign-id', type=int, required=True) + args = parser.parse_args() + asyncio.run(probe(args.socket, args.source, args.foreign_id)) + + +if __name__ == '__main__': + main() diff --git a/tests/sdbg/run_vscode_dap_probe.py b/tests/sdbg/run_vscode_dap_probe.py index a06ddc2..ada9720 100644 --- a/tests/sdbg/run_vscode_dap_probe.py +++ b/tests/sdbg/run_vscode_dap_probe.py @@ -20,6 +20,7 @@ PYTHON = Path.home()/'.pyenv/shims/python' sys.path.insert(0, str(ROOT/'toolchain')) from mame_interactive import resolve from sdbg.server import rpc_call +from sdbg.session import SessionError def send(process, sequence, command, arguments=None): @@ -89,8 +90,14 @@ def wait_event(process, buffer, event, timeout): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--build', default=None) + parser.add_argument('--socket', default=None, + help='фиксированный Unix socket DAP/MCP-сеанса') parser.add_argument('--app-hdd', default=None) parser.add_argument('--launch-path', default=None) + parser.add_argument('--mcp-python', default=None, + help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP') + parser.add_argument('--clear-while-running', action='store_true', + help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key') parser.add_argument('--exit-while-stopped', action='store_true', help='аварийно завершить собственный MAME на main и ждать DAP terminated') parser.add_argument('--stop-while-stopped', action='store_true', @@ -111,6 +118,8 @@ def main(): _, buffer, _ = wait_response(process, buffer, 'initialize', 10) debugger = 'osx' if '--osx' in sys.argv else 'sdbg' launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30} + if options.socket: + launch['socket'] = options.socket if options.app_hdd: launch['appHdd'] = str(Path(options.app_hdd).resolve()) if options.launch_path: @@ -128,6 +137,48 @@ def main(): frame = frame_response['body']['stackFrames'][0] if frame['name'] != 'main': raise RuntimeError('Не main: '+str(frame)) + if options.mcp_python: + source = str(ROOT/'tests/hello/hello.c') + send(process, 5, 'setBreakpoints', { + 'source': {'path': source}, 'breakpoints': [{'line': 31}]}) + points, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10) + dap_point = points['body']['breakpoints'][0] + if not dap_point['verified']: + raise RuntimeError('DAP-точка строки 31 не подтверждена') + probe = subprocess.run([ + options.mcp_python, str(ROOT/'tests/sdbg/run_mcp_sdk_probe.py'), + '--socket', launched['body']['socket'], '--source', source, + '--foreign-id', str(dap_point['id'])], + capture_output=True, text=True, timeout=45, check=False) + if probe.returncode: + raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:]) + send(process, 6, 'continue') + _, buffer, _ = wait_response(process, buffer, 'continue', 10) + stopped, buffer = wait_event(process, buffer, 'stopped', 20) + if not stopped: + raise RuntimeError('После MCP DAP-точка строки 31 не сработала') + send(process, 7, 'stackTrace') + at_point, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) + if at_point['body']['stackFrames'][0]['line'] != 31: + raise RuntimeError('После MCP ожидалась DAP-точка строки 31') + send(process, 8, 'setBreakpoints', { + 'source': {'path': source}, 'breakpoints': []}) + _, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10) + send(process, 9, 'continue') + _, buffer, _ = wait_response(process, buffer, 'continue', 10) + stale, buffer = wait_event(process, buffer, 'stopped', 3) + if stale: + raise RuntimeError('Точка MCP осталась после закрытия stdio-клиента') + print(probe.stdout.strip(), flush=True) + send(process, 10, 'disconnect') + wait_response(process, buffer, 'disconnect', 10) + if options.socket: + deadline = time.monotonic()+10 + while Path(options.socket).exists() and time.monotonic() < deadline: + time.sleep(.1) + if Path(options.socket).exists(): + raise RuntimeError('После DAP disconnect остался Unix socket сессии') + return 0 if options.stop_while_stopped: mame_pid = launched['body']['mamePid'] send(process, 5, 'disconnect') @@ -202,12 +253,29 @@ def main(): before = at_getchar['body']['stackFrames'][0] if before['line'] != 62: raise RuntimeError('Остановка не на getchar: '+str(before)) + socket_path = launched['body']['socket'] + clear_point = None + if options.clear_while_running: + clear_point = rpc_call(socket_path, 'break_line', { + 'file': str(ROOT/'tests/hello/hello.c'), 'line': 63, + 'owner': 'probe:running-clear'}) started = time.monotonic() send(process, 8, 'next') _, buffer, _ = wait_response(process, buffer, 'next', 3) response_ms = round((time.monotonic()-started)*1000, 2) + if clear_point: + snapshot = rpc_call(socket_path, 'snapshot', timeout=5) + if snapshot['state'] != 'running': + raise RuntimeError('CPU не выполняется перед очисткой точки') + try: + rpc_call(socket_path, 'read_memory', {'address': 0, 'length': 1}) + raise RuntimeError('Чтение памяти разрешено при running CPU') + except SessionError as error: + if 'остановленного CPU' not in str(error): + raise + rpc_call(socket_path, 'clear_breakpoint', { + 'id': clear_point['id'], 'owner': 'probe:running-clear'}) if '--emulated-key' in sys.argv: - socket_path = launched['body']['socket'] first = rpc_call(socket_path, 'snapshot', timeout=5) time.sleep(1) second = rpc_call(socket_path, 'snapshot', timeout=5) @@ -236,7 +304,8 @@ def main(): 'at': before, 'after': after, 'ascii': ascii_code, 'emulated_time_advanced': second['time']-first['time'], - 'next_response_ms': response_ms}, + 'next_response_ms': response_ms, + 'running_clear_verified': bool(clear_point)}, ensure_ascii=False), flush=True) send(process, 10, 'disconnect') wait_response(process, buffer, 'disconnect', 10) diff --git a/tests/sdbg/test_mcp_adapter.py b/tests/sdbg/test_mcp_adapter.py new file mode 100644 index 0000000..a9ac6d0 --- /dev/null +++ b/tests/sdbg/test_mcp_adapter.py @@ -0,0 +1,52 @@ +"""MCP передаёт команды одной сессии и не удаляет чужие точки.""" +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'toolchain')) + +from sdbg.mcp_adapter import McpSession +from sdbg.session import SessionError + + +class McpAdapterTests(unittest.TestCase): + def setUp(self): + self.calls = [] + + def rpc(path, method, arguments, timeout=10): + self.calls.append((path, method, arguments, timeout)) + return {'method': method, 'arguments': arguments} + + self.client = McpSession('/tmp/sprinter-test.sock', rpc=rpc) + + def test_personal_breakpoints_are_tagged_and_cleared_by_owner(self): + self.client.break_line('/src/main.c', 12) + self.client.break_function('main') + self.client.clear_breakpoint(7) + self.client.clear_owned_breakpoints() + self.assertTrue(self.client.owner.startswith('mcp:')) + for _, _, arguments, _ in self.calls: + self.assertEqual(arguments['owner'], self.client.owner) + self.assertEqual([item[1] for item in self.calls], + ['break_line', 'break_function', 'clear_breakpoint', + 'clear_owned_breakpoints']) + + def test_memory_and_events_validate_before_rpc(self): + self.client.read_memory('0xc000', 16) + self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16}) + self.client.events(after=4, timeout=12) + self.assertEqual(self.calls[-1][1:], + ('events', {'after': 4, 'timeout': 12}, 14)) + before = len(self.calls) + with self.assertRaisesRegex(SessionError, 'Адрес памяти'): + self.client.read_memory('not-an-address') + with self.assertRaisesRegex(SessionError, 'timeout'): + self.client.events(timeout=31) + with self.assertRaisesRegex(SessionError, 'kind'): + self.client.step_source('back') + self.assertEqual(len(self.calls), before) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py index 1f14255..02d939b 100644 --- a/tests/sdbg/test_server.py +++ b/tests/sdbg/test_server.py @@ -4,6 +4,7 @@ import sys import tempfile import threading import unittest +from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / 'toolchain')) @@ -26,6 +27,7 @@ class DummyBridge: self.calls.append((command, arguments)) if command == 'snapshot': return {'state': self.state} if command == 'console_print': return {'printed': True} + if command == 'memory': return {'hex': '00' * arguments['length']} raise AssertionError(command) @@ -44,13 +46,20 @@ class DummySession: return {'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100, 'sources': [{'file': '/src/main.c', 'line': 3}]} + def refresh(self): + return SimpleNamespace(generation=1, bank_pages={}) + def break_line(self, filename, line, enabled=True): if line == 99: raise SessionError('нет адреса') identifier = self.next_id self.next_id += 1 return {'id': identifier, 'backend_ids': [identifier], - 'conditions': [], 'locations': [{'line': line}], 'enabled': enabled} + 'conditions': [], 'locations': [{'line': line, 'link_address': 0x8100}], + 'enabled': enabled} + + def break_function(self, name, enabled=True): + return self.break_line('/src/main.c', 3, enabled=enabled) def clear_breakpoint(self, identifier): self.cleared.append(identifier) @@ -189,6 +198,39 @@ class ServerTests(unittest.TestCase): server.close() controller.close() + def test_individual_breakpoints_keep_owner_and_source_step_priority(self): + session = DummySession() + controller = SessionController(session) + try: + mine = controller.call('break_line', + {'file': '/src/main.c', 'line': 3, 'owner': 'mcp:a'}) + other = controller.call('break_function', {'name': 'main', 'owner': 'mcp:b'}) + self.assertTrue(controller._has_stop_breakpoint(session.where())) + with self.assertRaisesRegex(SessionError, 'не принадлежит'): + controller.call('clear_breakpoint', {'id': other['id'], 'owner': 'mcp:a'}) + self.assertEqual(controller.call('clear_owned_breakpoints', {'owner': 'mcp:a'}), + {'cleared': [mine['id']]}) + self.assertEqual(session.cleared, [mine['id']]) + self.assertIn(other['id'], controller.breakpoint_info) + controller.call('clear_breakpoint', {'id': other['id'], 'owner': 'mcp:b'}) + self.assertFalse(controller.breakpoint_info) + finally: + controller.close() + + def test_read_memory_is_bounded_and_generation_tied(self): + session = DummySession() + controller = SessionController(session) + try: + result = controller.call('read_memory', {'address': 0xfffe, 'length': 2}) + self.assertEqual(result, {'address': 0xfffe, 'length': 2, 'hex': '0000', + 'generation': 1, 'bank_pages': {}}) + with self.assertRaisesRegex(SessionError, 'за 64 КБ'): + controller.call('read_memory', {'address': 0xffff, 'length': 2}) + with self.assertRaisesRegex(SessionError, 'длина 1..256'): + controller.call('read_memory', {'address': 0, 'length': 257}) + finally: + controller.close() + def test_idle_snapshot_detects_invalidation(self): session = DummySession() controller = SessionController(session) diff --git a/tests/sdbg/test_session.py b/tests/sdbg/test_session.py index 1d0d7fb..eae4abb 100644 --- a/tests/sdbg/test_session.py +++ b/tests/sdbg/test_session.py @@ -127,6 +127,25 @@ class SessionTests(unittest.TestCase): bridge.calls) session.clear_breakpoint(result['id']) + def test_partial_breakpoint_clear_retains_remaining_backend_id(self): + class FlakyBridge(FakeBridge): + failed = False + + def request(self, command, **args): + if command == 'clear' and args['id'] == 11 and not self.failed: + self.failed = True + raise SessionError('временная ошибка удаления') + return super().request(command, **args) + + bridge = FlakyBridge(self.memory) + session = DebugSession(self.model, bridge) + session.breakpoints[1] = [10, 11] + with self.assertRaisesRegex(SessionError, 'временная ошибка'): + session.clear_breakpoint(1) + self.assertEqual(session.breakpoints[1], [11]) + self.assertEqual(session.clear_breakpoint(1)['backend_ids'], [11]) + self.assertNotIn(1, session.breakpoints) + def test_stale_source_and_unmapped_bank_are_rejected(self): session = DebugSession(self.model, FakeBridge(self.memory)) session.attach() diff --git a/toolchain/mcp/sdbgbridge/init.lua b/toolchain/mcp/sdbgbridge/init.lua index df12228..1c1b450 100644 --- a/toolchain/mcp/sdbgbridge/init.lua +++ b/toolchain/mcp/sdbgbridge/init.lua @@ -192,7 +192,8 @@ function exports.startplugin() end return {disabled=count} elseif command=="clear" then - stopped(request) + -- Удаление точки безопасно и при running: callback исполняется на + -- потоке MAME, а MCP должен очищать свои точки при закрытии stdio. local id=number(args.id,0x7fffffff) assert(owned[id],"Точка не принадлежит sdbg") cpu().debug:bpclear(id);owned[id]=nil diff --git a/toolchain/requirements-mcp.txt b/toolchain/requirements-mcp.txt new file mode 100644 index 0000000..1e993a5 --- /dev/null +++ b/toolchain/requirements-mcp.txt @@ -0,0 +1,2 @@ +# Необязательный stdio MCP-адаптер; основная сборка toolkit не зависит от SDK. +mcp>=2.2,<3 diff --git a/toolchain/sdbg/mcp_adapter.py b/toolchain/sdbg/mcp_adapter.py new file mode 100644 index 0000000..549cd5a --- /dev/null +++ b/toolchain/sdbg/mcp_adapter.py @@ -0,0 +1,77 @@ +"""Тонкий MCP-клиент общей source-debug сессии без зависимости от MCP SDK.""" +from __future__ import annotations + +from pathlib import Path +import uuid + +from .server import rpc_call +from .session import SessionError + + +class McpSession: + def __init__(self, socket_path: str, rpc=rpc_call): + self.socket_path = str(Path(socket_path).expanduser()) + self.owner = 'mcp:' + uuid.uuid4().hex + self._rpc = rpc + + def call(self, method: str, arguments: dict | None = None, timeout: float = 10): + return self._rpc(self.socket_path, method, arguments or {}, timeout=timeout) + + def status(self): + return self.call('status') + + def where(self): + return self.call('where') + + def registers(self): + return self.call('registers') + + def read_memory(self, address: str, length: int = 16): + try: + parsed = int(address, 16 if address.lower().startswith('0x') else 10) + except ValueError as error: + raise SessionError('Адрес памяти должен быть десятичным или 0xHEX') from error + return self.call('read_memory', {'address': parsed, 'length': length}) + + def variables(self): + return self.call('variables') + + def read_variable(self, name: str, module: str | None = None): + return self.call('read_variable', {'name': name, 'module': module}) + + def events(self, after: int = 0, timeout: float = 0): + if after < 0 or timeout < 0 or timeout > 30: + raise SessionError('after должен быть неотрицательным, timeout — от 0 до 30 с') + return self.call('events', {'after': after, 'timeout': timeout}, + timeout=max(10, timeout + 2)) + + def console_tail(self, count: int = 40): + if count < 1 or count > 200: + raise SessionError('count должен быть от 1 до 200') + return self.call('mame_console_tail', {'count': count}) + + def break_line(self, file: str, line: int): + return self.call('break_line', {'file': file, 'line': line, 'owner': self.owner}) + + def break_function(self, name: str): + return self.call('break_function', {'name': name, 'owner': self.owner}) + + def clear_breakpoint(self, identifier: int): + return self.call('clear_breakpoint', {'id': identifier, 'owner': self.owner}) + + def clear_owned_breakpoints(self): + return self.call('clear_owned_breakpoints', {'owner': self.owner}) + + def continue_execution(self): + return self.call('continue') + + def pause_execution(self): + return self.call('pause') + + def step_instruction(self): + return self.call('step') + + def step_source(self, kind: str = 'into'): + if kind not in ('into', 'over', 'out'): + raise SessionError('kind должен быть into, over или out') + return self.call('source_step', {'kind': kind}) diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py index 1899d44..981b957 100644 --- a/toolchain/sdbg/server.py +++ b/toolchain/sdbg/server.py @@ -116,6 +116,13 @@ class SessionController: self.poller.join(timeout=1) self.session.bridge.close() + @staticmethod + def _owner(arguments: dict) -> str: + owner = arguments.get('owner', 'cli') + if not isinstance(owner, str) or not owner or len(owner) > 128: + raise SessionError('owner должен быть непустой строкой до 128 символов') + return owner + def call(self, method: str, arguments: dict) -> object: if method == 'events': after = int(arguments.get('after', 0)) @@ -131,6 +138,9 @@ class SessionController: with self.lock: if self.closed: raise SessionError('Сессия закрыта или инвалидирована') + if self.running and method in ('where', 'registers', 'read_memory', + 'read_variable', 'break_line', 'break_function'): + raise SessionError('Операция требует остановленного CPU; сначала Pause') if method == 'status': return {**self.attached, 'running': self.running, 'event_sequence': self.sequence} @@ -151,16 +161,52 @@ class SessionController: mapping = self.session.refresh() return {'generation': mapping.generation, 'registers': mapping.registers, 'bank_pages': mapping.bank_pages} + if method == 'read_memory': + address = int(arguments['address']) + length = int(arguments['length']) + if address < 0 or address > 0xffff or length < 1 or length > 256 or \ + address + length > 0x10000: + raise SessionError('Чтение памяти: адрес 0..65535, длина 1..256 без выхода за 64 КБ') + mapping = self.session.refresh() + data = self.session.bridge.request('memory', address=address, length=length) + return {'address': address, 'length': length, 'hex': data['hex'], + 'generation': mapping.generation, 'bank_pages': mapping.bank_pages} if method == 'variables': return self.session.model.variables if method == 'read_variable': return self.session.read_variable(arguments['name'], arguments.get('module')) if method == 'break_line': - return self.session.break_line(arguments['file'], int(arguments['line'])) + owner = self._owner(arguments) + item = self.session.break_line(arguments['file'], int(arguments['line'])) + self.breakpoint_info[item['id']] = { + 'kind': 'stop', 'message': None, 'locations': item['locations'], + 'hits': 0, 'owner': owner} + return item if method == 'break_function': - return self.session.break_function(arguments['name']) + owner = self._owner(arguments) + item = self.session.break_function(arguments['name']) + self.breakpoint_info[item['id']] = { + 'kind': 'stop', 'message': None, 'locations': item['locations'], + 'hits': 0, 'owner': owner} + return item if method == 'clear_breakpoint': - return self.session.clear_breakpoint(int(arguments['id'])) + identifier = int(arguments['id']) + owner = arguments.get('owner') + if owner is not None and self.breakpoint_info.get(identifier, {}).get('owner') != owner: + raise SessionError('Точка не принадлежит этому клиенту') + result = self.session.clear_breakpoint(identifier) + self.breakpoint_info.pop(identifier, None) + return result + if method == 'clear_owned_breakpoints': + owner = arguments.get('owner') + if not isinstance(owner, str) or not owner: + raise SessionError('Для очистки точек требуется owner') + identifiers = [identifier for identifier, info in self.breakpoint_info.items() + if info.get('owner') == owner] + for identifier in identifiers: + self.session.clear_breakpoint(identifier) + self.breakpoint_info.pop(identifier, None) + return {'cleared': identifiers} if method == 'set_source_breakpoints': specifications = arguments.get('breakpoints') if specifications is None: diff --git a/toolchain/sdbg/session.py b/toolchain/sdbg/session.py index 531f2e8..907f047 100644 --- a/toolchain/sdbg/session.py +++ b/toolchain/sdbg/session.py @@ -246,12 +246,15 @@ class DebugSession: return self.bridge.request('deactivate_breakpoints') def clear_breakpoint(self, identifier: int) -> dict: - ids = self.breakpoints.pop(identifier, None) + ids = self.breakpoints.get(identifier) if ids is None: raise SessionError('Неизвестная логическая точка остановки') - for backend_id in ids: + backend_ids = list(ids) + for backend_id in backend_ids: self.bridge.request('clear', id=backend_id) - return {'cleared': identifier, 'backend_ids': ids} + ids.remove(backend_id) + self.breakpoints.pop(identifier) + return {'cleared': identifier, 'backend_ids': backend_ids} def read_variable(self, name: str, module: str | None = None) -> dict: matches = [variable for variable in self.model.variables diff --git a/toolchain/sdbg_launcher.py b/toolchain/sdbg_launcher.py index c4d85e3..2f79493 100644 --- a/toolchain/sdbg_launcher.py +++ b/toolchain/sdbg_launcher.py @@ -251,6 +251,7 @@ def main(): mame_process=subprocess.Popen(command,cwd=state,env=environment, stdout=log,stderr=subprocess.STDOUT) server=None + socket_identity=None def mame_diagnostics(): log.flush() output=(state/'mame.log').read_text(errors='replace')[-4000:].strip() @@ -261,8 +262,13 @@ def main(): if mame_process.poll() is not None: raise RuntimeError('MAME завершился до main; лог: '+ str(state/'mame.log')+mame_diagnostics()) - if (state/'main.json.error').exists() or time.monotonic()>deadline: - raise RuntimeError('Таймаут ожидания main; лог: '+ + error_file=state/'main.json.error' + if error_file.exists(): + raise RuntimeError('MAME не дошёл до main: '+ + error_file.read_text(errors='replace').strip()+ + '; лог: '+str(state/'mame.log')+mame_diagnostics()) + if time.monotonic()>deadline: + raise RuntimeError('Таймаут ожидания main без ответа Lua; лог: '+ str(state/'mame.log')+mame_diagnostics()) time.sleep(.05) entry=json.loads(ready.read_text()) @@ -273,6 +279,8 @@ def main(): if not line: raise RuntimeError('Session server не запустился'+mame_diagnostics()) server_ready=json.loads(line) + socket_state=Path(args.socket).lstat() + socket_identity=(socket_state.st_dev,socket_state.st_ino) print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid, 'entry':entry,'build_id':server_ready['build_id']}, ensure_ascii=False),flush=True) @@ -284,6 +292,14 @@ def main(): server.terminate() try: server.wait(timeout=3) except subprocess.TimeoutExpired: server.kill();server.wait() + if socket_identity is not None: + socket_path=Path(args.socket) + try: + current=socket_path.lstat() + if (current.st_dev,current.st_ino)==socket_identity: + socket_path.unlink() + except FileNotFoundError: + pass if mame_process.poll() is None: mame_process.terminate() try: mame_process.wait(timeout=5) diff --git a/toolchain/sdbg_mcp.py b/toolchain/sdbg_mcp.py new file mode 100644 index 0000000..a2bb943 --- /dev/null +++ b/toolchain/sdbg_mcp.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""stdio MCP-адаптер к единственной source-debug сессии Sprinter.""" +from __future__ import annotations + +import argparse +from functools import wraps +import os +import sys +from typing import Any + +from sdbg.mcp_adapter import McpSession +from sdbg.session import SessionError + + +def make_server(client: McpSession): + try: + from mcp.server import MCPServer + from mcp.server.mcpserver.exceptions import ToolError + from mcp.types import ToolAnnotations + except ImportError as error: + raise RuntimeError('Нужен официальный MCP SDK 2.x: python -m pip install "mcp>=2.2,<3"') from error + + server = MCPServer('sprinter-source-debug', + instructions='Все команды работают через одну sdbg-сессию. ' + 'Чтение переменных и памяти требует остановленного CPU. ' + 'Для длинного шага используйте recent_events или pause_execution.') + reading = ToolAnnotations(readOnlyHint=True, openWorldHint=False) + + def tool(*, annotations=None): + def register(function): + @wraps(function) + def guarded(*args, **kwargs): + try: + return function(*args, **kwargs) + except (SessionError, OSError, ValueError) as error: + raise ToolError(str(error)) from error + return server.tool(annotations=annotations)(guarded) + return register + + @tool(annotations=reading) + def session_status() -> dict[str, Any]: + """Получить build ID, generation, состояние CPU и ограничения текущей сессии.""" + return client.status() + + @tool(annotations=reading) + def where() -> dict[str, Any]: + """Получить проверенное соответствие PC исходнику C и текущему банку.""" + return client.where() + + @tool(annotations=reading) + def read_registers() -> dict[str, Any]: + """Читать регистры Z80 и отображение банков на остановленном CPU.""" + return client.registers() + + @tool(annotations=reading) + def read_memory(address: str, length: int = 16) -> dict[str, Any]: + """Читать 1..256 байт logical Z80 memory без side effects; адрес decimal или 0xHEX.""" + return client.read_memory(address, length) + + @tool(annotations=reading) + def list_variables() -> dict[str, Any]: + """Перечислить известные global/static переменные и поддержанность типов.""" + return {'variables': client.variables()} + + @tool(annotations=reading) + def read_variable(name: str, module: str | None = None) -> dict[str, Any]: + """Читать поддержанную global/static переменную; module разрешает неоднозначность.""" + return client.read_variable(name, module) + + @tool(annotations=reading) + def recent_events(after: int = 0, timeout: float = 0) -> dict[str, Any]: + """Читать общий журнал stopped/continued/output с курсором seq; timeout 0..30 с.""" + return client.events(after, timeout) + + @tool(annotations=reading) + def mame_console_tail(count: int = 40) -> dict[str, Any]: + """Получить последние строки консоли MAME, если bridge поддерживает её чтение.""" + return client.console_tail(count) + + @tool() + def set_line_breakpoint(file: str, line: int) -> dict[str, Any]: + """Поставить личную точку по C-файлу и строке без замены точек VS Code.""" + return client.break_line(file, line) + + @tool() + def set_function_breakpoint(name: str) -> dict[str, Any]: + """Поставить личную точку по имени C-функции.""" + return client.break_function(name) + + @tool() + def clear_breakpoint(identifier: int) -> dict[str, Any]: + """Удалить свою логическую точку по ID; чужие точки отклоняются.""" + return client.clear_breakpoint(identifier) + + @tool() + def clear_my_breakpoints() -> dict[str, Any]: + """Удалить только точки текущего MCP-подключения.""" + return client.clear_owned_breakpoints() + + @tool() + def continue_execution() -> dict[str, Any]: + """Продолжить CPU; событие остановки читать через recent_events.""" + return client.continue_execution() + + @tool() + def pause_execution() -> dict[str, Any]: + """Остановить CPU и вернуть текущую C-позицию.""" + return client.pause_execution() + + @tool() + def step_instruction() -> dict[str, Any]: + """Выполнить одну машинную инструкцию Z80 и вернуть текущую позицию.""" + return client.step_instruction() + + @tool() + def step_source(kind: str = 'into') -> dict[str, Any]: + """Начать асинхронный C-шаг: into, over или out; результат в recent_events.""" + return client.step_source(kind) + + return server + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--socket', default=os.environ.get('SDBG_SOCKET'), + help='Unix socket уже запущенного sdbg_server.py (или SDBG_SOCKET)') + args = parser.parse_args() + if not args.socket: + parser.error('укажите --socket или SDBG_SOCKET') + client = McpSession(args.socket) + try: + client.status() # Не публикуем инструменты для отсутствующей сессии. + server = make_server(client) + server.run(transport='stdio') + return 0 + except (SessionError, OSError, RuntimeError) as error: + print('sdbg-mcp: ' + str(error), file=sys.stderr) + return 1 + finally: + try: + client.clear_owned_breakpoints() + except (SessionError, OSError): + pass + + +if __name__ == '__main__': + sys.exit(main())