Add shared-session MCP source debugger adapter
This commit is contained in:
@@ -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'))
|
||||
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)
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user