Expand shared Sprinter MCP with managed launch, raw reads and key input
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP сам запускает hello, затем DAP подключается к тому же MAME."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from mcp import Client, StdioServerParameters
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from run_vscode_dap_probe import send, wait_response # noqa: E402
|
||||
|
||||
|
||||
async def probe() -> None:
|
||||
home = Path(os.environ['MAME_HOME']).resolve()
|
||||
binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve()
|
||||
package = ROOT / 'tests/hello/.sprinter-cc-hello'
|
||||
with tempfile.TemporaryDirectory(prefix='sprinter-managed-mcp-', dir='/tmp') as temp:
|
||||
socket = str(Path(temp) / 'session.sock')
|
||||
parameters = StdioServerParameters(
|
||||
command=sys.executable,
|
||||
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
|
||||
'--socket', socket, '--mame-home', str(home),
|
||||
'--mame-bin', str(binary)])
|
||||
async with Client(parameters) as client:
|
||||
async def call(name: str, arguments: dict | None = None) -> dict:
|
||||
result = await client.call_tool(name, arguments or {})
|
||||
if result.is_error or result.structured_content is None:
|
||||
raise RuntimeError(name + ': ' + repr(result.content))
|
||||
return result.structured_content
|
||||
|
||||
names = {tool.name for tool in (await client.list_tools()).tools}
|
||||
if not {'start_session', 'stop_session', 'where', 'read_variable',
|
||||
'press_key'} <= names:
|
||||
raise RuntimeError('Нет инструментов автономного запуска')
|
||||
initial = await call('session_status')
|
||||
if initial['phase'] != 'idle':
|
||||
raise RuntimeError('Ожидался idle: ' + repr(initial))
|
||||
started_at = time.monotonic()
|
||||
accepted = await call('start_session')
|
||||
start_ms = round((time.monotonic() - started_at) * 1000, 1)
|
||||
if not accepted['accepted'] or start_ms > 3000:
|
||||
raise RuntimeError('MCP start_session не ответил быстро: ' + repr(accepted))
|
||||
duplicate = await client.call_tool('start_session', {})
|
||||
if not duplicate.is_error or 'уже запущена' not in str(duplicate.content):
|
||||
raise RuntimeError('Повторный start_session не был отклонён')
|
||||
mame_pid = None
|
||||
try:
|
||||
deadline = time.monotonic() + 85
|
||||
while time.monotonic() < deadline:
|
||||
status = await call('session_status')
|
||||
if status['phase'] == 'ready':
|
||||
break
|
||||
if status['phase'] in ('failed', 'stopped'):
|
||||
raise RuntimeError('Launcher: ' + repr(status))
|
||||
await asyncio.sleep(.2)
|
||||
else:
|
||||
raise TimeoutError('MCP session не дошла до main')
|
||||
mame_pid = status['mame_pid']
|
||||
location = await call('where')
|
||||
if location.get('function', {}).get('name') != 'main':
|
||||
raise RuntimeError('MCP не остановился в main: ' + repr(location))
|
||||
await call('read_variable', {'name': 'errno'})
|
||||
shot = await call('screenshot')
|
||||
if Path(shot['path']).read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
raise RuntimeError('MCP не создал PNG')
|
||||
|
||||
dap = subprocess.Popen([sys.executable, str(ROOT / 'toolchain/sdbg_dap.py')],
|
||||
cwd=ROOT, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
buffer = b''
|
||||
try:
|
||||
send(dap, 1, 'initialize')
|
||||
_, buffer, _ = wait_response(dap, buffer, 'initialize', 10)
|
||||
send(dap, 2, 'attach', {'socket': socket})
|
||||
_, buffer, _ = wait_response(dap, buffer, 'attach', 10)
|
||||
send(dap, 3, 'configurationDone')
|
||||
_, buffer, _ = wait_response(dap, buffer, 'configurationDone', 10)
|
||||
send(dap, 4, 'stackTrace')
|
||||
frame, buffer, _ = wait_response(dap, buffer, 'stackTrace', 10)
|
||||
if frame['body']['stackFrames'][0]['name'] != 'main':
|
||||
raise RuntimeError('DAP attach не увидел main')
|
||||
send(dap, 5, 'disconnect')
|
||||
wait_response(dap, buffer, 'disconnect', 10)
|
||||
finally:
|
||||
if dap.poll() is None:
|
||||
dap.terminate()
|
||||
dap.communicate(timeout=8)
|
||||
|
||||
after_dap = await call('session_status')
|
||||
if after_dap['phase'] != 'ready' or after_dap['session_id'] != status['session_id']:
|
||||
raise RuntimeError('DAP disconnect завершил MCP-owned MAME')
|
||||
source = str(ROOT / 'tests/hello/hello.c')
|
||||
before = await call('set_line_breakpoint', {'file': source, 'line': 62})
|
||||
await call('continue_execution')
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
at_getchar = await call('session_status')
|
||||
if not at_getchar['running']:
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('MCP не остановился перед getchar')
|
||||
location = await call('where')
|
||||
if not any(item['line'] == 62 for item in location['sources']):
|
||||
raise RuntimeError('MCP остановился не перед getchar: ' + repr(location))
|
||||
await call('clear_breakpoint', {'identifier': before['id']})
|
||||
after = await call('set_line_breakpoint', {'file': source, 'line': 63})
|
||||
await call('continue_execution')
|
||||
await asyncio.sleep(.4)
|
||||
key = await call('press_key', {'key': 'x', 'frames': 3})
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
after_key = await call('session_status')
|
||||
if not after_key['running']:
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('MCP press_key не завершил getchar')
|
||||
location = await call('where')
|
||||
if not any(item['line'] == 63 for item in location['sources']):
|
||||
raise RuntimeError('После клавиши ожидалась строка 63: ' + repr(location))
|
||||
await call('clear_breakpoint', {'identifier': after['id']})
|
||||
print(json.dumps({'event': 'mcp_launch_dap_attach_verified',
|
||||
'tools': len(names), 'start_ms': start_ms,
|
||||
'session_id': status['session_id'],
|
||||
'socket': socket, 'pc': location['pc'],
|
||||
'key': key['key'], 'after_getchar_line': 63},
|
||||
ensure_ascii=False), flush=True)
|
||||
finally:
|
||||
await call('stop_session')
|
||||
deadline = time.monotonic() + 12
|
||||
while time.monotonic() < deadline:
|
||||
ended = await call('session_status')
|
||||
if ended['phase'] == 'stopped':
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise RuntimeError('stop_session не завершил launcher')
|
||||
if Path(socket).exists():
|
||||
raise RuntimeError('После stop_session остался Unix socket')
|
||||
if mame_pid is not None:
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
os.kill(mame_pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise RuntimeError('После stop_session остался процесс MAME')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(probe())
|
||||
Reference in New Issue
Block a user