diff --git a/DebugTools/MccMcpSampleClient/Program.cs b/DebugTools/MccMcpSampleClient/Program.cs index 591cc52c..0bd91fdb 100644 --- a/DebugTools/MccMcpSampleClient/Program.cs +++ b/DebugTools/MccMcpSampleClient/Program.cs @@ -1,182 +1,733 @@ -using System.Net.Http.Headers; -using System.Text; +using System.Diagnostics; 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}" } - })); - +string repoRoot = FindRepoRoot(); +string rconScript = Path.Combine(repoRoot, "tools", "mc-rcon.sh"); +string rconPort = Environment.GetEnvironmentVariable("MCC_RCON_PORT") ?? "25575"; +string rconPassword = Environment.GetEnvironmentVariable("MCC_RCON_PASSWORD") ?? "test123"; +bool skipSetup = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_SKIP_SETUP"), "1", StringComparison.Ordinal); +bool runLocalSetup = !useStdio && !skipSetup && IsLocalEndpoint(endpoint) && File.Exists(rconScript); var executed = new List(); +var checks = 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) +try { - 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[] + await using McpClient client = useStdio + ? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions())) + : await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions { - new { role = "system", content = "Summarize the MCP tool execution output briefly." }, - new { role = "user", content = evidenceJson } - } - }; + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken) + ? null + : new Dictionary { ["Authorization"] = $"Bearer {mcpAuthToken}" } + })); - HttpResponseMessage response = await http.PostAsync( - "chat/completions", - new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + ToolEnvelope initialWorldState = await CallSuccessAsync(client, executed, "mcc_world_state"); + JsonElement initialWorldData = RequireData(initialWorldState); + string botName = ReadString(initialWorldData, "username") ?? "CursorBot"; + Coordinate initialLocation = ReadCoordinate(initialWorldData, "location"); - string body = await response.Content.ReadAsStringAsync(); - Console.WriteLine(body); + long setupBaseline = 0; + if (runLocalSetup) + { + ToolEnvelope baselineEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary + { + ["afterId"] = 0L, + ["maxCount"] = 1 + }); + setupBaseline = ReadInt64(RequireData(baselineEvents), "latestId"); + + await PrepareWorldAsync(rconScript, rconPort, rconPassword, botName, initialLocation); + await Task.Delay(1500); + } + + ToolEnvelope worldState = await WaitForPredicateAsync( + client, + executed, + "mcc_world_state", + null, + envelope => + { + if (!envelope.Success || envelope.Data is not JsonElement data) + return false; + + return data.TryGetProperty("loadedChunkCount", out JsonElement loaded) + && loaded.TryGetInt32(out int loadedChunkCount) + && loadedChunkCount >= 0 + && HasNonNullProperty(data, "worldAge") + && HasNonNullProperty(data, "timeOfDay"); + }, + "mcc_world_state never reported chunk/time state."); + + JsonElement worldData = RequireData(worldState); + Coordinate worldLocation = ReadCoordinate(worldData, "location"); + string dimension = RequireString(worldData, "dimension"); + int loadedChunkCount = ReadInt32(worldData, "loadedChunkCount"); + int pendingChunkCount = ReadInt32(worldData, "pendingChunkCount"); + int totalChunkCount = ReadInt32(worldData, "totalChunkCount"); + double loadRatio = ReadDouble(worldData, "loadRatio"); + _ = RequireString(worldData, "host"); + _ = ReadInt32(worldData, "port"); + _ = RequireString(worldData, "username"); + _ = ReadInt32(worldData, "protocol"); + _ = ReadDouble(worldData, "tps"); + Ensure(!string.IsNullOrWhiteSpace(dimension), "mcc_world_state returned an empty dimension."); + Ensure(loadedChunkCount + pendingChunkCount == totalChunkCount, "mcc_world_state chunk counters are inconsistent."); + Ensure(loadRatio is >= 0 and <= 1, "mcc_world_state loadRatio is out of range."); + Ensure(HasNonNullProperty(worldData, "worldAge"), "mcc_world_state.worldAge is null."); + Ensure(HasNonNullProperty(worldData, "timeOfDay"), "mcc_world_state.timeOfDay is null."); + if (runLocalSetup || useStdio) + { + Ensure(HasNonNullProperty(worldData, "rainLevel"), "mcc_world_state.rainLevel is null after setup."); + Ensure(HasNonNullProperty(worldData, "thunderLevel"), "mcc_world_state.thunderLevel is null after setup."); + } + checks.Add("mcc_world_state"); + + ToolEnvelope chunkStatus = await CallSuccessAsync(client, executed, "mcc_chunk_status"); + JsonElement chunkData = RequireData(chunkStatus); + JsonElement chunk = RequireProperty(chunkData, "chunk"); + _ = ReadInt32(chunk, "x"); + _ = ReadInt32(chunk, "z"); + Ensure(ReadBoolean(chunkData, "loaded"), "mcc_chunk_status reported the current chunk as unloaded."); + Ensure(ReadInt32(chunkData, "loadedChunkCount") + ReadInt32(chunkData, "pendingChunkCount") == ReadInt32(chunkData, "totalChunkCount"), + "mcc_chunk_status chunk counters are inconsistent."); + checks.Add("mcc_chunk_status"); + + await CallSuccessAsync(client, executed, "mcc_look_direction", new Dictionary { ["direction"] = "Down" }); + ToolEnvelope raycast = await CallSuccessAsync(client, executed, "mcc_raycast_block", new Dictionary + { + ["maxDistance"] = 8.0, + ["includeNeighbors"] = true + }); + JsonElement raycastData = RequireData(raycast); + Ensure(ReadBoolean(raycastData, "hit"), "mcc_raycast_block did not report a hit after looking down."); + JsonElement raycastBlock = RequireProperty(raycastData, "block"); + Ensure(!string.Equals(RequireString(raycastBlock, "material"), "Air", StringComparison.OrdinalIgnoreCase), + "mcc_raycast_block hit Air instead of a solid block."); + Ensure(RequireProperty(raycastData, "neighbors").ValueKind == JsonValueKind.Object, + "mcc_raycast_block did not include neighbors when requested."); + checks.Add("mcc_raycast_block"); + + ToolEnvelope pathPreview = await CallSuccessAsync(client, executed, "mcc_path_preview", new Dictionary + { + ["x"] = Math.Floor(worldLocation.X) + 2, + ["y"] = worldLocation.Y, + ["z"] = Math.Floor(worldLocation.Z), + ["allowUnsafe"] = false, + ["timeoutMs"] = 2000, + ["maxWaypoints"] = 32 + }); + JsonElement pathData = RequireData(pathPreview); + Ensure(ReadBoolean(pathData, "pathFound"), "mcc_path_preview did not find a path to a nearby target."); + Ensure(RequireProperty(pathData, "waypoints").GetArrayLength() > 0, "mcc_path_preview returned no waypoints."); + checks.Add("mcc_path_preview"); + + ToolEnvelope stoneSearch = await WaitForPredicateAsync( + client, + executed, + "mcc_inventory_search", + new Dictionary + { + ["query"] = "Stone", + ["maxCount"] = 20, + ["exactMatch"] = true, + ["includeContainers"] = false + }, + envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0, + "mcc_inventory_search never found Stone in the player inventory."); + Ensure(ContainsItemType(RequireData(stoneSearch), "Stone"), "mcc_inventory_search results did not include Stone."); + + ToolEnvelope swordSearch = await WaitForPredicateAsync( + client, + executed, + "mcc_inventory_search", + new Dictionary + { + ["query"] = "DiamondSword", + ["maxCount"] = 20, + ["exactMatch"] = true, + ["includeContainers"] = false + }, + envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0, + "mcc_inventory_search never found DiamondSword in the player inventory."); + Ensure(ContainsItemType(RequireData(swordSearch), "DiamondSword"), "mcc_inventory_search results did not include DiamondSword."); + checks.Add("mcc_inventory_search"); + + ToolEnvelope selectItem = await CallSuccessAsync(client, executed, "mcc_select_item", new Dictionary + { + ["itemType"] = "DiamondSword", + ["preferLowestSlot"] = true + }); + JsonElement selectData = RequireData(selectItem); + int selectedSlot = ReadInt32(selectData, "selectedSlot"); + + ToolEnvelope playerStats = await CallSuccessAsync(client, executed, "mcc_player_stats"); + JsonElement playerStatsData = RequireData(playerStats); + Ensure(ReadInt32(playerStatsData, "currentSlot") == selectedSlot, "mcc_select_item did not update mcc_player_stats.currentSlot."); + _ = ReadInt32(playerStatsData, "playerEntityId"); + _ = ReadInt32(playerStatsData, "level"); + _ = ReadInt32(playerStatsData, "totalExperience"); + _ = ReadCoordinate(playerStatsData, "location"); + checks.Add("mcc_select_item"); + checks.Add("mcc_player_stats"); + + ToolEnvelope playersDetailed = await CallSuccessAsync(client, executed, "mcc_players_detailed", new Dictionary + { + ["includeSelf"] = true, + ["includeCoordinates"] = true + }); + JsonElement playersData = RequireData(playersDetailed); + JsonElement selfPlayer = FindPlayer(RequireProperty(playersData, "players"), botName); + _ = RequireString(selfPlayer, "uuid"); + _ = ReadInt32(selfPlayer, "ping"); + _ = ReadInt32(selfPlayer, "entityId"); + _ = ReadDouble(selfPlayer, "x"); + _ = ReadDouble(selfPlayer, "y"); + _ = ReadDouble(selfPlayer, "z"); + checks.Add("mcc_players_detailed"); + + ToolEnvelope statusEffects = await CallSuccessAsync(client, executed, "mcc_status_effects"); + Ensure(RequireProperty(RequireData(statusEffects), "effects").ValueKind == JsonValueKind.Array, + "mcc_status_effects.effects is not an array."); + checks.Add("mcc_status_effects"); + + ToolEnvelope animation = await CallSuccessAsync(client, executed, "mcc_animation", new Dictionary + { + ["hand"] = "MainHand" + }); + Ensure(ReadBoolean(RequireData(animation), "success"), "mcc_animation did not report success."); + + ToolEnvelope sneakOn = await CallSuccessAsync(client, executed, "mcc_toggle_sneak", new Dictionary { ["enabled"] = true }); + Ensure(ReadBoolean(RequireData(sneakOn), "enabled"), "mcc_toggle_sneak(true) did not report enabled=true."); + + ToolEnvelope sprintOn = await CallSuccessAsync(client, executed, "mcc_toggle_sprint", new Dictionary { ["enabled"] = true }); + Ensure(ReadBoolean(RequireData(sprintOn), "enabled"), "mcc_toggle_sprint(true) did not report enabled=true."); + + await CallSuccessAsync(client, executed, "mcc_look_angles", new Dictionary + { + ["yaw"] = 45.0f, + ["pitch"] = -15.0f + }); + ToolEnvelope updatedStats = await CallSuccessAsync(client, executed, "mcc_player_stats"); + JsonElement updatedStatsData = RequireData(updatedStats); + Ensure(Math.Abs(ReadDouble(updatedStatsData, "yaw") - 45.0) < 0.01, "mcc_look_angles did not update yaw."); + Ensure(Math.Abs(ReadDouble(updatedStatsData, "pitch") - (-15.0)) < 0.01, "mcc_look_angles did not update pitch."); + checks.Add("mcc_animation"); + checks.Add("mcc_toggle_sneak"); + checks.Add("mcc_toggle_sprint"); + checks.Add("mcc_look_direction"); + checks.Add("mcc_look_angles"); + + ToolEnvelope nearestEntity = await WaitForPredicateAsync( + client, + executed, + "mcc_entity_nearest", + new Dictionary + { + ["typeFilter"] = "ArmorStand", + ["radius"] = 16.0, + ["includePlayers"] = false + }, + envelope => envelope.Success, + "mcc_entity_nearest never found a nearby ArmorStand."); + JsonElement nearestData = RequireData(nearestEntity); + int entityId = ReadInt32(nearestData, "id"); + Ensure(string.Equals(RequireString(nearestData, "type"), "ArmorStand", StringComparison.OrdinalIgnoreCase), + "mcc_entity_nearest did not return an ArmorStand."); + + ToolEnvelope attackEntity = await CallSuccessAsync(client, executed, "mcc_entity_attack", new Dictionary + { + ["entityId"] = entityId + }); + Ensure(ReadBoolean(RequireData(attackEntity), "success"), "mcc_entity_attack did not report success."); + checks.Add("mcc_entity_nearest"); + checks.Add("mcc_entity_attack"); + + long recentSetupAfterId = runLocalSetup ? setupBaseline : 0; + if (runLocalSetup || useStdio) + { + ToolEnvelope setupEvents = await WaitForRecentEventTypesAsync( + client, + executed, + recentSetupAfterId, + "weather_rain", + "title", + "actionbar"); + JsonElement setupEventsData = RequireData(setupEvents); + Ensure(GetEventTypes(setupEventsData).Contains("weather_rain", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include weather_rain."); + Ensure(GetEventTypes(setupEventsData).Contains("title", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include title."); + Ensure(GetEventTypes(setupEventsData).Contains("actionbar", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include actionbar."); + } + + ToolEnvelope actionbarEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary + { + ["afterId"] = 0L, + ["maxCount"] = 20, + ["typeFilter"] = "actionbar" + }); + JsonElement actionbarData = RequireData(actionbarEvents); + Ensure(ReadInt32(actionbarData, "count") > 0, "mcc_recent_events typeFilter=actionbar returned no events."); + Ensure(AllEventsMatchType(actionbarData, "actionbar"), "mcc_recent_events typeFilter returned mixed event types."); + + long inventoryBaseline = ReadInt64(actionbarData, "latestId"); + int chestX = (int)Math.Floor(worldLocation.X) + 2; + int chestY = (int)Math.Floor(worldLocation.Y); + int chestZ = (int)Math.Floor(worldLocation.Z); + ToolEnvelope openContainer = await WaitForPredicateAsync( + client, + executed, + "mcc_container_open_at", + new Dictionary + { + ["x"] = chestX, + ["y"] = chestY, + ["z"] = chestZ, + ["timeoutMs"] = 3000, + ["closeCurrent"] = true + }, + envelope => envelope.Success, + "mcc_open_container_at never opened the nearby chest."); + JsonElement inventoryInfo = RequireProperty(RequireData(openContainer), "inventory"); + int openedInventoryId = ReadInt32(inventoryInfo, "id"); + ToolEnvelope closeContainer = await CallSuccessAsync(client, executed, "mcc_container_close", new Dictionary + { + ["inventoryId"] = openedInventoryId, + ["timeoutMs"] = 3000 + }); + Ensure(ReadBoolean(RequireData(closeContainer), "closed"), "mcc_close_container did not close the chest."); + + ToolEnvelope inventoryEvents = await WaitForRecentEventTypesAsync( + client, + executed, + inventoryBaseline, + "inventory_open", + "inventory_close"); + JsonElement inventoryEventsData = RequireData(inventoryEvents); + Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_open", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_open."); + Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_close", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_close."); + checks.Add("mcc_recent_events"); + + if (runLocalSetup) + { + long deathBaseline = ReadInt64(inventoryEventsData, "latestId"); + await RunRconCommandAsync(rconScript, rconPort, rconPassword, $"kill {botName}"); + ToolEnvelope deathEvents = await WaitForRecentEventTypesAsync(client, executed, deathBaseline, "death"); + Ensure(GetEventTypes(RequireData(deathEvents)).Contains("death", StringComparer.OrdinalIgnoreCase), + "mcc_recent_events never reported death after the RCON kill."); + + long respawnBaseline = ReadInt64(RequireData(deathEvents), "latestId"); + ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn"); + Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success."); + ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn"); + Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase), + "mcc_recent_events never reported respawn after mcc_respawn."); + checks.Add("mcc_respawn"); + } + else + { + long respawnBaseline = ReadInt64(RequireData(inventoryEvents), "latestId"); + ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn"); + Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success."); + ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn"); + Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase), + "mcc_recent_events never reported respawn after mcc_respawn."); + checks.Add("mcc_respawn"); + } + + ToolEnvelope loadedBots = await CallSuccessAsync(client, executed, "mcc_loaded_bots"); + JsonElement bots = RequireProperty(RequireData(loadedBots), "bots"); + Ensure(ContainsBot(bots, "McpServer"), "mcc_loaded_bots did not include McpServer."); + checks.Add("mcc_loaded_bots"); + + ToolEnvelope disconnect = await CallSuccessAsync(client, executed, "mcc_disconnect"); + Ensure(ReadBoolean(RequireData(disconnect), "disconnecting"), "mcc_disconnect did not report disconnecting=true."); + checks.Add("mcc_disconnect"); + + if (!useStdio) + { + await AssertDisconnectStopsEndpointAsync(client, executed); + } + + Console.WriteLine(JsonSerializer.Serialize(new + { + success = true, + endpoint, + useStdio, + runLocalSetup, + checks, + executed + }, new JsonSerializerOptions { WriteIndented = true })); + + Environment.ExitCode = 0; +} +catch (Exception ex) +{ + Console.WriteLine(JsonSerializer.Serialize(new + { + success = false, + endpoint, + useStdio, + runLocalSetup, + error = ex.Message, + checks, + executed + }, new JsonSerializerOptions { WriteIndented = true })); + Environment.ExitCode = 1; } -async Task CallAndStore(string toolName, IReadOnlyDictionary? args = null) +static async Task PrepareWorldAsync(string rconScript, string rconPort, string rconPassword, string botName, Coordinate location) +{ + string[] commands = + [ + $"op {botName}", + $"gamemode creative {botName}", + $"tp {botName} 0 80 0", + $"item replace entity {botName} hotbar.0 with minecraft:stone 32", + $"item replace entity {botName} hotbar.1 with minecraft:diamond_sword 1", + $"execute as {botName} at @s run setblock ~2 ~ ~ minecraft:chest", + $"execute as {botName} at @s run summon minecraft:armor_stand ~2 ~ ~1", + "weather clear", + "weather rain", + $"title {botName} title {{\"text\":\"mcp_title\"}}", + $"title {botName} actionbar {{\"text\":\"mcp_actionbar\"}}" + ]; + + foreach (string command in commands) + { + await RunRconCommandAsync(rconScript, rconPort, rconPassword, command); + } +} + +static async Task RunRconCommandAsync(string rconScript, string rconPort, string rconPassword, string command) +{ + ProcessStartInfo startInfo = new("bash") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + startInfo.ArgumentList.Add(rconScript); + startInfo.ArgumentList.Add(command); + startInfo.ArgumentList.Add(rconPort); + startInfo.ArgumentList.Add(rconPassword); + + using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mc-rcon.sh."); + string stdout = await process.StandardOutput.ReadToEndAsync(); + string stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"RCON command failed ({command}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}"); + } +} + +static async Task CallSuccessAsync( + McpClient client, + List executed, + string toolName, + IReadOnlyDictionary? args = null) +{ + ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args); + if (!envelope.Success) + { + throw new InvalidOperationException( + $"{toolName} failed with errorCode={envelope.ErrorCode ?? ""} message={envelope.Message ?? ""}."); + } + + return envelope; +} + +static async Task WaitForPredicateAsync( + McpClient client, + List executed, + string toolName, + IReadOnlyDictionary? args, + Func predicate, + string failureMessage, + int maxAttempts = 12, + int delayMs = 400) +{ + ToolEnvelope? lastEnvelope = null; + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args); + lastEnvelope = envelope; + if (predicate(envelope)) + return envelope; + + await Task.Delay(delayMs); + } + + throw new InvalidOperationException( + $"{failureMessage} Last result: success={lastEnvelope?.Success}, errorCode={lastEnvelope?.ErrorCode ?? ""}."); +} + +static async Task WaitForRecentEventTypesAsync( + McpClient client, + List executed, + long afterId, + params string[] expectedTypes) +{ + HashSet expected = expectedTypes.ToHashSet(StringComparer.OrdinalIgnoreCase); + ToolEnvelope? lastEnvelope = null; + + for (int attempt = 0; attempt < 12; attempt++) + { + ToolEnvelope envelope = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary + { + ["afterId"] = afterId, + ["maxCount"] = 100 + }); + lastEnvelope = envelope; + JsonElement data = RequireData(envelope); + HashSet actual = GetEventTypes(data); + if (expected.All(actual.Contains)) + return envelope; + + await Task.Delay(400); + } + + throw new InvalidOperationException( + $"mcc_recent_events never reported: {string.Join(", ", expectedTypes)} after event id {afterId}. Last latestId={ReadInt64(RequireData(lastEnvelope!), "latestId")}."); +} + +static async Task CallToolAsync( + McpClient client, + List executed, + string toolName, + IReadOnlyDictionary? args = null) { CallToolResult result = await client.CallToolAsync(toolName, args); + string responseJson = ExtractResponseJson(result); + JsonElement root = JsonDocument.Parse(responseJson).RootElement.Clone(); + JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement) ? dataElement.Clone() : null; + bool success = root.TryGetProperty("success", out JsonElement successElement) + && successElement.ValueKind == JsonValueKind.True; + string? errorCode = ReadString(root, "errorCode"); + string? message = ReadString(root, "message"); + executed.Add(new { tool = toolName, arguments = args, isError = result.IsError, - result = result + success, + errorCode, + message, + response = root }); - return result; + + return new ToolEnvelope(toolName, result.IsError ?? false, success, errorCode, message, root, data); } -static (double x, double y, double z) GetLookTarget(CallToolResult sessionStatus) +static async Task AssertDisconnectStopsEndpointAsync(McpClient client, List executed) { - 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)) + for (int attempt = 0; attempt < 15; attempt++) { - 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()) + try { - if (TryReadInt(slot, "slot", out int slotId)) - return slotId; + await CallToolAsync(client, executed, "mcc_world_state"); } + catch + { + return; + } + + await Task.Delay(300); } - return 0; + throw new InvalidOperationException("The MCP endpoint still responded after mcc_disconnect."); } -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) +static string ExtractResponseJson(CallToolResult result) { if (result.Content is null) - return null; + throw new InvalidOperationException("Tool response did not contain any content blocks."); 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(); - } + if (content is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text)) + return text.Text; } - return null; + throw new InvalidOperationException("Tool response did not contain a text payload."); } -static bool TryReadDouble(JsonElement element, string property, out double value) +static JsonElement RequireData(ToolEnvelope envelope) { - value = 0; - return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetDouble(out value); + if (envelope.Data is JsonElement data) + return data; + + throw new InvalidOperationException($"{envelope.ToolName} returned no data payload."); } -static bool TryReadInt(JsonElement element, string property, out int value) +static JsonElement RequireProperty(JsonElement element, string propertyName) { - value = 0; - return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetInt32(out value); + if (element.TryGetProperty(propertyName, out JsonElement property)) + return property; + + throw new InvalidOperationException($"Missing required property '{propertyName}'."); +} + +static string RequireString(JsonElement element, string propertyName) +{ + string? value = ReadString(element, propertyName); + if (!string.IsNullOrWhiteSpace(value)) + return value; + + throw new InvalidOperationException($"Property '{propertyName}' is missing or empty."); +} + +static string? ReadString(JsonElement element, string propertyName) +{ + return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; +} + +static int ReadInt32(JsonElement element, string propertyName) +{ + JsonElement property = RequireProperty(element, propertyName); + if (property.TryGetInt32(out int value)) + return value; + + throw new InvalidOperationException($"Property '{propertyName}' is not an Int32."); +} + +static long ReadInt64(JsonElement element, string propertyName) +{ + JsonElement property = RequireProperty(element, propertyName); + if (property.TryGetInt64(out long value)) + return value; + + throw new InvalidOperationException($"Property '{propertyName}' is not an Int64."); +} + +static double ReadDouble(JsonElement element, string propertyName) +{ + JsonElement property = RequireProperty(element, propertyName); + if (property.TryGetDouble(out double value)) + return value; + + throw new InvalidOperationException($"Property '{propertyName}' is not a Double."); +} + +static bool ReadBoolean(JsonElement element, string propertyName) +{ + JsonElement property = RequireProperty(element, propertyName); + return property.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => throw new InvalidOperationException($"Property '{propertyName}' is not a Boolean.") + }; +} + +static bool HasNonNullProperty(JsonElement element, string propertyName) +{ + return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind != JsonValueKind.Null; +} + +static Coordinate ReadCoordinate(JsonElement element, string propertyName) +{ + JsonElement coordinate = RequireProperty(element, propertyName); + return new Coordinate( + ReadDouble(coordinate, "x"), + ReadDouble(coordinate, "y"), + ReadDouble(coordinate, "z")); +} + +static JsonElement FindPlayer(JsonElement players, string playerName) +{ + foreach (JsonElement player in players.EnumerateArray()) + { + string? name = ReadString(player, "name"); + if (string.Equals(name, playerName, StringComparison.OrdinalIgnoreCase)) + return player; + } + + throw new InvalidOperationException($"Could not find player '{playerName}' in mcc_players_detailed."); +} + +static bool ContainsItemType(JsonElement searchData, string itemType) +{ + JsonElement matches = RequireProperty(searchData, "matches"); + foreach (JsonElement match in matches.EnumerateArray()) + { + if (string.Equals(ReadString(match, "itemType"), itemType, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; +} + +static bool ContainsBot(JsonElement bots, string botName) +{ + foreach (JsonElement bot in bots.EnumerateArray()) + { + if (string.Equals(ReadString(bot, "name"), botName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; +} + +static HashSet GetEventTypes(JsonElement recentEventsData) +{ + JsonElement events = RequireProperty(recentEventsData, "events"); + return events.EnumerateArray() + .Select(entry => RequireString(entry, "type")) + .ToHashSet(StringComparer.OrdinalIgnoreCase); +} + +static bool AllEventsMatchType(JsonElement recentEventsData, string type) +{ + JsonElement events = RequireProperty(recentEventsData, "events"); + foreach (JsonElement entry in events.EnumerateArray()) + { + if (!string.Equals(RequireString(entry, "type"), type, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; +} + +static void Ensure(bool condition, string message) +{ + if (!condition) + throw new InvalidOperationException(message); +} + +static bool IsLocalEndpoint(string endpoint) +{ + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri)) + return false; + + return string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Host, "::1", StringComparison.OrdinalIgnoreCase); +} + +static string FindRepoRoot() +{ + string current = Directory.GetCurrentDirectory(); + DirectoryInfo? directory = new(current); + + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MinecraftClient.sln"))) + return directory.FullName; + + directory = directory.Parent; + } + + return current; } static StdioClientTransportOptions CreateStdioOptions() @@ -209,3 +760,14 @@ static StdioClientTransportOptions CreateStdioOptions() ShutdownTimeout = TimeSpan.FromSeconds(5) }; } + +internal readonly record struct Coordinate(double X, double Y, double Z); + +internal sealed record ToolEnvelope( + string ToolName, + bool IsError, + bool Success, + string? ErrorCode, + string? Message, + JsonElement Root, + JsonElement? Data); diff --git a/DebugTools/MccMcpStdioHarness/Program.cs b/DebugTools/MccMcpStdioHarness/Program.cs index 441f7dac..1b90ddbb 100644 --- a/DebugTools/MccMcpStdioHarness/Program.cs +++ b/DebugTools/MccMcpStdioHarness/Program.cs @@ -24,14 +24,36 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities { private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + private readonly List recentEvents = []; + private long nextEventId = 1; + private double playerX = C(0.5); + private double playerY = C(80.0); + private double playerZ = C(0.5); + private float yaw; + private float pitch; + private int currentSlot = 1; + private bool sneaking; + private bool sprinting; + private float health = 20.0f; + private bool disconnecting; + + public DeterministicCapabilities() + { + AddRecentEvent("player_join", new { name = "HarnessBot" }); + AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest" }); + AddRecentEvent("weather_rain", new { level = 1.0 }); + AddRecentEvent("title", new { text = "mcp_title" }); + AddRecentEvent("actionbar", new { text = "mcp_actionbar" }); + } + public MccMcpResult GetSessionStatus() => MccMcpResult.Ok(new { - connected = true, + connected = !disconnecting, host = "deterministic.local", port = 25565, username = "HarnessBot", - location = new { x = C(0.5), y = C(80.0), z = C(0.5) } + location = new { x = playerX, y = playerY, z = playerZ } }); public MccMcpResult GetServerInfo() => @@ -47,22 +69,222 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities { nickname = "HarnessBot", username = "HarnessBot", - health = 20.0f, + health, 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) }, + currentSlot, + yaw, + pitch, + location = new { x = playerX, y = playerY, z = playerZ }, effects = new object[0] }); + public MccMcpResult GetWorldState() => + MccMcpResult.Ok(new + { + connected = !disconnecting, + host = "deterministic.local", + port = 25565, + username = "HarnessBot", + protocol = 769, + terrainEnabled = true, + inventoryEnabled = true, + entityHandlingEnabled = true, + location = new { x = playerX, y = playerY, z = playerZ }, + tps = 20.0, + dimension = "minecraft:overworld", + loadedChunkCount = 9, + pendingChunkCount = 0, + totalChunkCount = 9, + loadRatio = 1.0, + worldAge = 12000L, + timeOfDay = 6000L, + rainLevel = 1.0, + thunderLevel = 0.0 + }); + + public MccMcpResult GetChunkStatus(double? x, double? y, double? z) + { + double resolvedX = x ?? playerX; + double resolvedY = y ?? playerY; + double resolvedZ = z ?? playerZ; + int chunkX = (int)Math.Floor(resolvedX) >> 4; + int chunkZ = (int)Math.Floor(resolvedZ) >> 4; + + return MccMcpResult.Ok(new + { + location = new { x = C(resolvedX), y = C(resolvedY), z = C(resolvedZ) }, + chunk = new { x = chunkX, z = chunkZ }, + loaded = true, + fullyLoaded = true, + loadedChunkCount = 9, + pendingChunkCount = 0, + totalChunkCount = 9, + loadRatio = 1.0 + }); + } + + public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors) + { + object? neighbors = includeNeighbors + ? new + { + north = new { x = 0, y = 79, z = -1, material = "Air", typeLabel = "Air" }, + south = new { x = 0, y = 79, z = 1, material = "Air", typeLabel = "Air" }, + east = new { x = 1, y = 79, z = 0, material = "Air", typeLabel = "Air" }, + west = new { x = -1, y = 79, z = 0, material = "Air", typeLabel = "Air" }, + above = new { x = 0, y = 80, z = 0, material = "Air", typeLabel = "Air" }, + below = new { x = 0, y = 78, z = 0, material = "Stone", typeLabel = "Stone" } + } + : null; + + return MccMcpResult.Ok(new + { + hit = true, + maxDistance, + playerLocation = new { x = playerX, y = playerY, z = playerZ }, + eyeLocation = new { x = playerX, y = C(playerY + 1.62), z = playerZ }, + location = new { x = 0, y = 79, z = 0 }, + block = new { material = "Stone", typeLabel = "Stone", blockId = 1, blockMeta = 0 }, + distance = 1.12, + eyeDistance = 2.03, + neighbors + }); + } + + public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints) + { + object[] waypoints = + [ + new { x = playerX, y = playerY, z = playerZ }, + new { x = C((playerX + x) / 2), y = C((playerY + y) / 2), z = C((playerZ + z) / 2) }, + new { x = C(x), y = C(y), z = C(z) } + ]; + + return MccMcpResult.Ok(new + { + pathFound = true, + exactReachable = true, + target = new { x = C(x), y = C(y), z = C(z) }, + startLocation = new { x = playerX, y = playerY, z = playerZ }, + finalWaypoint = new { x = C(x), y = C(y), z = C(z) }, + finalDistance = 0.0, + waypointCount = waypoints.Length, + truncated = waypoints.Length > Math.Max(1, maxWaypoints), + waypoints = waypoints.Take(Math.Max(1, maxWaypoints)).ToArray(), + allowUnsafe, + maxOffset, + minOffset, + timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs + }); + } + public MccMcpResult GetPlayersList() => MccMcpResult.Ok(new { players = new[] { "HarnessBot", "PlayerOne" } }); + public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates) + { + List players = []; + if (includeSelf) + { + players.Add(new + { + name = "HarnessBot", + uuid = Guid.Parse("11111111-1111-1111-1111-111111111111"), + ping = 5, + gamemode = 1, + listed = true, + displayName = "HarnessBot", + entityId = 1, + x = includeCoordinates ? playerX : (double?)null, + y = includeCoordinates ? playerY : (double?)null, + z = includeCoordinates ? playerZ : (double?)null + }); + } + + players.Add(new + { + name = "PlayerOne", + uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"), + ping = 12, + gamemode = 1, + listed = true, + displayName = "PlayerOne", + entityId = 2, + x = includeCoordinates ? C(3.5) : (double?)null, + y = includeCoordinates ? C(80.0) : (double?)null, + z = includeCoordinates ? C(0.5) : (double?)null + }); + + return MccMcpResult.Ok(new + { + count = players.Count, + players = players.ToArray() + }); + } + + public MccMcpResult GetPlayerStats() => + MccMcpResult.Ok(new + { + health, + saturation = 20, + level = 12, + totalExperience = 245, + gamemode = 1, + playerEntityId = 1, + currentSlot, + yaw, + pitch, + sneaking, + sprinting, + location = new { x = playerX, y = playerY, z = playerZ }, + tps = 20.0 + }); + + public MccMcpResult GetStatusEffects() => + MccMcpResult.Ok(new + { + count = 0, + effects = Array.Empty() + }); + + public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter) + { + RecentEvent[] events = recentEvents + .Where(e => e.Id > afterId) + .Where(e => string.IsNullOrWhiteSpace(typeFilter) || string.Equals(e.Type, typeFilter, StringComparison.OrdinalIgnoreCase)) + .Take(Math.Max(1, maxCount)) + .ToArray(); + + return MccMcpResult.Ok(new + { + afterId, + latestId = recentEvents.Count > 0 ? recentEvents[^1].Id : 0, + count = events.Length, + events = events.Select(e => new + { + id = e.Id, + timestampUtc = e.TimestampUtc, + type = e.Type, + data = e.Data + }).ToArray() + }); + } + + public MccMcpResult GetLoadedBots() => + MccMcpResult.Ok(new + { + count = 2, + bots = new object[] + { + new { name = "McpServer", fullTypeName = "MinecraftClient.ChatBots.McpServer", isScript = false }, + new { name = "HarnessScript", fullTypeName = "MinecraftClient.ChatBots.Script", isScript = true } + } + }); + public MccMcpResult GetChatHistory(int maxCount, bool includeJson) => MccMcpResult.Ok(new { @@ -135,14 +357,38 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities public MccMcpResult QuitClient() => MccMcpResult.Ok(new { quitting = true }); + public MccMcpResult DisconnectClient() + { + disconnecting = true; + AddRecentEvent("disconnect", new { reason = "requested", message = "Disconnect requested by test client." }); + return MccMcpResult.Ok(new { disconnecting = 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 ChangeHotbarSlot(int slot) + { + currentSlot = slot; + return MccMcpResult.Ok(new { success = true, slot }); + } + + public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot) + { + currentSlot = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 2 : 1; + return MccMcpResult.Ok(new + { + success = true, + itemType, + inventorySlot = currentSlot - 1, + selectedSlot = currentSlot, + count = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 1 : 32, + preferLowestSlot + }); + } 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" }); @@ -169,6 +415,58 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities public MccMcpResult InteractEntity(int entityId, string interaction, string hand) => MccMcpResult.Ok(new { success = true, entityId, interaction, hand }); + public MccMcpResult AttackEntity(int entityId) => + MccMcpResult.Ok(new { success = true, entityId, interaction = "Attack" }); + + public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers) + { + bool wantsArmorStand = string.IsNullOrWhiteSpace(typeFilter) + || string.Equals(typeFilter, "ArmorStand", StringComparison.OrdinalIgnoreCase) + || string.Equals(typeFilter, "Armor Stand", StringComparison.OrdinalIgnoreCase); + + if (wantsArmorStand && radius >= 4.0) + { + return MccMcpResult.Ok(new + { + id = 7, + type = "ArmorStand", + typeLabel = "Armor Stand", + uuid = Guid.Parse("33333333-3333-3333-3333-333333333333"), + name = "Armor Stand", + customName = (string?)null, + x = C(2.5), + y = C(80.0), + z = C(0.5), + distance = 2.0, + health = 20.0f, + pose = "Standing", + latency = 0 + }); + } + + if (includePlayers && radius >= 3.0) + { + return MccMcpResult.Ok(new + { + id = 2, + type = "Player", + typeLabel = "Player", + uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"), + name = string.IsNullOrWhiteSpace(nameFilter) ? "PlayerOne" : nameFilter, + customName = (string?)null, + x = C(3.5), + y = C(80.0), + z = C(0.5), + distance = 3.0, + health = 20.0f, + pose = "Standing", + latency = 12 + }); + } + + return MccMcpResult.Fail("invalid_state", data: new { typeFilter, nameFilter, radius, includePlayers }); + } + public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) => MccMcpResult.Ok(new { @@ -298,6 +596,61 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities 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 LookDirection(string direction) + { + switch (direction.Trim().ToLowerInvariant()) + { + case "up": + yaw = 0.0f; + pitch = -90.0f; + break; + case "down": + yaw = 0.0f; + pitch = 90.0f; + break; + case "north": + yaw = 180.0f; + pitch = 0.0f; + break; + case "south": + yaw = 0.0f; + pitch = 0.0f; + break; + case "east": + yaw = -90.0f; + pitch = 0.0f; + break; + case "west": + yaw = 90.0f; + pitch = 0.0f; + break; + } + + return MccMcpResult.Ok(new { success = true, direction, yaw, pitch }); + } + + public MccMcpResult LookAngles(float yaw, float pitch) + { + this.yaw = yaw; + this.pitch = pitch; + return MccMcpResult.Ok(new { success = true, yaw, pitch }); + } + + public MccMcpResult PlayAnimation(string hand) => + MccMcpResult.Ok(new { success = true, hand }); + + public MccMcpResult ToggleSneak(bool enabled) + { + sneaking = enabled; + return MccMcpResult.Ok(new { success = true, enabled = sneaking }); + } + + public MccMcpResult ToggleSprint(bool enabled) + { + sprinting = enabled; + return MccMcpResult.Ok(new { success = true, enabled = sprinting }); + } + public MccMcpResult ListInventories() => MccMcpResult.Ok(new { @@ -322,8 +675,73 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities } }); - public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent) => - MccMcpResult.Ok(new + public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers) + { + List matches = []; + + if (query.Contains("stone", StringComparison.OrdinalIgnoreCase)) + { + matches.Add(new + { + inventoryId = 0, + inventoryType = "PlayerInventory", + inventoryTitle = "Player Inventory", + slot = 0, + itemType = "Stone", + typeLabel = "Stone", + count = 32, + isPlayerInventory = true, + hotbarSlot = 1 + }); + } + + if (query.Contains("diamond", StringComparison.OrdinalIgnoreCase) || query.Contains("sword", StringComparison.OrdinalIgnoreCase)) + { + matches.Add(new + { + inventoryId = 0, + inventoryType = "PlayerInventory", + inventoryTitle = "Player Inventory", + slot = 1, + itemType = "DiamondSword", + typeLabel = "Diamond Sword", + count = 1, + isPlayerInventory = true, + hotbarSlot = 2 + }); + } + + if (includeContainers) + { + matches.Add(new + { + inventoryId = 1, + inventoryType = "Generic_9x3", + inventoryTitle = "Chest", + slot = 0, + itemType = "Stone", + typeLabel = "Stone", + count = 16, + isPlayerInventory = false, + hotbarSlot = (int?)null + }); + } + + object[] result = matches.Take(Math.Max(1, maxCount)).ToArray(); + return MccMcpResult.Ok(new + { + query, + exactMatch, + includeContainers, + count = result.Length, + matches = result + }); + } + + public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent) + { + AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest", x, y, z }); + return MccMcpResult.Ok(new { success = true, openAccepted = true, @@ -335,15 +753,20 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities block = new { material = "Chest", typeLabel = "Chest", blockId = 0, blockMeta = 0 }, inventory = new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2 } }); + } - public MccMcpResult CloseContainer(int inventoryId, int timeoutMs) => - MccMcpResult.Ok(new + public MccMcpResult CloseContainer(int inventoryId, int timeoutMs) + { + int resolvedInventoryId = inventoryId <= 0 ? 1 : inventoryId; + AddRecentEvent("inventory_close", new { inventoryId = resolvedInventoryId }); + return MccMcpResult.Ok(new { success = true, closed = true, - inventoryId = inventoryId <= 0 ? 1 : inventoryId, + inventoryId = resolvedInventoryId, timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs }); + } public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) => MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType }); @@ -540,6 +963,22 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities } }); + public MccMcpResult Respawn() + { + health = 20.0f; + AddRecentEvent("respawn", new { location = new { x = playerX, y = playerY, z = playerZ } }); + return MccMcpResult.Ok(new { success = true, respawned = true }); + } + public MccMcpResult GetWorldBlockAt(int x, int y, int z) => MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 }); + + private void AddRecentEvent(string type, object? data) + { + recentEvents.Add(new RecentEvent(nextEventId++, DateTimeOffset.UtcNow, type, data)); + if (recentEvents.Count > 100) + recentEvents.RemoveAt(0); + } + + private sealed record RecentEvent(long Id, DateTimeOffset TimestampUtc, string Type, object? Data); } diff --git a/MinecraftClient/ChatBots/McpServer.cs b/MinecraftClient/ChatBots/McpServer.cs index 7657f0bb..3d8f7308 100644 --- a/MinecraftClient/ChatBots/McpServer.cs +++ b/MinecraftClient/ChatBots/McpServer.cs @@ -1,4 +1,5 @@ using System; +using MinecraftClient.Mapping; using MinecraftClient.Mcp; using MinecraftClient.Scripting; using Tomlet.Attributes; @@ -58,7 +59,7 @@ namespace MinecraftClient.ChatBots if (!Config.Enabled) return; - MccMcpChatHistoryStore.Clear(); + ClearStores(); MccMcpConfig mcpConfig = new() { @@ -86,15 +87,20 @@ namespace MinecraftClient.ChatBots public override bool OnDisconnect(DisconnectReason reason, string message) { + MccMcpRecentEventStore.Add("disconnect", new + { + reason = reason.ToString(), + message + }); StopHost(); - MccMcpChatHistoryStore.Clear(); + ClearStores(); return false; } public override void OnUnload() { StopHost(); - MccMcpChatHistoryStore.Clear(); + ClearStores(); } public override void GetText(string text, string? json) @@ -133,6 +139,120 @@ namespace MinecraftClient.ChatBots }); } + public override void OnTimeUpdate(long WorldAge, long TimeOfDay) + { + MccMcpRuntimeStateStore.SetTime(WorldAge, TimeOfDay); + } + + public override void OnRainLevelChange(float level) + { + MccMcpRuntimeStateStore.SetRainLevel(level); + MccMcpRecentEventStore.Add("weather_rain", new { level }); + } + + public override void OnThunderLevelChange(float level) + { + MccMcpRuntimeStateStore.SetThunderLevel(level); + MccMcpRecentEventStore.Add("weather_thunder", new { level }); + } + + public override void OnDeath() + { + MccMcpRecentEventStore.Add("death"); + } + + public override void OnRespawn() + { + MccMcpRecentEventStore.Add("respawn"); + } + + public override void OnPlayerJoin(Guid uuid, string name) + { + MccMcpRecentEventStore.Add("player_join", new + { + uuid, + name + }); + } + + public override void OnPlayerLeave(Guid uuid, string? name) + { + MccMcpRecentEventStore.Add("player_leave", new + { + uuid, + name + }); + } + + public override void OnInventoryOpen(int inventoryId) + { + MccMcpRecentEventStore.Add("inventory_open", new { inventoryId }); + } + + public override void OnInventoryClose(int inventoryId) + { + MccMcpRecentEventStore.Add("inventory_close", new { inventoryId }); + } + + public override void OnTitle(int action, string titletext, string subtitletext, string actionbartext, int fadein, int stay, int fadeout, string json) + { + if (action == 2) + { + MccMcpRecentEventStore.Add("actionbar", new + { + action, + text = actionbartext, + fadein, + stay, + fadeout, + json + }); + return; + } + + if (action is 0 or 1) + { + MccMcpRecentEventStore.Add("title", new + { + action, + titleText = titletext, + subtitleText = subtitletext, + fadein, + stay, + fadeout, + json + }); + } + } + + public override void OnBlockBreakAnimation(Entity entity, Location location, byte stage) + { + MccMcpRecentEventStore.Add("block_break_animation", new + { + entityId = entity.ID, + entityType = entity.Type.ToString(), + stage, + location = new + { + x = location.X, + y = location.Y, + z = location.Z + } + }); + } + + public override void OnEntityAnimation(Entity entity, byte animation) + { + MccMcpRecentEventStore.Add("entity_animation", new + { + entityId = entity.ID, + entityType = entity.Type.ToString(), + animation, + name = entity.Name, + customName = entity.CustomName + }); + } + private void StopHost() { if (host is null || !host.IsRunning) @@ -143,5 +263,12 @@ namespace MinecraftClient.ChatBots else LogToConsole(string.Format(Translations.bot_mcpserver_stop_failed, error ?? "unknown")); } + + private static void ClearStores() + { + MccMcpChatHistoryStore.Clear(); + MccMcpRuntimeStateStore.Clear(); + MccMcpRecentEventStore.Clear(); + } } } diff --git a/MinecraftClient/Mcp/IMccMcpCapabilities.cs b/MinecraftClient/Mcp/IMccMcpCapabilities.cs index 0e460a9e..055fb561 100644 --- a/MinecraftClient/Mcp/IMccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/IMccMcpCapabilities.cs @@ -5,7 +5,16 @@ public interface IMccMcpCapabilities MccMcpResult GetSessionStatus(); MccMcpResult GetServerInfo(); MccMcpResult GetPlayerState(); + MccMcpResult GetWorldState(); + MccMcpResult GetChunkStatus(double? x, double? y, double? z); + MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors); + MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints); MccMcpResult GetPlayersList(); + MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates); + MccMcpResult GetPlayerStats(); + MccMcpResult GetStatusEffects(); + MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter); + MccMcpResult GetLoadedBots(); MccMcpResult GetChatHistory(int maxCount, bool includeJson); MccMcpResult GetInternalCommands(); MccMcpResult GetMaterialsList(string? filter, int maxCount); @@ -13,23 +22,34 @@ public interface IMccMcpCapabilities MccMcpResult GetEntityTypesList(string? filter, int maxCount); MccMcpResult SendChat(string text); MccMcpResult QuitClient(); + MccMcpResult DisconnectClient(); + MccMcpResult Respawn(); MccMcpResult RunInternalCommand(string command); + MccMcpResult PlayAnimation(string hand); + MccMcpResult ToggleSneak(bool enabled); + MccMcpResult ToggleSprint(bool enabled); MccMcpResult UseItemOnHand(); MccMcpResult ChangeHotbarSlot(int slot); + MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot); MccMcpResult UseItemOnBlock(double x, double y, double z); MccMcpResult DigBlock(double x, double y, double z, double durationSeconds); MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock); MccMcpResult InteractEntity(int entityId, string interaction, string hand); + MccMcpResult AttackEntity(int entityId); MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter); 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 FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers); 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); + MccMcpResult LookDirection(string direction); + MccMcpResult LookAngles(float yaw, float pitch); MccMcpResult ListInventories(); MccMcpResult GetInventorySnapshot(int inventoryId); + MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers); MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent); MccMcpResult CloseContainer(int inventoryId, int timeoutMs); MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType); diff --git a/MinecraftClient/Mcp/MccMcpCapabilities.cs b/MinecraftClient/Mcp/MccMcpCapabilities.cs index 907b8b66..9b9b7e15 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; using MinecraftClient.Protocol.Message; using MinecraftClient.Scripting; @@ -20,6 +21,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities private const double SelfEntityDistanceThreshold = 0.2; private const int MaxBlockScanRadius = 12; private const int MaxBlockFindRadius = 32; + private const double MaxRaycastDistance = 128.0; private const double DigReachDistance = 5.0; private const double DigReachDistanceSquared = DigReachDistance * DigReachDistance; private const int DefaultPathQueryTimeoutMs = 5000; @@ -35,6 +37,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities private const int MinContainerWaitMs = 250; private const int MaxContainerWaitMs = 20000; private const int DefaultInventoryActionWaitMs = 3500; + private const int MaxPathPreviewWaypoints = 1000; private sealed class InternalCommandInfo { @@ -174,6 +177,226 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult GetWorldState() + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => + { + Location location = client.GetCurrentLocation(); + World world = client.GetWorld(); + Dimension dimension = World.GetDimension(); + MccMcpRuntimeStateSnapshot runtimeState = MccMcpRuntimeStateStore.GetSnapshot(); + int totalChunkCount = world.chunkCnt; + int pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted); + int loadedChunkCount = GetLoadedChunkCount(world); + + return MccMcpResult.Ok(new + { + host = client.GetServerHost(), + port = client.GetServerPort(), + username = client.GetUsername(), + protocol = client.GetProtocolVersion(), + protocolVersion = client.GetProtocolVersion(), + terrainEnabled = client.GetTerrainEnabled(), + inventoryEnabled = client.GetInventoryEnabled(), + entityEnabled = client.GetEntityHandlingEnabled(), + entityHandlingEnabled = client.GetEntityHandlingEnabled(), + location = ToCoordinate(location), + tps = client.GetServerTPS(), + dimension = dimension.Name, + dimensionDetails = new + { + name = dimension.Name, + minY = dimension.minY, + maxY = dimension.maxY, + height = dimension.height, + logicalHeight = dimension.logicalHeight, + coordinateScale = dimension.coordinateScale, + hasSkylight = dimension.hasSkylight, + hasCeiling = dimension.hasCeiling, + fixedTime = dimension.fixedTime >= 0 ? dimension.fixedTime : (long?)null + }, + loadedChunkCount, + pendingChunkCount, + totalChunkCount, + loadRatio = GetChunkLoadRatio(world), + worldAge = runtimeState.WorldAge, + timeOfDay = runtimeState.TimeOfDay, + rainLevel = runtimeState.RainLevel, + thunderLevel = runtimeState.ThunderLevel + }); + }); + } + + public MccMcpResult GetChunkStatus(double? x, double? y, double? z) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (!HasCompleteCoordinateTriple(x, y, z)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + Location queryLocation = x.HasValue && y.HasValue && z.HasValue + ? new Location(x.Value, y.Value, z.Value) + : client.GetCurrentLocation(); + + World world = client.GetWorld(); + ChunkColumn? chunkColumn = world.GetChunkColumn(queryLocation); + return MccMcpResult.Ok(new + { + location = ToCoordinate(queryLocation), + chunk = new + { + x = queryLocation.ChunkX, + z = queryLocation.ChunkZ + }, + chunkX = queryLocation.ChunkX, + chunkZ = queryLocation.ChunkZ, + loaded = chunkColumn is not null, + fullyLoaded = chunkColumn?.FullyLoaded ?? false, + loadedChunkCount = GetLoadedChunkCount(world), + pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted), + totalChunkCount = world.chunkCnt, + loadRatio = GetChunkLoadRatio(world) + }); + }); + } + + public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (maxDistance <= 0 || maxDistance > MaxRaycastDistance) + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "maxDistance", + minExclusive = 0, + max = MaxRaycastDistance + }); + } + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + Location playerLocation = client.GetCurrentLocation(); + Location eyeLocation = playerLocation.EyesLocation(); + Tuple raycast = RaycastHelper.RaycastBlock(client, maxDistance, includeFluids: false); + if (!raycast.Item1) + { + return MccMcpResult.Ok(new + { + hit = false, + maxDistance, + playerLocation = ToCoordinate(playerLocation), + eyeLocation = ToCoordinate(eyeLocation), + location = (object?)null, + block = (object?)null, + distance = (double?)null, + eyeDistance = (double?)null, + neighbors = (object?)null + }); + } + + Location blockLocation = raycast.Item2; + Block block = raycast.Item3; + Location targetCenter = blockLocation.ToCenter(); + object? neighbors = includeNeighbors ? GetNeighborBlockSnapshot(client.GetWorld(), blockLocation) : null; + + return MccMcpResult.Ok(new + { + hit = true, + maxDistance, + playerLocation = ToCoordinate(playerLocation), + eyeLocation = ToCoordinate(eyeLocation), + location = ToCoordinate(blockLocation), + block = ToBlockState(block), + distance = playerLocation.Distance(targetCenter), + eyeDistance = eyeLocation.Distance(targetCenter), + neighbors + }); + }); + } + + public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0 || maxWaypoints <= 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs, + maxWaypoints + }); + } + + 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); + int waypointLimit = Math.Clamp(maxWaypoints, 1, MaxPathPreviewWaypoints); + Queue? path = Movement.CalculatePath( + world, + startLocation, + goal, + allowUnsafe, + maxOffset, + minOffset, + TimeSpan.FromMilliseconds(effectiveTimeoutMs)); + Location[] waypoints = path?.Take(waypointLimit).ToArray() ?? []; + Location? finalWaypoint = path is not null && path.Count > 0 ? path.Last() : null; + + return MccMcpResult.Ok(new + { + pathFound = path is not null, + exactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(), + target = ToCoordinate(goal), + startLocation = ToCoordinate(startLocation), + finalWaypoint = finalWaypoint is Location waypoint ? ToCoordinate(waypoint) : (object?)null, + finalDistance = finalWaypoint is Location endWaypoint ? GetDistance(endWaypoint, goal) : (double?)null, + waypointCount = path?.Count ?? 0, + truncated = path is not null && path.Count > waypointLimit, + waypoints = waypoints.Select(ToCoordinate).ToArray(), + allowUnsafe, + maxOffset, + minOffset, + timeoutMs = effectiveTimeoutMs + }); + } + public MccMcpResult GetPlayersList() { if (!IsCategoryEnabled(t => t.SessionStatus)) @@ -189,6 +412,202 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities })); } + public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates) + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => + { + Dictionary onlinePlayers = client.GetOnlinePlayersWithUUID(); + Dictionary? trackedPlayers = client.GetEntityHandlingEnabled() + ? BuildTrackedPlayerSnapshots(client, includeSelf: true).ToDictionary(player => player.Uuid) + : null; + Guid selfUuid = client.GetUserUuid(); + string selfName = client.GetUsername(); + + var players = onlinePlayers + .Select(pair => + { + if (!Guid.TryParse(pair.Key, out Guid uuid)) + return null; + + bool isSelf = uuid == selfUuid || NameComparer.Equals(pair.Value, selfName); + if (!includeSelf && isSelf) + return null; + + PlayerInfo? playerInfo = client.GetPlayerInfo(uuid); + NearbyPlayerSnapshot? trackedPlayer = trackedPlayers is not null + && trackedPlayers.TryGetValue(uuid, out NearbyPlayerSnapshot? resolvedTrackedPlayer) + ? resolvedTrackedPlayer + : null; + Location? selfLocation = isSelf ? client.GetCurrentLocation() : null; + int? entityId = trackedPlayer?.EntityId ?? (isSelf ? client.GetPlayerEntityID() : null); + double? x = includeCoordinates + ? trackedPlayer?.X is double trackedX ? RoundCoordinate(trackedX) + : selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.X) + : (double?)null + : null; + double? y = includeCoordinates + ? trackedPlayer?.Y is double trackedY ? RoundCoordinate(trackedY) + : selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.Y) + : (double?)null + : null; + double? z = includeCoordinates + ? trackedPlayer?.Z is double trackedZ ? RoundCoordinate(trackedZ) + : selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.Z) + : (double?)null + : null; + + return new + { + name = playerInfo?.Name ?? pair.Value, + uuid, + ping = playerInfo?.Ping ?? trackedPlayer?.Latency ?? 0, + gamemode = playerInfo?.Gamemode ?? -1, + listed = playerInfo?.Listed ?? true, + displayName = playerInfo?.DisplayName, + entityId, + x, + y, + z + }; + }) + .Where(player => player is not null) + .OrderBy(player => player!.name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return MccMcpResult.Ok(new + { + includeSelf, + includeCoordinates, + count = players.Length, + players + }); + }); + } + + public MccMcpResult GetPlayerStats() + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => + { + Location location = client.GetCurrentLocation(); + return MccMcpResult.Ok(new + { + username = client.GetUsername(), + health = client.GetHealth(), + saturation = client.GetSaturation(), + level = client.GetLevel(), + totalExperience = client.GetTotalExperience(), + gamemode = client.GetGamemode(), + playerEntityId = client.GetPlayerEntityID(), + currentSlot = client.GetCurrentSlot() + 1, + yaw = client.GetYaw(), + pitch = client.GetPitch(), + location = ToCoordinate(location), + tps = client.GetServerTPS() + }); + }); + } + + public MccMcpResult GetStatusEffects() + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => + { + var effects = client.GetPlayerEffects() + .Values + .Where(effect => !effect.IsExpired) + .OrderBy(effect => effect.Effect) + .Select(effect => new + { + id = effect.Effect.ToString(), + name = effect.GetDisplayName(), + amplifier = effect.Amplifier, + remainingSeconds = effect.RemainingSeconds, + isInfinite = effect.IsInfinite + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + count = effects.Length, + effects + }); + }); + } + + public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter) + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + MccMcpRecentEventEntry[] events = MccMcpRecentEventStore.GetAfter(afterId, maxCount, typeFilter); + return MccMcpResult.Ok(new + { + afterId, + latestId = MccMcpRecentEventStore.GetLatestId(), + count = events.Length, + events = events.Select(entry => new + { + id = entry.Id, + timestampUtc = entry.TimestampUtc, + type = entry.Type, + data = entry.Data + }).ToArray() + }); + } + + public MccMcpResult GetLoadedBots() + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => + { + var bots = client.GetLoadedChatBots() + .Select(bot => new + { + name = bot.GetType().Name, + fullTypeName = bot.GetType().FullName, + isScript = bot is MinecraftClient.ChatBots.Script + }) + .OrderBy(bot => bot.name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return MccMcpResult.Ok(new + { + count = bots.Length, + bots + }); + }); + } + public MccMcpResult GetChatHistory(int maxCount, bool includeJson) { if (!IsCategoryEnabled(t => t.SessionStatus)) @@ -394,6 +813,48 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Ok(new { quitting = true }); } + public MccMcpResult DisconnectClient() + { + if (!IsCategoryEnabled(t => t.ChatAndCommands)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + _ = Task.Run(async () => + { + await Task.Delay(150).ConfigureAwait(false); + client.Disconnect(); + }); + + return MccMcpResult.Ok(new { disconnecting = true }); + } + + public MccMcpResult Respawn() + { + if (!IsCategoryEnabled(t => t.ChatAndCommands)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + float health = client.InvokeOnMainThread(client.GetHealth); + if (health > 0) + { + return MccMcpResult.Fail("invalid_state", data: new + { + health + }); + } + + bool ok = client.InvokeOnMainThread(client.SendRespawnPacket); + return ok + ? MccMcpResult.Ok(new { success = true }) + : MccMcpResult.Fail("action_failed", data: new { success = false }); + } + public MccMcpResult RunInternalCommand(string command) { if (!IsCategoryEnabled(t => t.ChatAndCommands)) @@ -409,6 +870,67 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return ExecuteInternalCommand(client, command.Trim()); } + public MccMcpResult PlayAnimation(string hand) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(hand) || !Enum.TryParse(hand, true, out Hand parsedHand)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + int animation = parsedHand == Hand.MainHand ? 1 : 0; + bool ok = client.DoAnimation(animation); + object resultData = new { success = ok, hand = parsedHand.ToString() }; + return ok + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_failed", data: resultData); + } + + public MccMcpResult ToggleSneak(bool enabled) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + EntityActionType action = enabled ? EntityActionType.StartSneaking : EntityActionType.StopSneaking; + bool ok = client.InvokeOnMainThread(() => + { + bool actionResult = client.SendEntityAction(action); + if (actionResult) + client.IsSneaking = enabled; + return actionResult; + }); + + object resultData = new { success = ok, enabled }; + return ok + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_failed", data: resultData); + } + + public MccMcpResult ToggleSprint(bool enabled) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + EntityActionType action = enabled ? EntityActionType.StartSprinting : EntityActionType.StopSprinting; + bool ok = client.SendEntityAction(action); + object resultData = new { success = ok, enabled }; + return ok + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_failed", data: resultData); + } + public MccMcpResult UseItemOnHand() { if (!IsCategoryEnabled(t => t.Movement)) @@ -441,6 +963,77 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Ok(new { success = ok, slot }); } + public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(itemType)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + if (!TryParseItemType(itemType, out ItemType parsedItemType)) + { + return MccMcpResult.Fail("invalid_args", data: new + { + itemType = itemType.Trim() + }); + } + + return client.InvokeOnMainThread(() => + { + Container? inventory = client.GetInventory(0); + if (inventory is null) + return MccMcpResult.Fail("invalid_state"); + + var matches = inventory.Items + .Where(pair => pair.Value.Type == parsedItemType && pair.Value.Count > 0) + .Select(pair => + { + bool isHotbar = inventory.IsHotbar(pair.Key, out int hotbar); + return new + { + inventorySlot = pair.Key, + hotbar, + isHotbar, + count = pair.Value.Count + }; + }) + .Where(match => match.isHotbar) + .OrderBy(match => preferLowestSlot ? match.hotbar : -match.hotbar) + .ToArray(); + + if (matches.Length == 0) + { + return MccMcpResult.Fail("invalid_state", data: new + { + itemType = parsedItemType.ToString() + }); + } + + var selected = matches[0]; + bool ok = client.ChangeSlot((short)selected.hotbar); + object resultData = new + { + success = ok, + itemType = parsedItemType.ToString(), + inventorySlot = selected.inventorySlot, + selectedSlot = selected.hotbar + 1, + count = selected.count + }; + + return ok + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_failed", data: resultData); + }); + } + public MccMcpResult UseItemOnBlock(double x, double y, double z) { if (!IsCategoryEnabled(t => t.Movement)) @@ -595,6 +1188,37 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Ok(new { success = ok, entityId, interaction = interactType.ToString(), hand = parsedHand.ToString() }); } + public MccMcpResult AttackEntity(int entityId) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + if (!client.GetEntities().ContainsKey(entityId)) + return MccMcpResult.Fail("invalid_state", data: new { entityId }); + + bool ok = client.InteractEntity(entityId, InteractType.Attack); + object resultData = new + { + success = ok, + entityId, + interaction = InteractType.Attack.ToString() + }; + + return ok + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_failed", data: resultData); + }); + } + public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) { if (!IsCategoryEnabled(t => t.EntityWorld)) @@ -931,6 +1555,86 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers) + { + 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"); + + string? normalizedTypeFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim(); + string? normalizedNameFilter = string.IsNullOrWhiteSpace(nameFilter) ? null : nameFilter.Trim(); + + return client.InvokeOnMainThread(() => + { + Location playerLocation = client.GetCurrentLocation(); + Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) + .ToDictionary(player => player.EntityId, player => player.Name); + + var nearest = client.GetEntities().Values + .Where(entity => includePlayers || entity.Type != EntityType.Player) + .Select(entity => + { + double dx = entity.Location.X - playerLocation.X; + double dy = entity.Location.Y - playerLocation.Y; + double dz = entity.Location.Z - playerLocation.Z; + string? resolvedName = entity.Type == EntityType.Player + && playerNamesByEntityId.TryGetValue(entity.ID, out string? mappedName) + ? mappedName + : entity.Name; + return new + { + entity, + resolvedName, + distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) + }; + }) + .Where(item => item.distance <= radius) + .Where(item => normalizedTypeFilter is null + || TextMatchesFilter(item.entity.Type.ToString(), normalizedTypeFilter) + || TextMatchesFilter(item.entity.GetTypeString(), normalizedTypeFilter)) + .Where(item => normalizedNameFilter is null || EntityNameMatches(item.resolvedName, item.entity.CustomName, normalizedNameFilter)) + .OrderBy(item => item.distance) + .FirstOrDefault(); + + if (nearest is null) + { + return MccMcpResult.Fail("invalid_state", data: new + { + typeFilter = normalizedTypeFilter, + nameFilter = normalizedNameFilter, + radius, + includePlayers + }); + } + + return MccMcpResult.Ok(new + { + id = nearest.entity.ID, + type = nearest.entity.Type.ToString(), + typeLabel = nearest.entity.GetTypeString(), + uuid = nearest.entity.UUID, + name = nearest.resolvedName, + customName = nearest.entity.CustomName, + x = RoundCoordinate(nearest.entity.Location.X), + y = RoundCoordinate(nearest.entity.Location.Y), + z = RoundCoordinate(nearest.entity.Location.Z), + distance = nearest.distance, + health = nearest.entity.Health, + pose = nearest.entity.Pose.ToString(), + latency = nearest.entity.Latency + }); + }); + } + public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) { if (!IsCategoryEnabled(t => t.Movement)) @@ -1096,6 +1800,60 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Ok(); } + public MccMcpResult LookDirection(string direction) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(direction) || !Enum.TryParse(direction, true, out Direction parsedDirection) || !IsSupportedLookDirection(parsedDirection)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + Location current = client.GetCurrentLocation(); + client.UpdateLocation(current, parsedDirection); + return MccMcpResult.Ok(new + { + direction = parsedDirection.ToString(), + yaw = client.GetYaw(), + pitch = client.GetPitch(), + location = ToCoordinate(current) + }); + }); + } + + public MccMcpResult LookAngles(float yaw, float pitch) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + Location current = client.GetCurrentLocation(); + client.UpdateLocation(current, yaw, pitch); + return MccMcpResult.Ok(new + { + yaw = client.GetYaw(), + pitch = client.GetPitch(), + location = ToCoordinate(current) + }); + }); + } + public MccMcpResult GetInventorySnapshot(int inventoryId) { if (!IsCategoryEnabled(t => t.Inventory)) @@ -1132,6 +1890,69 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(query)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + string normalizedQuery = query.Trim(); + ItemType? parsedItemType = exactMatch && TryParseItemType(normalizedQuery, out ItemType exactItemType) + ? exactItemType + : null; + int limit = Math.Clamp(maxCount, 1, 1000); + + return client.InvokeOnMainThread(() => + { + var matches = client.GetInventories() + .Where(entry => includeContainers || entry.Key == 0) + .OrderBy(entry => entry.Key) + .SelectMany(entry => + { + Container inventory = entry.Value; + return inventory.Items + .Where(pair => pair.Key >= 0 && pair.Value.Count > 0) + .Where(pair => ItemMatches(pair.Value, normalizedQuery, exactMatch, parsedItemType)) + .Select(pair => + { + bool isHotbar = inventory.IsHotbar(pair.Key, out int hotbar); + return new + { + inventoryId = entry.Key, + inventoryType = inventory.Type.ToString(), + inventoryTitle = inventory.Title, + slot = pair.Key, + itemType = pair.Value.Type.ToString(), + typeLabel = pair.Value.GetTypeString(), + count = pair.Value.Count, + isPlayerInventory = entry.Key == 0, + hotbarSlot = isHotbar ? hotbar + 1 : (int?)null + }; + }); + }) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + query = normalizedQuery, + exactMatch, + includeContainers, + count = matches.Length, + matches + }); + }); + } + public MccMcpResult ListInventories() { if (!IsCategoryEnabled(t => t.Inventory)) @@ -2812,6 +3633,67 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return maxOffset >= 0 && minOffset >= 0 && minOffset <= maxOffset; } + private static bool HasCompleteCoordinateTriple(double? x, double? y, double? z) + { + return x.HasValue == y.HasValue && y.HasValue == z.HasValue; + } + + private static int GetLoadedChunkCount(World world) + { + return Math.Max(0, world.chunkCnt - Math.Max(0, world.chunkLoadNotCompleted)); + } + + private static double GetChunkLoadRatio(World world) + { + return world.chunkCnt > 0 + ? GetLoadedChunkCount(world) / (double)world.chunkCnt + : 0.0; + } + + private static object GetNeighborBlockSnapshot(World world, Location location) + { + Location blockLocation = location.ToFloor(); + Location north = new(blockLocation.X, blockLocation.Y, blockLocation.Z - 1); + Location south = new(blockLocation.X, blockLocation.Y, blockLocation.Z + 1); + Location east = new(blockLocation.X + 1, blockLocation.Y, blockLocation.Z); + Location west = new(blockLocation.X - 1, blockLocation.Y, blockLocation.Z); + Location above = new(blockLocation.X, blockLocation.Y + 1, blockLocation.Z); + Location below = new(blockLocation.X, blockLocation.Y - 1, blockLocation.Z); + + return new + { + north = new { location = ToCoordinate(north), block = ToBlockState(world.GetBlock(north)) }, + south = new { location = ToCoordinate(south), block = ToBlockState(world.GetBlock(south)) }, + east = new { location = ToCoordinate(east), block = ToBlockState(world.GetBlock(east)) }, + west = new { location = ToCoordinate(west), block = ToBlockState(world.GetBlock(west)) }, + above = new { location = ToCoordinate(above), block = ToBlockState(world.GetBlock(above)) }, + below = new { location = ToCoordinate(below), block = ToBlockState(world.GetBlock(below)) } + }; + } + + private static bool ItemMatches(Item item, string query, bool exactMatch, ItemType? exactItemType) + { + if (exactItemType.HasValue) + return item.Type == exactItemType.Value; + + string typeName = item.Type.ToString(); + string typeLabel = item.GetTypeString(); + return exactMatch + ? TextEqualsFilter(typeName, query) || TextEqualsFilter(typeLabel, query) + : TextMatchesFilter(typeName, query) || TextMatchesFilter(typeLabel, query); + } + + private static bool EntityNameMatches(string? name, string? customName, string filter) + { + return (!string.IsNullOrWhiteSpace(name) && TextMatchesFilter(name, filter)) + || (!string.IsNullOrWhiteSpace(customName) && TextMatchesFilter(customName, filter)); + } + + private static bool IsSupportedLookDirection(Direction direction) + { + return direction is Direction.Up or Direction.Down or Direction.North or Direction.South or Direction.East or Direction.West; + } + private static NearbyItemSnapshot[] BuildNearbyItemSnapshots(McClient client, ItemType? itemType, double radius, int maxCount) { Location playerLocation = client.GetCurrentLocation(); diff --git a/MinecraftClient/Mcp/MccMcpRecentEventStore.cs b/MinecraftClient/Mcp/MccMcpRecentEventStore.cs new file mode 100644 index 00000000..49fd9b86 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpRecentEventStore.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpRecentEventEntry +{ + public required long Id { get; init; } + public required DateTimeOffset TimestampUtc { get; init; } + public required string Type { get; init; } + public object? Data { get; init; } +} + +public static class MccMcpRecentEventStore +{ + private static readonly object historyLock = new(); + private static readonly List history = new(); + private const int MaxEntries = 500; + private static long nextId = 1; + + public static long Add(string type, object? data = null) + { + ArgumentException.ThrowIfNullOrEmpty(type); + + lock (historyLock) + { + long id = nextId++; + history.Add(new MccMcpRecentEventEntry + { + Id = id, + TimestampUtc = DateTimeOffset.UtcNow, + Type = type, + Data = data + }); + + if (history.Count > MaxEntries) + history.RemoveRange(0, history.Count - MaxEntries); + + return id; + } + } + + public static long GetLatestId() + { + lock (historyLock) + { + return history.Count > 0 ? history[^1].Id : 0; + } + } + + public static MccMcpRecentEventEntry[] GetAfter(long afterId, int maxCount, string? typeFilter = null) + { + int count = Math.Clamp(maxCount, 1, MaxEntries); + string? normalizedFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim(); + + lock (historyLock) + { + return history + .Where(entry => entry.Id > afterId) + .Where(entry => normalizedFilter is null + || entry.Type.Contains(normalizedFilter, StringComparison.OrdinalIgnoreCase)) + .Take(count) + .ToArray(); + } + } + + public static void Clear() + { + lock (historyLock) + { + history.Clear(); + } + } +} diff --git a/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs b/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs new file mode 100644 index 00000000..0c602752 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs @@ -0,0 +1,70 @@ +using System; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpRuntimeStateSnapshot +{ + public long? WorldAge { get; init; } + public long? TimeOfDay { get; init; } + public float? RainLevel { get; init; } + public float? ThunderLevel { get; init; } +} + +public static class MccMcpRuntimeStateStore +{ + private static readonly object stateLock = new(); + private static long? worldAge; + private static long? timeOfDay; + private static float? rainLevel; + private static float? thunderLevel; + + public static void SetTime(long newWorldAge, long newTimeOfDay) + { + lock (stateLock) + { + worldAge = newWorldAge; + timeOfDay = newTimeOfDay; + } + } + + public static void SetRainLevel(float level) + { + lock (stateLock) + { + rainLevel = level; + } + } + + public static void SetThunderLevel(float level) + { + lock (stateLock) + { + thunderLevel = level; + } + } + + public static MccMcpRuntimeStateSnapshot GetSnapshot() + { + lock (stateLock) + { + return new MccMcpRuntimeStateSnapshot + { + WorldAge = worldAge, + TimeOfDay = timeOfDay, + RainLevel = rainLevel, + ThunderLevel = thunderLevel + }; + } + } + + public static void Clear() + { + lock (stateLock) + { + worldAge = null; + timeOfDay = null; + rainLevel = null; + thunderLevel = null; + } + } +} diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs index 64dc2789..8da6b413 100644 --- a/MinecraftClient/Mcp/MccMcpToolSet.cs +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -33,12 +33,66 @@ public sealed class MccMcpToolSet return capabilities.GetPlayerState(); } + [McpServerTool(Name = "mcc_world_state"), Description("Get current world state, chunk loading progress, and last observed runtime time/weather values.")] + public object WorldState() + { + return capabilities.GetWorldState(); + } + + [McpServerTool(Name = "mcc_chunk_status"), Description("Get chunk loading status for the player location or an explicit world coordinate.")] + public object ChunkStatus(double? x = null, double? y = null, double? z = null) + { + return capabilities.GetChunkStatus(x, y, z); + } + + [McpServerTool(Name = "mcc_raycast_block"), Description("Raycast from the player's current view and return the first non-air block hit.")] + public object RaycastBlock(double maxDistance = 8.0, bool includeNeighbors = false) + { + return capabilities.RaycastBlock(maxDistance, includeNeighbors); + } + + [McpServerTool(Name = "mcc_path_preview"), Description("Compute a path preview to a target world coordinate without moving there.")] + public object PathPreview(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0, int maxWaypoints = 128) + { + return capabilities.PreviewPath(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs, maxWaypoints); + } + [McpServerTool(Name = "mcc_players_list"), Description("List currently known online players.")] public object PlayersList() { return capabilities.GetPlayersList(); } + [McpServerTool(Name = "mcc_players_detailed"), Description("List online players with UUID, latency, gamemode, and tracked coordinates when available.")] + public object PlayersDetailed(bool includeSelf = false, bool includeCoordinates = true) + { + return capabilities.GetPlayersDetailed(includeSelf, includeCoordinates); + } + + [McpServerTool(Name = "mcc_player_stats"), Description("Get current controlled player stats, orientation, and location.")] + public object PlayerStats() + { + return capabilities.GetPlayerStats(); + } + + [McpServerTool(Name = "mcc_status_effects"), Description("Get active player status effects only.")] + public object StatusEffects() + { + return capabilities.GetStatusEffects(); + } + + [McpServerTool(Name = "mcc_recent_events"), Description("Get recent high-signal MCP runtime events after a given event ID.")] + public object RecentEvents(long afterId = 0, int maxCount = 50, string? typeFilter = null) + { + return capabilities.GetRecentEvents(afterId, maxCount, typeFilter); + } + + [McpServerTool(Name = "mcc_loaded_bots"), Description("List currently loaded MCC bots and scripts.")] + public object LoadedBots() + { + return capabilities.GetLoadedBots(); + } + [McpServerTool(Name = "mcc_chat_history"), Description("Get recent chat/system lines seen by MCC.")] public object ChatHistory(int maxCount = 50, bool includeJson = false) { @@ -87,18 +141,54 @@ public sealed class MccMcpToolSet return capabilities.QuitClient(); } + [McpServerTool(Name = "mcc_disconnect"), Description("Disconnect MCC from the current server without quitting the process.")] + public object Disconnect() + { + return capabilities.DisconnectClient(); + } + + [McpServerTool(Name = "mcc_respawn"), Description("Send the respawn packet when the controlled player is dead.")] + public object Respawn() + { + return capabilities.Respawn(); + } + [McpServerTool(Name = "mcc_run_internal_command"), Description("Run an internal MCC command.")] public object RunInternalCommand([Description("MCC command line without leading slash.")] string command) { return capabilities.RunInternalCommand(command); } + [McpServerTool(Name = "mcc_animation"), Description("Play a hand-swing animation with the selected hand.")] + public object Animation(string hand = "MainHand") + { + return capabilities.PlayAnimation(hand); + } + + [McpServerTool(Name = "mcc_toggle_sneak"), Description("Explicitly enable or disable sneaking.")] + public object ToggleSneak(bool enabled) + { + return capabilities.ToggleSneak(enabled); + } + + [McpServerTool(Name = "mcc_toggle_sprint"), Description("Explicitly send start or stop sprinting entity actions.")] + public object ToggleSprint(bool enabled) + { + return capabilities.ToggleSprint(enabled); + } + [McpServerTool(Name = "mcc_change_hotbar_slot"), Description("Change active hotbar slot (1-9).")] public object ChangeHotbarSlot(int slot) { return capabilities.ChangeHotbarSlot(slot); } + [McpServerTool(Name = "mcc_select_item"), Description("Select a hotbar item by item type without rearranging inventory contents.")] + public object SelectItem(string itemType, bool preferLowestSlot = true) + { + return capabilities.SelectHotbarItem(itemType, preferLowestSlot); + } + [McpServerTool(Name = "mcc_use_item_on_hand"), Description("Use the currently held item.")] public object UseItemOnHand() { @@ -129,6 +219,12 @@ public sealed class MccMcpToolSet return capabilities.InteractEntity(entityId, interaction, hand); } + [McpServerTool(Name = "mcc_entity_attack"), Description("Attack a tracked entity explicitly.")] + public object EntityAttack(int entityId) + { + return capabilities.AttackEntity(entityId); + } + [McpServerTool(Name = "mcc_block_scan"), Description("Scan nearby blocks around player location.")] public object BlockScan(int radius = 3, int maxCount = 200, string? materialFilter = null) { @@ -153,6 +249,12 @@ public sealed class MccMcpToolSet return capabilities.LocatePlayer(playerName, includeSelf); } + [McpServerTool(Name = "mcc_entity_nearest"), Description("Return the nearest tracked entity matching the requested filters.")] + public object EntityNearest(string? typeFilter = null, string? nameFilter = null, double radius = 64.0, bool includePlayers = true) + { + return capabilities.FindNearestEntity(typeFilter, nameFilter, radius, includePlayers); + } + [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) { @@ -177,12 +279,30 @@ public sealed class MccMcpToolSet return capabilities.LookAt(x, y, z); } + [McpServerTool(Name = "mcc_look_direction"), Description("Rotate player view to a cardinal direction or straight up/down.")] + public object LookDirection(string direction) + { + return capabilities.LookDirection(direction); + } + + [McpServerTool(Name = "mcc_look_angles"), Description("Rotate player view to explicit yaw and pitch angles.")] + public object LookAngles(float yaw, float pitch) + { + return capabilities.LookAngles(yaw, pitch); + } + [McpServerTool(Name = "mcc_inventory_snapshot"), Description("Get a snapshot of one inventory.")] public object InventorySnapshot([Description("Inventory ID. 0 is the player inventory.")] int inventoryId = 0) { return capabilities.GetInventorySnapshot(inventoryId); } + [McpServerTool(Name = "mcc_inventory_search"), Description("Search the player inventory and optionally open containers for items matching a query.")] + public object InventorySearch(string query, int maxCount = 100, bool exactMatch = false, bool includeContainers = true) + { + return capabilities.SearchInventories(query, maxCount, exactMatch, includeContainers); + } + [McpServerTool(Name = "mcc_inventories_list"), Description("List currently open inventories and containers known to MCC.")] public object InventoriesList() { diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs index 759e69ed..3ce80d79 100644 --- a/MinecraftClient/Program.cs +++ b/MinecraftClient/Program.cs @@ -807,13 +807,14 @@ namespace MinecraftClient /// Optional, keep account and server settings public static void Restart(int delaySeconds = 0, bool keepAccountAndServerSettings = false) { - ConsoleIO.Backend.StopReadThread(); + ConsoleIO.Backend?.StopReadThread(); new Thread(new ThreadStart(delegate { if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); } if (offlinePrompt is not null) { - ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; + if (ConsoleIO.Backend is not null) + ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; offlinePrompt.Item2.Cancel(); offlinePrompt.Item1.Join(); offlinePrompt = null; ConsoleIO.Reset(); } if (delaySeconds > 0) @@ -835,7 +836,8 @@ namespace MinecraftClient if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); } if (offlinePrompt is not null) { - ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; + if (ConsoleIO.Backend is not null) + ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler; offlinePrompt.Item2.Cancel(); if (Thread.CurrentThread != offlinePrompt.Item1) offlinePrompt.Item1.Join(1000); @@ -907,8 +909,9 @@ namespace MinecraftClient if (offlinePrompt is null) { - ConsoleIO.Backend.StopReadThread(); - ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler; + ConsoleIO.Backend?.StopReadThread(); + if (ConsoleIO.Backend is not null) + ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler; var cancellationTokenSource = new CancellationTokenSource(); offlinePrompt = new(new Thread(new ThreadStart(delegate