Add shared-session MCP source debugger adapter
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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