Skip to content

Add a command

Changing. Follow: #285, #579

A plugin adds a command by registering its nodes and writing a handler, which is a Bevy system. A node can also require a permission level.

Commands live in a typed command tree.1 Plugins register nodes into the tree, and the server sends each player the part of the tree that the player’s level allows. If you know Paper, the nearest thing is its Commands API with a node tree. Nothing here corresponds to plugin.yml and onCommand. The examples come from the repository test commands.rs, and we did not run them outside the repository tests.

add_command is a method on the app. It takes a builder, which adds the nodes of the command, and the handler, which runs the command.

The signature of add_command
pub trait AppCommandsExt {
/// Registers a command in the tree of the set `A`: `build` adds its nodes and returns the
/// handles its handler reads, and every dimension world runs `handler` when one of its
/// branches runs. Nothing is registered when `build` returns an error.
fn add_command<A, B, C, S, M>(
&mut self,
build: impl FnOnce(&mut CommandRegistrar<'_, A, B>) -> Result<C, BuildError>,
handler: S,
) -> Result<&mut Self, RegistrationError>
where
A: CommandSet,
B: Copy + PartialEq + Send + Sync + 'static,
C: Copy + Send + Sync + 'static,
S: IntoSystem<In<Call<B, C>>, CommandResult, M> + Clone + Send + Sync + 'static,
M: 'static;

crates/mcrs_minecraft_command_engine/src/app_commands.rs:39-53

Call it after CommandPlugin is in the app and before the app finishes. MinecraftServerPlugins already adds that plugin for the vanilla command set.

The test below builds an echo command with one integer argument:

Test: the nodes of an echo command with one integer argument
#[derive(Clone, Copy)]
struct Echo {
times: ArgumentHandle<IntegerArgument>,
}
fn echo_at(
name: &'static str,
) -> impl FnOnce(&mut CommandRegistrar<'_, VanillaCommands, ()>) -> Result<Echo, BuildError> {
move |r| {
let echo = r
.literal(NodeId::ROOT, name)
.requires(PermissionCheck::Require(CommandLevel::Gamemasters))
.id()?;
let times = r
.arg(
echo,
"times",
IntegerArgument {
min: 1,
..IntegerArgument::ANY
},
)
.runs(())
.id()?;
Ok(Echo { times })
}
}

crates/mcrs_minecraft_server/tests/it/commands.rs:51-77

The builder gets a CommandRegistrar. Starting from NodeId::ROOT, it adds a literal node with literal or an argument node with arg under a parent node. A node that ends a command line calls runs(...). The builder returns the handles of the arguments, and the handler uses them later to read the values.

The same file registers the command with app.add_command(echo_at("echo"), run_echo).unwrap();. The call returns a Result, because a node can clash with the tree. When that happens you get an error and nothing of the command is added, as the test at the end of the file shows.

A node asks for a level with requires(PermissionCheck::Require(level)). There are five ordered levels, and a source passes when it is at the required level or above.

CommandLevel
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CommandLevel {
#[default]
All,
Moderators,
Gamemasters,
Admins,
Owners,
}

crates/mcrs_minecraft_command/src/permission.rs:5-13

A player below the level of a node does not see the node in the tree and cannot run it. The level of a player comes from the operator list or from the default level of the server.

mcrs has no string permission nodes like myplugin.use. A permission system with groups is not part of mcrs today, and the plan for the command tree does not describe one.2

The handler of the echo command looks like this:

Test: the handler of the echo command
fn run_echo(
In(call): In<Call<(), Echo>>,
context: VanillaContext,
mut feedback: CommandOutput,
) -> CommandResult {
let command = call.command;
let times = context.arg(&call.invocation, command.times)?;
feedback.success(
&call.invocation,
format!("echo {times}").into_text(),
Feedback::Source,
);
Ok(times)
}

crates/mcrs_minecraft_server/tests/it/commands.rs:79-92

The first parameter of a handler is In<Call<...>>, and every other parameter is a normal system parameter. The handler returns a CommandResult, which is either the number the command reports on success or a failure. It sends text through CommandOutput, and Feedback says who hears that text: only the source, or the source and all operators.

A handler does not change a player directly, because the player can be in another dimension, which the handler cannot reach. The handler writes an effect through CommandOutput::effect, and the world applies the effects after the call, in the order they were written. Register the effect type with add_command_effect. This is how teleport and gamemode change players in other dimensions. The reasons are in docs/command-dimensions.md and on the page Dimensions.

You can also stop a command before it runs. The server raises CommandEvent in the world of the source dimension before it runs a command line. An observer, which is a function that Bevy calls when the event fires, can set cancelled, and then the command does not run. This is the command side of the planned public, cancellable player events.3

Next: Intercept chat.

  1. Works#579 Adds the typed command tree, the vanilla commands and the client chat screen. ↩

  2. Partial#285 Tracks the command tree with permission levels and the commands packet. ↩ ↩2 ↩3

  3. Partial#283 Tracks public player events that a plugin can cancel. ↩

Sources and verification

Verified: 26.4-snapshot-7, 3540f457b, 2026-11-10

Tracking: #285, #579