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/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 233eaf5..6368c73 100644 --- a/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java +++ b/src/client/java/cuspymd/mcp/mod/MCPServerModClient.java @@ -27,12 +27,18 @@ 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(), + true + ); 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()); } @@ -52,4 +58,4 @@ public void onClientShutdown() { LOGGER.info("HTTP MCP Server stopped"); } } -} \ No newline at end of file +} 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..ddf8ad4 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) { @@ -71,7 +76,7 @@ public static JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, result.add("area", areaInfo); // Scan blocks - List blockList = new ArrayList<>(); + List blockList = new ArrayList<>(); int totalBlocks = 0; for (int x = minX; x <= maxX; x++) { @@ -85,13 +90,7 @@ public static JsonObject scanBlocksInArea(JsonObject fromPos, JsonObject toPos, 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/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/main/java/cuspymd/mcp/mod/MCPServerModServer.java b/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java new file mode 100644 index 0000000..7d9d897 --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/MCPServerModServer.java @@ -0,0 +1,59 @@ +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(), + false + ); + 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"); + } + }); + } +} diff --git a/src/client/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java b/src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java similarity index 91% rename from src/client/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java rename to src/main/java/cuspymd/mcp/mod/bridge/HTTPMCPServer.java index 3db2508..9aeb18f 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,33 @@ 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 boolean screenshotToolEnabled; 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, commandExecutor, playerInfoProvider, blockScanner, screenshotUtils, true); + } + + public HTTPMCPServer( + MCPConfig config, + ICommandExecutor commandExecutor, + IPlayerInfoProvider playerInfoProvider, + IBlockScanner blockScanner, + IScreenshotUtils screenshotUtils, + boolean screenshotToolEnabled + ) { this.config = config; - this.commandExecutor = new CommandExecutor(config); + this.commandExecutor = commandExecutor; + this.playerInfoProvider = playerInfoProvider; + this.blockScanner = blockScanner; + this.screenshotUtils = screenshotUtils; + this.screenshotToolEnabled = screenshotToolEnabled; } public void start() throws IOException { @@ -275,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; } @@ -295,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 -> { @@ -315,7 +337,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 +369,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 +427,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/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/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/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/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java new file mode 100644 index 0000000..381f23f --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerBlockScanner.java @@ -0,0 +1,95 @@ +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) { + JsonObject error = new JsonObject(); + error.addProperty("error", "Server instance not available"); + return error; + } + + 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(); + 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) { + 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<>(); + 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()).toString(); + 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) { + JsonObject error = new JsonObject(); + error.addProperty("error", "Failed to scan blocks: " + e.getMessage()); + return error; + } + } +} \ No newline at end of file diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java new file mode 100644 index 0000000..804b37e --- /dev/null +++ b/src/main/java/cuspymd/mcp/mod/server/tools/ServerCommandExecutor.java @@ -0,0 +1,191 @@ +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.command.SafetyValidator; +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; + + private final SafetyValidator safetyValidator; + + public ServerCommandExecutor(MCPConfig config, MinecraftServer server) { + this.config = config; + this.server = server; + this.safetyValidator = new SafetyValidator(config); + } + + @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 appliedCount = 0; + int failedCount = 0; + + 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()) { + 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); + + 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(() -> { + 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 = 0; + try { + // The actual execute method for commands in this mappings version for parsing and execution + successCount = server.getCommandManager().getDispatcher().execute(command, capturingSource); + } catch (Exception ex) { + messages.add("Execution failed: " + ex.getMessage()); + } + + 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++; + 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); + 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", appliedCount); + 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 MCPProtocol.createSuccessResponse(responseJson.toString()); + } +} \ No newline at end of file diff --git a/src/main/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerPlayerInfoProvider.java new file mode 100644 index 0000000..923bed5 --- /dev/null +++ b/src/main/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.getCommandSource().getWorld().getRegistryKey().getValue().toString()); + info.addProperty("gameMode", player.interactionManager.getGameMode().name().toLowerCase()); + 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/main/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java b/src/main/java/cuspymd/mcp/mod/server/tools/ServerScreenshotUtils.java new file mode 100644 index 0000000..83200f7 --- /dev/null +++ b/src/main/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/client/java/cuspymd/mcp/mod/utils/BlockCompressor.java b/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java similarity index 92% rename from src/client/java/cuspymd/mcp/mod/utils/BlockCompressor.java rename to src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java index 983bc08..1a2a0ed 100644 --- a/src/client/java/cuspymd/mcp/mod/utils/BlockCompressor.java +++ b/src/main/java/cuspymd/mcp/mod/utils/BlockCompressor.java @@ -1,10 +1,25 @@ 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.*; 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; @@ -61,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/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/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++) { diff --git a/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java b/src/test/java/cuspymd/mcp/mod/bridge/HTTPMCPServerTest.java index 1c6d2bf..196d1ae 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")); @@ -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); + 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); + } }