From 6210eb42740206cf98b058ff4331bdcfb0b30a7e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:34:16 +0000 Subject: [PATCH 01/10] Implement dedicated server support for the MCP Server Mod - Refactored core logic (HTTPMCPServer, IPCServer, CommandExecutor, etc.) into shared `src/main/java/` and implemented client/server specific interfaces to separate dependencies safely. - Added `MCPServerModServer` entry point for dedicated servers. - Made server tools thread-safe by using `server.submit(() -> ...).get()` for operations mutating the world or executing commands. - Implemented `CommandOutput` interceptor in `ServerCommandExecutor` to retrieve command execution feedback. - Migrated the config file from `mcp-client.json` to `mcp.json` automatically. - Updated the `README.md` to clarify Server vs Client mode differences. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- README.md | 17 +- .../cuspymd/mcp/mod/MCPServerModClient.java | 9 +- .../mcp/mod/command/CommandExecutor.java | 2 +- .../cuspymd/mcp/mod/utils/BlockScanner.java | 9 +- .../mcp/mod/utils/PlayerInfoProvider.java | 9 +- .../mcp/mod/utils/ScreenshotUtils.java | 9 +- .../cuspymd/mcp/mod/bridge/HTTPMCPServer.java | 26 ++-- .../cuspymd/mcp/mod/bridge/IPCServer.java | 8 +- .../mcp/mod/command/ICommandExecutor.java | 7 + .../cuspymd/mcp/mod/config/MCPConfig.java | 16 +- .../mcp/mod/utils/BlockCompressor.java | 3 + .../mcp/mod/utils/CoordinateUtils.java | 0 .../cuspymd/mcp/mod/utils/IBlockScanner.java | 7 + .../mcp/mod/utils/IPlayerInfoProvider.java | 7 + .../mcp/mod/utils/IScreenshotUtils.java | 8 + src/main/resources/fabric.mod.json | 3 + .../cuspymd/mcp/mod/MCPServerModServer.java | 58 +++++++ .../mod/server/tools/ServerBlockScanner.java | 83 ++++++++++ .../server/tools/ServerCommandExecutor.java | 147 ++++++++++++++++++ .../tools/ServerPlayerInfoProvider.java | 52 +++++++ .../server/tools/ServerScreenshotUtils.java | 16 ++ .../mcp/mod/bridge/HTTPMCPServerTest.java | 10 +- 22 files changed, 473 insertions(+), 33 deletions(-) rename src/{client => main}/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java (94%) rename src/{client => main}/java/cuspymd/mcp/mod/bridge/IPCServer.java (95%) create mode 100644 src/main/java/cuspymd/mcp/mod/command/ICommandExecutor.java rename src/{client => main}/java/cuspymd/mcp/mod/utils/BlockCompressor.java (99%) rename src/{client => main}/java/cuspymd/mcp/mod/utils/CoordinateUtils.java (100%) create mode 100644 src/main/java/cuspymd/mcp/mod/utils/IBlockScanner.java create mode 100644 src/main/java/cuspymd/mcp/mod/utils/IPlayerInfoProvider.java create mode 100644 src/main/java/cuspymd/mcp/mod/utils/IScreenshotUtils.java create mode 100644 src/server/java/cuspymd/mcp/mod/MCPServerModServer.java create mode 100644 src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java create mode 100644 src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java create mode 100644 src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java create mode 100644 src/server/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java diff --git a/README.md b/README.md index 2dae57d..d2b157e 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ A Fabric mod that implements a Model Context Protocol (MCP) server, enabling AI ## Overview -This mod creates an HTTP server within the Minecraft client that accepts MCP protocol requests, allowing Large Language Models to execute Minecraft commands safely and efficiently. The mod includes comprehensive safety validation to prevent destructive operations. +This mod creates an HTTP server within the Minecraft client or dedicated server that accepts MCP protocol requests, allowing Large Language Models to execute Minecraft commands safely and efficiently. The mod includes comprehensive safety validation to prevent destructive operations. + +It is designed to be fully compatible with both Single-Player (Integrated Server) and Multiplayer Dedicated Servers. ## Features +- **Server and Client Support**: Works on both single-player and dedicated server environments. - **MCP Protocol Support**: Full implementation of Model Context Protocol for AI interaction - **Safety Validation**: Comprehensive command filtering and validation system - **Asynchronous Execution**: Non-blocking command execution to maintain game performance @@ -34,9 +37,17 @@ This mod creates an HTTP server within the Minecraft client that accepts MCP pro The MCP server starts automatically when you launch Minecraft with the mod installed. By default, it runs on `localhost:8080`. +### Server vs Client Modes + +The mod detects if it is running in a Client (Single Player) or a Dedicated Server environment: +- **Client Mode**: Full feature support, including the `take_screenshot` tool, which uses the local game window. +- **Dedicated Server Mode**: Has access to tools like `execute_commands`, `get_player_info`, and `get_blocks_in_area`, enabling full AI manipulation of the world without rendering. The `take_screenshot` tool is disabled in server mode since there is no rendering context. Note that `get_player_info` currently selects the first online player on the server to report its location. + +If playing Single Player, the integrated server logic runs through the client-side MCP. + ### Configuration -The mod creates a configuration file at `config/mcp-client.json`: +The mod creates a configuration file at `config/mcp.json`: ```json { @@ -267,7 +278,7 @@ Capture a screenshot of the current Minecraft game screen. Optionally, you can s For debugging purposes, you can enable local saving of every screenshot captured by the MCP server. -1. Open `config/mcp-client.json`. +1. Open `config/mcp.json`. 2. Set `"save_screenshots_for_debug": true` in the `client` section. 3. Screenshots will be saved to the `mcp_debug_screenshots/` directory in your Minecraft instance folder. 4. Files are named using the pattern: `screenshot_YYYYMMDD_HHMMSS_SSS.png`. diff --git a/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java b/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java index 233eaf5..55a4bd6 100644 --- a/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java +++ b/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java @@ -27,12 +27,17 @@ public void onInitializeClient() { String transport = config.getServer().getTransport(); if ("http".equals(transport)) { - httpServer = new HTTPMCPServer(config); + httpServer = new HTTPMCPServer(config, + new cuspymd.mcp.mod.command.CommandExecutor(config), + new cuspymd.mcp.mod.utils.PlayerInfoProvider(), + new cuspymd.mcp.mod.utils.BlockScanner(), + new cuspymd.mcp.mod.utils.ScreenshotUtils() + ); httpServer.start(); LOGGER.info("HTTP MCP Server started on port {}", httpServer.getPort()); } else { // Default to stdio transport - ipcServer = new IPCServer(config); + ipcServer = new IPCServer(config, new cuspymd.mcp.mod.command.CommandExecutor(config)); ipcServer.start(); LOGGER.info("IPC Server started on port {}", ipcServer.getPort()); } diff --git a/src/client/java/cuspymd/mcp/mod/command/CommandExecutor.java b/src/client/java/cuspymd/mcp/mod/command/CommandExecutor.java index f77a514..d857adc 100644 --- a/src/client/java/cuspymd/mcp/mod/command/CommandExecutor.java +++ b/src/client/java/cuspymd/mcp/mod/command/CommandExecutor.java @@ -19,7 +19,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeUnit; -public class CommandExecutor { +public class CommandExecutor implements cuspymd.mcp.mod.command.ICommandExecutor { private static final Logger LOGGER = LoggerFactory.getLogger(CommandExecutor.class); private static final long COMMAND_MESSAGE_WAIT_MS = 700L; private static final long COMMAND_MESSAGE_IDLE_MS = 120L; diff --git a/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java b/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java index d5a1357..0cee573 100644 --- a/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java +++ b/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java @@ -10,10 +10,15 @@ import java.util.ArrayList; import java.util.List; -public class BlockScanner { +public class BlockScanner implements cuspymd.mcp.mod.utils.IBlockScanner { private static final Logger LOGGER = LoggerFactory.getLogger(BlockScanner.class); - public static JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int maxAreaSize) { + @Override + public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int maxAreaSize) { + return scanBlocksInAreaStatic(fromPos, toPos, maxAreaSize); + } + + public static JsonObject scanBlocksInAreaStatic(JsonObject fromPos, JsonObject toPos, int maxAreaSize) { try { MinecraftClient client = MinecraftClient.getInstance(); if (client.world == null) { diff --git a/src/client/java/cuspymd/mcp/mod/utils/PlayerInfoProvider.java b/src/client/java/cuspymd/mcp/mod/utils/PlayerInfoProvider.java index 6a58d30..2c8a647 100644 --- a/src/client/java/cuspymd/mcp/mod/utils/PlayerInfoProvider.java +++ b/src/client/java/cuspymd/mcp/mod/utils/PlayerInfoProvider.java @@ -6,9 +6,14 @@ import net.minecraft.util.math.Vec3d; import net.minecraft.world.World; -public class PlayerInfoProvider { +public class PlayerInfoProvider implements cuspymd.mcp.mod.utils.IPlayerInfoProvider { - public static JsonObject getPlayerInfo() { + @Override + public JsonObject getPlayerInfo() { + return getPlayerInfoStatic(); + } + + public static JsonObject getPlayerInfoStatic() { MinecraftClient client = MinecraftClient.getInstance(); ClientPlayerEntity player = client.player; World world = client.world; diff --git a/src/client/java/cuspymd/mcp/mod/utils/ScreenshotUtils.java b/src/client/java/cuspymd/mcp/mod/utils/ScreenshotUtils.java index bb720f1..194329d 100644 --- a/src/client/java/cuspymd/mcp/mod/utils/ScreenshotUtils.java +++ b/src/client/java/cuspymd/mcp/mod/utils/ScreenshotUtils.java @@ -21,10 +21,15 @@ import java.util.List; import java.util.concurrent.CompletableFuture; -public class ScreenshotUtils { +public class ScreenshotUtils implements cuspymd.mcp.mod.utils.IScreenshotUtils { private static final Logger LOGGER = LoggerFactory.getLogger(ScreenshotUtils.class); static final List pendingDeferredTasks = Collections.synchronizedList(new ArrayList<>()); + @Override + public CompletableFuture takeScreenshot(JsonObject params) { + return takeScreenshotStatic(params); + } + static class DeferredTask { Runnable runnable; int remainingTicks; @@ -43,7 +48,7 @@ static class DeferredTask { * @param params JsonObject containing optional x, y, z, yaw, pitch * @return A CompletableFuture that completes with the Base64 encoded PNG image data */ - public static CompletableFuture takeScreenshot(JsonObject params) { + public static CompletableFuture takeScreenshotStatic(JsonObject params) { MinecraftClient client = MinecraftClient.getInstance(); CompletableFuture future = new CompletableFuture<>(); diff --git a/src/client/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java b/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java similarity index 94% rename from src/client/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java rename to src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java index 3db2508..d560c6b 100644 --- a/src/client/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java +++ b/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java @@ -6,12 +6,12 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; -import cuspymd.mcp.mod.command.CommandExecutor; +import cuspymd.mcp.mod.command.ICommandExecutor; import cuspymd.mcp.mod.config.MCPConfig; import cuspymd.mcp.mod.server.MCPProtocol; -import cuspymd.mcp.mod.utils.PlayerInfoProvider; -import cuspymd.mcp.mod.utils.BlockScanner; -import cuspymd.mcp.mod.utils.ScreenshotUtils; +import cuspymd.mcp.mod.utils.IPlayerInfoProvider; +import cuspymd.mcp.mod.utils.IBlockScanner; +import cuspymd.mcp.mod.utils.IScreenshotUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,14 +31,20 @@ public class HTTPMCPServer { private static final Gson GSON = new Gson(); private final MCPConfig config; - private final CommandExecutor commandExecutor; + private final ICommandExecutor commandExecutor; + private final IPlayerInfoProvider playerInfoProvider; + private final IBlockScanner blockScanner; + private final IScreenshotUtils screenshotUtils; private final AtomicBoolean running = new AtomicBoolean(false); private HttpServer httpServer; private ExecutorService executor; - public HTTPMCPServer(MCPConfig config) { + public HTTPMCPServer(MCPConfig config, ICommandExecutor commandExecutor, IPlayerInfoProvider playerInfoProvider, IBlockScanner blockScanner, IScreenshotUtils screenshotUtils) { this.config = config; - this.commandExecutor = new CommandExecutor(config); + this.commandExecutor = commandExecutor; + this.playerInfoProvider = playerInfoProvider; + this.blockScanner = blockScanner; + this.screenshotUtils = screenshotUtils; } public void start() throws IOException { @@ -315,7 +321,7 @@ private JsonObject handleToolsCall(JsonObject params) { private JsonObject handleGetPlayerInfo() { try { - JsonObject playerInfo = PlayerInfoProvider.getPlayerInfo(); + JsonObject playerInfo = playerInfoProvider.getPlayerInfo(); // Check if there was an error getting player info if (playerInfo.has("error")) { @@ -347,7 +353,7 @@ private JsonObject handleGetBlocksInArea(JsonObject arguments) { } int maxAreaSize = config.getServer().getMaxAreaSize(); - JsonObject result = BlockScanner.scanBlocksInArea(fromPos, toPos, maxAreaSize); + JsonObject result = blockScanner.scanBlocksInArea(fromPos, toPos, maxAreaSize); // Check if there was an error scanning blocks if (result.has("error")) { @@ -405,7 +411,7 @@ JsonObject awaitScreenshotResult(CompletableFuture future) { } CompletableFuture takeScreenshotAsync(JsonObject params) { - return ScreenshotUtils.takeScreenshot(params); + return screenshotUtils.takeScreenshot(params); } private JsonObject createSuccessResponse(JsonObject result, Integer requestId) { diff --git a/src/client/java/cuspymd/mcp/mod/bridge/IPCServer.java b/src/main/java/cuspymd/mcp/mod/bridge/IPCServer.java similarity index 95% rename from src/client/java/cuspymd/mcp/mod/bridge/IPCServer.java rename to src/main/java/cuspymd/mcp/mod/bridge/IPCServer.java index fd024b5..f528153 100644 --- a/src/client/java/cuspymd/mcp/mod/bridge/IPCServer.java +++ b/src/main/java/cuspymd/mcp/mod/bridge/IPCServer.java @@ -3,7 +3,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import cuspymd.mcp.mod.command.CommandExecutor; +import cuspymd.mcp.mod.command.ICommandExecutor; import cuspymd.mcp.mod.config.MCPConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,13 +20,13 @@ public class IPCServer { private static final Gson GSON = new Gson(); private static final int IPC_PORT = 25565; // Default port for IPC - private final CommandExecutor commandExecutor; + private final ICommandExecutor commandExecutor; private final AtomicBoolean running = new AtomicBoolean(false); private ServerSocket serverSocket; private ExecutorService executor; - public IPCServer(MCPConfig config) { - this.commandExecutor = new CommandExecutor(config); + public IPCServer(MCPConfig config, ICommandExecutor commandExecutor) { + this.commandExecutor = commandExecutor; } public void start() throws IOException { diff --git a/src/main/java/cuspymd/mcp/mod/command/ICommandExecutor.java b/src/main/java/cuspymd/mcp/mod/command/ICommandExecutor.java new file mode 100644 index 0000000..fbd38ee --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/command/ICommandExecutor.java @@ -0,0 +1,7 @@ +package cuspymd.mcp.mod.command; + +import com.google.gson.JsonObject; + +public interface ICommandExecutor { + JsonObject executeCommands(JsonObject arguments); +} diff --git a/src/main/java/cuspymd/mcp/mod/config/MCPConfig.java b/src/main/java/cuspymd/mcp/mod/config/MCPConfig.java index 6468981..a3ae3cb 100644 --- a/src/main/java/cuspymd/mcp/mod/config/MCPConfig.java +++ b/src/main/java/cuspymd/mcp/mod/config/MCPConfig.java @@ -26,7 +26,19 @@ public class MCPConfig { public static MCPConfig load() { Path configDir = FabricLoader.getInstance().getConfigDir(); - Path configFile = configDir.resolve("mcp-client.json"); + String configFileName = "mcp.json"; + Path configFile = configDir.resolve(configFileName); + + // Backwards compatibility with mcp-client.json + Path oldConfigFile = configDir.resolve("mcp-client.json"); + if (Files.exists(oldConfigFile) && !Files.exists(configFile)) { + try { + Files.move(oldConfigFile, configFile); + } catch (IOException e) { + LOGGER.warn("Failed to rename mcp-client.json to mcp.json", e); + configFile = oldConfigFile; + } + } if (Files.exists(configFile)) { try { @@ -44,7 +56,7 @@ public static MCPConfig load() { public void save() { Path configDir = FabricLoader.getInstance().getConfigDir(); - Path configFile = configDir.resolve("mcp-client.json"); + Path configFile = configDir.resolve("mcp.json"); try { Files.createDirectories(configDir); diff --git a/src/client/java/cuspymd/mcp/mod/utils/BlockCompressor.java b/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java similarity index 99% rename from src/client/java/cuspymd/mcp/mod/utils/BlockCompressor.java rename to src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java index 983bc08..be791e1 100644 --- a/src/client/java/cuspymd/mcp/mod/utils/BlockCompressor.java +++ b/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java @@ -1,5 +1,8 @@ package cuspymd.mcp.mod.utils; +import net.minecraft.util.math.BlockPos; +import java.util.Objects; + import com.google.gson.JsonArray; import com.google.gson.JsonObject; import java.util.*; diff --git a/src/client/java/cuspymd/mcp/mod/utils/CoordinateUtils.java b/src/main/java/cuspymd/mcp/mod/utils/CoordinateUtils.java similarity index 100% rename from src/client/java/cuspymd/mcp/mod/utils/CoordinateUtils.java rename to src/main/java/cuspymd/mcp/mod/utils/CoordinateUtils.java diff --git a/src/main/java/cuspymd/mcp/mod/utils/IBlockScanner.java b/src/main/java/cuspymd/mcp/mod/utils/IBlockScanner.java new file mode 100644 index 0000000..c332732 --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/utils/IBlockScanner.java @@ -0,0 +1,7 @@ +package cuspymd.mcp.mod.utils; + +import com.google.gson.JsonObject; + +public interface IBlockScanner { + JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int maxAreaSize); +} diff --git a/src/main/java/cuspymd/mcp/mod/utils/IPlayerInfoProvider.java b/src/main/java/cuspymd/mcp/mod/utils/IPlayerInfoProvider.java new file mode 100644 index 0000000..4314404 --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/utils/IPlayerInfoProvider.java @@ -0,0 +1,7 @@ +package cuspymd.mcp.mod.utils; + +import com.google.gson.JsonObject; + +public interface IPlayerInfoProvider { + JsonObject getPlayerInfo(); +} diff --git a/src/main/java/cuspymd/mcp/mod/utils/IScreenshotUtils.java b/src/main/java/cuspymd/mcp/mod/utils/IScreenshotUtils.java new file mode 100644 index 0000000..afd465f --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/utils/IScreenshotUtils.java @@ -0,0 +1,8 @@ +package cuspymd.mcp.mod.utils; + +import com.google.gson.JsonObject; +import java.util.concurrent.CompletableFuture; + +public interface IScreenshotUtils { + CompletableFuture takeScreenshot(JsonObject params); +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 7daa2bc..43c403d 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -21,6 +21,9 @@ ], "client": [ "cuspymd.mcp.mod.MCPServerModClient" + ], + "server": [ + "cuspymd.mcp.mod.MCPServerModServer" ] }, "mixins": [ diff --git a/src/server/java/cuspymd/mcp/mod/MCPServerModServer.java b/src/server/java/cuspymd/mcp/mod/MCPServerModServer.java new file mode 100644 index 0000000..2716e28 --- /dev/null +++ b/src/server/java/cuspymd/mcp/mod/MCPServerModServer.java @@ -0,0 +1,58 @@ +package cuspymd.mcp.mod; + +import net.fabricmc.api.DedicatedServerModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import cuspymd.mcp.mod.bridge.HTTPMCPServer; +import cuspymd.mcp.mod.bridge.IPCServer; +import cuspymd.mcp.mod.config.MCPConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MCPServerModServer implements DedicatedServerModInitializer { + public static final Logger LOGGER = LoggerFactory.getLogger("mcp-server-mod"); + private HTTPMCPServer httpServer; + private IPCServer ipcServer; + + @Override + public void onInitializeServer() { + LOGGER.info("Initializing Minecraft MCP Dedicated Server"); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + try { + MCPConfig config = MCPConfig.load(); + if (config.getServer().isAutoStart()) { + String transport = config.getServer().getTransport(); + + if ("http".equals(transport)) { + httpServer = new HTTPMCPServer(config, + new cuspymd.mcp.mod.server.tools.ServerCommandExecutor(config, server), + new cuspymd.mcp.mod.server.tools.ServerPlayerInfoProvider(server), + new cuspymd.mcp.mod.server.tools.ServerBlockScanner(server), + new cuspymd.mcp.mod.server.tools.ServerScreenshotUtils() + ); + httpServer.start(); + LOGGER.info("HTTP MCP Server started on port {}", httpServer.getPort()); + } else { + // Default to stdio transport + ipcServer = new IPCServer(config, new cuspymd.mcp.mod.server.tools.ServerCommandExecutor(config, server)); + ipcServer.start(); + LOGGER.info("IPC Server started on port {}", ipcServer.getPort()); + } + } + } catch (Exception e) { + LOGGER.error("Failed to start MCP Server", e); + } + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + if (ipcServer != null) { + ipcServer.stop(); + LOGGER.info("IPC Server stopped"); + } + if (httpServer != null) { + httpServer.stop(); + LOGGER.info("HTTP MCP Server stopped"); + } + }); + } +} \ No newline at end of file diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java b/src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java new file mode 100644 index 0000000..e06a37d --- /dev/null +++ b/src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java @@ -0,0 +1,83 @@ +package cuspymd.mcp.mod.server.tools; + +import com.google.gson.JsonObject; +import cuspymd.mcp.mod.utils.IBlockScanner; +import cuspymd.mcp.mod.server.MCPProtocol; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.math.BlockPos; + +public class ServerBlockScanner implements IBlockScanner { + private final MinecraftServer server; + + public ServerBlockScanner(MinecraftServer server) { + this.server = server; + } + + @Override + public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int maxAreaSize) { + try { + return server.submit(() -> { + if (server == null || server.getPlayerManager() == null || server.getPlayerManager().getPlayerList().isEmpty()) { + return MCPProtocol.createErrorResponse("No players online to determine dimension", null); + } + + // Just use the first player's world + ServerPlayerEntity player = server.getPlayerManager().getPlayerList().get(0); + ServerWorld world = player.getServerWorld(); + + int x1 = fromPos.get("x").getAsInt(); + int y1 = fromPos.get("y").getAsInt(); + int z1 = fromPos.get("z").getAsInt(); + int x2 = toPos.get("x").getAsInt(); + int y2 = toPos.get("y").getAsInt(); + int z2 = toPos.get("z").getAsInt(); + + int minX = Math.min(x1, x2); + int minY = Math.min(y1, y2); + int minZ = Math.min(z1, z2); + int maxX = Math.max(x1, x2); + int maxY = Math.max(y1, y2); + int maxZ = Math.max(z1, z2); + + int dx = maxX - minX + 1; + int dy = maxY - minY + 1; + int dz = maxZ - minZ + 1; + + if (dx > maxAreaSize || dy > maxAreaSize || dz > maxAreaSize) { + return MCPProtocol.createErrorResponse("Area too large. Max size is " + maxAreaSize + " per axis.", null); + } + + java.util.List blocks = new java.util.ArrayList<>(); + int count = 0; + + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + BlockPos pos = new BlockPos(x, y, z); + net.minecraft.block.BlockState state = world.getBlockState(pos); + + if (!state.isAir()) { + String name = net.minecraft.registry.Registries.BLOCK.getId(state.getBlock()).getPath(); + blocks.add(new cuspymd.mcp.mod.utils.BlockCompressor.BlockData(x, y, z, name)); + count++; + } + } + } + } + + JsonObject result = cuspymd.mcp.mod.utils.BlockCompressor.compressBlocks(blocks); + + JsonObject stats = new JsonObject(); + stats.addProperty("total_scanned", dx * dy * dz); + stats.addProperty("non_air_blocks", count); + result.add("stats", stats); + + return result; + }).get(); + } catch (Exception e) { + return MCPProtocol.createErrorResponse("Failed to scan blocks: " + e.getMessage(), null); + } + } +} \ No newline at end of file diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java b/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java new file mode 100644 index 0000000..e45ae16 --- /dev/null +++ b/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java @@ -0,0 +1,147 @@ +package cuspymd.mcp.mod.server.tools; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import cuspymd.mcp.mod.command.ICommandExecutor; +import cuspymd.mcp.mod.config.MCPConfig; +import cuspymd.mcp.mod.server.MCPProtocol; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.Text; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class ServerCommandExecutor implements ICommandExecutor { + private static final Logger LOGGER = LoggerFactory.getLogger(ServerCommandExecutor.class); + private final MCPConfig config; + private final MinecraftServer server; + + public ServerCommandExecutor(MCPConfig config, MinecraftServer server) { + this.config = config; + this.server = server; + } + + @Override + public JsonObject executeCommands(JsonObject arguments) { + if (!arguments.has("commands")) { + return MCPProtocol.createErrorResponse("Missing required parameter: commands", null); + } + + JsonArray commandsArray = arguments.getAsJsonArray("commands"); + int totalCommands = commandsArray.size(); + + JsonArray results = new JsonArray(); + int acceptedCount = 0; + int failedCount = 0; + + List allMessages = new ArrayList<>(); + + for (int i = 0; i < totalCommands; i++) { + JsonElement elem = commandsArray.get(i); + if (!elem.isJsonPrimitive() || !elem.getAsJsonPrimitive().isString()) { + continue; + } + final String originalCommand = elem.getAsString(); + final String command = originalCommand.startsWith("/") ? originalCommand.substring(1) : originalCommand; + + JsonObject resultObj = new JsonObject(); + resultObj.addProperty("index", i); + resultObj.addProperty("command", originalCommand); + + try { + // Execute on main server thread + JsonObject executionData = server.submit(() -> { + List messages = new ArrayList<>(); + + // Create a capturing command source based on the server source + ServerCommandSource originalSource = server.getCommandSource(); + ServerCommandSource capturingSource = originalSource.withOutput(new net.minecraft.server.command.CommandOutput() { + @Override + public void sendMessage(Text message) { + messages.add(message.getString()); + } + + @Override + public boolean shouldReceiveFeedback() { + return true; + } + + @Override + public boolean shouldTrackOutput() { + return true; + } + + @Override + public boolean shouldBroadcastConsoleToOps() { + return false; + } + }); + + int successCount = server.getCommandManager().executeWithPrefix(capturingSource, command); + + JsonObject partial = new JsonObject(); + partial.addProperty("successCount", successCount); + JsonArray messagesArray = new JsonArray(); + for(String m : messages) messagesArray.add(m); + partial.add("messages", messagesArray); + + return partial; + }).get(); + + int successCount = executionData.get("successCount").getAsInt(); + JsonArray msgs = executionData.getAsJsonArray("messages"); + + JsonArray perCommandMessages = new JsonArray(); + for (JsonElement m : msgs) { + perCommandMessages.add(m.getAsString()); + allMessages.add(m.getAsString()); + } + + resultObj.add("chatMessages", perCommandMessages); + + if (successCount > 0) { + acceptedCount++; + resultObj.addProperty("status", "success"); + resultObj.addProperty("accepted", true); + resultObj.addProperty("applied", true); + resultObj.addProperty("summary", "Command executed successfully. Feedback: " + (msgs.size() > 0 ? msgs.get(0).getAsString() : "")); + } else { + failedCount++; + resultObj.addProperty("status", "failed"); + resultObj.addProperty("accepted", true); + resultObj.addProperty("applied", false); + resultObj.addProperty("summary", "Command failed to execute. Feedback: " + (msgs.size() > 0 ? msgs.get(0).getAsString() : "")); + } + } catch (Exception e) { + LOGGER.error("Error executing server command: " + command, e); + failedCount++; + resultObj.addProperty("status", "error"); + resultObj.addProperty("accepted", false); + resultObj.addProperty("applied", false); + resultObj.addProperty("summary", "Error: " + e.getMessage()); + } + + results.add(resultObj); + } + + JsonObject responseJson = new JsonObject(); + responseJson.addProperty("totalCommands", totalCommands); + responseJson.addProperty("acceptedCount", acceptedCount); + responseJson.addProperty("appliedCount", acceptedCount); + responseJson.addProperty("failedCount", failedCount); + responseJson.add("results", results); + JsonArray allMessagesArray = new JsonArray(); + for (String msg : allMessages) { + allMessagesArray.add(msg); + } + responseJson.add("chatMessages", allMessagesArray); + responseJson.addProperty("hint", "Use get_blocks_in_area to verify the built structure and fix any issues."); + + return responseJson; + } +} \ No newline at end of file diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java b/src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java new file mode 100644 index 0000000..dc0cd28 --- /dev/null +++ b/src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java @@ -0,0 +1,52 @@ +package cuspymd.mcp.mod.server.tools; + +import com.google.gson.JsonObject; +import cuspymd.mcp.mod.utils.IPlayerInfoProvider; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; + +public class ServerPlayerInfoProvider implements IPlayerInfoProvider { + private final MinecraftServer server; + + public ServerPlayerInfoProvider(MinecraftServer server) { + this.server = server; + } + + @Override + public JsonObject getPlayerInfo() { + try { + return server.submit(() -> { + JsonObject info = new JsonObject(); + if (server == null || server.getPlayerManager() == null || server.getPlayerManager().getPlayerList().isEmpty()) { + info.addProperty("error", "No players online on the server"); + return info; + } + + // Just take the first player for now + ServerPlayerEntity player = server.getPlayerManager().getPlayerList().get(0); + + JsonObject pos = new JsonObject(); + pos.addProperty("x", player.getX()); + pos.addProperty("y", player.getY()); + pos.addProperty("z", player.getZ()); + info.add("position", pos); + + JsonObject blockPos = new JsonObject(); + blockPos.addProperty("x", player.getBlockPos().getX()); + blockPos.addProperty("y", player.getBlockPos().getY()); + blockPos.addProperty("z", player.getBlockPos().getZ()); + info.add("blockPosition", blockPos); + + info.addProperty("dimension", player.getWorld().getRegistryKey().getValue().toString()); + info.addProperty("gameMode", player.interactionManager.getGameMode().getName()); + info.addProperty("health", player.getHealth()); + + return info; + }).get(); + } catch (Exception e) { + JsonObject error = new JsonObject(); + error.addProperty("error", "Failed to retrieve player info: " + e.getMessage()); + return error; + } + } +} \ No newline at end of file diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java b/src/server/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java new file mode 100644 index 0000000..83200f7 --- /dev/null +++ b/src/server/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java @@ -0,0 +1,16 @@ +package cuspymd.mcp.mod.server.tools; + +import com.google.gson.JsonObject; +import cuspymd.mcp.mod.utils.IScreenshotUtils; +import cuspymd.mcp.mod.server.MCPProtocol; + +import java.util.concurrent.CompletableFuture; + +public class ServerScreenshotUtils implements IScreenshotUtils { + @Override + public CompletableFuture takeScreenshot(JsonObject params) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new UnsupportedOperationException("take_screenshot is not supported in dedicated server mode.")); + return future; + } +} \ No newline at end of file diff --git a/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java b/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java index 1c6d2bf..3728c3b 100644 --- a/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java +++ b/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java @@ -16,7 +16,7 @@ public class HTTPMCPServerTest { @Test public void testAwaitScreenshotResult_Success() { - HTTPMCPServer server = new HTTPMCPServer(new MCPConfig()); + HTTPMCPServer server = new HTTPMCPServer(new MCPConfig(), null, null, null, null); CompletableFuture future = CompletableFuture.completedFuture("abc123"); JsonObject response = server.awaitScreenshotResult(future); @@ -30,7 +30,7 @@ public void testAwaitScreenshotResult_Success() { @Test public void testAwaitScreenshotResult_Timeout() { - HTTPMCPServer server = new HTTPMCPServer(new MCPConfig()); + HTTPMCPServer server = new HTTPMCPServer(new MCPConfig(), null, null, null, null); TimeoutFuture future = new TimeoutFuture(); JsonObject response = server.awaitScreenshotResult(future); @@ -42,7 +42,7 @@ public void testAwaitScreenshotResult_Timeout() { @Test public void testAwaitScreenshotResult_Interrupted() { - HTTPMCPServer server = new HTTPMCPServer(new MCPConfig()); + HTTPMCPServer server = new HTTPMCPServer(new MCPConfig(), null, null, null, null); InterruptedFuture future = new InterruptedFuture(); try { @@ -58,7 +58,7 @@ public void testAwaitScreenshotResult_Interrupted() { @Test public void testAwaitScreenshotResult_ExecutionFailure() { - HTTPMCPServer server = new HTTPMCPServer(new MCPConfig()); + HTTPMCPServer server = new HTTPMCPServer(new MCPConfig(), null, null, null, null); CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(new IllegalStateException("capture failed")); @@ -180,7 +180,7 @@ private static class TestableHTTPMCPServer extends HTTPMCPServer { CompletableFuture nextFuture; TestableHTTPMCPServer(MCPConfig config) { - super(config); + super(config, null, null, null, null); } @Override From 5f28551478f3e99159c7290c5ba2a80944ac5cc4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:59:52 +0000 Subject: [PATCH 02/10] Address PR feedback: safety validation and response format - `ServerCommandExecutor` now runs `SafetyValidator` on incoming commands when `validate_safety` is true (the default) to prevent unauthorized/destructive commands via the server console. - `ServerCommandExecutor` now wraps its result in the standard MCP JSON-RPC format via `MCPProtocol.createSuccessResponse()`, ensuring consistency with the client executor and MCP clients. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../mcp/mod/command/SafetyValidator.java | 0 .../server/tools/ServerCommandExecutor.java | 37 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) rename src/{client => main}/java/cuspymd/mcp/mod/command/SafetyValidator.java (100%) diff --git a/src/client/java/cuspymd/mcp/mod/command/SafetyValidator.java b/src/main/java/cuspymd/mcp/mod/command/SafetyValidator.java similarity index 100% rename from src/client/java/cuspymd/mcp/mod/command/SafetyValidator.java rename to src/main/java/cuspymd/mcp/mod/command/SafetyValidator.java diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java b/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java index e45ae16..d19d1ea 100644 --- a/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java +++ b/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import cuspymd.mcp.mod.command.ICommandExecutor; +import cuspymd.mcp.mod.command.SafetyValidator; import cuspymd.mcp.mod.config.MCPConfig; import cuspymd.mcp.mod.server.MCPProtocol; import net.minecraft.server.MinecraftServer; @@ -21,9 +22,12 @@ public class ServerCommandExecutor implements ICommandExecutor { private final MCPConfig config; private final MinecraftServer server; + private final SafetyValidator safetyValidator; + public ServerCommandExecutor(MCPConfig config, MinecraftServer server) { this.config = config; this.server = server; + this.safetyValidator = new SafetyValidator(config); } @Override @@ -41,6 +45,8 @@ public JsonObject executeCommands(JsonObject arguments) { List allMessages = new ArrayList<>(); + boolean validateSafety = !arguments.has("validate_safety") || arguments.get("validate_safety").getAsBoolean(); + for (int i = 0; i < totalCommands; i++) { JsonElement elem = commandsArray.get(i); if (!elem.isJsonPrimitive() || !elem.getAsJsonPrimitive().isString()) { @@ -53,6 +59,35 @@ public JsonObject executeCommands(JsonObject arguments) { resultObj.addProperty("index", i); resultObj.addProperty("command", originalCommand); + if (validateSafety) { + SafetyValidator.ValidationResult validationResult = safetyValidator.validate(command); + if (!validationResult.isValid()) { + failedCount++; + resultObj.addProperty("status", "rejected_by_safety"); + resultObj.addProperty("accepted", false); + resultObj.addProperty("applied", false); + resultObj.addProperty("summary", "Safety validation failed: " + validationResult.getErrorMessage()); + resultObj.add("chatMessages", new JsonArray()); + results.add(resultObj); + + // Fail fast: Stop executing further commands + for (int j = i + 1; j < totalCommands; j++) { + JsonElement remainingElem = commandsArray.get(j); + if (!remainingElem.isJsonPrimitive() || !remainingElem.getAsJsonPrimitive().isString()) continue; + JsonObject skippedObj = new JsonObject(); + skippedObj.addProperty("index", j); + skippedObj.addProperty("command", remainingElem.getAsString()); + skippedObj.addProperty("status", "skipped"); + skippedObj.addProperty("accepted", false); + skippedObj.addProperty("applied", false); + skippedObj.addProperty("summary", "Skipped due to previous command failing safety validation."); + skippedObj.add("chatMessages", new JsonArray()); + results.add(skippedObj); + } + break; // stop processing + } + } + try { // Execute on main server thread JsonObject executionData = server.submit(() -> { @@ -142,6 +177,6 @@ public boolean shouldBroadcastConsoleToOps() { responseJson.add("chatMessages", allMessagesArray); responseJson.addProperty("hint", "Use get_blocks_in_area to verify the built structure and fix any issues."); - return responseJson; + return MCPProtocol.createSuccessResponse(responseJson.toString()); } } \ No newline at end of file From 2c4a07bc574b2de4f464ee4726f18e6acb3f1bd6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:17:41 +0000 Subject: [PATCH 03/10] Fix build: Move server classes to main source set and resolve symbol mappings - Moved `MCPServerModServer` and server tools from `src/server` to `src/main` since there is no `server` sourceSet configured, ensuring they are compiled and packaged into the mod JAR. - Resolved symbol mapping issues with `CommandManager` and `ServerPlayerEntity` for Fabric 1.21.11. - Updated `BlockCompressor` to use a dedicated `BlockData` object to avoid `JsonObject` mapping conflicts across sides. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../cuspymd/mcp/mod/utils/BlockScanner.java | 10 ++------ .../cuspymd/mcp/mod/MCPServerModServer.java | 0 .../mod/server/tools/ServerBlockScanner.java | 2 +- .../server/tools/ServerCommandExecutor.java | 9 ++++++- .../tools/ServerPlayerInfoProvider.java | 4 +-- .../server/tools/ServerScreenshotUtils.java | 0 .../mcp/mod/utils/BlockCompressor.java | 25 ++++++++++++------- .../cuspymd/mcp/mod/BlockCompressorTest.java | 23 +++++++---------- 8 files changed, 38 insertions(+), 35 deletions(-) rename src/{server => main}/java/cuspymd/mcp/mod/MCPServerModServer.java (100%) rename src/{server => main}/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java (97%) rename src/{server => main}/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java (94%) rename src/{server => main}/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java (92%) rename src/{server => main}/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java (100%) diff --git a/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java b/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java index 0cee573..ddf8ad4 100644 --- a/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java +++ b/src/client/java/cuspymd/mcp/mod/utils/BlockScanner.java @@ -76,7 +76,7 @@ public static JsonObject scanBlocksInAreaStatic(JsonObject fromPos, JsonObject t result.add("area", areaInfo); // Scan blocks - List blockList = new ArrayList<>(); + List blockList = new ArrayList<>(); int totalBlocks = 0; for (int x = minX; x <= maxX; x++) { @@ -90,13 +90,7 @@ public static JsonObject scanBlocksInAreaStatic(JsonObject fromPos, JsonObject t continue; } - JsonObject blockInfo = new JsonObject(); - blockInfo.addProperty("x", x); - blockInfo.addProperty("y", y); - blockInfo.addProperty("z", z); - blockInfo.addProperty("type", blockState.getBlock().toString()); - - blockList.add(blockInfo); + blockList.add(new BlockCompressor.BlockData(x, y, z, net.minecraft.registry.Registries.BLOCK.getId(blockState.getBlock()).toString())); totalBlocks++; } } diff --git a/src/server/java/cuspymd/mcp/mod/MCPServerModServer.java b/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java similarity index 100% rename from src/server/java/cuspymd/mcp/mod/MCPServerModServer.java rename to src/main/java/cuspymd/mcp/mod/MCPServerModServer.java diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java similarity index 97% rename from src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java rename to src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java index e06a37d..75aca35 100644 --- a/src/server/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java @@ -25,7 +25,7 @@ public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int max // Just use the first player's world ServerPlayerEntity player = server.getPlayerManager().getPlayerList().get(0); - ServerWorld world = player.getServerWorld(); + ServerWorld world = player.getCommandSource().getWorld(); int x1 = fromPos.get("x").getAsInt(); int y1 = fromPos.get("y").getAsInt(); diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java similarity index 94% rename from src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java rename to src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java index d19d1ea..f633af3 100644 --- a/src/server/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java @@ -117,7 +117,14 @@ public boolean shouldBroadcastConsoleToOps() { } }); - int successCount = server.getCommandManager().executeWithPrefix(capturingSource, command); + int successCount = 0; + try { + // The actual execute method for commands in this mappings version for parsing and execution + server.getCommandManager().getDispatcher().execute(command, capturingSource); + successCount = 1; + } catch (Exception ex) { + messages.add("Execution failed: " + ex.getMessage()); + } JsonObject partial = new JsonObject(); partial.addProperty("successCount", successCount); diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java similarity index 92% rename from src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java rename to src/main/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java index dc0cd28..923bed5 100644 --- a/src/server/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java @@ -37,8 +37,8 @@ public JsonObject getPlayerInfo() { blockPos.addProperty("z", player.getBlockPos().getZ()); info.add("blockPosition", blockPos); - info.addProperty("dimension", player.getWorld().getRegistryKey().getValue().toString()); - info.addProperty("gameMode", player.interactionManager.getGameMode().getName()); + info.addProperty("dimension", player.getCommandSource().getWorld().getRegistryKey().getValue().toString()); + info.addProperty("gameMode", player.interactionManager.getGameMode().name().toLowerCase()); info.addProperty("health", player.getHealth()); return info; diff --git a/src/server/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java similarity index 100% rename from src/server/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java rename to src/main/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java diff --git a/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java b/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java index be791e1..1a2a0ed 100644 --- a/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java +++ b/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java @@ -8,6 +8,18 @@ import java.util.*; public class BlockCompressor { + + public static class BlockData { + public final int x, y, z; + public final String type; + + public BlockData(int x, int y, int z, String type) { + this.x = x; + this.y = y; + this.z = z; + this.type = type; + } + } public static class BlockRegion { public final int startX, startY, startZ; @@ -64,18 +76,13 @@ public int hashCode() { } } - public static JsonObject compressBlocks(List blockList) { + public static JsonObject compressBlocks(List blockList) { Map> blocksByType = new HashMap<>(); // Group blocks by type - for (JsonObject block : blockList) { - String blockType = block.get("type").getAsString(); - int x = block.get("x").getAsInt(); - int y = block.get("y").getAsInt(); - int z = block.get("z").getAsInt(); - - blocksByType.computeIfAbsent(blockType, k -> new HashSet<>()) - .add(new BlockPosition(x, y, z)); + for (BlockData block : blockList) { + blocksByType.computeIfAbsent(block.type, k -> new HashSet<>()) + .add(new BlockPosition(block.x, block.y, block.z)); } JsonObject result = new JsonObject(); diff --git a/src/test/java/cuspymd/mcp/mod/BlockCompressorTest.java b/src/test/java/cuspymd/mcp/mod/BlockCompressorTest.java index 4122dd9..a3d03c4 100644 --- a/src/test/java/cuspymd/mcp/mod/BlockCompressorTest.java +++ b/src/test/java/cuspymd/mcp/mod/BlockCompressorTest.java @@ -12,18 +12,13 @@ public class BlockCompressorTest { - private JsonObject createBlock(int x, int y, int z, String type) { - JsonObject block = new JsonObject(); - block.addProperty("x", x); - block.addProperty("y", y); - block.addProperty("z", z); - block.addProperty("type", type); - return block; + private BlockCompressor.BlockData createBlock(int x, int y, int z, String type) { + return new BlockCompressor.BlockData(x, y, z, type); } @Test public void testSingleBlockCompression() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); blocks.add(createBlock(10, 64, 10, "minecraft:stone")); JsonObject result = BlockCompressor.compressBlocks(blocks); @@ -47,7 +42,7 @@ public void testSingleBlockCompression() { @Test public void testConnectedBlocksCompression() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); // Create a 2x2x2 cube of stone blocks for (int x = 10; x <= 11; x++) { for (int y = 64; y <= 65; y++) { @@ -85,7 +80,7 @@ public void testConnectedBlocksCompression() { @Test public void testMixedBlocksCompression() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); // Single stone block blocks.add(createBlock(0, 64, 0, "minecraft:stone")); @@ -149,7 +144,7 @@ public void testMixedBlocksCompression() { @Test public void testDisconnectedBlocksOfSameType() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); // Two separate stone blocks blocks.add(createBlock(0, 64, 0, "minecraft:stone")); @@ -170,7 +165,7 @@ public void testDisconnectedBlocksOfSameType() { @Test public void testEmptyBlockList() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); JsonObject result = BlockCompressor.compressBlocks(blocks); JsonArray blockTypes = result.getAsJsonArray("blocks"); @@ -180,7 +175,7 @@ public void testEmptyBlockList() { @Test public void testLShapedConnectedBlocks() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); // Create an L-shaped structure blocks.add(createBlock(10, 64, 10, "minecraft:stone")); @@ -215,7 +210,7 @@ public void testLShapedConnectedBlocks() { @Test public void testVerticallyConnectedBlocks() { - List blocks = new ArrayList<>(); + List blocks = new ArrayList<>(); // Create a vertical tower for (int y = 64; y <= 67; y++) { From 754a75e233d6583e4fb567ca0527589e0f150bfd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 04:16:38 +0000 Subject: [PATCH 04/10] Return correct error format from ServerBlockScanner `ServerBlockScanner` now returns a JSON object with an `"error"` property when validation or execution fails (e.g., no online players, area too large). This ensures that `HTTPMCPServer` correctly interprets the failure and wraps it as a proper MCP error response, instead of misclassifying it as a successful result containing an embedded error payload. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../mcp/mod/server/tools/ServerBlockScanner.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java index 75aca35..d366838 100644 --- a/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java @@ -20,7 +20,9 @@ public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int max try { return server.submit(() -> { if (server == null || server.getPlayerManager() == null || server.getPlayerManager().getPlayerList().isEmpty()) { - return MCPProtocol.createErrorResponse("No players online to determine dimension", null); + JsonObject error = new JsonObject(); + error.addProperty("error", "No players online to determine dimension"); + return error; } // Just use the first player's world @@ -46,7 +48,9 @@ public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int max int dz = maxZ - minZ + 1; if (dx > maxAreaSize || dy > maxAreaSize || dz > maxAreaSize) { - return MCPProtocol.createErrorResponse("Area too large. Max size is " + maxAreaSize + " per axis.", null); + JsonObject error = new JsonObject(); + error.addProperty("error", "Area too large. Max size is " + maxAreaSize + " per axis."); + return error; } java.util.List blocks = new java.util.ArrayList<>(); @@ -77,7 +81,9 @@ public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int max return result; }).get(); } catch (Exception e) { - return MCPProtocol.createErrorResponse("Failed to scan blocks: " + e.getMessage(), null); + JsonObject error = new JsonObject(); + error.addProperty("error", "Failed to scan blocks: " + e.getMessage()); + return error; } } } \ No newline at end of file From 40c915e9db23f312e5d343b49b61588aa9e702b8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:40:15 +0000 Subject: [PATCH 05/10] Fix ServerCommandExecutor to capture command failure states - Commands executed by the dispatcher now correctly reflect their execution result by assigning `successCount = server.getCommandManager().getDispatcher().execute(...)`. Previously, `successCount` was hardcoded to `1` as long as it didn't throw an exception, resulting in commands with no effect being marked `applied=true`. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java index f633af3..9d4ac44 100644 --- a/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java @@ -120,8 +120,7 @@ public boolean shouldBroadcastConsoleToOps() { int successCount = 0; try { // The actual execute method for commands in this mappings version for parsing and execution - server.getCommandManager().getDispatcher().execute(command, capturingSource); - successCount = 1; + successCount = server.getCommandManager().getDispatcher().execute(command, capturingSource); } catch (Exception ex) { messages.add("Execution failed: " + ex.getMessage()); } From a5feab757c63ba4bc7a5b227e7286754274a5a3b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:50:51 +0000 Subject: [PATCH 06/10] Fix appliedCount metric in ServerCommandExecutor - Added a separate `appliedCount` integer to track how many commands successfully returned a non-zero side effect from `dispatcher.execute()`. Previously, `appliedCount` was incorrectly set to `acceptedCount`, breaking consistency with the `results[]` array where parsing failures or empty selectors are marked `applied=false` but `accepted=true`. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java index 9d4ac44..804b37e 100644 --- a/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java @@ -41,6 +41,7 @@ public JsonObject executeCommands(JsonObject arguments) { JsonArray results = new JsonArray(); int acceptedCount = 0; + int appliedCount = 0; int failedCount = 0; List allMessages = new ArrayList<>(); @@ -147,11 +148,13 @@ public boolean shouldBroadcastConsoleToOps() { if (successCount > 0) { acceptedCount++; + appliedCount++; resultObj.addProperty("status", "success"); resultObj.addProperty("accepted", true); resultObj.addProperty("applied", true); resultObj.addProperty("summary", "Command executed successfully. Feedback: " + (msgs.size() > 0 ? msgs.get(0).getAsString() : "")); } else { + acceptedCount++; failedCount++; resultObj.addProperty("status", "failed"); resultObj.addProperty("accepted", true); @@ -173,7 +176,7 @@ public boolean shouldBroadcastConsoleToOps() { JsonObject responseJson = new JsonObject(); responseJson.addProperty("totalCommands", totalCommands); responseJson.addProperty("acceptedCount", acceptedCount); - responseJson.addProperty("appliedCount", acceptedCount); + responseJson.addProperty("appliedCount", appliedCount); responseJson.addProperty("failedCount", failedCount); responseJson.add("results", results); JsonArray allMessagesArray = new JsonArray(); From 3b8b8756e30c278ee17b9a7a003ae3c4f57aba80 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:54:49 +0000 Subject: [PATCH 07/10] Fix block scanner to include full namespace - Updated `ServerBlockScanner` to use `getId().toString()` rather than `getId().getPath()` so that the block `minecraft:stone` is correctly identified rather than just `stone`. This matches the behavior of the client scanner and avoids merging blocks from different namespaces with the same path. Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java index d366838..16a8910 100644 --- a/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java @@ -63,7 +63,7 @@ public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int max net.minecraft.block.BlockState state = world.getBlockState(pos); if (!state.isAir()) { - String name = net.minecraft.registry.Registries.BLOCK.getId(state.getBlock()).getPath(); + String name = net.minecraft.registry.Registries.BLOCK.getId(state.getBlock()).toString(); blocks.add(new cuspymd.mcp.mod.utils.BlockCompressor.BlockData(x, y, z, name)); count++; } From 6fc6d656b44b41556bc9fdf792dd28a17a7faac3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:11:35 +0000 Subject: [PATCH 08/10] Empty commit for comment resolution Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> From e19c24aadfa36a889d0b1358ce1dc05d860f8e56 Mon Sep 17 00:00:00 2001 From: Myoungdo Park Date: Mon, 2 Mar 2026 16:32:29 +0900 Subject: [PATCH 09/10] Fix server MCP tool exposure and runServer dev setup --- build.gradle | 8 +- .../cuspymd/mcp/mod/MCPServerModClient.java | 5 +- .../cuspymd/mcp/mod/MCPServerModServer.java | 5 +- .../cuspymd/mcp/mod/bridge/HTTPMCPServer.java | 18 ++++- .../cuspymd/mcp/mod/server/MCPProtocol.java | 76 ++++++++++--------- .../mcp/mod/bridge/HTTPMCPServerTest.java | 49 +++++++++++- .../mcp/mod/server/MCPProtocolTest.java | 12 +++ 7 files changed, 131 insertions(+), 42 deletions(-) diff --git a/build.gradle b/build.gradle index 091e26b..3eea397 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,12 @@ loom { } } + runs { + server { + source sourceSets.client + } + } + } dependencies { @@ -95,4 +101,4 @@ publishing { // The repositories here will be used for publishing your artifact, not for // retrieving dependencies. } -} \ No newline at end of file +} diff --git a/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java b/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java index 55a4bd6..6368c73 100644 --- a/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java +++ b/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java @@ -31,7 +31,8 @@ public void onInitializeClient() { new cuspymd.mcp.mod.command.CommandExecutor(config), new cuspymd.mcp.mod.utils.PlayerInfoProvider(), new cuspymd.mcp.mod.utils.BlockScanner(), - new cuspymd.mcp.mod.utils.ScreenshotUtils() + new cuspymd.mcp.mod.utils.ScreenshotUtils(), + true ); httpServer.start(); LOGGER.info("HTTP MCP Server started on port {}", httpServer.getPort()); @@ -57,4 +58,4 @@ public void onClientShutdown() { LOGGER.info("HTTP MCP Server stopped"); } } -} \ No newline at end of file +} diff --git a/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java b/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java index 2716e28..7d9d897 100644 --- a/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java +++ b/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java @@ -28,7 +28,8 @@ public void onInitializeServer() { new cuspymd.mcp.mod.server.tools.ServerCommandExecutor(config, server), new cuspymd.mcp.mod.server.tools.ServerPlayerInfoProvider(server), new cuspymd.mcp.mod.server.tools.ServerBlockScanner(server), - new cuspymd.mcp.mod.server.tools.ServerScreenshotUtils() + new cuspymd.mcp.mod.server.tools.ServerScreenshotUtils(), + false ); httpServer.start(); LOGGER.info("HTTP MCP Server started on port {}", httpServer.getPort()); @@ -55,4 +56,4 @@ public void onInitializeServer() { } }); } -} \ No newline at end of file +} diff --git a/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java b/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java index d560c6b..9aeb18f 100644 --- a/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java +++ b/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java @@ -35,16 +35,29 @@ public class HTTPMCPServer { private final IPlayerInfoProvider playerInfoProvider; private final IBlockScanner blockScanner; private final IScreenshotUtils screenshotUtils; + private final boolean screenshotToolEnabled; private final AtomicBoolean running = new AtomicBoolean(false); private HttpServer httpServer; private ExecutorService executor; public HTTPMCPServer(MCPConfig config, ICommandExecutor commandExecutor, IPlayerInfoProvider playerInfoProvider, IBlockScanner blockScanner, IScreenshotUtils screenshotUtils) { + this(config, commandExecutor, playerInfoProvider, blockScanner, screenshotUtils, true); + } + + public HTTPMCPServer( + MCPConfig config, + ICommandExecutor commandExecutor, + IPlayerInfoProvider playerInfoProvider, + IBlockScanner blockScanner, + IScreenshotUtils screenshotUtils, + boolean screenshotToolEnabled + ) { this.config = config; this.commandExecutor = commandExecutor; this.playerInfoProvider = playerInfoProvider; this.blockScanner = blockScanner; this.screenshotUtils = screenshotUtils; + this.screenshotToolEnabled = screenshotToolEnabled; } public void start() throws IOException { @@ -281,7 +294,7 @@ private JsonObject handlePing() { private JsonObject handleToolsList() { JsonObject response = new JsonObject(); - response.add("tools", MCPProtocol.getToolsListResponse(config)); + response.add("tools", MCPProtocol.getToolsListResponse(config, screenshotToolEnabled)); return response; } @@ -301,6 +314,9 @@ private JsonObject handleToolsCall(JsonObject params) { return handleGetBlocksInArea(arguments); } case "take_screenshot" -> { + if (!screenshotToolEnabled) { + return MCPProtocol.createErrorResponse("Tool not available in dedicated server mode", null); + } return handleTakeScreenshot(arguments); } case null, default -> { diff --git a/src/main/java/cuspymd/mcp/mod/server/MCPProtocol.java b/src/main/java/cuspymd/mcp/mod/server/MCPProtocol.java index 7dd3a17..4a5ab7c 100644 --- a/src/main/java/cuspymd/mcp/mod/server/MCPProtocol.java +++ b/src/main/java/cuspymd/mcp/mod/server/MCPProtocol.java @@ -11,6 +11,10 @@ public class MCPProtocol { private static final Set DESCRIBABLE_COMMANDS = Set.copyOf(MCPConfig.DEFAULT_ALLOWED_COMMANDS); public static JsonArray getToolsListResponse(MCPConfig config) { + return getToolsListResponse(config, true); + } + + public static JsonArray getToolsListResponse(MCPConfig config, boolean includeScreenshotTool) { JsonArray tools = new JsonArray(); List configuredAllowedCommands = (config != null && config.getServer() != null && config.getServer().getAllowedCommands() != null) ? config.getServer().getAllowedCommands() @@ -184,50 +188,52 @@ public static JsonArray getToolsListResponse(MCPConfig config) { getBlocksInAreaTool.add("inputSchema", blocksInputSchema); tools.add(getBlocksInAreaTool); - // Take screenshot tool - JsonObject takeScreenshotTool = new JsonObject(); - takeScreenshotTool.addProperty("name", "take_screenshot"); - takeScreenshotTool.addProperty("description", "Capture a screenshot of the current Minecraft game screen. " + - "This allows you to visually inspect the world, your builds, or the player's surroundings. " + - "Optionally, you can specify coordinates and rotation to move the player and set their gaze before taking the screenshot. " + - "IMPORTANT: If x, y, and z are provided, the player WILL be teleported to that location. " + - "If yaw and pitch are provided, the player's camera direction WILL be changed. " + - "Use this to get the perfect angle for inspecting structures."); + if (includeScreenshotTool) { + // Take screenshot tool (client-only) + JsonObject takeScreenshotTool = new JsonObject(); + takeScreenshotTool.addProperty("name", "take_screenshot"); + takeScreenshotTool.addProperty("description", "Capture a screenshot of the current Minecraft game screen. " + + "This allows you to visually inspect the world, your builds, or the player's surroundings. " + + "Optionally, you can specify coordinates and rotation to move the player and set their gaze before taking the screenshot. " + + "IMPORTANT: If x, y, and z are provided, the player WILL be teleported to that location. " + + "If yaw and pitch are provided, the player's camera direction WILL be changed. " + + "Use this to get the perfect angle for inspecting structures."); - JsonObject screenshotInputSchema = new JsonObject(); - screenshotInputSchema.addProperty("type", "object"); + JsonObject screenshotInputSchema = new JsonObject(); + screenshotInputSchema.addProperty("type", "object"); - JsonObject screenshotProperties = new JsonObject(); + JsonObject screenshotProperties = new JsonObject(); - JsonObject xCoord = new JsonObject(); - xCoord.addProperty("type", "number"); - xCoord.addProperty("description", "Optional X coordinate to teleport the player to"); + JsonObject xCoord = new JsonObject(); + xCoord.addProperty("type", "number"); + xCoord.addProperty("description", "Optional X coordinate to teleport the player to"); - JsonObject yCoord = new JsonObject(); - yCoord.addProperty("type", "number"); - yCoord.addProperty("description", "Optional Y coordinate to teleport the player to"); + JsonObject yCoord = new JsonObject(); + yCoord.addProperty("type", "number"); + yCoord.addProperty("description", "Optional Y coordinate to teleport the player to"); - JsonObject zCoord = new JsonObject(); - zCoord.addProperty("type", "number"); - zCoord.addProperty("description", "Optional Z coordinate to teleport the player to"); + JsonObject zCoord = new JsonObject(); + zCoord.addProperty("type", "number"); + zCoord.addProperty("description", "Optional Z coordinate to teleport the player to"); - JsonObject yawProp = new JsonObject(); - yawProp.addProperty("type", "number"); - yawProp.addProperty("description", "Optional Yaw rotation (0 to 360, or -180 to 180) to set the player's horizontal view direction"); + JsonObject yawProp = new JsonObject(); + yawProp.addProperty("type", "number"); + yawProp.addProperty("description", "Optional Yaw rotation (0 to 360, or -180 to 180) to set the player's horizontal view direction"); - JsonObject pitchProp = new JsonObject(); - pitchProp.addProperty("type", "number"); - pitchProp.addProperty("description", "Optional Pitch rotation (-90 to 90) to set the player's vertical view direction (looking down to up)"); + JsonObject pitchProp = new JsonObject(); + pitchProp.addProperty("type", "number"); + pitchProp.addProperty("description", "Optional Pitch rotation (-90 to 90) to set the player's vertical view direction (looking down to up)"); - screenshotProperties.add("x", xCoord); - screenshotProperties.add("y", yCoord); - screenshotProperties.add("z", zCoord); - screenshotProperties.add("yaw", yawProp); - screenshotProperties.add("pitch", pitchProp); + screenshotProperties.add("x", xCoord); + screenshotProperties.add("y", yCoord); + screenshotProperties.add("z", zCoord); + screenshotProperties.add("yaw", yawProp); + screenshotProperties.add("pitch", pitchProp); - screenshotInputSchema.add("properties", screenshotProperties); - takeScreenshotTool.add("inputSchema", screenshotInputSchema); - tools.add(takeScreenshotTool); + screenshotInputSchema.add("properties", screenshotProperties); + takeScreenshotTool.add("inputSchema", screenshotInputSchema); + tools.add(takeScreenshotTool); + } return tools; } diff --git a/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java b/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java index 3728c3b..196d1ae 100644 --- a/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java +++ b/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java @@ -135,6 +135,49 @@ public void testHandleMCPRequest_ToolsCallTakeScreenshotWithoutArguments() throw assertEquals("no-args-image", firstContent.get("data").getAsString()); } + @Test + public void testHandleMCPRequest_ToolsListWithoutScreenshotInDedicatedMode() throws Exception { + TestableHTTPMCPServer server = new TestableHTTPMCPServer(new MCPConfig(), false); + + JsonObject request = new JsonObject(); + request.addProperty("jsonrpc", "2.0"); + request.addProperty("id", 10); + request.addProperty("method", "tools/list"); + request.add("params", new JsonObject()); + + JsonObject response = invokeHandleMCPRequest(server, request); + JsonArray tools = response.getAsJsonObject("result").getAsJsonArray("tools"); + + boolean hasTakeScreenshot = false; + for (int i = 0; i < tools.size(); i++) { + if ("take_screenshot".equals(tools.get(i).getAsJsonObject().get("name").getAsString())) { + hasTakeScreenshot = true; + break; + } + } + assertFalse(hasTakeScreenshot); + } + + @Test + public void testHandleMCPRequest_ToolsCallTakeScreenshotDisabledInDedicatedMode() throws Exception { + TestableHTTPMCPServer server = new TestableHTTPMCPServer(new MCPConfig(), false); + + JsonObject request = new JsonObject(); + request.addProperty("jsonrpc", "2.0"); + request.addProperty("id", 11); + request.addProperty("method", "tools/call"); + JsonObject params = new JsonObject(); + params.addProperty("name", "take_screenshot"); + params.add("arguments", new JsonObject()); + request.add("params", params); + + JsonObject response = invokeHandleMCPRequest(server, request); + JsonObject result = response.getAsJsonObject("result"); + + assertTrue(result.get("isError").getAsBoolean()); + assertTrue(extractText(result).contains("Tool not available in dedicated server mode")); + } + private JsonObject invokeHandleMCPRequest(HTTPMCPServer server, JsonObject request) throws Exception { Method method = HTTPMCPServer.class.getDeclaredMethod("handleMCPRequest", JsonObject.class); method.setAccessible(true); @@ -180,7 +223,11 @@ private static class TestableHTTPMCPServer extends HTTPMCPServer { CompletableFuture nextFuture; TestableHTTPMCPServer(MCPConfig config) { - super(config, null, null, null, null); + this(config, true); + } + + TestableHTTPMCPServer(MCPConfig config, boolean screenshotToolEnabled) { + super(config, null, null, null, null, screenshotToolEnabled); } @Override diff --git a/src/test/java/cuspymd/mcp/mod/server/MCPProtocolTest.java b/src/test/java/cuspymd/mcp/mod/server/MCPProtocolTest.java index 4baf0cd..e6f104e 100644 --- a/src/test/java/cuspymd/mcp/mod/server/MCPProtocolTest.java +++ b/src/test/java/cuspymd/mcp/mod/server/MCPProtocolTest.java @@ -46,4 +46,16 @@ public void toolsListIncludesTakeScreenshot() { assertTrue(hasTakeScreenshot); } + + @Test + public void toolsListCanExcludeTakeScreenshot() { + MCPConfig config = GSON.fromJson("{\"server\":{}}", MCPConfig.class); + JsonArray tools = MCPProtocol.getToolsListResponse(config, false); + + boolean hasTakeScreenshot = IntStream.range(0, tools.size()) + .mapToObj(i -> tools.get(i).getAsJsonObject()) + .anyMatch(tool -> "take_screenshot".equals(tool.get("name").getAsString())); + + assertFalse(hasTakeScreenshot); + } } From 16d097054d2c85a2d5babfad8ba55c751ce7ae22 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:28:18 +0000 Subject: [PATCH 10/10] Fallback to overworld on ServerBlockScanner when no players online If a dedicated server runs headless automation via MCP, `get_blocks_in_area` should still function even if the server is completely idle (0 players online). Instead of returning an error, it now falls back to scanning the server's Overworld dimension (`server.getOverworld()`). Co-authored-by: cuspymd <8870299+cuspymd@users.noreply.github.com> --- .../mcp/mod/server/tools/ServerBlockScanner.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java index 16a8910..381f23f 100644 --- a/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java @@ -19,15 +19,21 @@ public ServerBlockScanner(MinecraftServer server) { public JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, int maxAreaSize) { try { return server.submit(() -> { - if (server == null || server.getPlayerManager() == null || server.getPlayerManager().getPlayerList().isEmpty()) { + if (server == null) { JsonObject error = new JsonObject(); - error.addProperty("error", "No players online to determine dimension"); + error.addProperty("error", "Server instance not available"); return error; } - // Just use the first player's world - ServerPlayerEntity player = server.getPlayerManager().getPlayerList().get(0); - ServerWorld world = player.getCommandSource().getWorld(); + ServerWorld world; + if (server.getPlayerManager() == null || server.getPlayerManager().getPlayerList().isEmpty()) { + // Fallback to the overworld if no players are online + world = server.getOverworld(); + } else { + // Use the first player's world + ServerPlayerEntity player = server.getPlayerManager().getPlayerList().get(0); + world = player.getCommandSource().getWorld(); + } int x1 = fromPos.get("x").getAsInt(); int y1 = fromPos.get("y").getAsInt();