2026 · actif
MineMCP : Interfaçage Asynchrone et Capture de Flux sur Bukkit
Un pont Model Context Protocol (MCP) pour Minecraft, permettant à des LLM d'exécuter des commandes et d'interagir avec le jeu en temps réel via JSON-RPC.

Overview
MineMCP est une implémentation native du Model Context Protocol (MCP) pour les serveurs Minecraft basés sur l'API Bukkit/Spigot. L'objectif de ce projet est de permettre à des agents IA ou des clients externes de communiquer avec le serveur, d'interroger son état et d'exécuter des actions de manière standardisée via un canal de communication JSON-RPC.
Créer un serveur MCP requiert généralement l'implémentation de transports stdio ou sse. Le défi majeur de ce projet a été de concevoir cette architecture réseau robuste au sein d'un plugin, et surtout de résoudre un problème d'ingénierie complexe : l'exécution de commandes Minecraft de manière asynchrone tout en capturant leur sortie texte.
1. Architecture réseau et Transport SSE (Server-Sent Events)
Pour éviter de bloquer le serveur Minecraft, j'ai implémenté le serveur MCP en utilisant Javalin, un framework web léger. Les clients MCP modernes nécessitent un transport HTTP avec Server-Sent Events pour le streaming de réponses asynchrones.
L'initialisation réseau isole le contexte de chargement des classes (ClassLoader) pour éviter les conflits avec le système de plugins de Bukkit :
// Dans MineServer.java
public void start(int port) {
// Isolation du ClassLoader pour protéger l'exécution de Javalin
ClassLoader originalLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(MineServer.class.getClassLoader());
try {
app = Javalin.create(config -> {
config.bundledPlugins.enableCors(cors -> cors.addRule(CorsPluginConfig.CorsRule::anyHost));
config.showJavalinBanner = false;
});
// Définition des endpoints MCP
app.before(this::handleAuth);
app.sse("/sse", this::handleSseConnection); // Canal unidirectionnel Serveur -> Client
app.post("/messages", this::handleMessage); // Canal unidirectionnel Client -> Serveur
app.start(port);
} finally {
// Restauration du ClassLoader de Bukkit
Thread.currentThread().setContextClassLoader(originalLoader);
}
}2. Abstraction des Outils (Tool Pattern)
Pour respecter les spécifications MCP, chaque fonctionnalité du serveur doit exposer un schéma JSON strict. J'ai utilisé l'abstraction pour que chaque nouvel outil soit standardisé.
Chaque outil hérite de McpTool et doit obligatoirement fournir son schéma de paramètres (via getSchema()) ainsi que sa logique d'exécution (execute()) :
// Dans McpTool.java
public abstract class McpTool {
protected static final ObjectMapper mapper = new ObjectMapper();
protected final String name;
protected final String description;
// L'outil doit exposer un JSON Schema compréhensible par le LLM client
public abstract ObjectNode getSchema();
// L'exécution prend des arguments JSON parsés et retourne une réponse JSON
public abstract ObjectNode execute(JsonNode args) throws Exception;
// Utilitaire interne pour générer une réponse texte standard MCP
protected ObjectNode createTextResult(String text) {
ObjectNode result = mapper.createObjectNode();
result.putArray("content").addObject().put("type", "text").put("text", text);
return result;
}
}3. Le défi de l'exécution de commandes sur Bukkit
Dans l'architecture de Minecraft (et de Bukkit), le flux d'exécution d'une commande pose trois problèmes majeurs pour notre API MCP :
- L'isolement des threads : Les requêtes HTTP Javalin tournent sur leurs propres threads asynchrones, mais l'API Bukkit interdit l'exécution de commandes en dehors du Main Server Thread.
- Le retour implicite : La méthode native
Bukkit.dispatchCommand()renvoie un booléen. Le texte de résultat est envoyé via une méthodesendMessage()à l'entité qui exécute. - Le comportement erratique des plugins : Certains plugins tiers contournent complètement l'entité exécutrice (
CommandSender) et écrivent directement dans le Logger standard du serveur.
Pour résoudre cela dans l'outil ExecuteCommandTool, j'ai combiné le Proxy Pattern, la synchronisation asynchrone et l'injection dynamique.
A. Le pont Asynchrone/Synchrone
Lorsqu'une requête JSON-RPC demande l'exécution, nous la soumettons au Main Thread de Bukkit et utilisons un CompletableFuture pour bloquer le thread HTTP jusqu'à obtention du résultat.
// Dans ExecuteCommandTool.java
public ObjectNode execute(JsonNode args) throws Exception {
String command = args.get("command").asText();
CompletableFuture<String> future = new CompletableFuture<>();
// Exécution forcée sur le Thread principal du jeu
Bukkit.getScheduler().runTask(MineMCP.getInstance(), () -> {
try {
// [...] Logique d'exécution (détaillée ci-dessous)
future.complete(capturedOutput);
} catch (Throwable t) {
future.completeExceptionally(t);
}
});
try {
// Le thread MCP attend la réponse avec un Timeout de sécurité de 10s
String result = future.get(10, TimeUnit.SECONDS);
return createTextResult(result);
} catch (TimeoutException e) {
return createTextResult("Command sent but response timed out.");
}
}B. Intercepter le flux avec un Proxy (McpCommandSender)
Pour capturer le texte qu'une commande renvoie normalement à la console, j'ai créé la classe McpCommandSender. Ce Proxy Pattern enveloppe le ConsoleSender natif. Il intercepte les méthodes de type sendMessage et convertit les composants texte (Spigot API) en texte brut.
// Dans McpCommandSender.java
public class McpCommandSender implements CommandSender {
private final StringBuilder output = new StringBuilder();
private final CommandSender wrappedSender; // Le sender natif enveloppé
public McpCommandSender(CommandSender wrappedSender) {
this.wrappedSender = wrappedSender;
}
// Interception de l'API Spigot (BaseComponent)
private final CommandSender.Spigot spigotProxy = new CommandSender.Spigot() {
@Override
public void sendMessage(BaseComponent... components) {
try {
// Conversion des composants formatés en texte lisible par l'IA
String text = TextComponent.toLegacyText(components);
if (!text.isEmpty()) {
output.append(text).append('\n');
}
} catch (Throwable ignored) {}
// On fait suivre au sender original pour maintenir le fonctionnement habituel
wrappedSender.spigot().sendMessage(components);
}
};
// ... Autres méthodes d'interface qui alimentent le StringBuilder
}C. Le Fallback par Injection de Logger (Hooking)
Si le Proxy échoue face à des plugins mal codés qui utilisent System.out.println ou le logger natif (Bukkit.getLogger()), j'injecte dynamiquement un java.util.logging.Handler éphémère directement dans le logger racine pour capturer les flux à la volée.
// Fallback critique dans ExecuteCommandTool.java
if (captured == null || captured.isEmpty()) {
StringBuilder logCapture = new StringBuilder();
Logger bukkitLogger = Bukkit.getLogger();
// Handler fantôme pour intercepter les stdout
Handler handler = new Handler() {
@Override
public void publish(LogRecord record) {
if (record == null) return;
String msg = record.getMessage();
if (msg != null && !msg.isEmpty()) {
logCapture.append(msg).append('\n');
}
}
@Override public void flush() {}
@Override public void close() throws SecurityException {}
};
try {
bukkitLogger.addHandler(handler);
// On exécute avec la vraie Console pour garantir que le log s'affiche
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command);
} finally {
// Désamorçage obligatoire : Empêche une fuite de mémoire sévère
bukkitLogger.removeHandler(handler);
}
captured = "(captured from server logs)\n" + logCapture.toString();
}Conclusion et Bilan
Cette implémentation illustre parfaitement la nécessité de comprendre le fonctionnement bas-niveau des moteurs sur lesquels nous développons.
Le véritable défi de MineMCP n'était pas de respecter la spécification du protocole de l'IA, mais de tordre la boucle de jeu de Minecraft pour l'interfacer avec une architecture web asynchrone sans créer de plantage ou de fuite de mémoire (Thread-Safety).
L'utilisation combinée de Javalin pour le transport SSE réseau isolé, du Proxy Pattern pour intercepter les flux sortants virtuels, et du hooking dynamique de logs Java a permis de créer un pont robuste qui convertit 100% des actions ingame en données JSON exploitables par un LLM client.