Add shared-session MCP source debugger adapter

This commit is contained in:
Александр Петров
2026-09-16 20:43:50 +03:00
parent addc00a0f3
commit 9ad5a018a2
19 changed files with 740 additions and 22 deletions
+67
View File
@@ -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()
+71 -2
View File
@@ -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)
+52
View File
@@ -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()
+43 -1
View File
@@ -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)
+19
View File
@@ -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()