Add shared-session MCP source debugger adapter
This commit is contained in:
@@ -279,12 +279,39 @@ Quick wins:
|
|||||||
|
|
||||||
## Прочий backlog
|
## Прочий 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 (отдельная
|
- [ ] **Несколько одновременных экземпляров MAME и MCP bridge (отдельная
|
||||||
задача, позже).** Проверить два изолированных процесса с разными
|
задача, позже).** Проверить два изолированных процесса с разными
|
||||||
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
|
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
|
||||||
команду с PID/session ID, не допускать ответа от чужого процесса,
|
команду с PID/session ID, не допускать ответа от чужого процесса,
|
||||||
проверить сброс/exit и одновременные команды. До успешного end-to-end
|
проверить сброс/exit и одновременные команды. До успешного end-to-end
|
||||||
теста параллельная работа MAME через MCP не гарантируется.
|
теста параллельная работа 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
|
- [ ] factoring parse_argv из crt0/crt0_banked в общий argv.s
|
||||||
- [ ] `restore SP on EXIT` (паттерн z88dk +pps) — проверить нужность
|
- [ ] `restore SP on EXIT` (паттерн z88dk +pps) — проверить нужность
|
||||||
- [x] ~~CI: MAME с -aviwrite для screenshot-сравнения без человека~~ —
|
- [x] ~~CI: MAME с -aviwrite для screenshot-сравнения без человека~~ —
|
||||||
|
|||||||
@@ -484,7 +484,9 @@ global/static переменной, ненулевое значение озна
|
|||||||
[руководство по SDBG_LOG](sdbg-log-macros.md).
|
[руководство по SDBG_LOG](sdbg-log-macros.md).
|
||||||
Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них
|
Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них
|
||||||
недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console
|
недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console
|
||||||
при подключённой source-debug сессии; без неё макрос сам ничего не печатает.
|
при подключённой source-debug сессии, а MCP-клиент читает тот же журнал
|
||||||
|
через `recent_events` ([руководство по MCP](sdbg-mcp.md)); без сессии макрос
|
||||||
|
сам ничего не печатает.
|
||||||
|
|
||||||
## <sprinter.h> — платформа
|
## <sprinter.h> — платформа
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Дата: 2026-09-16. План: [mame-source-debug.md](mame-source-debug.md).
|
Дата: 2026-09-16. План: [mame-source-debug.md](mame-source-debug.md).
|
||||||
Реализованы сборка/карта, проверенный транспорт, базовая C-сессия,
|
Реализованы сборка/карта, проверенный транспорт, базовая 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 и расширенная отладка ещё не готовы.
|
безопасный restart и расширенная отладка ещё не готовы.
|
||||||
Этапы плана не считаются завершёнными целиком по одному успешному репро.
|
Этапы плана не считаются завершёнными целиком по одному успешному репро.
|
||||||
|
|
||||||
@@ -118,7 +119,21 @@
|
|||||||
копию системного HDD и отдельные state-каталоги, показывает окно Sprinter,
|
копию системного HDD и отдельные state-каталоги, показывает окно Sprinter,
|
||||||
ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`,
|
ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`,
|
||||||
проверяет сигнатуру entry и запускает session server. Закрытие DAP
|
проверяет сигнатуру 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 --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 --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 --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
|
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 не участвует |
|
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
|
||||||
| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются |
|
| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются |
|
||||||
|
|
||||||
Полный host-набор: 39 тестов (38 прошли, один Unix-socket тест пропущен только
|
Полный host-набор: 44 теста (43 прошли, один Unix-socket тест пропущен только
|
||||||
из-за запрета `bind` в sandbox). Тот же socket path проверен живым DAP-запуском.
|
из-за запрета `bind` в sandbox). Тот же socket path проверен живым DAP-запуском.
|
||||||
После разделения репозиториев установленный VSIX в изолированном профиле
|
После разделения репозиториев установленный VSIX в изолированном профиле
|
||||||
VS Code запустил сборку SprPoP и остановился в `src/sprpop.c:264` (`main`)
|
VS Code запустил сборку SprPoP и остановился в `src/sprpop.c:264` (`main`)
|
||||||
@@ -328,8 +345,9 @@ host-моста пока не готова: FileBridge использует `fcn
|
|||||||
|
|
||||||
Также startup debugscript с `g` может продолжить первое реальное попадание;
|
Также startup debugscript с `g` может продолжить первое реальное попадание;
|
||||||
живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot
|
живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot
|
||||||
на reset защищена от дублирования callback. Старый mamebridge параллельно
|
на reset защищена от дублирования callback. Raw `-plugin mamebridge`
|
||||||
с sdbgbridge не загружать: общая арбитрирующая сессия ещё не реализована.
|
параллельно с `sdbgbridge` не загружать: общая арбитрирующая сессия для
|
||||||
|
двух Lua-мостов ещё не реализована.
|
||||||
Два одновременно запущенных процесса MAME через MCP bridge не проверялись;
|
Два одновременно запущенных процесса MAME через MCP bridge не проверялись;
|
||||||
корректная маршрутизация команд между ними не гарантируется. Это отдельная
|
корректная маршрутизация команд между ними не гарантируется. Это отдельная
|
||||||
отложенная задача в [TODO.md](TODO.md).
|
отложенная задача в [TODO.md](TODO.md).
|
||||||
@@ -353,8 +371,12 @@ Crash reports в 19:22/19:23 относились к неподдержанно
|
|||||||
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
|
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
|
||||||
позже новых `mame.arm-*.ips` не появилось.
|
позже новых `mame.arm-*.ips` не появилось.
|
||||||
|
|
||||||
Далее по плану: сделать MCP-адаптер, безопасный restart и расширенные
|
Далее по плану: арбитраж управления CPU и lease/cleanup для MCP-точек при
|
||||||
выражения. VSIX уже упакован;
|
аварийном выходе клиента, затем безопасный restart и расширенные выражения.
|
||||||
|
Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с новым
|
||||||
|
`-plugin mamebridge` либо старым `mame_bridge.lua`) нельзя просто перенести: они должны
|
||||||
|
проходить через общий session server и получить проверенный контракт для
|
||||||
|
ввода, скриншотов, записи/watchpoints. VSIX уже упакован;
|
||||||
для IDE ещё нужны Run-команда, выбор профиля
|
для IDE ещё нужны Run-команда, выбор профиля
|
||||||
сборки/данных и расширенная диагностика assembler/linker.
|
сборки/данных и расширенная диагностика assembler/linker.
|
||||||
Базовый attach уже проверяет принадлежность resident/current-bank к build,
|
Базовый attach уже проверяет принадлежность resident/current-bank к build,
|
||||||
|
|||||||
@@ -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 и развитие
|
### 8.2 DAP MVP и развитие
|
||||||
|
|
||||||
MVP: initialize, attach, configurationDone, disconnect, setBreakpoints,
|
MVP: initialize, attach, configurationDone, disconnect, setBreakpoints,
|
||||||
@@ -751,6 +758,17 @@ SDCC. Добавить launch/restart/stopOnMain и упаковку VSIX. До
|
|||||||
без side effects, после проверки размера, доступного банка, границ и image
|
без side effects, после проверки размера, доступного банка, границ и image
|
||||||
identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный
|
identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный
|
||||||
pointee дают явный `unavailable`/truncated, а не неверное значение.
|
pointee дают явный `unavailable`/truncated, а не неверное значение.
|
||||||
|
- Исследовать временную блокировку физического ввода в MAME до запуска
|
||||||
|
отлаживаемого EXE: окно может получить фокус, пока пользователь вводит
|
||||||
|
текст в другой программе. По возможности сохранить фокус и ввод за этой
|
||||||
|
программой до запуска EXE. Автоматический ввод команды launcher в DSS
|
||||||
|
должен работать и при блокировке. В момент запуска EXE сразу открыть
|
||||||
|
прямой ввод, включая ожидание `getchar()`; восстановить исходное состояние
|
||||||
|
при Stop, ошибке и restart. Если источники ввода нельзя разделить штатно,
|
||||||
|
проверить управление фокусом или узкий patch MAME. Приёмка — живой тест
|
||||||
|
ранних нажатий, ввода в другое приложение, переключения окон и ввода после
|
||||||
|
запуска. Подробная задача
|
||||||
|
сохранена в [TODO.md](TODO.md).
|
||||||
|
|
||||||
**Выход:** доказательства и ограничения каждой функции опубликованы.
|
**Выход:** доказательства и ограничения каждой функции опубликованы.
|
||||||
Эвристика не выдаётся за стек, anchors — за гарантированную идентичность,
|
Эвристика не выдаётся за стек, anchors — за гарантированную идентичность,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
кода печати в приложении. Сообщение попадает в Debug Console VS Code и в
|
кода печати в приложении. Сообщение попадает в Debug Console VS Code и в
|
||||||
журнал debugger MAME. Отдельное окно MAME debugger видно при launch с
|
журнал debugger MAME. Отдельное окно MAME debugger видно при launch с
|
||||||
`"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME,
|
`"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME,
|
||||||
но не открывает штатное debugger-окно.
|
но не открывает штатное debugger-окно. Подключённый
|
||||||
|
[MCP-адаптер C-уровня](sdbg-mcp.md) читает те же записи `output` через
|
||||||
|
`recent_events(after=...)`; он не ставит второй набор макросных точек.
|
||||||
|
|
||||||
## Быстрый пример
|
## Быстрый пример
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -151,7 +151,8 @@ Launcher монтирует временную копию CHD как `-hard2`,
|
|||||||
Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу
|
Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу
|
||||||
Sprinter Build. После успешной сборки должно появиться окно Sprinter;
|
Sprinter Build. После успешной сборки должно появиться окно Sprinter;
|
||||||
launcher дождётся prompt DSS, введёт
|
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
|
4. Проверьте Call Stack, scope Registers и Debug Console. После Continue
|
||||||
должна сработать подтверждённая точка строки 31.
|
должна сработать подтверждённая точка строки 31.
|
||||||
5. На остановке проверьте F11 и F10. Курсор должен переходить только после
|
5. На остановке проверьте F11 и F10. Курсор должен переходить только после
|
||||||
@@ -168,6 +169,13 @@ launcher задаёт этому процессу `SDL_NO_SIGNAL_HANDLERS=1`, е
|
|||||||
не была задана вручную. Прямой `SIGTERM` и Stop проверены живыми пробниками
|
не была задана вручную. Прямой `SIGTERM` и Stop проверены живыми пробниками
|
||||||
на остановке в `main`; они не требуют отдельного патча MAME.
|
на остановке в `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` собирает приложение по
|
Команда палитры `Sprinter: Build Active Project` собирает приложение по
|
||||||
Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в
|
Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в
|
||||||
`Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию,
|
`Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -20,6 +20,7 @@ PYTHON = Path.home()/'.pyenv/shims/python'
|
|||||||
sys.path.insert(0, str(ROOT/'toolchain'))
|
sys.path.insert(0, str(ROOT/'toolchain'))
|
||||||
from mame_interactive import resolve
|
from mame_interactive import resolve
|
||||||
from sdbg.server import rpc_call
|
from sdbg.server import rpc_call
|
||||||
|
from sdbg.session import SessionError
|
||||||
|
|
||||||
|
|
||||||
def send(process, sequence, command, arguments=None):
|
def send(process, sequence, command, arguments=None):
|
||||||
@@ -89,8 +90,14 @@ def wait_event(process, buffer, event, timeout):
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--build', default=None)
|
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('--app-hdd', default=None)
|
||||||
parser.add_argument('--launch-path', 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',
|
parser.add_argument('--exit-while-stopped', action='store_true',
|
||||||
help='аварийно завершить собственный MAME на main и ждать DAP terminated')
|
help='аварийно завершить собственный MAME на main и ждать DAP terminated')
|
||||||
parser.add_argument('--stop-while-stopped', action='store_true',
|
parser.add_argument('--stop-while-stopped', action='store_true',
|
||||||
@@ -111,6 +118,8 @@ def main():
|
|||||||
_, buffer, _ = wait_response(process, buffer, 'initialize', 10)
|
_, buffer, _ = wait_response(process, buffer, 'initialize', 10)
|
||||||
debugger = 'osx' if '--osx' in sys.argv else 'sdbg'
|
debugger = 'osx' if '--osx' in sys.argv else 'sdbg'
|
||||||
launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30}
|
launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30}
|
||||||
|
if options.socket:
|
||||||
|
launch['socket'] = options.socket
|
||||||
if options.app_hdd:
|
if options.app_hdd:
|
||||||
launch['appHdd'] = str(Path(options.app_hdd).resolve())
|
launch['appHdd'] = str(Path(options.app_hdd).resolve())
|
||||||
if options.launch_path:
|
if options.launch_path:
|
||||||
@@ -128,6 +137,48 @@ def main():
|
|||||||
frame = frame_response['body']['stackFrames'][0]
|
frame = frame_response['body']['stackFrames'][0]
|
||||||
if frame['name'] != 'main':
|
if frame['name'] != 'main':
|
||||||
raise RuntimeError('Не main: '+str(frame))
|
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:
|
if options.stop_while_stopped:
|
||||||
mame_pid = launched['body']['mamePid']
|
mame_pid = launched['body']['mamePid']
|
||||||
send(process, 5, 'disconnect')
|
send(process, 5, 'disconnect')
|
||||||
@@ -202,12 +253,29 @@ def main():
|
|||||||
before = at_getchar['body']['stackFrames'][0]
|
before = at_getchar['body']['stackFrames'][0]
|
||||||
if before['line'] != 62:
|
if before['line'] != 62:
|
||||||
raise RuntimeError('Остановка не на getchar: '+str(before))
|
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()
|
started = time.monotonic()
|
||||||
send(process, 8, 'next')
|
send(process, 8, 'next')
|
||||||
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
||||||
response_ms = round((time.monotonic()-started)*1000, 2)
|
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:
|
if '--emulated-key' in sys.argv:
|
||||||
socket_path = launched['body']['socket']
|
|
||||||
first = rpc_call(socket_path, 'snapshot', timeout=5)
|
first = rpc_call(socket_path, 'snapshot', timeout=5)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
second = rpc_call(socket_path, 'snapshot', timeout=5)
|
second = rpc_call(socket_path, 'snapshot', timeout=5)
|
||||||
@@ -236,7 +304,8 @@ def main():
|
|||||||
'at': before, 'after': after,
|
'at': before, 'after': after,
|
||||||
'ascii': ascii_code,
|
'ascii': ascii_code,
|
||||||
'emulated_time_advanced': second['time']-first['time'],
|
'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)
|
ensure_ascii=False), flush=True)
|
||||||
send(process, 10, 'disconnect')
|
send(process, 10, 'disconnect')
|
||||||
wait_response(process, buffer, 'disconnect', 10)
|
wait_response(process, buffer, 'disconnect', 10)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -4,6 +4,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / 'toolchain'))
|
sys.path.insert(0, str(ROOT / 'toolchain'))
|
||||||
@@ -26,6 +27,7 @@ class DummyBridge:
|
|||||||
self.calls.append((command, arguments))
|
self.calls.append((command, arguments))
|
||||||
if command == 'snapshot': return {'state': self.state}
|
if command == 'snapshot': return {'state': self.state}
|
||||||
if command == 'console_print': return {'printed': True}
|
if command == 'console_print': return {'printed': True}
|
||||||
|
if command == 'memory': return {'hex': '00' * arguments['length']}
|
||||||
raise AssertionError(command)
|
raise AssertionError(command)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,13 +46,20 @@ class DummySession:
|
|||||||
return {'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100,
|
return {'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100,
|
||||||
'sources': [{'file': '/src/main.c', 'line': 3}]}
|
'sources': [{'file': '/src/main.c', 'line': 3}]}
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
return SimpleNamespace(generation=1, bank_pages={})
|
||||||
|
|
||||||
def break_line(self, filename, line, enabled=True):
|
def break_line(self, filename, line, enabled=True):
|
||||||
if line == 99:
|
if line == 99:
|
||||||
raise SessionError('нет адреса')
|
raise SessionError('нет адреса')
|
||||||
identifier = self.next_id
|
identifier = self.next_id
|
||||||
self.next_id += 1
|
self.next_id += 1
|
||||||
return {'id': identifier, 'backend_ids': [identifier],
|
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):
|
def clear_breakpoint(self, identifier):
|
||||||
self.cleared.append(identifier)
|
self.cleared.append(identifier)
|
||||||
@@ -189,6 +198,39 @@ class ServerTests(unittest.TestCase):
|
|||||||
server.close()
|
server.close()
|
||||||
controller.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):
|
def test_idle_snapshot_detects_invalidation(self):
|
||||||
session = DummySession()
|
session = DummySession()
|
||||||
controller = SessionController(session)
|
controller = SessionController(session)
|
||||||
|
|||||||
@@ -127,6 +127,25 @@ class SessionTests(unittest.TestCase):
|
|||||||
bridge.calls)
|
bridge.calls)
|
||||||
session.clear_breakpoint(result['id'])
|
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):
|
def test_stale_source_and_unmapped_bank_are_rejected(self):
|
||||||
session = DebugSession(self.model, FakeBridge(self.memory))
|
session = DebugSession(self.model, FakeBridge(self.memory))
|
||||||
session.attach()
|
session.attach()
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ function exports.startplugin()
|
|||||||
end
|
end
|
||||||
return {disabled=count}
|
return {disabled=count}
|
||||||
elseif command=="clear" then
|
elseif command=="clear" then
|
||||||
stopped(request)
|
-- Удаление точки безопасно и при running: callback исполняется на
|
||||||
|
-- потоке MAME, а MCP должен очищать свои точки при закрытии stdio.
|
||||||
local id=number(args.id,0x7fffffff)
|
local id=number(args.id,0x7fffffff)
|
||||||
assert(owned[id],"Точка не принадлежит sdbg")
|
assert(owned[id],"Точка не принадлежит sdbg")
|
||||||
cpu().debug:bpclear(id);owned[id]=nil
|
cpu().debug:bpclear(id);owned[id]=nil
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Необязательный stdio MCP-адаптер; основная сборка toolkit не зависит от SDK.
|
||||||
|
mcp>=2.2,<3
|
||||||
@@ -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})
|
||||||
@@ -116,6 +116,13 @@ class SessionController:
|
|||||||
self.poller.join(timeout=1)
|
self.poller.join(timeout=1)
|
||||||
self.session.bridge.close()
|
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:
|
def call(self, method: str, arguments: dict) -> object:
|
||||||
if method == 'events':
|
if method == 'events':
|
||||||
after = int(arguments.get('after', 0))
|
after = int(arguments.get('after', 0))
|
||||||
@@ -131,6 +138,9 @@ class SessionController:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
if self.closed:
|
if self.closed:
|
||||||
raise SessionError('Сессия закрыта или инвалидирована')
|
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':
|
if method == 'status':
|
||||||
return {**self.attached, 'running': self.running,
|
return {**self.attached, 'running': self.running,
|
||||||
'event_sequence': self.sequence}
|
'event_sequence': self.sequence}
|
||||||
@@ -151,16 +161,52 @@ class SessionController:
|
|||||||
mapping = self.session.refresh()
|
mapping = self.session.refresh()
|
||||||
return {'generation': mapping.generation, 'registers': mapping.registers,
|
return {'generation': mapping.generation, 'registers': mapping.registers,
|
||||||
'bank_pages': mapping.bank_pages}
|
'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':
|
if method == 'variables':
|
||||||
return self.session.model.variables
|
return self.session.model.variables
|
||||||
if method == 'read_variable':
|
if method == 'read_variable':
|
||||||
return self.session.read_variable(arguments['name'], arguments.get('module'))
|
return self.session.read_variable(arguments['name'], arguments.get('module'))
|
||||||
if method == 'break_line':
|
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':
|
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':
|
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':
|
if method == 'set_source_breakpoints':
|
||||||
specifications = arguments.get('breakpoints')
|
specifications = arguments.get('breakpoints')
|
||||||
if specifications is None:
|
if specifications is None:
|
||||||
|
|||||||
@@ -246,12 +246,15 @@ class DebugSession:
|
|||||||
return self.bridge.request('deactivate_breakpoints')
|
return self.bridge.request('deactivate_breakpoints')
|
||||||
|
|
||||||
def clear_breakpoint(self, identifier: int) -> dict:
|
def clear_breakpoint(self, identifier: int) -> dict:
|
||||||
ids = self.breakpoints.pop(identifier, None)
|
ids = self.breakpoints.get(identifier)
|
||||||
if ids is None:
|
if ids is None:
|
||||||
raise SessionError('Неизвестная логическая точка остановки')
|
raise SessionError('Неизвестная логическая точка остановки')
|
||||||
for backend_id in ids:
|
backend_ids = list(ids)
|
||||||
|
for backend_id in backend_ids:
|
||||||
self.bridge.request('clear', id=backend_id)
|
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:
|
def read_variable(self, name: str, module: str | None = None) -> dict:
|
||||||
matches = [variable for variable in self.model.variables
|
matches = [variable for variable in self.model.variables
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ def main():
|
|||||||
mame_process=subprocess.Popen(command,cwd=state,env=environment,
|
mame_process=subprocess.Popen(command,cwd=state,env=environment,
|
||||||
stdout=log,stderr=subprocess.STDOUT)
|
stdout=log,stderr=subprocess.STDOUT)
|
||||||
server=None
|
server=None
|
||||||
|
socket_identity=None
|
||||||
def mame_diagnostics():
|
def mame_diagnostics():
|
||||||
log.flush()
|
log.flush()
|
||||||
output=(state/'mame.log').read_text(errors='replace')[-4000:].strip()
|
output=(state/'mame.log').read_text(errors='replace')[-4000:].strip()
|
||||||
@@ -261,8 +262,13 @@ def main():
|
|||||||
if mame_process.poll() is not None:
|
if mame_process.poll() is not None:
|
||||||
raise RuntimeError('MAME завершился до main; лог: '+
|
raise RuntimeError('MAME завершился до main; лог: '+
|
||||||
str(state/'mame.log')+mame_diagnostics())
|
str(state/'mame.log')+mame_diagnostics())
|
||||||
if (state/'main.json.error').exists() or time.monotonic()>deadline:
|
error_file=state/'main.json.error'
|
||||||
raise RuntimeError('Таймаут ожидания main; лог: '+
|
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())
|
str(state/'mame.log')+mame_diagnostics())
|
||||||
time.sleep(.05)
|
time.sleep(.05)
|
||||||
entry=json.loads(ready.read_text())
|
entry=json.loads(ready.read_text())
|
||||||
@@ -273,6 +279,8 @@ def main():
|
|||||||
if not line:
|
if not line:
|
||||||
raise RuntimeError('Session server не запустился'+mame_diagnostics())
|
raise RuntimeError('Session server не запустился'+mame_diagnostics())
|
||||||
server_ready=json.loads(line)
|
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,
|
print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid,
|
||||||
'entry':entry,'build_id':server_ready['build_id']},
|
'entry':entry,'build_id':server_ready['build_id']},
|
||||||
ensure_ascii=False),flush=True)
|
ensure_ascii=False),flush=True)
|
||||||
@@ -284,6 +292,14 @@ def main():
|
|||||||
server.terminate()
|
server.terminate()
|
||||||
try: server.wait(timeout=3)
|
try: server.wait(timeout=3)
|
||||||
except subprocess.TimeoutExpired: server.kill();server.wait()
|
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:
|
if mame_process.poll() is None:
|
||||||
mame_process.terminate()
|
mame_process.terminate()
|
||||||
try: mame_process.wait(timeout=5)
|
try: mame_process.wait(timeout=5)
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user