Files
horizon-airdef/tools/launcher_mcp_e2e.py
T
Ludovic Marie b0abb62883 Launcher MCP: play the running game through game_* tools
An agent now needs one MCP server. horizon-launcher mcp runs the selected
version's horizon-ctl mcp as a child and offers its tools as game_<name>
(all but launch, which launch_client covers), passing calls and image
results through. launch_client starts the game with a control socket in
the launcher's data folder, takes world, creative and offscreen, and
returns once the socket answers. A game tool called while no game listens
says to call launch_client.

tools/launcher_mcp_e2e.py checks it end to end against this checkout's
builds in a scratch launcher home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:57 +02:00

135 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""End-to-end check of the launcher's MCP playing the game through its game_* tools.
A scratch launcher home gets one "installed" version made of this checkout's builds
(horizon-client and horizon-ctl) and the prepared resources in .local/horizon-client. An MCP
client then talks to `horizon-launcher mcp` alone: the game tools are listed, a call before the
game runs says to launch it, launch_client starts it under control, and game_wait_ready,
game_state, game_ui and game_screenshot play it until game_quit.
python3 tools/launcher_mcp_e2e.py --bin-dir target/fast --launcher launcher/target/debug/horizon-launcher
On macOS the game renders offscreen (it runs in a locked session or over SSH); on Linux it needs
a display (vscreen on the desktop). Games started here have no audio.
"""
import argparse
import base64
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TAG = 'v2099.1.1'
class Failed(Exception):
pass
class Mcp:
def __init__(self, launcher, env):
self.proc = subprocess.Popen([str(launcher), 'mcp'], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True)
self.next_id = 0
def rpc(self, method, params):
self.next_id += 1
self.proc.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': self.next_id, 'method': method,
'params': params}) + '\n')
self.proc.stdin.flush()
reply = json.loads(self.proc.stdout.readline())
if 'error' in reply:
raise Failed(f'{method}: {reply["error"]}')
return reply['result']
def call(self, tool, **args):
result = self.rpc('tools/call', {'name': tool, 'arguments': args})
texts = [c['text'] for c in result['content'] if c['type'] == 'text']
images = [c for c in result['content'] if c['type'] == 'image']
print(f'[launcher-mcp] {tool} {args} -> {(texts[-1] if texts else "")[:300]}')
return result, texts, images
def close(self):
self.proc.stdin.close()
self.proc.wait(timeout=60)
def scenario(m, out):
init = m.rpc('initialize', {'protocolVersion': '2025-06-18', 'capabilities': {},
'clientInfo': {'name': 'launcher_mcp_e2e', 'version': '1'}})
assert init['serverInfo']['name'] == 'horizon-launcher', init
tools = {t['name'] for t in m.rpc('tools/list', {})['tools']}
for name in ['launch_client', 'game_state', 'game_ui', 'game_click', 'game_screenshot']:
assert name in tools, f'{name} missing from {sorted(tools)}'
assert 'game_launch' not in tools, 'launch_client covers horizon-ctl launch'
result, texts, _ = m.call('game_state')
assert result['isError'] and 'launch_client' in texts[-1], texts
result, texts, _ = m.call('launch_client', world='Agent', creative=True,
offscreen=sys.platform == 'darwin')
assert not result['isError'], texts
result, texts, _ = m.call('game_wait_ready', seconds=300)
assert not result['isError'], texts
result, texts, _ = m.call('game_state')
state = json.loads(texts[-1])
assert state['screen'] == 'playing' and state['gamemode'] == 'creative', state
m.call('game_pause')
result, texts, _ = m.call('game_ui')
ids = {w['id'] for w in json.loads(texts[-1])['widgets']}
assert 'back-to-game' in ids, ids
result, texts, _ = m.call('game_click', id='back-to-game')
assert json.loads(texts[-1])['screen'] == 'playing', texts
result, _, images = m.call('game_screenshot')
assert images, 'no image'
(out / 'launcher-mcp.png').write_bytes(base64.b64decode(images[0]['data']))
m.call('game_quit')
return {'tools': len(tools)}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', required=True, help='horizon-client and horizon-ctl built from this checkout')
parser.add_argument('--launcher', required=True, help='a horizon-launcher binary')
parser.add_argument('--out', default=str(ROOT / '.local/launcher-mcp-e2e'), help='the screenshot goes here')
args = parser.parse_args()
bins = Path(args.bin_dir).resolve()
resources = (ROOT / '.local/horizon-client/26.3').resolve()
if not (resources / 'horizon-resources.json').exists():
sys.exit(f'no prepared resources in {resources}')
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix='hzlm-'))
version = work / 'launcher/versions' / TAG
(version / '.local/horizon-client').mkdir(parents=True)
for name in ['horizon-client', 'horizon-ctl']:
os.symlink(bins / name, version / name)
os.symlink(resources, version / '.local/horizon-client/26.3')
(version / '.installed').write_text('')
env = dict(os.environ, HORIZON_LAUNCHER_HOME=str(work / 'launcher'), HORIZON_HOME=str(work / 'home'),
HORIZON_NO_AUDIO='1')
m = Mcp(Path(args.launcher).resolve(), env)
try:
summary = scenario(m, out)
print(f'[launcher-mcp] PASS: {summary}')
ok = True
except (Failed, AssertionError, KeyError, json.JSONDecodeError) as e:
print(f'[launcher-mcp] FAIL: {e!r}')
ok = False
try:
m.call('game_quit')
except Exception:
pass
finally:
m.close()
shutil.rmtree(work, ignore_errors=True)
sys.exit(0 if ok else 1)
if __name__ == '__main__':
main()