horizon-ctl: describe the replies of hello and status (#8242)

The command table gains a reply field list per command. hello and status
carry theirs; the generated reference gets a Replies section and the MCP
tool list declares outputSchema (with structuredContent in the call result).
A test requires every command in the group to name its reply fields.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
HrafnaValkyrjaandClaude Sonnet 5.5 committed 2026-11-03 10:48:01 -05:00
1 parent a700c4d819
commit ad261e3ae4
4 files changed
+294 -3

No files matched your search

@@ -61,6 +61,122 @@ impl Group {
}
}
/// One field of a command's JSON reply. `ty` is a JSON type (`string`, `integer`, `number`,
/// `boolean`, `array`, `object`); `a|null` marks a field that is `null` when it does not apply.
#[derive(Clone, Copy)]
pub struct Field {
pub name: &'static str,
pub ty: &'static str,
pub help: &'static str,
}
const fn f(name: &'static str, ty: &'static str, help: &'static str) -> Field {
Field { name, ty, help }
}
/// `hello`: always these three fields.
const HELLO_REPLY: &[Field] = &[
f(
"protocol",
"string",
"the control protocol, MAJOR.MINOR.PATCH (docs/api/stability.md)",
),
f(
"release",
"string",
"the game's version (MCP serverInfo carries the same string)",
),
f(
"features",
"array",
"the command groups the game answers, each a string",
),
];
/// `status`: the top-level fields the reply always carries. It holds more (sounds,
/// projectiles, maps and the like), so a reader must ignore the keys it does not know.
const STATUS_REPLY: &[Field] = &[
f(
"screen",
"string",
"the open screen (playing, inventory, paused...)",
),
f(
"debug",
"object",
"the F3 overlay flags: overlay, hitboxes, chunk_borders, pause_menu, advanced_tooltips, pause_on_lost_focus, clipboard",
),
f("dimension", "string", "the dimension the player is in"),
f("creative", "boolean", "creative mode"),
f(
"player",
"object|null",
"position, velocity (blocks), on_ground, health, max_health, food; null without a player",
),
f("yaw", "number", "view yaw, degrees"),
f("pitch", "number", "view pitch, degrees"),
f(
"target",
"object|null",
"the block under the crosshair as pos and block; null when none",
),
f("selected", "integer", "the selected hotbar slot, 1 to 9"),
f(
"held",
"object|null",
"the held stack as item and count; null when empty",
),
f(
"offhand",
"object|null",
"the offhand stack as item, count and damage; null when empty",
),
f(
"keys",
"array",
"the keys the game holds down, each a string",
),
f("fps", "number", "frames per second"),
f("frames", "integer", "frames drawn so far"),
f("ticks", "integer", "game ticks so far"),
f("day_time", "integer", "the world's time of day, ticks"),
f("uptime_seconds", "number", "seconds since the game started"),
f("render_distance", "integer", "chunks"),
f("chunks", "integer", "chunks loaded"),
f("mobs", "integer", "mobs this side knows"),
f("block_entities", "integer", "block entities loaded"),
f(
"pending_chunks",
"integer",
"chunks still being generated, meshed, uploaded or received",
),
f(
"streaming",
"object",
"chunk streaming stages and their counts",
),
f(
"lod",
"object",
"far terrain: distances, tiles, quads and sampling progress",
),
f(
"renderer",
"object|null",
"GPU, driver, backend, size, draw counts and GPU memory; null without a renderer",
),
f(
"display",
"object|null",
"the monitor: width, height, scale, refresh_hz; null without a window",
),
f(
"server",
"object|null",
"the connected server's address, name and state; null when not connected",
),
];
pub struct Cmd {
pub name: &'static str,
/// What the game receives instead of `name` (aliases such as look-at-block).
@@ -75,6 +191,8 @@ pub struct Cmd {
pub mcp: bool,
/// Runs in horizon-ctl itself, not in the game.
pub local: bool,
/// The reply's fields; empty until the command's reply is described (`REPLY_GROUP`).
pub reply: &'static [Field],
}
const fn p(name: &'static str, ty: Ty, help: &'static str) -> Param {
@@ -167,6 +285,12 @@ pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
macro_rules! cmd {
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr, reply: $reply:expr) => {
Cmd {
reply: $reply,
..cmd!($name, $group, $usage, $summary, $details, [$($ex),*], $params, mcp: $mcp, local: $local)
}
};
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
Cmd {
name: $name,
@@ -179,6 +303,7 @@ macro_rules! cmd {
params: $params,
mcp: $mcp,
local: $local,
reply: &[],
}
};
(@wire) => { None };
@@ -216,10 +341,10 @@ pub const COMMANDS: &[Cmd] = &[
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
mcp: true, local: true),
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false, reply: STATUS_REPLY),
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
["horizon-ctl hello"], &[], mcp: true, local: false),
["horizon-ctl hello"], &[], mcp: true, local: false, reply: HELLO_REPLY),
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts. A build with the alloc-census feature adds heap_by_tag_mb: the live heap by startup step, timed span, chunk job and thread.", ["horizon-ctl memory"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
@@ -533,6 +658,34 @@ pub fn detail(c: &Cmd) -> String {
out
}
/// The commands whose replies are described so far, one group at a time: the test
/// requires each to name its fields.
#[cfg(test)]
pub const REPLY_GROUP: &[&str] = &["hello", "status"];
/// The JSON schema of a command's reply, or none while its reply is undescribed.
pub fn reply_schema(c: &Cmd) -> Option<Value> {
if c.reply.is_empty() {
return None;
}
let mut props = Map::new();
let mut required = Vec::new();
for fld in c.reply {
let types: Vec<&str> = fld.ty.split('|').collect();
let ty = if types.len() == 1 {
json!(types[0])
} else {
json!(types)
};
props.insert(
fld.name.to_owned(),
json!({ "type": ty, "description": fld.help }),
);
required.push(fld.name);
}
Some(json!({ "type": "object", "properties": props, "required": required }))
}
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
pub fn markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|");
@@ -597,6 +750,26 @@ pub fn markdown() -> String {
));
}
}
out.push_str(
"\n## Replies\n\nThe reply fields of the commands described so far (more follow, a group at a \
time). Each reply is one JSON object; a type ending in `null` may be `null` when the field \
does not apply. A reader ignores keys it does not know: replies may gain fields within a \
protocol line. The MCP tool list declares the same fields as `outputSchema`.\n",
);
for c in COMMANDS.iter().filter(|c| !c.reply.is_empty()) {
out.push_str(&format!(
"\n### `{}`\n\n| Field | Type | Meaning |\n| --- | --- | --- |\n",
c.name
));
for fld in c.reply {
out.push_str(&format!(
"| `{}` | {} | {} |\n",
fld.name,
fld.ty.replace('|', ", "),
cell(fld.help)
));
}
}
out
}
@@ -838,6 +1011,52 @@ mod tests {
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
}
#[test]
fn every_command_in_the_reply_group_names_its_reply_fields() {
for name in REPLY_GROUP {
let c = find(name).unwrap_or_else(|| panic!("{name} is not a command"));
assert!(!c.reply.is_empty(), "{name} names no reply fields");
let mut seen = std::collections::HashSet::new();
for fld in c.reply {
assert!(seen.insert(fld.name), "{name}: {} twice", fld.name);
assert!(!fld.help.is_empty(), "{name}.{} has no meaning", fld.name);
for t in fld.ty.split('|') {
assert!(
[
"string", "integer", "number", "boolean", "array", "object", "null"
]
.contains(&t),
"{name}.{}: unknown type {t}",
fld.name
);
}
}
assert_eq!(
reply_schema(c).unwrap()["required"]
.as_array()
.unwrap()
.len(),
c.reply.len()
);
}
// Nothing outside the group claims a reply the group list does not cover.
for c in COMMANDS.iter().filter(|c| !c.reply.is_empty()) {
assert!(
REPLY_GROUP.contains(&c.name),
"{} has fields but is not in REPLY_GROUP",
c.name
);
}
assert_eq!(
reply_schema(find("hello").unwrap()).unwrap()["properties"]["features"]["type"],
"array"
);
assert_eq!(
reply_schema(find("status").unwrap()).unwrap()["properties"]["player"]["type"],
json!(["object", "null"])
);
}
#[test]
fn the_generated_reference_is_up_to_date() {
let path = concat!(
@@ -30,6 +30,9 @@ fn tools() -> Vec<Value> {
description = "Capture the game window and return it as a PNG image.".into();
}
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
if let Some(out) = commands::reply_schema(c) {
tool["outputSchema"] = out;
}
if commands::DESTRUCTIVE.contains(&c.name) {
tool["annotations"] = json!({ "destructiveHint": true });
} else if commands::OPEN_WORLD.contains(&c.name) {
@@ -152,7 +155,20 @@ impl Session {
}
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let (content, error) = self.call(name, &args);
Ok(json!({ "content": content, "isError": error }))
let mut result = json!({ "content": content, "isError": error });
// A tool that declares an outputSchema answers with structuredContent too.
if !error
&& tools()
.iter()
.any(|t| t["name"] == name && t.get("outputSchema").is_some())
&& let Some(v) = result["content"][0]["text"]
.as_str()
.and_then(|t| serde_json::from_str::<Value>(t).ok())
.filter(Value::is_object)
{
result["structuredContent"] = v;
}
Ok(result)
}
_ => Err((-32601, format!("Method not found: {method}"))),
}
@@ -185,6 +201,16 @@ mod tests {
assert!(names.insert(t["name"].as_str().unwrap().to_owned()));
assert_eq!(t["inputSchema"]["type"], "object");
}
let by = |n: &str| list.iter().find(|t| t["name"] == n).unwrap();
assert_eq!(
by("hello")["outputSchema"]["required"],
json!(["protocol", "release", "features"])
);
assert_eq!(
by("status")["outputSchema"]["properties"]["screen"]["type"],
"string"
);
assert!(by("state").get("outputSchema").is_none());
for want in [
"launch",
"state",
+2
View File
@@ -44,6 +44,8 @@ API offers; the other commands the game's socket answers are test hooks and expe
- **Reply schemas.** Each command's reply described in the table (fields, types, units), so the reference documents
replies as well as arguments and MCP tools declare an output schema.
`hello` and `status` are described (the reference's "Replies" section; MCP `outputSchema` with `structuredContent`);
the other groups follow one at a time.
- **Events as a stream.** `events --since` polls today; a subscription that pushes events as they happen.
- **Test hooks apart.** The hooks only harnesses use move under their own prefix, out of the agent's tool list.
- **Compatibility tests.** A transcript per protocol version: commands and the reply keys each must keep.
+44
View File
@@ -149,3 +149,47 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| `record-list` | `record_list` | The recordings and repro bundles on disk | none |
| `record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]` | `record_replay` | Replay a recording, best effort | `name` (string): recording from record-list<br>`--stop` (boolean): stop the running replay<br>`--in-place` (boolean): feed the open world instead of opening the copy<br>`--keep-world` (boolean): leave the copied world in saves/<br>`--timeout` (integer): give up after this many seconds (default 300) |
| `repro-export [NAME] [PATH] [--no-world]` | `repro_export` | Zip a recording with the logs, crash report and status | `name` (string): recording from record-list (default the newest)<br>`path` (string): where to write the zip<br>`--no-world` (boolean): leave the world out |
## Replies
The reply fields of the commands described so far (more follow, a group at a time). Each reply is one JSON object; a type ending in `null` may be `null` when the field does not apply. A reader ignores keys it does not know: replies may gain fields within a protocol line. The MCP tool list declares the same fields as `outputSchema`.
### `status`
| Field | Type | Meaning |
| --- | --- | --- |
| `screen` | string | the open screen (playing, inventory, paused...) |
| `debug` | object | the F3 overlay flags: overlay, hitboxes, chunk_borders, pause_menu, advanced_tooltips, pause_on_lost_focus, clipboard |
| `dimension` | string | the dimension the player is in |
| `creative` | boolean | creative mode |
| `player` | object, null | position, velocity (blocks), on_ground, health, max_health, food; null without a player |
| `yaw` | number | view yaw, degrees |
| `pitch` | number | view pitch, degrees |
| `target` | object, null | the block under the crosshair as pos and block; null when none |
| `selected` | integer | the selected hotbar slot, 1 to 9 |
| `held` | object, null | the held stack as item and count; null when empty |
| `offhand` | object, null | the offhand stack as item, count and damage; null when empty |
| `keys` | array | the keys the game holds down, each a string |
| `fps` | number | frames per second |
| `frames` | integer | frames drawn so far |
| `ticks` | integer | game ticks so far |
| `day_time` | integer | the world's time of day, ticks |
| `uptime_seconds` | number | seconds since the game started |
| `render_distance` | integer | chunks |
| `chunks` | integer | chunks loaded |
| `mobs` | integer | mobs this side knows |
| `block_entities` | integer | block entities loaded |
| `pending_chunks` | integer | chunks still being generated, meshed, uploaded or received |
| `streaming` | object | chunk streaming stages and their counts |
| `lod` | object | far terrain: distances, tiles, quads and sampling progress |
| `renderer` | object, null | GPU, driver, backend, size, draw counts and GPU memory; null without a renderer |
| `display` | object, null | the monitor: width, height, scale, refresh_hz; null without a window |
| `server` | object, null | the connected server's address, name and state; null when not connected |
### `hello`
| Field | Type | Meaning |
| --- | --- | --- |
| `protocol` | string | the control protocol, MAJOR.MINOR.PATCH (docs/api/stability.md) |
| `release` | string | the game's version (MCP serverInfo carries the same string) |
| `features` | array | the command groups the game answers, each a string |