Launcher MCP: reach a game started from its window with Agent Control on

The launcher's game_* tools only looked at the endpoint in its data folder,
so a game the player started, which listens on the game's default endpoint,
answered "not running under agent control". Each call now goes to the first
endpoint where a game answers, the launcher's own first, restarting the
horizon-ctl child when that changes: an installed horizon-ctl keeps the
endpoint it started with.

Whether a game answers is now tested on Windows too, with the endpoint's port
and token, rather than by the file existing: a crashed game leaves the file.

Fixes #9465

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Mayonesu0andClaude Opus 5.5 committed 2026-11-05 01:39:00 +01:00
1 parent 7298c80f8f
commit 7975ff7942
4 files changed
+307 -29

No files matched your search

+19 -8
View File
@@ -42,13 +42,20 @@ An agent adds one server, `horizon-launcher mcp`. It holds:
- `account_sign_in`, `account_list`.
- **The game's tools.** It runs the selected version's `horizon-ctl mcp` (shipped beside the
game) as a child. It lists that server's tools as `game_<name>`, all but `launch`, and passes
calls and results through, images included. `play` starts the game with a control socket in
the launcher's data folder. While no game answers, `game_*` calls say to call `play`.
calls and results through, images included. `play` starts the game with a control endpoint in
the launcher's data folder. A game the player started and turned Agent Control on in answers on
the game's default endpoint ([Connecting](#connecting)). Each call goes to the first of the two
where a game answers, the launcher's first, so a game from `play` wins over one from the window.
While no game answers on either, `game_*` calls say to call `play` or to turn Agent Control on.
The default endpoint is worked out in the launcher's own process, so `$HORIZON_CONTROL`, and on
Linux `$XDG_RUNTIME_DIR`, must be the same for `horizon-launcher mcp` as for the game.
Why one server rather than two: the game's tools stay defined once, in horizon-ctl's table. The
agent's client needs one entry and one approval. The version that plays is always the one the
launcher selected, so the game's tools always match the game. `horizon-ctl mcp` stays available
on its own, for a game started by hand or by a script.
agent's client needs one entry and one approval, whichever way the game was started. The tools
are always those of the version the launcher selected, even for a game of another version: a
command that game does not know answers with an error, and `game_hello` tells which game it is.
`horizon-ctl mcp` stays available on its own, for a game started by hand or by a script.
## Protocol revisions
@@ -185,12 +192,16 @@ turn the switch off.
- `horizon-launcher agent-config [--client ID] [--json] [--test]` prints the same thing.
- **Game, Horizon Settings > AI Agent:**
- **Agent Control** (`agentControl` in horizon-extras.txt, off by default) opens the default
endpoint (`$HORIZON_CONTROL`, else `horizon-control.sock` in the runtime directory) in a game
the player started. Turning it off closes the endpoint and stops its listener.
endpoint (`$HORIZON_CONTROL`, else `horizon-control.sock` in the runtime directory, or
`horizon-control.json` in the temporary directory on Windows) in a game the player started.
`horizon-launcher mcp` reaches it as it is, with no second server. Turning it off closes the
endpoint and stops its listener.
- The page shows whether a client is connected (`Server::clients`), when the last command came,
and the socket's name.
- **Copy Connection Details** copies the socket path, a `horizon-ctl --socket ... state` line
and an `mcpServers` config for this game's `horizon-ctl mcp`.
and an `mcpServers` config for this game's `horizon-ctl mcp`. That config points into the
version's folder, so it stops working once the launcher removes that version; an agent that
already has `horizon-launcher mcp` does not need it.
- **The default:** a game an agent starts (`play` in MCP, `horizon-ctl launch`, `--control`) is
under control. A game the player starts from the window is not, until they turn Agent Control
on. An open socket lets any program of that user drive the game, so it opens only when an agent
+219 -12
View File
@@ -1,12 +1,16 @@
//! The running game's tools in the launcher's MCP, so an agent needs one server
//! (docs/agent-control.md): the selected version's `horizon-ctl mcp` runs as a child, its tools
//! are offered as `game_<name>`, and calls go to it unchanged. `play` starts the game
//! with the control socket that child talks to.
//! are offered as `game_<name>`, and calls go to it unchanged. `play` starts the game with
//! the launcher's own control endpoint; a game the player started with Agent Control on answers
//! on the game's default endpoint instead. The child talks to whichever of them answers.
use horizon_launcher::backend;
use serde_json::{json, Value};
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Stdio};
use std::time::Duration;
pub const PREFIX: &str = "game_";
@@ -37,16 +41,80 @@ pub fn control_args() -> Vec<String> {
]
}
/// Whether a game answers on the control endpoint (a crashed game's socket refuses).
pub fn listening() -> bool {
let path = socket_path();
/// The endpoint a game listens on when nothing names one, which is where Agent Control opens it.
/// The launcher runs whichever version is installed, so this is a contract with every game
/// rather than shared code: `control::default_path` in horizon-client, and `horizon-ctl help`.
fn default_endpoint(
control: Option<OsString>,
runtime: Option<OsString>,
temp: PathBuf,
) -> PathBuf {
if let Some(path) = control {
return path.into();
}
if !cfg!(unix) {
return temp.join("horizon-control.json");
}
runtime
.map_or(temp, PathBuf::from)
.join("horizon-control.sock")
}
/// The endpoint to talk to: the game `play` started, else the game answering on the default
/// endpoint. With neither it stays the launcher's, the one `play` will open.
fn choose_endpoint(own: PathBuf, default: PathBuf) -> PathBuf {
if !answers(&own) && answers(&default) {
default
} else {
own
}
}
fn current_endpoint() -> PathBuf {
choose_endpoint(
socket_path(),
default_endpoint(
std::env::var_os("HORIZON_CONTROL"),
std::env::var_os("XDG_RUNTIME_DIR"),
std::env::temp_dir(),
),
)
}
/// Whether a game answers on an endpoint. The file alone says nothing: a crashed game leaves it.
fn answers(path: &Path) -> bool {
#[cfg(unix)]
return std::os::unix::net::UnixStream::connect(path).is_ok();
#[cfg(not(unix))]
return path.exists();
if path.extension().is_none_or(|e| e != "json") {
return std::os::unix::net::UnixStream::connect(path).is_ok();
}
tcp_answers(path).unwrap_or(false)
}
/// The file names a localhost port and its token (`control::connect` in horizon-client): a game
/// accepts that token, and whatever took over the port of a game long gone does not.
fn tcp_answers(path: &Path) -> Option<bool> {
let endpoint: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
let port = u16::try_from(endpoint["port"].as_u64()?).ok()?;
let token = endpoint["token"].as_str()?;
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let stream = TcpStream::connect_timeout(&address, Duration::from_millis(500)).ok()?;
stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
writeln!(&stream, "auth {token}").ok()?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply).ok()?;
Some(reply.contains("\"ok\""))
}
/// Whether the game `play` started answers yet.
pub fn listening() -> bool {
answers(&socket_path())
}
pub struct GameTools {
/// The installed version this `horizon-ctl` belongs to.
tag: String,
/// The endpoint it talks to.
endpoint: PathBuf,
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
@@ -57,13 +125,17 @@ pub struct GameTools {
impl GameTools {
/// Start `horizon-ctl mcp` of an installed version and read its tools.
pub fn start(tag: &str) -> Result<Self, String> {
Self::start_on(tag, current_endpoint())
}
fn start_on(tag: &str, endpoint: PathBuf) -> Result<Self, String> {
let ctl = backend::version_dir(tag).join(CTL);
if !ctl.exists() {
return Err(format!("{tag} has no {CTL}"));
}
let mut child = horizon_launcher::process::command(&ctl)
.arg("--socket")
.arg(socket_path())
.arg(&endpoint)
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -73,6 +145,8 @@ impl GameTools {
let stdin = child.stdin.take().ok_or("no stdin")?;
let stdout = BufReader::new(child.stdout.take().ok_or("no stdout")?);
let mut tools = Self {
tag: tag.to_owned(),
endpoint,
child,
stdin,
stdout,
@@ -97,9 +171,15 @@ impl GameTools {
&self.tools
}
/// Call a `game_` tool; the result is horizon-ctl's (text and image content).
/// Call a `game_` tool; the result is horizon-ctl's (text and image content). The game can
/// start, stop or turn Agent Control on between two calls, and an installed horizon-ctl keeps
/// the endpoint it started with, so another endpoint means another child.
pub fn call(&mut self, name: &str, args: &Value) -> Result<Value, String> {
let inner = name.strip_prefix(PREFIX).ok_or("not a game tool")?;
let endpoint = current_endpoint();
if endpoint != self.endpoint {
*self = Self::start_on(&self.tag, endpoint)?;
}
let result = self.rpc("tools/call", json!({ "name": inner, "arguments": args }))?;
Ok(explain_unreachable(result))
}
@@ -175,7 +255,7 @@ fn explain_unreachable(mut result: Value) -> Value {
});
if unreachable {
result["content"] = json!([{ "type": "text", "text": json!({
"error": "The game is not running under agent control. Start it with play, then game_wait_ready.",
"error": "The game is not running under agent control. Start it with play, then game_wait_ready, or ask the player to turn on Agent Control in the game they started (Options > Horizon Settings > AI Agent).",
"kind": "state",
}).to_string() }]);
}
@@ -185,6 +265,129 @@ fn explain_unreachable(mut result: Value) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
/// A folder for one test's endpoint files.
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-game-endpoint-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_endpoint(path: &Path, port: u16, token: &str) {
let endpoint = json!({ "transport": "tcp", "port": port, "token": token });
std::fs::write(path, endpoint.to_string()).unwrap();
}
/// An endpoint a game listens on: the game's half of the token exchange.
fn live_endpoint(path: &Path) {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
write_endpoint(path, listener.local_addr().unwrap().port(), "secret");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut line = String::new();
let _ = BufReader::new(&stream).read_line(&mut line);
let reply = if line.trim() == "auth secret" {
r#"{"ok":true}"#
} else {
r#"{"error":"bad or missing token: the first line must be auth TOKEN"}"#
};
let _ = writeln!(&stream, "{reply}");
}
});
}
/// What a crashed game leaves: the file, and nothing on its port.
fn stale_endpoint(path: &Path) {
let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.unwrap()
.local_addr()
.unwrap()
.port();
write_endpoint(path, port, "secret");
}
#[test]
fn a_game_the_launcher_started_comes_before_the_default_endpoint() {
let dir = scratch("both");
let (own, default) = (dir.join("game-control.json"), dir.join("default.json"));
live_endpoint(&own);
live_endpoint(&default);
assert_eq!(choose_endpoint(own.clone(), default), own);
let _ = std::fs::remove_dir_all(dir);
}
/// #9465: a game started from its window, with Agent Control turned on.
#[test]
fn with_no_game_from_the_launcher_the_default_endpoint_is_used() {
let dir = scratch("fallback");
let (own, default) = (dir.join("game-control.json"), dir.join("default.json"));
live_endpoint(&default);
assert_eq!(choose_endpoint(own.clone(), default.clone()), default);
// A crashed game's file on the launcher's endpoint hides nothing.
stale_endpoint(&own);
assert_eq!(choose_endpoint(own, default.clone()), default);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn with_no_game_answering_the_launchers_endpoint_stays() {
let dir = scratch("none");
let (own, default) = (dir.join("game-control.json"), dir.join("default.json"));
assert_eq!(choose_endpoint(own.clone(), default.clone()), own);
stale_endpoint(&default);
assert_eq!(choose_endpoint(own.clone(), default), own);
let _ = std::fs::remove_dir_all(dir);
}
/// A program that took over the port of a game long gone is not a game, and neither is a
/// file that names no port.
#[test]
fn an_endpoint_that_refuses_its_token_does_not_answer() {
let dir = scratch("refused");
let refused = dir.join("refused.json");
live_endpoint(&refused);
let port = serde_json::from_str::<Value>(&std::fs::read_to_string(&refused).unwrap())
.unwrap()["port"]
.as_u64()
.unwrap();
assert!(answers(&refused));
write_endpoint(&refused, port as u16, "another");
assert!(!answers(&refused));
let broken = dir.join("broken.json");
std::fs::write(&broken, "{}").unwrap();
assert!(!answers(&broken));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_default_endpoint_is_the_games() {
let temp = PathBuf::from("temp");
let runtime = || Some(OsString::from("run"));
assert_eq!(
default_endpoint(Some("mine.sock".into()), runtime(), temp.clone()),
PathBuf::from("mine.sock")
);
if cfg!(unix) {
assert_eq!(
default_endpoint(None, runtime(), temp.clone()),
Path::new("run").join("horizon-control.sock")
);
assert_eq!(
default_endpoint(None, None, temp.clone()),
temp.join("horizon-control.sock")
);
} else {
assert_eq!(
default_endpoint(None, runtime(), temp.clone()),
temp.join("horizon-control.json")
);
}
}
#[test]
fn offers_the_game_tools_prefixed_without_launch() {
@@ -212,6 +415,10 @@ mod tests {
.as_str()
.unwrap()
.contains("with play"));
assert!(result["content"][0]["text"]
.as_str()
.unwrap()
.contains("Agent Control"));
let other = json!({ "content": [{ "type": "text", "text": "{\"error\":\"no world\"}" }], "isError": true });
assert_eq!(explain_unreachable(other.clone()), other);
}
+1 -1
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
use std::time::Duration;
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...";
const INSTRUCTIONS: &str = "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, or the player turned on Agent Control in a game they started, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...";
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
+68 -8
View File
@@ -3,9 +3,10 @@
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: every CLI command is listed as a tool of the same
name (versions, settings, mods_search...), a destructive one needs confirm, the game tools are
listed, a call before the game runs says to start it, play starts it under control, and
client then talks to `horizon-launcher mcp` alone. It first plays a game started from its window with
Agent Control on, and a game on the launcher's own endpoint wins over it. Then every CLI command is
listed as a tool of the same name (versions, settings, mods_search...), a destructive one needs confirm,
the game tools are listed, a call before the game runs says to start it, play starts it under control, and
game_wait_ready, game_state, game_ui and game_screenshot play it until game_quit. Then No AI is turned on with
`horizon-launcher no-ai on`: `horizon-launcher mcp` refuses to start, and a session already running
refuses its next call.
@@ -23,6 +24,7 @@ import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
try:
@@ -40,8 +42,9 @@ class Failed(Exception):
class Mcp:
def __init__(self, launcher, env):
self.env = env
self.proc = subprocess.Popen([str(launcher), 'mcp'], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True)
stdout=subprocess.PIPE, text=True, encoding='utf-8')
self.next_id = 0
def rpc(self, method, params):
@@ -112,6 +115,56 @@ def scenario(m, out):
return {'tools': len(tools)}
def state_until(m, screen):
"""game_state, until the game that answers is on this screen (a game that quit takes a moment to go)."""
deadline = time.time() + 300
while True:
result, texts, _ = m.call('game_state')
if not result['isError'] and json.loads(texts[-1])['screen'] == screen:
return
assert time.time() < deadline, f'no game on {screen}: {texts}'
time.sleep(1)
def window_game_scenario(m, client, resources, work):
"""A game the player started (no --control) with Agent Control on is reached on the default endpoint,
and a game on the launcher's own endpoint comes first while both run (#9465). That second game has the
arguments play gives it (game_tools::control_args), started here because play needs a signed-in owner."""
m.rpc('initialize', {'protocolVersion': '2025-06-18', 'capabilities': {},
'clientInfo': {'name': 'launcher_mcp_e2e', 'version': '1'}})
def start(home, *extra):
home.mkdir(exist_ok=True)
# A first launch shows the accessibility onboarding before the title.
(home / 'options.txt').write_text('onboardAccessibility:false\n')
args = [str(client), '--resources', str(resources), *extra]
if sys.platform == 'darwin':
args.append('--offscreen')
return subprocess.Popen(args, cwd=home, env=dict(m.env, HORIZON_HOME=str(home)),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
window_home = work / 'window-home'
window_home.mkdir()
(window_home / 'horizon-extras.txt').write_text('agentControl:true\n')
own = work / 'launcher' / ('game-control.sock' if os.name == 'posix' else 'game-control.json')
games = [start(window_home)]
try:
state_until(m, 'title')
games.append(start(work / 'launcher-game-home', '--control-socket', str(own), '--world', 'Agent', '--creative'))
state_until(m, 'playing')
m.call('game_quit')
# Its game gone, the session is back to the window's.
state_until(m, 'title')
m.call('game_quit')
for game in games:
game.wait(timeout=60)
finally:
for game in games:
if game.poll() is None:
game.kill()
return {'window_game': 'reached'}
REFUSAL = "AI features are off: turn off No AI in the launcher's settings"
@@ -163,14 +216,21 @@ def main():
work = Path(tempfile.mkdtemp(prefix='hzlm-'))
version = work / 'launcher/versions' / TAG
(version / '.local/horizon-client').mkdir(parents=True)
suffix = '.exe' if sys.platform == 'win32' else ''
for name in ['horizon-client', 'horizon-ctl']:
os.symlink(bins / name, version / name)
os.symlink(resources, version / '.local/horizon-client' / lab.VERSION)
# Linking a file on Windows needs Developer Mode or an administrator; a hard link needs neither.
link = os.link if sys.platform == 'win32' else os.symlink
link(bins / (name + suffix), version / (name + suffix))
lab.link_dir(resources, version / '.local/horizon-client' / lab.VERSION)
(version / '.installed').write_text('')
env = lab.game_env(HORIZON_LAUNCHER_HOME=work / 'launcher', HORIZON_HOME=work / 'home', HORIZON_TELEMETRY_URL='off')
(work / 'home').mkdir()
# The game's default endpoint, this run's: a player's game with Agent Control on stays out of it.
env = lab.game_env(HORIZON_LAUNCHER_HOME=work / 'launcher', HORIZON_HOME=work / 'home', HORIZON_TELEMETRY_URL='off',
HORIZON_CONTROL=work / 'default.sock')
m = Mcp(Path(args.launcher).resolve(), env)
try:
summary = scenario(m, out)
summary = window_game_scenario(m, bins / ('horizon-client' + suffix), resources, work)
summary.update(scenario(m, out))
summary.update(no_ai_scenario(Path(args.launcher).resolve(), env, work / 'home'))
print(f'[launcher-mcp] PASS: {summary}')
ok = True