diff --git a/DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj b/DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj new file mode 100644 index 00000000..0edf5faa --- /dev/null +++ b/DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/DebugTools/MccMcpSampleClient/Program.cs b/DebugTools/MccMcpSampleClient/Program.cs new file mode 100644 index 00000000..591cc52c --- /dev/null +++ b/DebugTools/MccMcpSampleClient/Program.cs @@ -0,0 +1,211 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +string endpoint = Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp"; +string model = "minimax/minimax-m2.7"; +bool useStdio = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_USE_STDIO"), "1", StringComparison.Ordinal); +string? openRouterApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); +string openRouterBaseUrl = Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1"; +string? mcpAuthToken = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); + +await using McpClient client = useStdio + ? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions())) + : await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken) + ? null + : new Dictionary { ["Authorization"] = $"Bearer {mcpAuthToken}" } + })); + +var executed = new List(); + +CallToolResult sessionStatus = await CallAndStore("mcc_session_status"); +await CallAndStore("mcc_players_list"); +await CallAndStore("mcc_send_chat", new Dictionary { ["text"] = "/say mcp_full_sweep" }); +await CallAndStore("mcc_run_internal_command", new Dictionary { ["command"] = "debug state" }); + +(double lookX, double lookY, double lookZ) = GetLookTarget(sessionStatus); +await CallAndStore("mcc_look_at", new Dictionary { ["x"] = lookX, ["y"] = lookY, ["z"] = lookZ }); +await CallAndStore("mcc_move_to", new Dictionary { ["x"] = lookX, ["y"] = lookY, ["z"] = lookZ, ["timeoutMs"] = 2000 }); + +CallToolResult inventorySnapshot = await CallAndStore("mcc_inventory_snapshot", new Dictionary { ["inventoryId"] = 0 }); +int actionSlot = GetInventoryActionSlot(inventorySnapshot); +await CallAndStore("mcc_inventory_window_action", new Dictionary { ["inventoryId"] = 0, ["slotId"] = actionSlot, ["actionType"] = "LeftClick" }); + +await CallAndStore("mcc_entities_query", new Dictionary { ["maxCount"] = 20 }); +CallToolResult entitiesList = await CallAndStore("mcc_entities_list", new Dictionary { ["maxCount"] = 20 }); +int? firstEntityId = GetFirstEntityId(entitiesList); +if (firstEntityId.HasValue) +{ + await CallAndStore("mcc_entity_info", new Dictionary + { + ["entityId"] = firstEntityId.Value, + ["includeMetadata"] = false, + ["includeEquipment"] = true, + ["includeEffects"] = true + }); +} +await CallAndStore("mcc_blocks_find", new Dictionary { ["query"] = "Grass", ["radius"] = 6, ["maxCount"] = 50 }); +await CallAndStore("mcc_player_nearby", new Dictionary { ["radius"] = 48.0, ["includeSelf"] = false }); +await CallAndStore("mcc_world_block_at", new Dictionary { ["x"] = 0, ["y"] = 80, ["z"] = 0 }); + +string evidenceJson = JsonSerializer.Serialize(executed, new JsonSerializerOptions { WriteIndented = true }); +Console.WriteLine(evidenceJson); + +if (!useStdio && !string.IsNullOrWhiteSpace(openRouterApiKey)) +{ + using HttpClient http = new(); + http.BaseAddress = new Uri(openRouterBaseUrl.TrimEnd('/') + "/"); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", openRouterApiKey); + http.DefaultRequestHeaders.Add("HTTP-Referer", "https://localhost/mcc-mcp-sample"); + http.DefaultRequestHeaders.Add("X-Title", "MCC MCP Sample Client"); + + var payload = new + { + model, + messages = new object[] + { + new { role = "system", content = "Summarize the MCP tool execution output briefly." }, + new { role = "user", content = evidenceJson } + } + }; + + HttpResponseMessage response = await http.PostAsync( + "chat/completions", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + string body = await response.Content.ReadAsStringAsync(); + Console.WriteLine(body); +} + +async Task CallAndStore(string toolName, IReadOnlyDictionary? args = null) +{ + CallToolResult result = await client.CallToolAsync(toolName, args); + executed.Add(new + { + tool = toolName, + arguments = args, + isError = result.IsError, + result = result + }); + return result; +} + +static (double x, double y, double z) GetLookTarget(CallToolResult sessionStatus) +{ + JsonElement? data = TryReadData(sessionStatus); + if (data is JsonElement jsonData && + jsonData.TryGetProperty("location", out JsonElement location) && + TryReadDouble(location, "x", out double x) && + TryReadDouble(location, "y", out double y) && + TryReadDouble(location, "z", out double z)) + { + return (x, y, z); + } + + return (0.5, 80.0, 0.5); +} + +static int GetInventoryActionSlot(CallToolResult inventorySnapshot) +{ + JsonElement? data = TryReadData(inventorySnapshot); + if (data is JsonElement jsonData && + jsonData.TryGetProperty("slots", out JsonElement slots) && + slots.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement slot in slots.EnumerateArray()) + { + if (TryReadInt(slot, "slot", out int slotId)) + return slotId; + } + } + + return 0; +} + +static int? GetFirstEntityId(CallToolResult entitiesList) +{ + JsonElement? data = TryReadData(entitiesList); + if (data is not JsonElement jsonData) + return null; + + if (!jsonData.TryGetProperty("entities", out JsonElement entities) + || entities.ValueKind != JsonValueKind.Array + || entities.GetArrayLength() == 0) + { + return null; + } + + JsonElement first = entities[0]; + if (TryReadInt(first, "id", out int entityId)) + return entityId; + + return null; +} + +static JsonElement? TryReadData(CallToolResult result) +{ + if (result.Content is null) + return null; + + foreach (ContentBlock content in result.Content) + { + if (content is TextContentBlock text && + !string.IsNullOrWhiteSpace(text.Text)) + { + using JsonDocument doc = JsonDocument.Parse(text.Text); + if (doc.RootElement.TryGetProperty("data", out JsonElement data)) + return data.Clone(); + } + } + + return null; +} + +static bool TryReadDouble(JsonElement element, string property, out double value) +{ + value = 0; + return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetDouble(out value); +} + +static bool TryReadInt(JsonElement element, string property, out int value) +{ + value = 0; + return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetInt32(out value); +} + +static StdioClientTransportOptions CreateStdioOptions() +{ + string? stdioBin = Environment.GetEnvironmentVariable("MCC_MCP_STDIO_BIN"); + if (!string.IsNullOrWhiteSpace(stdioBin)) + { + return new StdioClientTransportOptions + { + Name = "MCC MCP Stdio Harness", + Command = stdioBin, + Arguments = [], + ShutdownTimeout = TimeSpan.FromSeconds(5) + }; + } + + return new StdioClientTransportOptions + { + Name = "MCC MCP Stdio Harness", + Command = "dotnet", + Arguments = + [ + "run", + "--project", + "DebugTools/MccMcpStdioHarness", + "-c", + "Release", + "--no-build" + ], + ShutdownTimeout = TimeSpan.FromSeconds(5) + }; +} diff --git a/DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj b/DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj new file mode 100644 index 00000000..2261edaa --- /dev/null +++ b/DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/DebugTools/MccMcpStdioHarness/Program.cs b/DebugTools/MccMcpStdioHarness/Program.cs new file mode 100644 index 00000000..d105f12f --- /dev/null +++ b/DebugTools/MccMcpStdioHarness/Program.cs @@ -0,0 +1,469 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MinecraftClient.Mcp; +using ModelContextProtocol.Server; + +var builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddConsole(options => +{ + options.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services.AddSingleton(); +builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); + +internal sealed class DeterministicCapabilities : IMccMcpCapabilities +{ + private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + + public MccMcpResult GetSessionStatus() => + MccMcpResult.Ok(new + { + connected = true, + host = "deterministic.local", + port = 25565, + username = "HarnessBot", + location = new { x = C(0.5), y = C(80.0), z = C(0.5) } + }); + + public MccMcpResult GetServerInfo() => + MccMcpResult.Ok(new + { + host = "deterministic.local", + port = 25565, + tps = 20.0 + }); + + public MccMcpResult GetPlayerState() => + MccMcpResult.Ok(new + { + nickname = "HarnessBot", + username = "HarnessBot", + health = 20.0f, + saturation = 20, + gamemode = 1, + currentSlot = 1, + yaw = 0.0f, + pitch = 0.0f, + location = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + effects = new object[0] + }); + + public MccMcpResult GetPlayersList() => + MccMcpResult.Ok(new + { + players = new[] { "HarnessBot", "PlayerOne" } + }); + + public MccMcpResult GetChatHistory(int maxCount, bool includeJson) => + MccMcpResult.Ok(new + { + count = 2, + entries = new object[] + { + new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-10), kind = "chat", text = " hello", sender = "PlayerOne", message = "hello", json = includeJson ? "{}" : null }, + new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-5), kind = "system", text = "HarnessBot joined the game", sender = (string?)null, message = (string?)null, json = includeJson ? "{}" : null } + } + }); + + public MccMcpResult GetInternalCommands() => + MccMcpResult.Ok(new + { + count = 4, + commands = new[] + { + new { name = "debug", usage = "debug [on|off|state]", description = "Toggle debug or print state." }, + new { name = "move", usage = "move ", description = "Move to location." }, + new { name = "useitem", usage = "useitem [x] [y] [z]", description = "Use current held item." }, + new { name = "dig", usage = "dig [duration]", description = "Dig block at location." } + } + }); + + public MccMcpResult GetMaterialsList(string? filter, int maxCount) => + MccMcpResult.Ok(new + { + total = 3, + count = 3, + filter, + materials = new[] + { + new { name = "Air", typeLabel = "Air" }, + new { name = "GrassBlock", typeLabel = "Grass Block" }, + new { name = "OakLog", typeLabel = "Oak Log" } + } + }); + + public MccMcpResult GetBlockTypesList(string? filter, int maxCount) => + MccMcpResult.Ok(new + { + total = 3, + count = 3, + filter, + blockTypes = new[] + { + new { name = "Air", typeLabel = "Air" }, + new { name = "GrassBlock", typeLabel = "Grass Block" }, + new { name = "OakLog", typeLabel = "Oak Log" } + } + }); + + public MccMcpResult GetEntityTypesList(string? filter, int maxCount) => + MccMcpResult.Ok(new + { + total = 3, + count = 3, + filter, + entityTypes = new[] + { + new { name = "Player", typeLabel = "Player" }, + new { name = "Item", typeLabel = "Item" }, + new { name = "Villager", typeLabel = "Villager" } + } + }); + + public MccMcpResult SendChat(string text) => + MccMcpResult.Ok(new { echoed = text }); + + public MccMcpResult QuitClient() => + MccMcpResult.Ok(new { quitting = true }); + + public MccMcpResult RunInternalCommand(string command) => + MccMcpResult.Ok(new { command, status = "Done", output = "deterministic" }); + + public MccMcpResult UseItemOnHand() => + MccMcpResult.Ok(new { success = true, action = "use_item_on_hand" }); + + public MccMcpResult ChangeHotbarSlot(int slot) => + MccMcpResult.Ok(new { success = true, slot }); + + public MccMcpResult UseItemOnBlock(double x, double y, double z) => + MccMcpResult.Ok(new { success = true, x = C(x), y = C(y), z = C(z), action = "useitem" }); + + public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds) => + MccMcpResult.Ok(new + { + success = true, + target = new { x = C(x), y = C(y), z = C(z) }, + beforeBlock = new { material = "OakLog", typeLabel = "Oak Log", blockId = 137, blockMeta = 0 }, + afterBlock = new { material = "Air", typeLabel = "Air", blockId = 0, blockMeta = 0 }, + commandAccepted = true, + changed = true, + destroyed = true, + attempts = 1, + attemptedDurationsSeconds = new[] { durationSeconds > 0 ? durationSeconds : 1.5 }, + distance = 1.5, + playerLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) } + }); + + public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) => + MccMcpResult.Ok(new { success = true, x, y, z, face, hand, lookAtBlock, action = "place_block" }); + + public MccMcpResult InteractEntity(int entityId, string interaction, string hand) => + MccMcpResult.Ok(new { success = true, entityId, interaction, hand }); + + public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) => + MccMcpResult.Ok(new + { + center = new { x = 0, y = 79, z = 0 }, + radius, + count = 1, + blocks = new[] + { + new { x = 0, y = 79, z = 0, material = materialFilter ?? "GrassBlock", blockId = 9, blockMeta = 0, distance = 0.0 } + } + }); + + public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch) => + MccMcpResult.Ok(new + { + center = new { x = 0, y = 79, z = 0 }, + radius, + query, + exactMatch, + count = 2, + blocks = new object[] + { + new { x = 1, y = 79, z = 0, material = "GrassBlock", typeLabel = "Grass Block", blockId = 9, blockMeta = 0, distance = 1.0 }, + new { x = 2, y = 79, z = 0, material = "Dirt", typeLabel = "Dirt", blockId = 10, blockMeta = 0, distance = 2.0 } + } + }); + + public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) => + MccMcpResult.Ok(new + { + radius, + playerName, + includeSelf, + anyNearby = true, + count = 1, + players = new object[] + { + new + { + entityId = 1, + uuid = Guid.Empty, + name = "PlayerOne", + customName = (string?)null, + x = C(3.5), + y = C(80.0), + z = C(0.5), + distance = 3.0, + latency = 5 + } + } + }); + + public MccMcpResult LocatePlayer(string playerName, bool includeSelf) => + MccMcpResult.Ok(new + { + playerName, + matchedName = "PlayerOne", + entityId = 1, + uuid = Guid.Empty, + x = C(3.5), + y = C(80.0), + z = C(0.5), + distance = 3.0 + }); + + public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) => + MccMcpResult.Ok(new + { + reachable = true, + exactReachable = true, + target = new { x = C(x), y = C(y), z = C(z) }, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalWaypoint = new { x = C(x), y = C(y), z = C(z) }, + finalDistance = 0.0, + waypointCount = 4, + allowUnsafe, + maxOffset, + minOffset, + timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs + }); + + public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) => + MccMcpResult.Ok(new + { + pathFound = true, + arrived = true, + tolerance = 1.5, + verifyWaitMs = 250, + target = new { x = C(x), y = C(y), z = C(z) }, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalLocation = new { x = C(x), y = C(y), z = C(z) }, + finalDistance = 0.0, + distanceMoved = 3.0, + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }); + + public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) => + MccMcpResult.Ok(new + { + pathFound = true, + arrived = true, + tolerance = 1.5, + verifyWaitMs = 250, + target = new + { + playerName = "PlayerOne", + entityId = 1, + x = C(3.5), + y = C(80.0), + z = C(0.5) + }, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalLocation = new { x = C(3.5), y = C(80.0), z = C(0.5) }, + finalDistance = 0.0, + distanceMoved = 3.0, + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }); + + public MccMcpResult LookAt(double x, double y, double z) => + MccMcpResult.Ok(new { looked = true, x = C(x), y = C(y), z = C(z) }); + + public MccMcpResult GetInventorySnapshot(int inventoryId) => + MccMcpResult.Ok(new + { + id = inventoryId, + slots = new[] + { + new { slot = 0, type = "Stone", count = 64 } + } + }); + + public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) => + MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType }); + + public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack) => + MccMcpResult.Ok(new + { + success = true, + itemType, + requestedCount = count, + droppedCount = count, + beforeCount = 64, + afterCount = Math.Max(0, 64 - count), + inventoryId, + touchedSlots = new[] { 36 }, + preferStack + }); + + public MccMcpResult QueryEntities(int maxCount) => + MccMcpResult.Ok(new + { + count = 1, + entities = new[] + { + new { id = 1, type = "Player", x = C(0.5), y = C(80.0), z = C(0.5) } + } + }); + + public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius) => + MccMcpResult.Ok(new + { + totalTracked = 1, + count = 1, + entities = new[] + { + new + { + id = 1, + type = "Player", + typeLabel = "Player", + uuid = Guid.Empty, + name = "HarnessBot", + customName = (string?)null, + x = C(0.5), + y = C(80.0), + z = C(0.5), + distance = 0.0, + health = 20.0f, + pose = "Standing", + latency = 5 + } + } + }); + + public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects) => + MccMcpResult.Ok(new + { + id = entityId, + type = "Player", + typeLabel = "Player", + uuid = Guid.Empty, + name = "HarnessBot", + customName = (string?)null, + customNameVisible = false, + x = C(0.5), + y = C(80.0), + z = C(0.5), + yaw = 0.0f, + pitch = 0.0f, + health = 20.0f, + pose = "Standing", + latency = 5, + objectData = -1, + metadata = includeMetadata ? new { flags = 0 } : null, + equipment = includeEquipment ? new[] { new { slot = 0, type = "Stone", count = 1 } } : null, + activeEffects = includeEffects ? new object[0] : null + }); + + public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) => + MccMcpResult.Ok(new + { + text, + exactMatch, + radius, + includeBackText, + count = 1, + signs = new[] + { + new + { + x = 2, + y = 80, + z = 1, + material = "OakSign", + typeLabel = "Oak Sign", + distance = 1.8, + isWaxed = false, + frontText = new[] { "home", "storage" }, + backText = includeBackText ? new[] { "north wall" } : Array.Empty(), + matchedLines = new[] { text } + } + } + }); + + public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) => + MccMcpResult.Ok(new + { + itemType = itemType ?? "OakLog", + radius, + count = 1, + items = new[] + { + new + { + entityId = 99, + itemType = "OakLog", + typeLabel = "Oak Log", + count = 3, + x = C(2.5), + y = C(80.0), + z = C(1.5), + distance = 2.24 + } + } + }); + + public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) => + MccMcpResult.Ok(new + { + itemType, + radius, + maxItems, + allowUnsafe, + timeoutMs = timeoutMs <= 0 ? 2500 : timeoutMs, + attempted = 1, + successfulPickups = 1, + collectedCount = 3, + initialInventoryCount = 0, + finalInventoryCount = 3, + remainingNearby = 0, + attempts = new object[] + { + new + { + entityId = 99, + itemType, + typeLabel = "Oak Log", + expectedCount = 3, + target = new { x = C(2.5), y = C(80.0), z = C(1.5) }, + pathFound = true, + arrived = true, + entityGone = true, + inventoryDelta = 3, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalLocation = new { x = C(2.5), y = C(80.0), z = C(1.5) }, + finalDistance = 0.0 + } + } + }); + + public MccMcpResult GetWorldBlockAt(int x, int y, int z) => + MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 }); +} diff --git a/DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj b/DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj new file mode 100644 index 00000000..0ee15b7a --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/DebugTools/MccMcpWebPlayground/Program.cs b/DebugTools/MccMcpWebPlayground/Program.cs new file mode 100644 index 00000000..e2131393 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Program.cs @@ -0,0 +1,1116 @@ +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient("openrouter"); + +var app = builder.Build(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +const string AgentSystemPrompt = """ +You are an agent controlling Minecraft Console Client (MCC) through MCP tools. +Use a plan-execute-verify loop. + +Operating mode +- For simple social turns like "hello" or "thanks", do not waste tool calls. Finish directly unless MCC state is required. +- For MCC questions and actions, think in steps and use tools to gather evidence before you finish. +- Never output plain assistant text before calling agent_finish(answer). + +Planning policy +- If the task is multi-step or physical, first decompose it into a short internal plan. +- Prefer the smallest plan that can succeed. +- For long or branchy tasks, keep a short checklist and update it as you go. +- Default sequence: + 1) inspect current state + 2) locate the target + 3) move into a valid position if needed + 4) perform the action + 5) verify with fresh tool calls + 6) call agent_finish(answer) +- If a step fails, revise the plan using the latest observation. Do not blindly repeat the same failing action. + +Todo policy +- Use todo_write, todo_read, and todo_list for tasks with 4 or more steps, retries, or branching verification. +- Keep todos short, concrete, and action-oriented. +- Update todo status as facts change. +- Todo state is request-scoped for the current chat request only. +- Skip todo tools for simple one-step tasks. + +Tool-use policy +- Use MCP tools for MCC/game-state questions and actions. +- Prefer the most direct high-signal tool first. +- If a tool result says success=false or includes an errorCode, treat that as a failed observation even if the transport call itself succeeded. +- Do not guess tool arguments repeatedly. If a tool returns invalid_args: + - simplify to the minimum required arguments, + - try at most one nearby variant, + - or switch to a broader inspection tool. +- Avoid long speculative tool chains. + +Verification policy +- Never claim success from intent alone. +- Never claim movement succeeded just because a move command was accepted. Check arrived or a fresh location result. +- Never claim an item was collected unless inventory or nearby entity state changed. +- Never claim blocks were removed unless block/world search results changed. +- If evidence is partial, say it is partial. +- If the request cannot be completed, say exactly what was verified and what remains unverified. + +Action-specific guidance +- Move or approach: + - locate the target, + - choose a reachable nearby standing position when exact occupancy is risky, + - move, + - verify arrival before finishing. +- Dig or collect: + - locate the blocks, + - move next to them if needed, + - dig in a sensible order, + - re-check remaining blocks, + - re-check inventory or nearby item entities before finishing. +- Search: + - start with the most direct search tool, + - use the user's requested radius when supported, + - if a query fails, simplify it instead of trying many near-duplicates. + +Good examples +1) User: "Pick up those logs." + Good: + - if the task looks long, write a short todo list + - find the logs + - move next to them + - dig them + - verify the logs are gone or reduced + - verify inventory increased + - then finish +2) User: "Is Zarko near you?" + Good: + - call a nearby-player tool + - report the matched player and distance + - then finish +3) User: "Hello" + Good: + - finish with a short greeting + - no MCP tools + +Wrong examples +1) Wrong: + - inventory did not change + - blocks may still exist + - but you still say "I picked them up" +2) Wrong: + - move returns pathFound=true but arrived=false + - and you still say "I walked there" +3) Wrong: + - a tool returns invalid_args several times + - and you keep guessing similar argument combinations +4) Wrong: + - you write assistant prose before agent_finish(answer) + +Finish rules +- Complete only by calling agent_finish(answer). +- The final answer must be natural language for a human and include exactly: + Reasoning: + - brief bullets with the important verified observations + Answer: + - direct user-facing result with uncertainty stated when relevant +"""; + +const string BudgetReminderPrompt = """ +Budget is nearly exhausted. +Use the strongest verified evidence you already have. +Do not start speculative new branches. +If the task is complete or partially complete, call agent_finish(answer) now and clearly distinguish verified facts from unverified assumptions. +Do not output plain assistant text before finishing. +"""; + +app.MapGet("/api/health", () => Results.Ok(new { ok = true })); +app.MapGet("/api/config", () => +{ + return Results.Ok(new + { + model = GetModel(), + openRouterBaseUrl = GetOpenRouterBaseUrl(), + mcpEndpoint = GetMcpEndpoint(), + hasApiKey = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")) + }); +}); + +app.MapPost("/api/chat/stream", async (ChatStreamRequest request, IHttpClientFactory httpClientFactory, HttpContext context, CancellationToken cancellationToken) => +{ + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + + try + { + string? apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + await WriteEvent(context.Response, "error", new { message = "OPENROUTER_API_KEY is not set." }, cancellationToken); + return; + } + + List messages = BuildMessages(request.Messages); + if (messages.Count == 0) + { + await WriteEvent(context.Response, "error", new { message = "No messages provided." }, cancellationToken); + return; + } + + string model = GetModel(); + int maxIterations = GetBoundedInt("MCC_WEB_MAX_ITERATIONS", 24, 4, 80); + int maxToolCalls = GetBoundedInt("MCC_WEB_MAX_TOOL_CALLS", 80, 4, 256); + TimeSpan maxWallTime = TimeSpan.FromSeconds(GetBoundedInt("MCC_WEB_MAX_SECONDS", 120, 10, 300)); + + await using McpClient mcp = await CreateMcpClientAsync(cancellationToken); + IList mcpTools = await mcp.ListToolsAsync(cancellationToken: cancellationToken); + Dictionary mcpToolsByName = mcpTools + .ToDictionary(tool => tool.Name, StringComparer.OrdinalIgnoreCase); + + object[] openRouterTools = + [ + .. mcpTools.Select(ToOpenRouterTool), + BuildTodoWriteToolSchema(), + BuildTodoReadToolSchema(), + BuildTodoListToolSchema(), + BuildAgentFinishToolSchema() + ]; + + using HttpClient openRouter = httpClientFactory.CreateClient("openrouter"); + openRouter.BaseAddress = new Uri(GetOpenRouterBaseUrl().TrimEnd('/') + "/"); + openRouter.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + openRouter.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground"); + openRouter.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground"); + + Stopwatch wallClock = Stopwatch.StartNew(); + int toolCallCount = 0; + bool reminderInjected = false; + string? finalAnswer = null; + List observations = new(); + Dictionary todos = new(StringComparer.OrdinalIgnoreCase); + int nextTodoOrder = 0; + + for (int iteration = 1; iteration <= maxIterations && !cancellationToken.IsCancellationRequested; iteration++) + { + if (!reminderInjected && ShouldInjectReminder(iteration, maxIterations, toolCallCount, maxToolCalls, wallClock.Elapsed, maxWallTime)) + { + messages.Add(new Dictionary + { + ["role"] = "system", + ["content"] = BudgetReminderPrompt + }); + reminderInjected = true; + } + + if (wallClock.Elapsed >= maxWallTime || toolCallCount >= maxToolCalls) + break; + + JsonElement choiceMessage = await RequestToolIterationAsync(openRouter, model, messages, openRouterTools, context.Response, cancellationToken); + if (choiceMessage.ValueKind == JsonValueKind.Undefined) + return; + + string assistantContent = choiceMessage.TryGetProperty("content", out JsonElement contentElement) + ? contentElement.GetString() ?? string.Empty + : string.Empty; + + if (choiceMessage.TryGetProperty("tool_calls", out JsonElement toolCallsElement) + && toolCallsElement.ValueKind == JsonValueKind.Array + && toolCallsElement.GetArrayLength() > 0) + { + List toolCallsForHistory = new(); + List toolMessages = new(); + bool stopLoop = false; + + foreach (JsonElement toolCall in toolCallsElement.EnumerateArray()) + { + if (!TryReadToolCall(toolCall, out string callId, out string toolName, out string argumentsRaw)) + continue; + + toolCallsForHistory.Add(new Dictionary + { + ["id"] = callId, + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = toolName, + ["arguments"] = argumentsRaw + } + }); + + await WriteEvent(context.Response, "tool_call", new + { + id = callId, + name = toolName, + arguments = argumentsRaw + }, cancellationToken); + + if (TryHandleLocalToolCall(toolName, argumentsRaw, todos, ref nextTodoOrder, out bool localIsError, out string localResultText, out string? completedAnswer)) + { + await WriteEvent(context.Response, "tool_result", new + { + id = callId, + name = toolName, + isError = localIsError, + content = localResultText + }, cancellationToken); + + toolMessages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = localResultText + }); + + toolCallCount++; + observations.Add(SummarizeObservation(toolName, localResultText, localIsError)); + + if (completedAnswer is not null) + { + finalAnswer = EnsureFinalAnswerFormat(completedAnswer, observations); + stopLoop = true; + break; + } + + continue; + } + + if (!mcpToolsByName.ContainsKey(toolName)) + { + string resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "unknown_tool", + message = $"Unknown tool '{toolName}'." + }); + await WriteEvent(context.Response, "tool_result", new + { + id = callId, + name = toolName, + isError = true, + content = resultText + }, cancellationToken); + + observations.Add($"Tool {toolName} was rejected because it is unknown."); + toolMessages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = resultText + }); + continue; + } + + if (toolCallCount >= maxToolCalls) + { + stopLoop = true; + break; + } + + bool isError = false; + string toolResultText; + try + { + Dictionary arguments = ParseArguments(argumentsRaw); + CallToolResult toolResult = await mcp.CallToolAsync(toolName, arguments, cancellationToken: cancellationToken); + toolResultText = ReadToolResultText(toolResult); + isError = toolResult.IsError == true || InferStructuredToolError(toolResultText); + } + catch (Exception ex) + { + isError = true; + toolResultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "tool_call_failed", + message = ex.Message + }); + } + + toolCallCount++; + observations.Add(SummarizeObservation(toolName, toolResultText, isError)); + await WriteEvent(context.Response, "tool_result", new + { + id = callId, + name = toolName, + isError, + content = toolResultText + }, cancellationToken); + + toolMessages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = toolResultText + }); + } + + messages.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = assistantContent, + ["tool_calls"] = toolCallsForHistory + }); + foreach (object toolMessage in toolMessages) + messages.Add(toolMessage); + + if (finalAnswer is not null || stopLoop) + break; + + continue; + } + + if (!string.IsNullOrWhiteSpace(assistantContent)) + observations.Add($"Model attempted direct text before finishing: {Truncate(assistantContent, 140)}"); + + messages.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = assistantContent + }); + messages.Add(new Dictionary + { + ["role"] = "system", + ["content"] = "Do not return assistant prose yet. Continue with tool calls and end only by calling agent_finish(answer)." + }); + } + + finalAnswer ??= BuildForcedFinalAnswer(observations, toolCallCount, wallClock.Elapsed, maxIterations, maxToolCalls, maxWallTime); + await StreamFinalAnswer(context.Response, finalAnswer, cancellationToken); + } + catch (OperationCanceledException) + { + await WriteEvent(context.Response, "error", new { message = "Request cancelled." }, CancellationToken.None); + } + catch (Exception ex) + { + await WriteEvent(context.Response, "error", new + { + message = "Unhandled server error.", + detail = ex.Message + }, CancellationToken.None); + } +}); + +app.Run(); + +static string GetModel() +{ + return Environment.GetEnvironmentVariable("OPENROUTER_MODEL") ?? "minimax/minimax-m2.7"; +} + +static string GetOpenRouterBaseUrl() +{ + return Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1"; +} + +static string GetMcpEndpoint() +{ + return Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp"; +} + +static async Task CreateMcpClientAsync(CancellationToken cancellationToken) +{ + string endpoint = GetMcpEndpoint(); + string? token = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); + + return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + AdditionalHeaders = string.IsNullOrWhiteSpace(token) + ? null + : new Dictionary { ["Authorization"] = $"Bearer {token}" } + }), cancellationToken: cancellationToken); +} + +List BuildMessages(List? incoming) +{ + List messages = + [ + new Dictionary + { + ["role"] = "system", + ["content"] = AgentSystemPrompt + } + ]; + + if (incoming is null) + return messages; + + foreach (ChatMessage message in incoming) + { + if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content)) + continue; + + string role = message.Role.Trim().ToLowerInvariant(); + if (role is not ("system" or "user" or "assistant")) + continue; + + messages.Add(new Dictionary + { + ["role"] = role, + ["content"] = message.Content + }); + } + + return messages; +} + +static object ToOpenRouterTool(McpClientTool tool) +{ + JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject() + }; + + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["parameters"] = parameters + } + }; +} + +static object BuildAgentFinishToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "agent_finish", + ["description"] = "Finalize the response to the user after all required tool calls and verification are done.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["answer"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Final natural-language response for the user." + } + }, + ["required"] = new[] { "answer" }, + ["additionalProperties"] = false + } + } + }; +} + +static object BuildTodoWriteToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "todo_write", + ["description"] = "Create or update a short request-scoped todo item for complex task tracking.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Stable todo identifier, for example move_to_logs or verify_inventory." + }, + ["content"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Short actionable todo text. Required when creating a new item." + }, + ["status"] = new Dictionary + { + ["type"] = "string", + ["description"] = "One of pending, in_progress, completed, blocked, cancelled." + }, + ["notes"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Optional brief note with the latest observation." + } + }, + ["required"] = new[] { "id" }, + ["additionalProperties"] = false + } + } + }; +} + +static object BuildTodoReadToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "todo_read", + ["description"] = "Read one request-scoped todo item by id.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Todo identifier." + } + }, + ["required"] = new[] { "id" }, + ["additionalProperties"] = false + } + } + }; +} + +static object BuildTodoListToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "todo_list", + ["description"] = "List all request-scoped todo items in creation order.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary(), + ["additionalProperties"] = false + } + } + }; +} + +static bool TryHandleLocalToolCall( + string toolName, + string argumentsRaw, + Dictionary todos, + ref int nextTodoOrder, + out bool isError, + out string resultText, + out string? completedAnswer) +{ + isError = false; + resultText = string.Empty; + completedAnswer = null; + + if (toolName.Equals("agent_finish", StringComparison.OrdinalIgnoreCase)) + { + completedAnswer = ParseAgentFinishAnswer(argumentsRaw); + resultText = JsonSerializer.Serialize(new + { + success = true, + finished = true + }); + return true; + } + + if (toolName.Equals("todo_list", StringComparison.OrdinalIgnoreCase)) + { + resultText = JsonSerializer.Serialize(new + { + success = true, + data = new + { + count = todos.Count, + items = todos.Values + .OrderBy(item => item.Order) + .Select(ToTodoDto) + .ToArray() + } + }); + return true; + } + + Dictionary arguments = ParseArguments(argumentsRaw); + if (toolName.Equals("todo_read", StringComparison.OrdinalIgnoreCase)) + { + string? id = ReadOptionalStringArgument(arguments, "id"); + if (string.IsNullOrWhiteSpace(id)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "todo_read requires a non-empty id." + }); + return true; + } + + if (!todos.TryGetValue(id, out TodoEntry? item)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_state", + message = $"Todo '{id}' does not exist." + }); + return true; + } + + resultText = JsonSerializer.Serialize(new + { + success = true, + data = new + { + item = ToTodoDto(item) + } + }); + return true; + } + + if (!toolName.Equals("todo_write", StringComparison.OrdinalIgnoreCase)) + return false; + + string? todoId = ReadOptionalStringArgument(arguments, "id"); + if (string.IsNullOrWhiteSpace(todoId)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "todo_write requires a non-empty id." + }); + return true; + } + + todos.TryGetValue(todoId, out TodoEntry? existingItem); + string? rawContent = ReadOptionalStringArgument(arguments, "content"); + string content = string.IsNullOrWhiteSpace(rawContent) + ? existingItem?.Content ?? string.Empty + : rawContent.Trim(); + if (content.Length == 0) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "todo_write requires content when creating a new item." + }); + return true; + } + + string requestedStatus = ReadOptionalStringArgument(arguments, "status") ?? existingItem?.Status ?? "pending"; + if (!TryNormalizeTodoStatus(requestedStatus, out string normalizedStatus)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "Invalid todo status.", + data = new + { + status = requestedStatus, + allowed = GetTodoStatusValues() + } + }); + return true; + } + + string? notes = ReadOptionalStringArgument(arguments, "notes") ?? existingItem?.Notes; + TodoEntry entry = existingItem ?? new TodoEntry + { + Id = todoId, + Order = ++nextTodoOrder + }; + entry.Content = content; + entry.Status = normalizedStatus; + entry.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); + todos[todoId] = entry; + + resultText = JsonSerializer.Serialize(new + { + success = true, + data = new + { + item = ToTodoDto(entry), + totalCount = todos.Count + } + }); + return true; +} + +static async Task RequestToolIterationAsync( + HttpClient openRouter, + string model, + List messages, + object[] tools, + HttpResponse response, + CancellationToken cancellationToken) +{ + var payload = new Dictionary + { + ["model"] = model, + ["messages"] = messages, + ["tools"] = tools, + ["tool_choice"] = "auto" + }; + + using HttpResponseMessage completion = await openRouter.PostAsync( + "chat/completions", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), + cancellationToken); + + string body = await completion.Content.ReadAsStringAsync(cancellationToken); + if (!completion.IsSuccessStatusCode) + { + await WriteEvent(response, "error", new + { + message = "OpenRouter request failed.", + statusCode = (int)completion.StatusCode, + body + }, cancellationToken); + return default; + } + + using JsonDocument doc = JsonDocument.Parse(body); + if (!TryGetFirstChoiceMessage(doc.RootElement, out JsonElement message)) + { + await WriteEvent(response, "error", new { message = "No completion choice returned by OpenRouter." }, cancellationToken); + return default; + } + + return message.Clone(); +} + +static bool TryReadToolCall(JsonElement toolCall, out string id, out string name, out string arguments) +{ + id = string.Empty; + name = string.Empty; + arguments = "{}"; + + if (!toolCall.TryGetProperty("id", out JsonElement idElement) + || !toolCall.TryGetProperty("function", out JsonElement functionElement) + || !functionElement.TryGetProperty("name", out JsonElement nameElement)) + { + return false; + } + + id = idElement.GetString() ?? string.Empty; + name = nameElement.GetString() ?? string.Empty; + arguments = functionElement.TryGetProperty("arguments", out JsonElement argsElement) + ? argsElement.GetString() ?? "{}" + : "{}"; + return true; +} + +static bool TryGetFirstChoiceMessage(JsonElement root, out JsonElement message) +{ + message = default; + if (!root.TryGetProperty("choices", out JsonElement choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0) + { + return false; + } + + JsonElement first = choices[0]; + return first.TryGetProperty("message", out message); +} + +static Dictionary ParseArguments(string raw) +{ + try + { + using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return new Dictionary(); + + Dictionary parsed = new(); + foreach (JsonProperty property in doc.RootElement.EnumerateObject()) + parsed[property.Name] = ConvertJsonElement(property.Value); + return parsed; + } + catch + { + return new Dictionary(); + } +} + +static string? ReadOptionalStringArgument(Dictionary arguments, string key) +{ + if (!arguments.TryGetValue(key, out object? value) || value is null) + return null; + + return value switch + { + string text => text.Trim(), + _ => Convert.ToString(value)?.Trim() + }; +} + +static object? ConvertJsonElement(JsonElement element) +{ + return element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => element.TryGetInt64(out long i64) + ? i64 + : element.TryGetDouble(out double d) ? d : element.GetRawText(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToArray(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(prop => prop.Name, prop => ConvertJsonElement(prop.Value)), + _ => element.GetRawText() + }; +} + +static string ReadToolResultText(CallToolResult result) +{ + if (result.Content is null) + return result.IsError == true ? "{\"success\":false}" : "{\"success\":true}"; + + StringBuilder sb = new(); + foreach (ContentBlock block in result.Content) + { + if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text)) + { + if (sb.Length > 0) + sb.Append('\n'); + sb.Append(text.Text); + } + } + + if (sb.Length > 0) + return sb.ToString(); + + return JsonSerializer.Serialize(new { isError = result.IsError }); +} + +static bool InferStructuredToolError(string toolResultText) +{ + try + { + using JsonDocument doc = JsonDocument.Parse(toolResultText); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + + if (doc.RootElement.TryGetProperty("success", out JsonElement successElement) + && successElement.ValueKind == JsonValueKind.False) + { + return true; + } + + return doc.RootElement.TryGetProperty("errorCode", out JsonElement errorCodeElement) + && errorCodeElement.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(errorCodeElement.GetString()); + } + catch + { + return false; + } +} + +static bool TryNormalizeTodoStatus(string rawStatus, out string normalizedStatus) +{ + normalizedStatus = rawStatus.Trim().ToLowerInvariant(); + return normalizedStatus is "pending" or "in_progress" or "completed" or "blocked" or "cancelled"; +} + +static string[] GetTodoStatusValues() +{ + return ["pending", "in_progress", "completed", "blocked", "cancelled"]; +} + +static object ToTodoDto(TodoEntry item) +{ + return new + { + id = item.Id, + content = item.Content, + status = item.Status, + notes = item.Notes, + order = item.Order + }; +} + +static string ParseAgentFinishAnswer(string argumentsRaw) +{ + try + { + using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsRaw) ? "{}" : argumentsRaw); + if (doc.RootElement.TryGetProperty("answer", out JsonElement answerElement) + && answerElement.ValueKind == JsonValueKind.String) + { + string answer = answerElement.GetString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(answer)) + return answer.Trim(); + } + } + catch + { + // ignore and use fallback below + } + + return """ +Reasoning: +- The model requested completion without a textual payload. +- Returning a safe fallback response. + +Answer: +I completed the requested tool workflow but did not receive a final textual answer payload. +"""; +} + +static string EnsureFinalAnswerFormat(string text, IReadOnlyList observations) +{ + string trimmed = text.Trim(); + if (trimmed.Length == 0) + trimmed = "I completed the tool workflow but produced no textual output."; + + bool hasReasoning = trimmed.Contains("Reasoning:", StringComparison.OrdinalIgnoreCase); + bool hasAnswer = trimmed.Contains("Answer:", StringComparison.OrdinalIgnoreCase); + if (hasReasoning && hasAnswer) + return trimmed; + + string[] latestObservations = observations + .TakeLast(3) + .ToArray(); + if (latestObservations.Length == 0) + latestObservations = ["Tool-assisted reasoning completed."]; + + string observationBullets = string.Join('\n', latestObservations.Select(observation => $"- {observation}")); + return $""" +Reasoning: +{observationBullets} +- Final response generated after tool execution and verification. + +Answer: +{trimmed} +"""; +} + +static bool ShouldInjectReminder(int iteration, int maxIterations, int toolCallCount, int maxToolCalls, TimeSpan elapsed, TimeSpan maxWallTime) +{ + return iteration >= maxIterations - 2 + || toolCallCount >= maxToolCalls - 4 + || elapsed >= maxWallTime - TimeSpan.FromSeconds(10); +} + +static string BuildForcedFinalAnswer( + IReadOnlyList observations, + int toolCalls, + TimeSpan elapsed, + int maxIterations, + int maxToolCalls, + TimeSpan maxWallTime) +{ + string lastObservation = observations.Count > 0 ? observations[^1] : "No tool observation was captured."; + return $""" +Reasoning: +- The agent loop reached its safety budget before `agent_finish` was called. +- Last observation: {lastObservation} +- Budget usage: toolCalls={toolCalls}/{maxToolCalls}, elapsed={elapsed.TotalSeconds:F1}s/{maxWallTime.TotalSeconds:F1}s, maxIterations={maxIterations}. + +Answer: +I could not complete this request within the configured tool budget. Ask me to retry and I will continue with a fresh loop. +"""; +} + +static string SummarizeObservation(string toolName, string toolResultText, bool isError) +{ + string status = isError ? "error" : "ok"; + return $"{toolName} => {status}: {Truncate(toolResultText.Replace('\n', ' '), 180)}"; +} + +static string Truncate(string text, int maxLength) +{ + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) + return text; + return text[..maxLength] + "..."; +} + +static async Task StreamFinalAnswer(HttpResponse response, string finalText, CancellationToken cancellationToken) +{ + string text = finalText.Trim(); + if (text.Length == 0) + text = "I completed the request but no final text was generated."; + + MatchCollection tokens = Regex.Matches(text, @"\S+\s*", RegexOptions.CultureInvariant); + if (tokens.Count == 0) + { + await WriteEvent(response, "token", new { text }, cancellationToken); + await WriteEvent(response, "final", new { text }, cancellationToken); + return; + } + + const int wordsPerChunk = 10; + StringBuilder chunk = new(); + int words = 0; + + foreach (Match token in tokens.Cast()) + { + chunk.Append(token.Value); + words++; + if (words >= wordsPerChunk) + { + await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); + chunk.Clear(); + words = 0; + } + } + + if (chunk.Length > 0) + await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); + + await WriteEvent(response, "final", new { text }, cancellationToken); +} + +static int GetBoundedInt(string envName, int fallback, int min, int max) +{ + string? raw = Environment.GetEnvironmentVariable(envName); + if (!int.TryParse(raw, out int parsed)) + return fallback; + return Math.Clamp(parsed, min, max); +} + +static async Task WriteEvent(HttpResponse response, string eventName, object payload, CancellationToken cancellationToken) +{ + string json = JsonSerializer.Serialize(payload); + await response.WriteAsync($"event: {eventName}\n", cancellationToken); + await response.WriteAsync($"data: {json}\n\n", cancellationToken); + await response.Body.FlushAsync(cancellationToken); +} + +public sealed class ChatStreamRequest +{ + public List? Messages { get; set; } +} + +public sealed class ChatMessage +{ + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} + +public sealed class TodoEntry +{ + public required string Id { get; init; } + public required int Order { get; init; } + public string Content { get; set; } = string.Empty; + public string Status { get; set; } = "pending"; + public string? Notes { get; set; } +} diff --git a/DebugTools/MccMcpWebPlayground/Properties/launchSettings.json b/DebugTools/MccMcpWebPlayground/Properties/launchSettings.json new file mode 100644 index 00000000..3701e48f --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5295", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7104;http://localhost:5295", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DebugTools/MccMcpWebPlayground/appsettings.Development.json b/DebugTools/MccMcpWebPlayground/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DebugTools/MccMcpWebPlayground/appsettings.json b/DebugTools/MccMcpWebPlayground/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DebugTools/MccMcpWebPlayground/wwwroot/index.html b/DebugTools/MccMcpWebPlayground/wwwroot/index.html new file mode 100644 index 00000000..68ae6c67 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/wwwroot/index.html @@ -0,0 +1,1117 @@ + + + + + + MCC MCP Live Playground + + + + + + + + + + + + + MCC MCP Playground + + + Booting… + + + + + + + + + + + + + + + + + + + + + + + + + + Chat + + + + + + + + + + + + + + No messages yet.Ask the LLM to control MCC via MCP. + + + Thinking + + + + + + + + + + + + + + + Tool Events + + + + + + + + + + + + + + + + + + + + No tool events yet. + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index cb6b169b..d64ef70a 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -113,6 +113,8 @@ namespace MinecraftClient // Entity handling private readonly Dictionary entities = new(); + private readonly Lock signDataLock = new(); + private readonly Dictionary<(int x, int y, int z), (string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)> knownSigns = new(); // server TPS private long lastAge = 0; @@ -166,6 +168,21 @@ namespace MinecraftClient public void GetCookie(string key, out byte[]? data) => Cookies.TryGetValue(key, out data); public void SetCookie(string key, byte[] data) => Cookies[key] = data; public void DeleteCookie(string key) => Cookies.Remove(key, out var data); + public (Location location, string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)[] GetKnownSigns() + { + lock (signDataLock) + { + return knownSigns + .Select(pair => ( + location: new Location(pair.Key.x, pair.Key.y, pair.Key.z), + material: pair.Value.material, + typeLabel: pair.Value.typeLabel, + frontText: (string[])pair.Value.frontText.Clone(), + backText: (string[])pair.Value.backText.Clone(), + isWaxed: pair.Value.isWaxed)) + .ToArray(); + } + } TcpClient client = null!; IMinecraftCom handler = null!; @@ -478,6 +495,7 @@ namespace MinecraftClient physicsInput.Reset(); world.Clear(); entities.Clear(); + ClearKnownSigns(); ClearInventories(); } @@ -763,6 +781,7 @@ namespace MinecraftClient handler.Dispose(); world.Clear(); + ClearKnownSigns(); if (timeoutdetector is not null) { @@ -2804,6 +2823,7 @@ namespace MinecraftClient } entities.Clear(); + ClearKnownSigns(); ClearInventories(); DispatchBotEvent(bot => bot.OnRespawn()); } @@ -4036,9 +4056,16 @@ namespace MinecraftClient public void OnBlockChange(Location location, Block block) { world.SetBlock(location, block); + if (!IsSignMaterial(block.Type)) + RemoveKnownSign(location); DispatchBotEvent(bot => bot.OnBlockChange(location, block)); } + public void OnBlockEntityData(Location location, Dictionary? nbt) + { + UpdateKnownSign(location, nbt); + } + /// /// Called when "AutoComplete" completes. /// @@ -4068,6 +4095,137 @@ namespace MinecraftClient return handler.ClickContainerButton(windowId, buttonId); } + private void ClearKnownSigns() + { + lock (signDataLock) + { + knownSigns.Clear(); + } + } + + private void RemoveKnownSign(Location location) + { + var key = ToBlockKey(location); + lock (signDataLock) + { + knownSigns.Remove(key); + } + } + + private void UpdateKnownSign(Location location, Dictionary? nbt) + { + var key = ToBlockKey(location); + var block = world.GetBlock(new Location(key.x, key.y, key.z)); + if (!IsSignMaterial(block.Type) || !TryExtractSignText(nbt, out string[] frontText, out string[] backText, out bool isWaxed)) + { + lock (signDataLock) + { + knownSigns.Remove(key); + } + + return; + } + + lock (signDataLock) + { + knownSigns[key] = (block.Type.ToString(), block.GetTypeString(), frontText, backText, isWaxed); + } + } + + private static bool TryExtractSignText(Dictionary? nbt, out string[] frontText, out string[] backText, out bool isWaxed) + { + frontText = ExtractSignLines(nbt, "front_text"); + backText = ExtractSignLines(nbt, "back_text"); + if (frontText.Length == 0 && backText.Length == 0) + frontText = ExtractLegacySignLines(nbt); + + isWaxed = nbt is not null + && nbt.TryGetValue("is_waxed", out object? waxedValue) + && waxedValue is bool waxed + && waxed; + return frontText.Length > 0 || backText.Length > 0; + } + + private static string[] ExtractSignLines(Dictionary? nbt, string sideKey) + { + if (nbt is null + || !nbt.TryGetValue(sideKey, out object? sideValue) + || sideValue is not Dictionary sideData + || !sideData.TryGetValue("messages", out object? messagesValue) + || messagesValue is not object[] messages) + { + return []; + } + + return messages + .Take(4) + .Select(ConvertSignMessage) + .ToArray(); + } + + private static string[] ExtractLegacySignLines(Dictionary? nbt) + { + if (nbt is null) + return []; + + List lines = new(4); + for (int i = 1; i <= 4; i++) + { + if (nbt.TryGetValue($"Text{i}", out object? value)) + lines.Add(ConvertSignMessage(value)); + } + + return lines.ToArray(); + } + + private static string ConvertSignMessage(object? value) + { + try + { + return value switch + { + null => string.Empty, + string text => ParseMaybeJsonText(text), + Dictionary nbt => ChatParser.ParseText(nbt), + object[] items => string.Concat(items.Select(ConvertSignMessage)), + _ => value.ToString() ?? string.Empty + }; + } + catch + { + return value?.ToString() ?? string.Empty; + } + } + + private static string ParseMaybeJsonText(string text) + { + string trimmed = text.Trim(); + if ((trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal)) + || (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal))) + { + try + { + return ChatParser.ParseText(trimmed); + } + catch + { + } + } + + return text; + } + + private static bool IsSignMaterial(Material material) + { + return material.ToString().Contains("Sign", StringComparison.Ordinal); + } + + private static (int x, int y, int z) ToBlockKey(Location location) + { + Location blockLocation = location.ToFloor(); + return ((int)blockLocation.X, (int)blockLocation.Y, (int)blockLocation.Z); + } + #endregion } } diff --git a/MinecraftClient/Mcp/IMccMcpCapabilities.cs b/MinecraftClient/Mcp/IMccMcpCapabilities.cs index cae36647..6d5b118a 100644 --- a/MinecraftClient/Mcp/IMccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/IMccMcpCapabilities.cs @@ -8,6 +8,9 @@ public interface IMccMcpCapabilities MccMcpResult GetPlayersList(); MccMcpResult GetChatHistory(int maxCount, bool includeJson); MccMcpResult GetInternalCommands(); + MccMcpResult GetMaterialsList(string? filter, int maxCount); + MccMcpResult GetBlockTypesList(string? filter, int maxCount); + MccMcpResult GetEntityTypesList(string? filter, int maxCount); MccMcpResult SendChat(string text); MccMcpResult QuitClient(); MccMcpResult RunInternalCommand(string command); @@ -21,6 +24,7 @@ public interface IMccMcpCapabilities MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch); MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf); MccMcpResult LocatePlayer(string playerName, bool includeSelf); + MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs); MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs); MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs); MccMcpResult LookAt(double x, double y, double z); @@ -30,5 +34,8 @@ public interface IMccMcpCapabilities MccMcpResult QueryEntities(int maxCount); MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius); MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects); + MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText); + MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount); + MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs); MccMcpResult GetWorldBlockAt(int x, int y, int z); } diff --git a/MinecraftClient/Mcp/MccMcpCapabilities.cs b/MinecraftClient/Mcp/MccMcpCapabilities.cs index 097db7d2..055786a6 100644 --- a/MinecraftClient/Mcp/MccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/MccMcpCapabilities.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using MinecraftClient.CommandHandler; using MinecraftClient.Inventory; using MinecraftClient.Mapping; +using MinecraftClient.Protocol.Message; using MinecraftClient.Scripting; namespace MinecraftClient.Mcp; @@ -14,13 +15,22 @@ namespace MinecraftClient.Mcp; public sealed class MccMcpCapabilities : IMccMcpCapabilities { private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase; + private static readonly double[] s_defaultDigAttemptDurations = [1.5, 3.0, 5.0]; private const int CoordinateRoundingPrecision = 2; private const double SelfEntityDistanceThreshold = 0.2; + private const int MaxBlockScanRadius = 12; + private const int MaxBlockFindRadius = 32; + private const double DigReachDistance = 5.0; + private const double DigReachDistanceSquared = DigReachDistance * DigReachDistance; + private const int DefaultPathQueryTimeoutMs = 5000; + private const int MinPathQueryTimeoutMs = 250; + private const int MaxPathQueryTimeoutMs = 15000; private const int DefaultArrivalWaitMs = 3500; private const int MinArrivalWaitMs = 250; private const int MaxArrivalWaitMs = 15000; private const double DefaultArrivalTolerance = 1.5; private const int ArrivalPollIntervalMs = 125; + private const int MaxBlockVerifyWaitMs = 12000; private sealed class InternalCommandInfo { @@ -42,6 +52,18 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities public required int Latency { get; init; } } + private sealed class NearbyItemSnapshot + { + public required int EntityId { get; init; } + public required ItemType ItemType { get; init; } + public required string TypeLabel { get; init; } + public required int Count { get; init; } + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } + public required double Distance { get; init; } + } + private readonly Func togglesProvider; public MccMcpCapabilities(Func togglesProvider) @@ -226,6 +248,96 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult GetMaterialsList(string? filter, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim(); + Material[] allMaterials = Enum.GetValues(); + var materials = allMaterials + .Select(material => new + { + name = material.ToString(), + typeLabel = GetMaterialTypeLabel(material) + }) + .Where(material => normalizedFilter is null + || TextMatchesFilter(material.name, normalizedFilter) + || TextMatchesFilter(material.typeLabel, normalizedFilter)) + .OrderBy(material => material.name, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + total = allMaterials.Length, + count = materials.Length, + filter = normalizedFilter, + materials + }); + } + + public MccMcpResult GetBlockTypesList(string? filter, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim(); + Material[] allMaterials = Enum.GetValues(); + var blockTypes = allMaterials + .Select(material => new + { + name = material.ToString(), + typeLabel = GetMaterialTypeLabel(material) + }) + .Where(blockType => normalizedFilter is null + || TextMatchesFilter(blockType.name, normalizedFilter) + || TextMatchesFilter(blockType.typeLabel, normalizedFilter)) + .OrderBy(blockType => blockType.name, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + total = allMaterials.Length, + count = blockTypes.Length, + filter = normalizedFilter, + blockTypes + }); + } + + public MccMcpResult GetEntityTypesList(string? filter, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim(); + EntityType[] allEntityTypes = Enum.GetValues(); + var entityTypes = allEntityTypes + .Select(entityType => new + { + name = entityType.ToString(), + typeLabel = Entity.GetTypeString(entityType) + }) + .Where(entityType => normalizedFilter is null + || TextMatchesFilter(entityType.name, normalizedFilter) + || TextMatchesFilter(entityType.typeLabel, normalizedFilter)) + .OrderBy(entityType => entityType.name, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + total = allEntityTypes.Length, + count = entityTypes.Length, + filter = normalizedFilter, + entityTypes + }); + } + public MccMcpResult SendChat(string text) { if (!IsCategoryEnabled(t => t.ChatAndCommands)) @@ -343,7 +455,13 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("capability_disabled"); if (durationSeconds < 0) - return MccMcpResult.Fail("invalid_args"); + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "durationSeconds", + min = 0 + }); + } McClient? client = GetClient(); if (client is null) @@ -352,13 +470,74 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!client.GetTerrainEnabled()) return MccMcpResult.Fail("feature_disabled"); - string sx = x.ToString(CultureInfo.InvariantCulture); - string sy = y.ToString(CultureInfo.InvariantCulture); - string sz = z.ToString(CultureInfo.InvariantCulture); - string command = durationSeconds > 0 - ? $"dig {sx} {sy} {sz} {durationSeconds.ToString(CultureInfo.InvariantCulture)}" - : $"dig {sx} {sy} {sz}"; - return ExecuteInternalCommand(client, command); + Location target = ToBlockLocation(x, y, z); + Location currentLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + Location eyesLocation = currentLocation.EyesLocation(); + Location centeredTarget = target.ToCenter(); + Block beforeBlock = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target)); + if (beforeBlock.Type == Material.Air) + { + return MccMcpResult.Fail("invalid_state", data: new + { + target = ToCoordinate(target), + beforeBlock = ToBlockState(beforeBlock) + }); + } + + double distance = eyesLocation.Distance(centeredTarget); + if (distance > DigReachDistance) + { + return MccMcpResult.Fail("action_incomplete", data: new + { + reason = "too_far", + target = ToCoordinate(target), + playerLocation = ToCoordinate(currentLocation), + distance, + maxReach = DigReachDistance, + beforeBlock = ToBlockState(beforeBlock) + }); + } + + double[] attemptDurations = GetDigAttemptDurations(durationSeconds); + List attemptedDurations = new(); + Block afterBlock = beforeBlock; + bool changed = false; + bool commandAccepted = false; + + foreach (double attemptDuration in attemptDurations) + { + attemptedDurations.Add(attemptDuration); + bool accepted = client.InvokeOnMainThread(() => client.DigBlock(target, Direction.Down, duration: attemptDuration)); + commandAccepted |= accepted; + if (!accepted) + continue; + + if (WaitForBlockChange(client, target, beforeBlock, GetDigVerifyWaitMs(attemptDuration), out afterBlock)) + { + changed = true; + break; + } + } + + afterBlock = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target)); + object resultData = new + { + success = changed, + target = ToCoordinate(target), + beforeBlock = ToBlockState(beforeBlock), + afterBlock = ToBlockState(afterBlock), + commandAccepted, + changed, + destroyed = changed && afterBlock.Type == Material.Air, + attempts = attemptedDurations.Count, + attemptedDurationsSeconds = attemptedDurations.ToArray(), + distance, + playerLocation = ToCoordinate(currentLocation) + }; + + return changed + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); } public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) @@ -411,8 +590,15 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!IsCategoryEnabled(t => t.EntityWorld)) return MccMcpResult.Fail("capability_disabled"); - if (radius is < 1 or > 8) - return MccMcpResult.Fail("invalid_args"); + if (radius is < 1 or > MaxBlockScanRadius) + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "radius", + min = 1, + max = MaxBlockScanRadius + }); + } McClient? client = GetClient(); if (client is null) @@ -444,8 +630,13 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities continue; string material = block.Type.ToString(); - if (filter is not null && !material.Contains(filter, StringComparison.OrdinalIgnoreCase)) + string typeLabel = block.GetTypeString(); + if (filter is not null + && !TextMatchesFilter(material, filter) + && !TextMatchesFilter(typeLabel, filter)) + { continue; + } double dx = x + 0.5 - playerLocation.X; double dy = y + 0.5 - playerLocation.Y; @@ -456,6 +647,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities y, z, material, + typeLabel, blockId = block.BlockId, blockMeta = block.BlockMeta, distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) @@ -479,8 +671,15 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!IsCategoryEnabled(t => t.EntityWorld)) return MccMcpResult.Fail("capability_disabled"); - if (radius is < 1 or > 16) - return MccMcpResult.Fail("invalid_args"); + if (radius is < 1 or > MaxBlockFindRadius) + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "radius", + min = 1, + max = MaxBlockFindRadius + }); + } McClient? client = GetClient(); if (client is null) @@ -558,6 +757,61 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs + }); + } + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + Location goal = new(x, y, z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + World world = client.InvokeOnMainThread(client.GetWorld); + int effectiveTimeoutMs = GetPathQueryTimeoutMs(timeoutMs); + Queue? path = Movement.CalculatePath( + world, + startLocation, + goal, + allowUnsafe, + maxOffset, + minOffset, + TimeSpan.FromMilliseconds(effectiveTimeoutMs)); + Location? finalWaypoint = path?.LastOrDefault(); + double? finalDistance = finalWaypoint is Location waypoint + ? GetDistance(waypoint, goal) + : null; + + return MccMcpResult.Ok(new + { + reachable = path is not null, + exactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(), + target = ToCoordinate(goal), + startLocation = ToCoordinate(startLocation), + finalWaypoint = finalWaypoint is Location finalLocation ? ToCoordinate(finalLocation) : null, + finalDistance, + waypointCount = path?.Count ?? 0, + allowUnsafe, + maxOffset, + minOffset, + timeoutMs = effectiveTimeoutMs + }); + } + public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) { if (!IsCategoryEnabled(t => t.EntityWorld)) @@ -672,6 +926,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!IsCategoryEnabled(t => t.Movement)) return MccMcpResult.Fail("capability_disabled"); + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs + }); + } + McClient? client = GetClient(); if (client is null) return NotConnected(); @@ -680,6 +944,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("feature_disabled"); Location goal = new(x, y, z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout)); @@ -687,16 +952,28 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities double tolerance = GetArrivalTolerance(maxOffset, minOffset); Location? finalLocation = null; bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); - - return MccMcpResult.Ok(new + finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation); + object resultData = new { pathFound, arrived, tolerance, verifyWaitMs, target = ToCoordinate(goal), - finalLocation = finalLocation is Location location ? ToCoordinate(location) : null - }); + startLocation = ToCoordinate(startLocation), + finalLocation = ToCoordinate(finalLocation.Value), + finalDistance = GetDistance(finalLocation.Value, goal), + distanceMoved = GetDistance(startLocation, finalLocation.Value), + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }; + + return pathFound && arrived + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); } public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) @@ -707,6 +984,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (string.IsNullOrWhiteSpace(playerName)) return MccMcpResult.Fail("invalid_args"); + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs + }); + } + McClient? client = GetClient(); if (client is null) return NotConnected(); @@ -718,53 +1005,68 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("feature_disabled"); string nameFilter = playerName.Trim(); - return client.InvokeOnMainThread(() => + NearbyPlayerSnapshot? target = client.InvokeOnMainThread(() => { List trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf: false); - NearbyPlayerSnapshot? target = trackedPlayers + return trackedPlayers .Where(player => PlayerNameMatches(player, nameFilter)) .OrderBy(player => player.Distance) .FirstOrDefault(); - - if (target is null) - { - return MccMcpResult.Fail("invalid_state", data: new - { - playerName = nameFilter, - trackedPlayers = trackedPlayers - .Select(player => player.Name) - .OfType() - .Distinct(NameComparer) - .ToArray() - }); - } - - Location goal = new(target.X, target.Y, target.Z); - TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; - bool pathFound = client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout); - - int verifyWaitMs = GetArrivalWaitMs(timeoutMs); - double tolerance = GetArrivalTolerance(maxOffset, minOffset); - Location? finalLocation = null; - bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); - - return MccMcpResult.Ok(new - { - pathFound, - arrived, - tolerance, - verifyWaitMs, - target = new - { - playerName = target.Name, - entityId = target.EntityId, - x = RoundCoordinate(target.X), - y = RoundCoordinate(target.Y), - z = RoundCoordinate(target.Z) - }, - finalLocation = finalLocation is Location location ? ToCoordinate(location) : null - }); }); + + if (target is null) + { + string[] trackedPlayers = client.InvokeOnMainThread(() => BuildTrackedPlayerSnapshots(client, includeSelf: false) + .Select(player => player.Name) + .OfType() + .Distinct(NameComparer) + .ToArray()); + return MccMcpResult.Fail("invalid_state", data: new + { + playerName = nameFilter, + trackedPlayers + }); + } + + Location goal = new(target.X, target.Y, target.Z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; + bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout)); + + int verifyWaitMs = GetArrivalWaitMs(timeoutMs); + double tolerance = GetArrivalTolerance(maxOffset, minOffset); + Location? finalLocation = null; + bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); + finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation); + + object resultData = new + { + pathFound, + arrived, + tolerance, + verifyWaitMs, + target = new + { + playerName = target.Name, + entityId = target.EntityId, + x = RoundCoordinate(target.X), + y = RoundCoordinate(target.Y), + z = RoundCoordinate(target.Z) + }, + startLocation = ToCoordinate(startLocation), + finalLocation = ToCoordinate(finalLocation.Value), + finalDistance = GetDistance(finalLocation.Value, goal), + distanceMoved = GetDistance(startLocation, finalLocation.Value), + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }; + + return pathFound && arrived + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); } public MccMcpResult LookAt(double x, double y, double z) @@ -1168,6 +1470,237 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(text) || radius is < 1 or > MaxBlockFindRadius) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + string filter = text.Trim(); + int limit = Math.Clamp(maxCount, 1, 500); + + return client.InvokeOnMainThread(() => + { + Location playerLocation = client.GetCurrentLocation(); + World world = client.GetWorld(); + var signs = client.GetKnownSigns() + .Select(sign => + { + double dx = sign.location.X + 0.5 - playerLocation.X; + double dy = sign.location.Y + 0.5 - playerLocation.Y; + double dz = sign.location.Z + 0.5 - playerLocation.Z; + return new + { + sign, + distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) + }; + }) + .Where(entry => entry.distance <= radius) + .Where(entry => IsSignMaterial(world.GetBlock(entry.sign.location).Type)) + .Select(entry => + { + string[] frontText = entry.sign.frontText.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray(); + string[] backText = includeBackText + ? entry.sign.backText.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray() + : []; + string[] matchedLines = frontText + .Concat(backText) + .Where(line => exactMatch ? TextEqualsFilter(line, filter) : TextMatchesFilter(line, filter)) + .Distinct(NameComparer) + .ToArray(); + + return new + { + entry.sign, + entry.distance, + frontText, + backText, + matchedLines + }; + }) + .Where(entry => entry.matchedLines.Length > 0) + .OrderBy(entry => entry.distance) + .Take(limit) + .Select(entry => new + { + x = (int)Math.Floor(entry.sign.location.X), + y = (int)Math.Floor(entry.sign.location.Y), + z = (int)Math.Floor(entry.sign.location.Z), + material = entry.sign.material, + typeLabel = entry.sign.typeLabel, + distance = entry.distance, + isWaxed = entry.sign.isWaxed, + frontText = entry.frontText, + backText = entry.backText, + matchedLines = entry.matchedLines + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + text = filter, + exactMatch, + radius, + includeBackText, + count = signs.Length, + signs + }); + }); + } + + public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (radius <= 0 || radius > 1024) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + ItemType? parsedItemType = null; + string? itemTypeFilter = null; + if (!string.IsNullOrWhiteSpace(itemType)) + { + itemTypeFilter = itemType.Trim(); + if (!TryParseItemType(itemTypeFilter, out ItemType resolvedType)) + return MccMcpResult.Fail("invalid_args"); + parsedItemType = resolvedType; + } + + int limit = Math.Clamp(maxCount, 1, 500); + return client.InvokeOnMainThread(() => + { + NearbyItemSnapshot[] items = BuildNearbyItemSnapshots(client, parsedItemType, radius, limit); + return MccMcpResult.Ok(new + { + itemType = parsedItemType?.ToString() ?? itemTypeFilter, + radius, + count = items.Length, + items = items.Select(item => new + { + entityId = item.EntityId, + itemType = item.ItemType.ToString(), + typeLabel = item.TypeLabel, + count = item.Count, + x = RoundCoordinate(item.X), + y = RoundCoordinate(item.Y), + z = RoundCoordinate(item.Z), + distance = item.Distance + }).ToArray() + }); + }); + } + + public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) + { + if (!IsCategoryEnabled(t => t.EntityWorld) || !IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(itemType) || radius <= 0 || radius > 1024 || maxItems < 1 || timeoutMs < 0) + return MccMcpResult.Fail("invalid_args"); + + if (!TryParseItemType(itemType.Trim(), out ItemType parsedItemType)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled() || !client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + int limit = Math.Clamp(maxItems, 1, 50); + NearbyItemSnapshot[] targets = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, limit)); + if (targets.Length == 0) + { + return MccMcpResult.Fail("invalid_state", data: new + { + itemType = parsedItemType.ToString(), + radius, + maxItems = limit + }); + } + + bool inventoryEnabled = client.GetInventoryEnabled(); + int beforeCount = inventoryEnabled ? client.InvokeOnMainThread(() => GetInventoryItemCount(client, parsedItemType)) : 0; + int initialCount = beforeCount; + int verifyWaitMs = timeoutMs > 0 ? Math.Clamp(timeoutMs, MinArrivalWaitMs, MaxArrivalWaitMs) : 2500; + List attempts = new(targets.Length); + int successfulPickups = 0; + + foreach (NearbyItemSnapshot target in targets) + { + Location targetLocation = new(target.X, target.Y, target.Z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + TimeSpan? moveTimeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; + bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(targetLocation, allowUnsafe, false, 0, 0, moveTimeout)); + Location? finalLocation = null; + bool arrived = pathFound && WaitForArrival(client, targetLocation, verifyWaitMs, 2.0, out finalLocation); + finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation); + bool entityGone = WaitForEntityRemoval(client, target.EntityId, verifyWaitMs); + int afterCount = inventoryEnabled ? client.InvokeOnMainThread(() => GetInventoryItemCount(client, parsedItemType)) : beforeCount; + int inventoryDelta = inventoryEnabled ? Math.Max(0, afterCount - beforeCount) : 0; + bool pickedUp = entityGone || inventoryDelta > 0; + if (pickedUp) + successfulPickups++; + + attempts.Add(new + { + entityId = target.EntityId, + itemType = target.ItemType.ToString(), + typeLabel = target.TypeLabel, + expectedCount = target.Count, + target = ToCoordinate(target.X, target.Y, target.Z), + pathFound, + arrived, + entityGone, + inventoryDelta, + startLocation = ToCoordinate(startLocation), + finalLocation = ToCoordinate(finalLocation.Value), + finalDistance = GetDistance(finalLocation.Value, targetLocation) + }); + + beforeCount = afterCount; + } + + int remainingNearby = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, 1000).Length); + int collectedCount = inventoryEnabled ? Math.Max(0, beforeCount - initialCount) : successfulPickups; + object resultData = new + { + itemType = parsedItemType.ToString(), + radius, + maxItems = limit, + allowUnsafe, + timeoutMs = verifyWaitMs, + attempted = attempts.Count, + successfulPickups, + collectedCount, + initialInventoryCount = inventoryEnabled ? (int?)initialCount : null, + finalInventoryCount = inventoryEnabled ? (int?)beforeCount : null, + remainingNearby, + attempts = attempts.ToArray() + }; + + return successfulPickups > 0 || collectedCount > 0 + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); + } + public MccMcpResult GetWorldBlockAt(int x, int y, int z) { if (!IsCategoryEnabled(t => t.EntityWorld)) @@ -1436,6 +1969,112 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return Math.Max(DefaultArrivalTolerance, toleranceFromOffset); } + private static int GetPathQueryTimeoutMs(int timeoutMs) + { + if (timeoutMs <= 0) + return DefaultPathQueryTimeoutMs; + return Math.Clamp(timeoutMs, MinPathQueryTimeoutMs, MaxPathQueryTimeoutMs); + } + + private static bool WaitForBlockChange(McClient client, Location target, Block beforeBlock, int waitMs, out Block afterBlock) + { + afterBlock = beforeBlock; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + Block current = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target)); + afterBlock = current; + if (!AreEquivalentBlocks(current, beforeBlock)) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static bool AreEquivalentBlocks(Block left, Block right) + { + return left.BlockId == right.BlockId + && left.BlockMeta == right.BlockMeta + && left.Type == right.Type; + } + + private static double[] GetDigAttemptDurations(double durationSeconds) + { + if (durationSeconds > 0) + return [durationSeconds]; + return s_defaultDigAttemptDurations; + } + + private static int GetDigVerifyWaitMs(double durationSeconds) + { + int waitMs = (int)Math.Ceiling(durationSeconds * 1000) + 2000; + return Math.Clamp(waitMs, 1500, MaxBlockVerifyWaitMs); + } + + private static bool AreValidPathOffsets(int maxOffset, int minOffset) + { + return maxOffset >= 0 && minOffset >= 0 && minOffset <= maxOffset; + } + + private static NearbyItemSnapshot[] BuildNearbyItemSnapshots(McClient client, ItemType? itemType, double radius, int maxCount) + { + Location playerLocation = client.GetCurrentLocation(); + return client.GetEntities().Values + .Where(entity => entity.Type == EntityType.Item && !entity.Item.IsEmpty) + .Where(entity => !itemType.HasValue || entity.Item.Type == itemType.Value) + .Select(entity => + { + double dx = entity.Location.X - playerLocation.X; + double dy = entity.Location.Y - playerLocation.Y; + double dz = entity.Location.Z - playerLocation.Z; + return new NearbyItemSnapshot + { + EntityId = entity.ID, + ItemType = entity.Item.Type, + TypeLabel = entity.Item.GetTypeString(), + Count = entity.Item.Count, + X = entity.Location.X, + Y = entity.Location.Y, + Z = entity.Location.Z, + Distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) + }; + }) + .Where(item => item.Distance <= radius) + .OrderBy(item => item.Distance) + .Take(maxCount) + .ToArray(); + } + + private static bool WaitForEntityRemoval(McClient client, int entityId, int waitMs) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + bool exists = client.InvokeOnMainThread(() => client.GetEntities().ContainsKey(entityId)); + if (!exists) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static int GetInventoryItemCount(McClient client, ItemType itemType) + { + Container? inventory = client.GetInventory(0); + if (inventory is null) + return 0; + + return inventory.Items.Values + .Where(item => item.Type == itemType) + .Sum(item => item.Count); + } + private static object ToCoordinate(Location location) { return ToCoordinate(location.X, location.Y, location.Z); @@ -1456,6 +2095,51 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return Math.Round(value, CoordinateRoundingPrecision, MidpointRounding.AwayFromZero); } + private static Location ToBlockLocation(double x, double y, double z) + { + return new Location(Math.Floor(x), Math.Floor(y), Math.Floor(z)); + } + + private static object ToBlockState(Block block) + { + return new + { + material = block.Type.ToString(), + typeLabel = block.GetTypeString(), + blockId = block.BlockId, + blockMeta = block.BlockMeta + }; + } + + private static string GetMaterialTypeLabel(Material material) + { + string key = "block.minecraft." + ToTranslationKey(material.ToString()); + string? translation = ChatParser.TranslateString(key); + return string.IsNullOrEmpty(translation) ? material.ToString() : translation; + } + + private static string ToTranslationKey(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + List chars = new(value.Length * 2); + for (int i = 0; i < value.Length; i++) + { + char current = value[i]; + if (char.IsUpper(current) && i > 0 && (char.IsLower(value[i - 1]) || char.IsDigit(value[i - 1]))) + chars.Add('_'); + chars.Add(char.ToLowerInvariant(current)); + } + + return new string(chars.ToArray()); + } + + private static bool IsSignMaterial(Material material) + { + return material.ToString().Contains("Sign", StringComparison.Ordinal); + } + private static string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary uuidToName) { if (!string.IsNullOrWhiteSpace(entity.Name)) @@ -1492,12 +2176,30 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities string typeLabel = block.GetTypeString(); if (exactMatch) { - return material.Equals(filter, StringComparison.OrdinalIgnoreCase) - || typeLabel.Equals(filter, StringComparison.OrdinalIgnoreCase); + return TextEqualsFilter(material, filter) + || TextEqualsFilter(typeLabel, filter); } - return material.Contains(filter, StringComparison.OrdinalIgnoreCase) - || typeLabel.Contains(filter, StringComparison.OrdinalIgnoreCase); + return TextMatchesFilter(material, filter) + || TextMatchesFilter(typeLabel, filter); + } + + private static bool TextEqualsFilter(string text, string filter) + { + return text.Equals(filter, StringComparison.OrdinalIgnoreCase) + || NormalizeToken(text) == NormalizeToken(filter); + } + + private static bool TextMatchesFilter(string text, string filter) + { + if (text.Contains(filter, StringComparison.OrdinalIgnoreCase)) + return true; + + string normalizedFilter = NormalizeToken(filter); + if (normalizedFilter.Length == 0) + return false; + + return NormalizeToken(text).Contains(normalizedFilter, StringComparison.Ordinal); } private static void ParseBlockQuery(string? query, out int? blockId, out int? blockMeta) diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs index 6eb41caa..d0d0307b 100644 --- a/MinecraftClient/Mcp/MccMcpToolSet.cs +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -49,6 +49,24 @@ public sealed class MccMcpToolSet return capabilities.GetInternalCommands(); } + [McpServerTool(Name = "mcc_materials_list"), Description("List known MCC material names with optional filtering.")] + public object MaterialsList(string? filter = null, int maxCount = 500) + { + return capabilities.GetMaterialsList(filter, maxCount); + } + + [McpServerTool(Name = "mcc_block_types_list"), Description("List known MCC block type names with optional filtering.")] + public object BlockTypesList(string? filter = null, int maxCount = 500) + { + return capabilities.GetBlockTypesList(filter, maxCount); + } + + [McpServerTool(Name = "mcc_entity_types_list"), Description("List known MCC entity type names with optional filtering.")] + public object EntityTypesList(string? filter = null, int maxCount = 500) + { + return capabilities.GetEntityTypesList(filter, maxCount); + } + [McpServerTool(Name = "mcc_send_chat"), Description("Send chat text or slash-command to the connected Minecraft server.")] public object SendChat([Description("Text to send to server chat.")] string text) { @@ -127,6 +145,12 @@ public sealed class MccMcpToolSet return capabilities.LocatePlayer(playerName, includeSelf); } + [McpServerTool(Name = "mcc_can_reach_position"), Description("Check whether MCC can currently path to a world coordinate without moving there.")] + public object CanReachPosition(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) + { + return capabilities.CanReachPosition(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs); + } + [McpServerTool(Name = "mcc_move_to"), Description("Request movement/pathing to a world coordinate and verify arrival.")] public object MoveTo(double x, double y, double z, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) { @@ -185,6 +209,24 @@ public sealed class MccMcpToolSet return capabilities.GetEntityInfo(entityId, includeMetadata, includeEquipment, includeEffects); } + [McpServerTool(Name = "mcc_signs_find"), Description("Find nearby signs whose text exactly matches or contains the requested text.")] + public object SignsFind(string text, bool exactMatch = false, int radius = 16, int maxCount = 50, bool includeBackText = true) + { + return capabilities.FindSigns(text, exactMatch, radius, maxCount, includeBackText); + } + + [McpServerTool(Name = "mcc_items_list"), Description("List nearby dropped item entities with optional item type filtering.")] + public object ItemsList(string? itemType = null, double radius = 32, int maxCount = 100) + { + return capabilities.ListItemEntities(itemType, radius, maxCount); + } + + [McpServerTool(Name = "mcc_items_pickup"), Description("Move to and pick up nearby dropped items of a given item type.")] + public object ItemsPickup(string itemType, double radius = 32, int maxItems = 20, bool allowUnsafe = false, int timeoutMs = 0) + { + return capabilities.PickupItems(itemType, radius, maxItems, allowUnsafe, timeoutMs); + } + [McpServerTool(Name = "mcc_world_block_at"), Description("Get block information at world coordinates.")] public object WorldBlockAt(int x, int y, int z) { diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index b6cdcd05..a36eaf72 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -1569,6 +1569,7 @@ namespace MinecraftClient.Protocol.Handlers var dataSize = dataTypes.ReadNextVarInt(packetData); // Size pTerrain.ProcessChunkColumnData(chunkX, chunkZ, verticalStripBitmask, packetData); + ProcessChunkBlockEntityData(chunkX, chunkZ, packetData); Interlocked.Decrement(ref handler.GetWorld().chunkLoadNotCompleted); // Block Entity data: ignored @@ -2957,17 +2958,16 @@ namespace MinecraftClient.Protocol.Handlers // TODO: Use break; + case PacketTypesIn.BlockEntityData: + if (handler.GetTerrainEnabled() && protocolVersion >= MC_1_17_Version) + { + var location_ = dataTypes.ReadNextLocation(packetData); + dataTypes.ReadNextVarInt(packetData); // Block entity type registry id + var nbt = dataTypes.ReadNextNbt(packetData); + handler.OnBlockEntityData(location_, nbt); + } - // Temporarily disabled until I find a fix - /*case PacketTypesIn.BlockEntityData: - var location_ = dataTypes.ReadNextLocation(packetData); - var type_ = dataTypes.ReadNextInt(packetData); - var nbt = dataTypes.ReadNextNbt(packetData); - var nbtJson = JsonConvert.SerializeObject(nbt["messages"]); - - //log.Info($"BLOCK ENTITY DATA -> {location_.ToString()} [{type_}] -> NBT: {nbtJson}"); - - break;*/ + break; case PacketTypesIn.SetTickingState: dataTypes.ReadNextFloat(packetData); @@ -3162,6 +3162,24 @@ namespace MinecraftClient.Protocol.Handlers SendPacket(packetPalette.GetOutgoingIdByType(packet), packetData); } + private void ProcessChunkBlockEntityData(int chunkX, int chunkZ, Queue packetData) + { + if (protocolVersion < MC_1_17_Version || packetData.Count == 0) + return; + + int blockEntityCount = dataTypes.ReadNextVarInt(packetData); + for (int i = 0; i < blockEntityCount; i++) + { + int packedXZ = dataTypes.ReadNextByte(packetData); + int y = dataTypes.ReadNextShort(packetData); + dataTypes.ReadNextVarInt(packetData); // Block entity type registry id + Dictionary? nbt = dataTypes.ReadNextNbt(packetData); + int blockX = chunkX * Chunk.SizeX + ((packedXZ >> 4) & 0x0F); + int blockZ = chunkZ * Chunk.SizeZ + (packedXZ & 0x0F); + handler.OnBlockEntityData(new Location(blockX, y, blockZ), nbt); + } + } + /// /// Send a configuration packet to the server. Packet ID, compression, and encryption will be handled automatically. /// diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index 94fe0590..85618c07 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -508,6 +508,13 @@ namespace MinecraftClient.Protocol /// The block public void OnBlockChange(Location location, Block block); + /// + /// Called when block entity update data is received for a loaded block. + /// + /// The block location. + /// The block entity NBT payload. + public void OnBlockEntityData(Location location, Dictionary? nbt); + /// /// Called when "AutoComplete" completes. ///
No messages yet.Ask the LLM to control MCC via MCP.
No tool events yet.