From 79b091cab67129c7056e742ad539e9def7b8cee1 Mon Sep 17 00:00:00 2001 From: Anon Date: Sat, 28 Mar 2026 02:08:47 +0100 Subject: [PATCH 01/13] Implemented a first version of an MCP server as a Chat Bot --- MinecraftClient.sln | 56 +- MinecraftClient/ChatBots/McpServer.cs | 147 ++ MinecraftClient/McClient.cs | 1 + MinecraftClient/Mcp/IMccMcpCapabilities.cs | 34 + MinecraftClient/Mcp/MccEmbeddedMcpHost.cs | 133 ++ MinecraftClient/Mcp/MccMcpCapabilities.cs | 1528 +++++++++++++++++ MinecraftClient/Mcp/MccMcpChatHistory.cs | 49 + MinecraftClient/Mcp/MccMcpConfig.cs | 46 + MinecraftClient/Mcp/MccMcpResult.cs | 33 + MinecraftClient/Mcp/MccMcpToolSet.cs | 193 +++ MinecraftClient/MinecraftClient.csproj | 2 + .../ConfigComments/ConfigComments.resx | 42 + .../Translations/Translations.Designer.cs | 54 + .../Resources/Translations/Translations.resx | 23 +- MinecraftClient/Settings.cs | 11 + 15 files changed, 2350 insertions(+), 2 deletions(-) create mode 100644 MinecraftClient/ChatBots/McpServer.cs create mode 100644 MinecraftClient/Mcp/IMccMcpCapabilities.cs create mode 100644 MinecraftClient/Mcp/MccEmbeddedMcpHost.cs create mode 100644 MinecraftClient/Mcp/MccMcpCapabilities.cs create mode 100644 MinecraftClient/Mcp/MccMcpChatHistory.cs create mode 100644 MinecraftClient/Mcp/MccMcpConfig.cs create mode 100644 MinecraftClient/Mcp/MccMcpResult.cs create mode 100644 MinecraftClient/Mcp/MccMcpToolSet.cs diff --git a/MinecraftClient.sln b/MinecraftClient.sln index 8f0049d8..ebdf1f09 100644 --- a/MinecraftClient.sln +++ b/MinecraftClient.sln @@ -7,27 +7,81 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftClient", "Minecraf EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleInteractive", "ConsoleInteractive\ConsoleInteractive\ConsoleInteractive\ConsoleInteractive.csproj", "{93DA4D71-EFAD-4493-BE21-A105AF663660}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DebugTools", "DebugTools", "{02313C6C-37F1-D66D-F235-6A4537C03113}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "DebugTools\MccMcpStdioHarness\MccMcpStdioHarness.csproj", "{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x64.Build.0 = Debug|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x86.Build.0 = Debug|Any CPU {1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|Any CPU.Build.0 = Release|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x64.ActiveCfg = Release|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x64.Build.0 = Release|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x86.ActiveCfg = Release|Any CPU + {1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x86.Build.0 = Release|Any CPU {93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x64.ActiveCfg = Debug|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x64.Build.0 = Debug|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x86.ActiveCfg = Debug|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x86.Build.0 = Debug|Any CPU {93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|Any CPU.ActiveCfg = Release|Any CPU {93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|Any CPU.Build.0 = Release|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x64.ActiveCfg = Release|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x64.Build.0 = Release|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x86.ActiveCfg = Release|Any CPU + {93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x86.Build.0 = Release|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x64.Build.0 = Debug|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x86.Build.0 = Debug|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|Any CPU.Build.0 = Release|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x64.ActiveCfg = Release|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x64.Build.0 = Release|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x86.ActiveCfg = Release|Any CPU + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x86.Build.0 = Release|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x64.Build.0 = Debug|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x86.Build.0 = Debug|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|Any CPU.Build.0 = Release|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.ActiveCfg = Release|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU + {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F032D2BB-A0A9-4726-A58F-C02F7EA606D6} = {02313C6C-37F1-D66D-F235-6A4537C03113} + {5F620CF6-BC7D-449A-B779-2D51985059C6} = {02313C6C-37F1-D66D-F235-6A4537C03113} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - RESX_ShowErrorsInErrorList = False SolutionGuid = {6DED60F4-9CF4-4DB3-8966-582B2EBE8487} + RESX_ShowErrorsInErrorList = False RESX_SortFileContentOnSave = False EndGlobalSection EndGlobal diff --git a/MinecraftClient/ChatBots/McpServer.cs b/MinecraftClient/ChatBots/McpServer.cs new file mode 100644 index 00000000..7657f0bb --- /dev/null +++ b/MinecraftClient/ChatBots/McpServer.cs @@ -0,0 +1,147 @@ +using System; +using MinecraftClient.Mcp; +using MinecraftClient.Scripting; +using Tomlet.Attributes; + +namespace MinecraftClient.ChatBots +{ + public class McpServer : ChatBot + { + public static Configs Config = new(); + + [TomlDoNotInlineObject] + public class Configs + { + [NonSerialized] + private const string BotName = "McpServer"; + + [TomlInlineComment("$ChatBot.McpServer.Enabled$")] + public bool Enabled = false; + + [TomlPrecedingComment("$ChatBot.McpServer.Transport$")] + public MccMcpTransportConfig Transport = new(); + + [TomlPrecedingComment("$ChatBot.McpServer.Capabilities$")] + public MccMcpCapabilityToggles Capabilities = new(); + + public void OnSettingUpdate() + { + Transport ??= new MccMcpTransportConfig(); + Capabilities ??= new MccMcpCapabilityToggles(); + + if (Transport.Port is < 1 or > 65535) + Transport.Port = 33333; + + if (string.IsNullOrWhiteSpace(Transport.BindHost)) + Transport.BindHost = "127.0.0.1"; + + if (string.IsNullOrWhiteSpace(Transport.Route)) + Transport.Route = "/mcp"; + + if (!Transport.Route.StartsWith('/')) + Transport.Route = "/" + Transport.Route; + + if (string.IsNullOrWhiteSpace(Transport.AuthTokenEnvVar)) + Transport.AuthTokenEnvVar = "MCC_MCP_AUTH_TOKEN"; + } + } + + private MccEmbeddedMcpHost? host; + + public override void Initialize() + { + Config.OnSettingUpdate(); + } + + public override void AfterGameJoined() + { + if (!Config.Enabled) + return; + + MccMcpChatHistoryStore.Clear(); + + MccMcpConfig mcpConfig = new() + { + Enabled = Config.Enabled, + Transport = Config.Transport, + Capabilities = Config.Capabilities + }; + + host ??= new MccEmbeddedMcpHost(mcpConfig, new MccMcpCapabilities(() => Config.Capabilities)); + + if (host.IsRunning) + return; + + LogToConsole(Translations.bot_mcpserver_starting); + if (!host.Start(out string? error)) + { + if (error == "missing_auth_token") + LogToConsole(string.Format(Translations.bot_mcpserver_missing_auth_token, Config.Transport.AuthTokenEnvVar)); + LogToConsole(string.Format(Translations.bot_mcpserver_start_failed, error ?? "unknown")); + return; + } + + LogToConsole(string.Format(Translations.bot_mcpserver_started, host.Endpoint)); + } + + public override bool OnDisconnect(DisconnectReason reason, string message) + { + StopHost(); + MccMcpChatHistoryStore.Clear(); + return false; + } + + public override void OnUnload() + { + StopHost(); + MccMcpChatHistoryStore.Clear(); + } + + public override void GetText(string text, string? json) + { + string clean = GetVerbatim(text); + if (string.IsNullOrWhiteSpace(clean)) + return; + + string kind = "system"; + string? sender = null; + string? message = null; + + string parsedMessage = string.Empty; + string parsedSender = string.Empty; + if (IsPrivateMessage(clean, ref parsedMessage, ref parsedSender)) + { + kind = "private"; + sender = parsedSender; + message = parsedMessage; + } + else if (IsChatMessage(clean, ref parsedMessage, ref parsedSender)) + { + kind = "chat"; + sender = parsedSender; + message = parsedMessage; + } + + MccMcpChatHistoryStore.Add(new MccMcpChatHistoryEntry + { + TimestampUtc = DateTimeOffset.UtcNow, + Kind = kind, + Text = clean, + Sender = sender, + Message = message, + Json = json + }); + } + + private void StopHost() + { + if (host is null || !host.IsRunning) + return; + + if (host.Stop(out string? error)) + LogToConsole(Translations.bot_mcpserver_stopped); + else + LogToConsole(string.Format(Translations.bot_mcpserver_stop_failed, error ?? "unknown")); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 938a85bf..cb6b169b 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -510,6 +510,7 @@ namespace MinecraftClient if (Config.ChatBot.TelegramBridge.Enabled) { BotLoad(new TelegramBridge()); } if (Config.ChatBot.ItemsCollector.Enabled) { BotLoad(new ItemsCollector()); } if (Config.ChatBot.DiscordRpc.Enabled) { BotLoad(new DiscordRpc()); } + if (Config.ChatBot.McpServer.Enabled) { BotLoad(new McpServer()); } if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MCC_FILE_INPUT"))) BotLoad(new FileInputBot()); } diff --git a/MinecraftClient/Mcp/IMccMcpCapabilities.cs b/MinecraftClient/Mcp/IMccMcpCapabilities.cs new file mode 100644 index 00000000..cae36647 --- /dev/null +++ b/MinecraftClient/Mcp/IMccMcpCapabilities.cs @@ -0,0 +1,34 @@ +namespace MinecraftClient.Mcp; + +public interface IMccMcpCapabilities +{ + MccMcpResult GetSessionStatus(); + MccMcpResult GetServerInfo(); + MccMcpResult GetPlayerState(); + MccMcpResult GetPlayersList(); + MccMcpResult GetChatHistory(int maxCount, bool includeJson); + MccMcpResult GetInternalCommands(); + MccMcpResult SendChat(string text); + MccMcpResult QuitClient(); + MccMcpResult RunInternalCommand(string command); + MccMcpResult UseItemOnHand(); + MccMcpResult ChangeHotbarSlot(int slot); + 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 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 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 GetInventorySnapshot(int inventoryId); + MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType); + MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack); + MccMcpResult QueryEntities(int maxCount); + MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius); + MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects); + MccMcpResult GetWorldBlockAt(int x, int y, int z); +} diff --git a/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs b/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs new file mode 100644 index 00000000..cf481d97 --- /dev/null +++ b/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs @@ -0,0 +1,133 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace MinecraftClient.Mcp; + +public sealed class MccEmbeddedMcpHost +{ + private readonly MccMcpConfig config; + private readonly IMccMcpCapabilities capabilities; + private readonly object stateLock = new(); + private WebApplication? app; + + public MccEmbeddedMcpHost(MccMcpConfig config, IMccMcpCapabilities capabilities) + { + this.config = config; + this.capabilities = capabilities; + } + + public bool IsRunning + { + get + { + lock (stateLock) + { + return app is not null; + } + } + } + + public string Endpoint => $"http://{config.Transport.BindHost}:{config.Transport.Port}{NormalizeRoute(config.Transport.Route)}"; + + public bool Start(out string? error) + { + lock (stateLock) + { + error = null; + if (app is not null) + return true; + + string route = NormalizeRoute(config.Transport.Route); + string bindHost = string.IsNullOrWhiteSpace(config.Transport.BindHost) ? "127.0.0.1" : config.Transport.BindHost.Trim(); + if (config.Transport.Port is < 1 or > 65535) + { + error = "invalid_port"; + return false; + } + + string? requiredToken = null; + if (config.Transport.RequireAuthToken) + { + requiredToken = Environment.GetEnvironmentVariable(config.Transport.AuthTokenEnvVar); + if (string.IsNullOrWhiteSpace(requiredToken)) + { + error = "missing_auth_token"; + return false; + } + } + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.Logging.AddFilter(_ => false); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(config); + builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + + builder.WebHost.UseUrls($"http://{bindHost}:{config.Transport.Port}"); + WebApplication builtApp = builder.Build(); + + if (config.Transport.RequireAuthToken) + { + builtApp.Use(async (context, next) => + { + if (context.Request.Path.StartsWithSegments(route, StringComparison.OrdinalIgnoreCase)) + { + string auth = context.Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + || !string.Equals(auth[7..], requiredToken, StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("Unauthorized"); + return; + } + } + + await next(); + }); + } + + builtApp.MapMcp(route); + builtApp.StartAsync().GetAwaiter().GetResult(); + app = builtApp; + return true; + } + } + + public bool Stop(out string? error) + { + lock (stateLock) + { + error = null; + if (app is null) + return true; + + try + { + app.StopAsync().GetAwaiter().GetResult(); + app.DisposeAsync().AsTask().GetAwaiter().GetResult(); + app = null; + return true; + } + catch + { + error = "stop_failed"; + return false; + } + } + } + + private static string NormalizeRoute(string route) + { + string normalized = string.IsNullOrWhiteSpace(route) ? "/mcp" : route.Trim(); + if (!normalized.StartsWith('/')) + normalized = '/' + normalized; + return normalized; + } +} diff --git a/MinecraftClient/Mcp/MccMcpCapabilities.cs b/MinecraftClient/Mcp/MccMcpCapabilities.cs new file mode 100644 index 00000000..097db7d2 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpCapabilities.cs @@ -0,0 +1,1528 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MinecraftClient.CommandHandler; +using MinecraftClient.Inventory; +using MinecraftClient.Mapping; +using MinecraftClient.Scripting; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpCapabilities : IMccMcpCapabilities +{ + private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase; + private const int CoordinateRoundingPrecision = 2; + private const double SelfEntityDistanceThreshold = 0.2; + private const int DefaultArrivalWaitMs = 3500; + private const int MinArrivalWaitMs = 250; + private const int MaxArrivalWaitMs = 15000; + private const double DefaultArrivalTolerance = 1.5; + private const int ArrivalPollIntervalMs = 125; + + private sealed class InternalCommandInfo + { + public required string Name { get; init; } + public required string Usage { get; init; } + public required string Description { get; init; } + } + + private sealed class NearbyPlayerSnapshot + { + public required int EntityId { get; init; } + public required Guid Uuid { get; init; } + public string? Name { get; set; } + public string? CustomName { get; init; } + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } + public required double Distance { get; init; } + public required int Latency { get; init; } + } + + private readonly Func togglesProvider; + + public MccMcpCapabilities(Func togglesProvider) + { + this.togglesProvider = togglesProvider; + } + + private static McClient? GetClient() + { + return McClient.Instance as McClient; + } + + private static MccMcpResult NotConnected() + { + return MccMcpResult.Fail("disconnected"); + } + + private bool IsCategoryEnabled(Func selector) + { + return selector(togglesProvider()); + } + + public MccMcpResult GetSessionStatus() + { + 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 + { + host = client.GetServerHost(), + port = client.GetServerPort(), + username = client.GetUsername(), + protocolVersion = client.GetProtocolVersion(), + terrainEnabled = client.GetTerrainEnabled(), + inventoryEnabled = client.GetInventoryEnabled(), + entityEnabled = client.GetEntityHandlingEnabled(), + location = ToCoordinate(location) + }); + }); + } + + public MccMcpResult GetServerInfo() + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => MccMcpResult.Ok(new + { + host = client.GetServerHost(), + port = client.GetServerPort(), + tps = client.GetServerTPS() + })); + } + + public MccMcpResult GetPlayerState() + { + 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(); + Dictionary effects = client.GetPlayerEffects(); + return MccMcpResult.Ok(new + { + nickname = client.GetUsername(), + username = client.GetUsername(), + health = client.GetHealth(), + saturation = client.GetSaturation(), + gamemode = client.GetGamemode(), + currentSlot = client.GetCurrentSlot() + 1, + yaw = client.GetYaw(), + pitch = client.GetPitch(), + location = ToCoordinate(location), + effects = effects.Values.Select(effect => new + { + id = effect.Effect.ToString(), + amplifier = effect.Amplifier, + remainingSeconds = effect.RemainingSeconds, + isInfinite = effect.IsInfinite + }).ToArray() + }); + }); + } + + public MccMcpResult GetPlayersList() + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return client.InvokeOnMainThread(() => MccMcpResult.Ok(new + { + players = client.GetOnlinePlayers() + })); + } + + public MccMcpResult GetChatHistory(int maxCount, bool includeJson) + { + if (!IsCategoryEnabled(t => t.SessionStatus)) + return MccMcpResult.Fail("capability_disabled"); + + int count = Math.Clamp(maxCount, 1, 500); + MccMcpChatHistoryEntry[] entries = MccMcpChatHistoryStore.GetLatest(count); + return MccMcpResult.Ok(new + { + count = entries.Length, + entries = entries.Select(entry => new + { + timestampUtc = entry.TimestampUtc, + kind = entry.Kind, + text = entry.Text, + sender = entry.Sender, + message = entry.Message, + json = includeJson ? entry.Json : null + }).ToArray() + }); + } + + public MccMcpResult GetInternalCommands() + { + if (!IsCategoryEnabled(t => t.ChatAndCommands)) + return MccMcpResult.Fail("capability_disabled"); + + Type[] commandTypes = Program.GetTypesInNamespace("MinecraftClient.Commands"); + List commands = new(); + + foreach (Type type in commandTypes) + { + if (!type.IsSubclassOf(typeof(Command))) + continue; + + try + { + if (Activator.CreateInstance(type) is Command cmd) + { + commands.Add(new InternalCommandInfo + { + Name = cmd.CmdName, + Usage = cmd.CmdUsage, + Description = ChatBot.GetVerbatim(cmd.CmdDesc) + }); + } + } + catch + { + // ignore command constructors that fail for reflection-only list generation. + } + } + + InternalCommandInfo[] ordered = commands + .OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return MccMcpResult.Ok(new + { + count = ordered.Length, + commands = ordered.Select(command => new + { + name = command.Name, + usage = command.Usage, + description = command.Description + }).ToArray() + }); + } + + public MccMcpResult SendChat(string text) + { + if (!IsCategoryEnabled(t => t.ChatAndCommands)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(text)) + return MccMcpResult.Fail("invalid_args"); + + string normalized = text.Trim(); + if (normalized.Equals("quit", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + return MccMcpResult.Fail("internal_command_text_blocked"); + } + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + bool sent = client.InvokeOnMainThread(() => + { + client.SendText(normalized); + return true; + }); + + return sent ? MccMcpResult.Ok() : MccMcpResult.Fail("action_failed"); + } + + public MccMcpResult QuitClient() + { + 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); + Program.Exit(); + }); + + return MccMcpResult.Ok(new { quitting = true }); + } + + public MccMcpResult RunInternalCommand(string command) + { + if (!IsCategoryEnabled(t => t.ChatAndCommands)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(command)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + return ExecuteInternalCommand(client, command.Trim()); + } + + public MccMcpResult UseItemOnHand() + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + bool ok = client.InvokeOnMainThread(() => client.UseItemOnHand()); + return MccMcpResult.Ok(new { success = ok }); + } + + public MccMcpResult ChangeHotbarSlot(int slot) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + if (slot is < 1 or > 9) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + bool ok = client.InvokeOnMainThread(() => client.ChangeSlot((short)(slot - 1))); + return MccMcpResult.Ok(new { success = ok, slot }); + } + + public MccMcpResult UseItemOnBlock(double x, double y, double z) + { + 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"); + + string sx = x.ToString(CultureInfo.InvariantCulture); + string sy = y.ToString(CultureInfo.InvariantCulture); + string sz = z.ToString(CultureInfo.InvariantCulture); + return ExecuteInternalCommand(client, $"useitem {sx} {sy} {sz}"); + } + + public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (durationSeconds < 0) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + string sx = x.ToString(CultureInfo.InvariantCulture); + string sy = y.ToString(CultureInfo.InvariantCulture); + string sz = z.ToString(CultureInfo.InvariantCulture); + string command = durationSeconds > 0 + ? $"dig {sx} {sy} {sz} {durationSeconds.ToString(CultureInfo.InvariantCulture)}" + : $"dig {sx} {sy} {sz}"; + return ExecuteInternalCommand(client, command); + } + + public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) + { + 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"); + + if (!Enum.TryParse(face, true, out Direction parsedFace)) + return MccMcpResult.Fail("invalid_args"); + + if (!Enum.TryParse(hand, true, out Hand parsedHand)) + return MccMcpResult.Fail("invalid_args"); + + Location location = new(x, y, z); + bool ok = client.InvokeOnMainThread(() => client.PlaceBlock(location, parsedFace, parsedHand, lookAtBlock)); + return MccMcpResult.Ok(new { success = ok, x, y, z, face = parsedFace.ToString(), hand = parsedHand.ToString(), lookAtBlock }); + } + + public MccMcpResult InteractEntity(int entityId, string interaction, string hand) + { + 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"); + + if (!Enum.TryParse(interaction, true, out InteractType interactType)) + return MccMcpResult.Fail("invalid_args"); + + if (!Enum.TryParse(hand, true, out Hand parsedHand)) + return MccMcpResult.Fail("invalid_args"); + + bool ok = client.InvokeOnMainThread(() => client.InteractEntity(entityId, interactType, parsedHand)); + return MccMcpResult.Ok(new { success = ok, entityId, interaction = interactType.ToString(), hand = parsedHand.ToString() }); + } + + public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (radius is < 1 or > 8) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + int limit = Math.Clamp(maxCount, 1, 2000); + string? filter = string.IsNullOrWhiteSpace(materialFilter) ? null : materialFilter.Trim(); + + return client.InvokeOnMainThread(() => + { + Location playerLocation = client.GetCurrentLocation(); + int cx = (int)Math.Floor(playerLocation.X); + int cy = (int)Math.Floor(playerLocation.Y) - 1; + int cz = (int)Math.Floor(playerLocation.Z); + + List found = new(); + World world = client.GetWorld(); + for (int y = cy - radius; y <= cy + radius && found.Count < limit; y++) + { + for (int z = cz - radius; z <= cz + radius && found.Count < limit; z++) + { + for (int x = cx - radius; x <= cx + radius && found.Count < limit; x++) + { + Block block = world.GetBlock(new Location(x, y, z)); + if (block.Type == Material.Air) + continue; + + string material = block.Type.ToString(); + if (filter is not null && !material.Contains(filter, StringComparison.OrdinalIgnoreCase)) + continue; + + double dx = x + 0.5 - playerLocation.X; + double dy = y + 0.5 - playerLocation.Y; + double dz = z + 0.5 - playerLocation.Z; + found.Add(new + { + x, + y, + z, + material, + blockId = block.BlockId, + blockMeta = block.BlockMeta, + distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) + }); + } + } + } + + return MccMcpResult.Ok(new + { + center = new { x = cx, y = cy, z = cz }, + radius, + count = found.Count, + blocks = found.ToArray() + }); + }); + } + + public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (radius is < 1 or > 16) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? filter = string.IsNullOrWhiteSpace(query) ? null : query.Trim(); + ParseBlockQuery(filter, out int? blockIdFilter, out int? blockMetaFilter); + + return client.InvokeOnMainThread(() => + { + Location playerLocation = client.GetCurrentLocation(); + int cx = (int)Math.Floor(playerLocation.X); + int cy = (int)Math.Floor(playerLocation.Y) - 1; + int cz = (int)Math.Floor(playerLocation.Z); + + List<(int x, int y, int z, string material, string typeLabel, int blockId, byte blockMeta, double distance)> found = new(); + World world = client.GetWorld(); + + for (int y = cy - radius; y <= cy + radius && found.Count < limit; y++) + { + for (int z = cz - radius; z <= cz + radius && found.Count < limit; z++) + { + for (int x = cx - radius; x <= cx + radius && found.Count < limit; x++) + { + Block block = world.GetBlock(new Location(x, y, z)); + if (block.Type == Material.Air) + continue; + + if (!BlockMatches(block, filter, exactMatch, blockIdFilter, blockMetaFilter)) + continue; + + double dx = x + 0.5 - playerLocation.X; + double dy = y + 0.5 - playerLocation.Y; + double dz = z + 0.5 - playerLocation.Z; + + found.Add(( + x, + y, + z, + block.Type.ToString(), + block.GetTypeString(), + block.BlockId, + block.BlockMeta, + Math.Sqrt(dx * dx + dy * dy + dz * dz))); + } + } + } + + return MccMcpResult.Ok(new + { + center = new { x = cx, y = cy, z = cz }, + radius, + query = filter, + exactMatch, + count = found.Count, + blocks = found + .OrderBy(entry => entry.distance) + .Select(entry => new + { + entry.x, + entry.y, + entry.z, + entry.material, + entry.typeLabel, + entry.blockId, + entry.blockMeta, + entry.distance + }) + .ToArray() + }); + }); + } + + public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) + { + 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? nameFilter = string.IsNullOrWhiteSpace(playerName) ? null : playerName.Trim(); + + return client.InvokeOnMainThread(() => + { + double radiusValue = radius; + List trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf); + + var players = trackedPlayers + .Where(player => player.Distance <= radiusValue) + .Where(player => + { + if (nameFilter is null) + return true; + return PlayerNameMatches(player, nameFilter); + }) + .OrderBy(player => player.Distance) + .Select(player => new + { + entityId = player.EntityId, + uuid = player.Uuid, + name = player.Name, + customName = player.CustomName, + x = RoundCoordinate(player.X), + y = RoundCoordinate(player.Y), + z = RoundCoordinate(player.Z), + distance = player.Distance, + latency = player.Latency + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + radius = radiusValue, + playerName = nameFilter, + includeSelf, + anyNearby = players.Length > 0, + count = players.Length, + players + }); + }); + } + + public MccMcpResult LocatePlayer(string playerName, bool includeSelf) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(playerName)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + string nameFilter = playerName.Trim(); + return client.InvokeOnMainThread(() => + { + List trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf); + NearbyPlayerSnapshot[] matches = trackedPlayers + .Where(player => PlayerNameMatches(player, nameFilter)) + .OrderBy(player => player.Distance) + .ToArray(); + + if (matches.Length == 0) + { + return MccMcpResult.Fail("invalid_state", data: new + { + playerName = nameFilter, + trackedPlayers = trackedPlayers + .Select(player => player.Name) + .OfType() + .Distinct(NameComparer) + .ToArray() + }); + } + + NearbyPlayerSnapshot selected = matches[0]; + return MccMcpResult.Ok(new + { + playerName = nameFilter, + matchedName = selected.Name, + entityId = selected.EntityId, + uuid = selected.Uuid, + x = RoundCoordinate(selected.X), + y = RoundCoordinate(selected.Y), + z = RoundCoordinate(selected.Z), + distance = selected.Distance + }); + }); + } + + public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) + { + 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"); + + Location goal = new(x, y, z); + TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; + bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout)); + + int verifyWaitMs = GetArrivalWaitMs(timeoutMs); + double tolerance = GetArrivalTolerance(maxOffset, minOffset); + Location? finalLocation = null; + bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); + + return MccMcpResult.Ok(new + { + pathFound, + arrived, + tolerance, + verifyWaitMs, + target = ToCoordinate(goal), + finalLocation = finalLocation is Location location ? ToCoordinate(location) : null + }); + } + + public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(playerName)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + if (!client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + string nameFilter = playerName.Trim(); + return client.InvokeOnMainThread(() => + { + List trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf: false); + NearbyPlayerSnapshot? target = trackedPlayers + .Where(player => PlayerNameMatches(player, nameFilter)) + .OrderBy(player => player.Distance) + .FirstOrDefault(); + + if (target is null) + { + return MccMcpResult.Fail("invalid_state", data: new + { + playerName = nameFilter, + trackedPlayers = trackedPlayers + .Select(player => player.Name) + .OfType() + .Distinct(NameComparer) + .ToArray() + }); + } + + Location goal = new(target.X, target.Y, target.Z); + TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; + bool pathFound = client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout); + + int verifyWaitMs = GetArrivalWaitMs(timeoutMs); + double tolerance = GetArrivalTolerance(maxOffset, minOffset); + Location? finalLocation = null; + bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); + + return MccMcpResult.Ok(new + { + pathFound, + arrived, + tolerance, + verifyWaitMs, + target = new + { + playerName = target.Name, + entityId = target.EntityId, + x = RoundCoordinate(target.X), + y = RoundCoordinate(target.Y), + z = RoundCoordinate(target.Z) + }, + finalLocation = finalLocation is Location location ? ToCoordinate(location) : null + }); + }); + } + + public MccMcpResult LookAt(double x, double y, double z) + { + 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"); + + Location target = new(x, y, z); + client.InvokeOnMainThread(() => client.UpdateLocation(client.GetCurrentLocation(), target)); + return MccMcpResult.Ok(); + } + + public MccMcpResult GetInventorySnapshot(int inventoryId) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + Dictionary inventories = client.GetInventories(); + if (!inventories.TryGetValue(inventoryId, out Container? inventory)) + return MccMcpResult.Fail("invalid_state"); + + var slots = inventory.Items.Select(item => new + { + slot = item.Key, + type = item.Value.Type.ToString(), + count = item.Value.Count + }).ToArray(); + + return MccMcpResult.Ok(new + { + id = inventory.ID, + type = inventory.Type.ToString(), + title = inventory.Title, + slotCount = inventory.Type.SlotCount(), + slots + }); + }); + } + + public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(actionType)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + if (!TryParseWindowAction(actionType, out WindowActionType parsedAction)) + return MccMcpResult.Fail("invalid_args"); + + bool ok = client.InvokeOnMainThread(() => client.DoWindowAction(inventoryId, slotId, parsedAction)); + return MccMcpResult.Ok(new { success = ok, normalizedActionType = parsedAction.ToString() }); + } + + public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(itemType) || count <= 0) + 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(() => + { + Dictionary inventories = client.GetInventories(); + if (!inventories.TryGetValue(inventoryId, out Container? inventory)) + return MccMcpResult.Fail("invalid_state"); + + var matchingSlotQuery = inventory.Items + .Where(pair => pair.Value.Type == parsedItemType && pair.Value.Count > 0) + .Select(pair => new { slot = pair.Key, count = pair.Value.Count }); + var matchingSlots = (preferStack + ? matchingSlotQuery.OrderByDescending(pair => pair.count).ThenBy(pair => pair.slot) + : matchingSlotQuery.OrderBy(pair => pair.count).ThenBy(pair => pair.slot)) + .ToArray(); + + int beforeCount = matchingSlots.Sum(pair => pair.count); + if (beforeCount < count) + { + return MccMcpResult.Fail("invalid_state", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + availableCount = beforeCount, + inventoryId + }); + } + + int remaining = count; + List touchedSlots = new(); + + foreach (var entry in matchingSlots) + { + if (remaining <= 0) + break; + + if (!inventory.Items.TryGetValue(entry.slot, out Item? currentItem) || currentItem.Count <= 0) + continue; + + int dropFromSlot = Math.Min(remaining, currentItem.Count); + touchedSlots.Add(entry.slot); + bool ok = true; + + if (dropFromSlot == currentItem.Count) + { + ok = client.DoWindowAction(inventoryId, entry.slot, WindowActionType.DropItemStack); + } + else + { + for (int i = 0; i < dropFromSlot; i++) + { + if (!client.DoWindowAction(inventoryId, entry.slot, WindowActionType.DropItem)) + { + ok = false; + break; + } + } + } + + if (!ok) + { + return MccMcpResult.Fail("action_failed", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + droppedCount = count - remaining, + remainingCount = remaining, + inventoryId, + touchedSlots = touchedSlots.ToArray() + }); + } + + remaining -= dropFromSlot; + } + + int afterCount = inventory.Items + .Where(pair => pair.Value.Type == parsedItemType) + .Sum(pair => pair.Value.Count); + int droppedCount = beforeCount - afterCount; + + if (remaining > 0) + { + return MccMcpResult.Fail("action_failed", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + droppedCount, + remainingCount = remaining, + beforeCount, + afterCount, + inventoryId, + touchedSlots = touchedSlots.ToArray() + }); + } + + return MccMcpResult.Ok(new + { + success = true, + itemType = parsedItemType.ToString(), + requestedCount = count, + droppedCount, + beforeCount, + afterCount, + inventoryId, + touchedSlots = touchedSlots.ToArray() + }); + }); + } + + public MccMcpResult QueryEntities(int maxCount) + { + 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"); + + int count = Math.Clamp(maxCount, 1, 1000); + return client.InvokeOnMainThread(() => + { + Dictionary entities = client.GetEntities(); + Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) + .ToDictionary(player => player.EntityId, player => player.Name); + var data = entities.Take(count) + .Select(pair => new + { + id = pair.Key, + type = pair.Value.Type.ToString(), + name = pair.Value.Type == EntityType.Player + && playerNamesByEntityId.TryGetValue(pair.Key, out string? mappedName) + ? mappedName + : pair.Value.Name, + uuid = pair.Value.UUID, + x = RoundCoordinate(pair.Value.Location.X), + y = RoundCoordinate(pair.Value.Location.Y), + z = RoundCoordinate(pair.Value.Location.Z) + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + count = entities.Count, + entities = data + }); + }); + } + + public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius) + { + 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"); + + int count = Math.Clamp(maxCount, 1, 1000); + string? filter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim(); + double radiusValue = Math.Max(radius, 0); + + return client.InvokeOnMainThread(() => + { + Dictionary entities = client.GetEntities(); + Location playerLocation = client.GetCurrentLocation(); + Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) + .ToDictionary(player => player.EntityId, player => player.Name); + + var data = entities.Values + .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, + distance = Math.Sqrt(dx * dx + dy * dy + dz * dz), + resolvedName + }; + }) + .Where(item => radiusValue <= 0 || item.distance <= radiusValue) + .Where(item => + { + if (filter is null) + return true; + return item.entity.Type.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase) + || item.entity.GetTypeString().Contains(filter, StringComparison.OrdinalIgnoreCase); + }) + .OrderBy(item => item.distance) + .Take(count) + .Select(item => new + { + id = item.entity.ID, + type = item.entity.Type.ToString(), + typeLabel = item.entity.GetTypeString(), + uuid = item.entity.UUID, + name = item.resolvedName, + customName = item.entity.CustomName, + x = RoundCoordinate(item.entity.Location.X), + y = RoundCoordinate(item.entity.Location.Y), + z = RoundCoordinate(item.entity.Location.Z), + distance = item.distance, + health = item.entity.Health, + pose = item.entity.Pose.ToString(), + latency = item.entity.Latency + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + totalTracked = entities.Count, + count = data.Length, + entities = data + }); + }); + } + + public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects) + { + 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(() => + { + Dictionary entities = client.GetEntities(); + if (!entities.TryGetValue(entityId, out Entity? entity)) + return MccMcpResult.Fail("invalid_state"); + + string? resolvedName = entity.Name; + if (entity.Type == EntityType.Player) + { + Dictionary playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true) + .ToDictionary(player => player.EntityId, player => player.Name); + if (playerNamesByEntityId.TryGetValue(entityId, out string? mappedName)) + resolvedName = mappedName; + } + + object? metadata = includeMetadata + ? entity.Metadata?.ToDictionary( + pair => pair.Key.ToString(CultureInfo.InvariantCulture), + pair => DescribeMetadataValue(pair.Value)) + : null; + + object? equipment = includeEquipment + ? entity.Equipment.Select(pair => new + { + slot = pair.Key, + type = pair.Value.Type.ToString(), + count = pair.Value.Count + }).ToArray() + : null; + + object? activeEffects = includeEffects + ? entity.ActiveEffects.Values.Select(effect => new + { + id = effect.Effect.ToString(), + amplifier = effect.Amplifier, + remainingSeconds = effect.RemainingSeconds, + isInfinite = effect.IsInfinite + }).ToArray() + : null; + + return MccMcpResult.Ok(new + { + id = entity.ID, + type = entity.Type.ToString(), + typeLabel = entity.GetTypeString(), + uuid = entity.UUID, + name = resolvedName, + customName = entity.CustomName, + customNameVisible = entity.IsCustomNameVisible, + x = RoundCoordinate(entity.Location.X), + y = RoundCoordinate(entity.Location.Y), + z = RoundCoordinate(entity.Location.Z), + yaw = entity.Yaw, + pitch = entity.Pitch, + health = entity.Health, + pose = entity.Pose.ToString(), + latency = entity.Latency, + objectData = entity.ObjectData, + metadata, + equipment, + activeEffects + }); + }); + } + + public MccMcpResult GetWorldBlockAt(int x, int y, int z) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + 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 location = new(x, y, z); + Block block = client.GetWorld().GetBlock(location); + return MccMcpResult.Ok(new + { + x, + y, + z, + material = block.Type.ToString(), + blockId = block.BlockId, + blockMeta = block.BlockMeta + }); + }); + } + + private static MccMcpResult ExecuteInternalCommand(McClient client, string command) + { + return client.InvokeOnMainThread(() => + { + CmdResult result = new(); + bool ok = client.PerformInternalCommand(command, ref result); + return MccMcpResult.Ok(new + { + success = ok, + status = result.status.ToString(), + output = result.ToString() + }); + }); + } + + private static List BuildTrackedPlayerSnapshots(McClient client, bool includeSelf) + { + Location playerLocation = client.GetCurrentLocation(); + string username = client.GetUsername(); + Dictionary uuidToName = client.GetOnlinePlayersWithUUID(); + string[] onlinePlayers = client.GetOnlinePlayers(); + + List trackedPlayers = client.GetEntities().Values + .Where(entity => 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; + double distance = Math.Sqrt(dx * dx + dy * dy + dz * dz); + string? rawName = ResolvePlayerEntityName(entity, uuidToName); + return new NearbyPlayerSnapshot + { + EntityId = entity.ID, + Uuid = entity.UUID, + Name = rawName, + CustomName = entity.CustomName, + X = entity.Location.X, + Y = entity.Location.Y, + Z = entity.Location.Z, + Distance = distance, + Latency = entity.Latency + }; + }) + .ToList(); + + if (!includeSelf) + { + trackedPlayers = trackedPlayers + .Where(player => !string.Equals(player.Name, username, StringComparison.OrdinalIgnoreCase)) + .Where(player => player.Distance > SelfEntityDistanceThreshold) + .ToList(); + } + + List unnamedTracked = trackedPlayers + .Where(player => string.IsNullOrWhiteSpace(player.Name)) + .OrderBy(player => player.Distance) + .ToList(); + if (unnamedTracked.Count == 0) + return trackedPlayers; + + HashSet assignedNames = trackedPlayers + .Select(player => player.Name) + .OfType() + .ToHashSet(NameComparer); + + string[] unmatchedOnline = onlinePlayers + .Where(name => includeSelf || !string.Equals(name, username, StringComparison.OrdinalIgnoreCase)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Where(name => !assignedNames.Contains(name)) + .Distinct(NameComparer) + .ToArray(); + + if (unmatchedOnline.Length == 0) + return trackedPlayers; + + if (unnamedTracked.Count == 1 && unmatchedOnline.Length == 1) + { + unnamedTracked[0].Name = unmatchedOnline[0]; + return trackedPlayers; + } + + int pairCount = Math.Min(unnamedTracked.Count, unmatchedOnline.Length); + string[] sortedNames = unmatchedOnline + .OrderBy(name => name, NameComparer) + .ToArray(); + for (int i = 0; i < pairCount; i++) + unnamedTracked[i].Name = sortedNames[i]; + + return trackedPlayers; + } + + private static object? DescribeMetadataValue(object? value) + { + return value switch + { + null => null, + string s => s, + bool b => b, + byte b => b, + sbyte b => b, + short s => s, + ushort s => s, + int i => i, + uint i => i, + long l => l, + ulong l => l, + float f => f, + double d => d, + decimal d => d, + Enum e => e.ToString(), + Location location => ToCoordinate(location), + Item item => new { type = item.Type.ToString(), count = item.Count }, + byte[] data => new { bytes = data.Length }, + _ => value.ToString() + }; + } + + private static bool PlayerNameMatches(NearbyPlayerSnapshot player, string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + return true; + + string trimmed = filter.Trim(); + if (!string.IsNullOrWhiteSpace(player.Name) && player.Name.Contains(trimmed, StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrWhiteSpace(player.CustomName) && player.CustomName.Contains(trimmed, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private static bool TryParseWindowAction(string rawActionType, out WindowActionType actionType) + { + if (Enum.TryParse(rawActionType, true, out actionType)) + return true; + + string normalized = NormalizeToken(rawActionType); + if (normalized.Length == 0) + return false; + + return normalized switch + { + "left" or "leftclick" => SetAction(WindowActionType.LeftClick, out actionType), + "right" or "rightclick" => SetAction(WindowActionType.RightClick, out actionType), + "middle" or "mid" or "middleclick" => SetAction(WindowActionType.MiddleClick, out actionType), + "shift" or "shiftclick" => SetAction(WindowActionType.ShiftClick, out actionType), + "shiftright" or "shiftrightclick" => SetAction(WindowActionType.ShiftRightClick, out actionType), + "drop" or "dropitem" or "q" => SetAction(WindowActionType.DropItem, out actionType), + "dropstack" or "dropall" or "dropitemstack" or "ctrlq" or "ctrldrop" => SetAction(WindowActionType.DropItemStack, out actionType), + _ => false + }; + } + + private static bool SetAction(WindowActionType value, out WindowActionType actionType) + { + actionType = value; + return true; + } + + private static bool TryParseItemType(string rawItemType, out ItemType itemType) + { + if (Enum.TryParse(rawItemType, true, out itemType) && itemType is not (ItemType.Unknown or ItemType.Null)) + return true; + + string normalized = NormalizeToken(rawItemType); + if (normalized.Length == 0) + { + itemType = ItemType.Unknown; + return false; + } + + foreach (ItemType candidate in Enum.GetValues()) + { + if (candidate is ItemType.Unknown or ItemType.Null) + continue; + if (NormalizeToken(candidate.ToString()) == normalized) + { + itemType = candidate; + return true; + } + } + + itemType = ItemType.Unknown; + return false; + } + + private static string NormalizeToken(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + char[] buffer = value + .Where(char.IsLetterOrDigit) + .Select(char.ToLowerInvariant) + .ToArray(); + return new string(buffer); + } + + private static bool WaitForArrival(McClient client, Location goal, int waitMs, double tolerance, out Location? finalLocation) + { + finalLocation = null; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + Location location = client.InvokeOnMainThread(client.GetCurrentLocation); + finalLocation = location; + double distance = GetDistance(location, goal); + if (distance <= tolerance) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static double GetDistance(Location from, Location to) + { + double dx = from.X - to.X; + double dy = from.Y - to.Y; + double dz = from.Z - to.Z; + return Math.Sqrt(dx * dx + dy * dy + dz * dz); + } + + private static int GetArrivalWaitMs(int timeoutMs) + { + if (timeoutMs <= 0) + return DefaultArrivalWaitMs; + return Math.Clamp(timeoutMs, MinArrivalWaitMs, MaxArrivalWaitMs); + } + + private static double GetArrivalTolerance(int maxOffset, int minOffset) + { + double toleranceFromOffset = Math.Max(maxOffset, minOffset) + 1.0; + return Math.Max(DefaultArrivalTolerance, toleranceFromOffset); + } + + private static object ToCoordinate(Location location) + { + return ToCoordinate(location.X, location.Y, location.Z); + } + + private static object ToCoordinate(double x, double y, double z) + { + return new + { + x = RoundCoordinate(x), + y = RoundCoordinate(y), + z = RoundCoordinate(z) + }; + } + + private static double RoundCoordinate(double value) + { + return Math.Round(value, CoordinateRoundingPrecision, MidpointRounding.AwayFromZero); + } + + private static string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary uuidToName) + { + if (!string.IsNullOrWhiteSpace(entity.Name)) + return entity.Name; + + if (entity.UUID != Guid.Empty + && uuidToName.TryGetValue(entity.UUID.ToString(), out string? mappedName) + && !string.IsNullOrWhiteSpace(mappedName)) + { + return mappedName; + } + + if (!string.IsNullOrWhiteSpace(entity.CustomName)) + return entity.CustomName; + + return null; + } + + private static bool BlockMatches(Block block, string? filter, bool exactMatch, int? blockIdFilter, int? blockMetaFilter) + { + if (blockIdFilter.HasValue) + { + if (block.BlockId != blockIdFilter.Value) + return false; + if (blockMetaFilter.HasValue && block.BlockMeta != blockMetaFilter.Value) + return false; + return true; + } + + if (filter is null) + return true; + + string material = block.Type.ToString(); + string typeLabel = block.GetTypeString(); + if (exactMatch) + { + return material.Equals(filter, StringComparison.OrdinalIgnoreCase) + || typeLabel.Equals(filter, StringComparison.OrdinalIgnoreCase); + } + + return material.Contains(filter, StringComparison.OrdinalIgnoreCase) + || typeLabel.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + private static void ParseBlockQuery(string? query, out int? blockId, out int? blockMeta) + { + blockId = null; + blockMeta = null; + if (string.IsNullOrWhiteSpace(query)) + return; + + string trimmed = query.Trim(); + int separator = trimmed.IndexOf(':'); + if (separator >= 0) + { + string idPart = trimmed[..separator].Trim(); + string metaPart = trimmed[(separator + 1)..].Trim(); + if (int.TryParse(idPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedId)) + { + blockId = parsedId; + if (int.TryParse(metaPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedMeta)) + blockMeta = parsedMeta; + } + return; + } + + if (int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out int blockStateId)) + blockId = blockStateId; + } +} diff --git a/MinecraftClient/Mcp/MccMcpChatHistory.cs b/MinecraftClient/Mcp/MccMcpChatHistory.cs new file mode 100644 index 00000000..a5acda58 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpChatHistory.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpChatHistoryEntry +{ + public required DateTimeOffset TimestampUtc { get; init; } + public required string Kind { get; init; } + public required string Text { get; init; } + public string? Sender { get; init; } + public string? Message { get; init; } + public string? Json { get; init; } +} + +public static class MccMcpChatHistoryStore +{ + private static readonly object historyLock = new(); + private static readonly List history = new(); + private const int MaxEntries = 500; + + public static void Add(MccMcpChatHistoryEntry entry) + { + lock (historyLock) + { + history.Add(entry); + if (history.Count > MaxEntries) + history.RemoveRange(0, history.Count - MaxEntries); + } + } + + public static MccMcpChatHistoryEntry[] GetLatest(int maxCount) + { + int count = Math.Clamp(maxCount, 1, MaxEntries); + lock (historyLock) + { + return history.TakeLast(count).ToArray(); + } + } + + public static void Clear() + { + lock (historyLock) + { + history.Clear(); + } + } +} diff --git a/MinecraftClient/Mcp/MccMcpConfig.cs b/MinecraftClient/Mcp/MccMcpConfig.cs new file mode 100644 index 00000000..1655be62 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpConfig.cs @@ -0,0 +1,46 @@ +using Tomlet.Attributes; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpConfig +{ + public bool Enabled { get; set; } + public MccMcpTransportConfig Transport { get; set; } = new(); + public MccMcpCapabilityToggles Capabilities { get; set; } = new(); +} + +public sealed class MccMcpTransportConfig +{ + [TomlInlineComment("$ChatBot.McpServer.Transport.BindHost$")] + public string BindHost { get; set; } = "127.0.0.1"; + + [TomlInlineComment("$ChatBot.McpServer.Transport.Port$")] + public int Port { get; set; } = 33333; + + [TomlInlineComment("$ChatBot.McpServer.Transport.Route$")] + public string Route { get; set; } = "/mcp"; + + [TomlInlineComment("$ChatBot.McpServer.Transport.RequireAuthToken$")] + public bool RequireAuthToken { get; set; } + + [TomlInlineComment("$ChatBot.McpServer.Transport.AuthTokenEnvVar$")] + public string AuthTokenEnvVar { get; set; } = "MCC_MCP_AUTH_TOKEN"; +} + +public sealed class MccMcpCapabilityToggles +{ + [TomlInlineComment("$ChatBot.McpServer.Capabilities.SessionStatus$")] + public bool SessionStatus { get; set; } = true; + + [TomlInlineComment("$ChatBot.McpServer.Capabilities.ChatAndCommands$")] + public bool ChatAndCommands { get; set; } = true; + + [TomlInlineComment("$ChatBot.McpServer.Capabilities.Movement$")] + public bool Movement { get; set; } = true; + + [TomlInlineComment("$ChatBot.McpServer.Capabilities.Inventory$")] + public bool Inventory { get; set; } = true; + + [TomlInlineComment("$ChatBot.McpServer.Capabilities.EntityWorld$")] + public bool EntityWorld { get; set; } = true; +} diff --git a/MinecraftClient/Mcp/MccMcpResult.cs b/MinecraftClient/Mcp/MccMcpResult.cs new file mode 100644 index 00000000..0c12fbf2 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpResult.cs @@ -0,0 +1,33 @@ +using System; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpResult +{ + public bool Success { get; init; } + public string? ErrorCode { get; init; } + public string? Message { get; init; } + public object? Data { get; init; } + + public static MccMcpResult Ok(object? data = null, string? message = null) + { + return new MccMcpResult + { + Success = true, + Data = data, + Message = message + }; + } + + public static MccMcpResult Fail(string errorCode, string? message = null, object? data = null) + { + ArgumentException.ThrowIfNullOrEmpty(errorCode); + return new MccMcpResult + { + Success = false, + ErrorCode = errorCode, + Message = message, + Data = data + }; + } +} diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs new file mode 100644 index 00000000..6eb41caa --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace MinecraftClient.Mcp; + +[McpServerToolType] +public sealed class MccMcpToolSet +{ + private readonly IMccMcpCapabilities capabilities; + + public MccMcpToolSet(IMccMcpCapabilities capabilities) + { + this.capabilities = capabilities; + } + + [McpServerTool(Name = "mcc_session_status"), Description("Get current MCC session and feature status.")] + public object SessionStatus() + { + return capabilities.GetSessionStatus(); + } + + [McpServerTool(Name = "mcc_server_info"), Description("Get active MCC server connection info and current TPS.")] + public object ServerInfo() + { + return capabilities.GetServerInfo(); + } + + [McpServerTool(Name = "mcc_player_state"), Description("Get current controlled player state.")] + public object PlayerState() + { + return capabilities.GetPlayerState(); + } + + [McpServerTool(Name = "mcc_players_list"), Description("List currently known online players.")] + public object PlayersList() + { + return capabilities.GetPlayersList(); + } + + [McpServerTool(Name = "mcc_chat_history"), Description("Get recent chat/system lines seen by MCC.")] + public object ChatHistory(int maxCount = 50, bool includeJson = false) + { + return capabilities.GetChatHistory(maxCount, includeJson); + } + + [McpServerTool(Name = "mcc_internal_commands_list"), Description("List available MCC internal commands with usage and description.")] + public object InternalCommandsList() + { + return capabilities.GetInternalCommands(); + } + + [McpServerTool(Name = "mcc_send_chat"), Description("Send chat text or slash-command to the connected Minecraft server.")] + public object SendChat([Description("Text to send to server chat.")] string text) + { + return capabilities.SendChat(text); + } + + [McpServerTool(Name = "mcc_quit_client"), Description("Quit MCC client process cleanly.")] + public object QuitClient() + { + return capabilities.QuitClient(); + } + + [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_change_hotbar_slot"), Description("Change active hotbar slot (1-9).")] + public object ChangeHotbarSlot(int slot) + { + return capabilities.ChangeHotbarSlot(slot); + } + + [McpServerTool(Name = "mcc_use_item_on_hand"), Description("Use the currently held item.")] + public object UseItemOnHand() + { + return capabilities.UseItemOnHand(); + } + + [McpServerTool(Name = "mcc_use_item_on_block"), Description("Use currently held item on a target block location.")] + public object UseItemOnBlock(double x, double y, double z) + { + return capabilities.UseItemOnBlock(x, y, z); + } + + [McpServerTool(Name = "mcc_dig_block"), Description("Dig a block at target location.")] + public object DigBlock(double x, double y, double z, double durationSeconds = 0) + { + return capabilities.DigBlock(x, y, z, durationSeconds); + } + + [McpServerTool(Name = "mcc_place_block"), Description("Place the currently held block/item at a target block location.")] + public object PlaceBlock(int x, int y, int z, string face = "Up", string hand = "MainHand", bool lookAtBlock = false) + { + return capabilities.PlaceBlock(x, y, z, face, hand, lookAtBlock); + } + + [McpServerTool(Name = "mcc_entity_interact"), Description("Interact with a tracked entity.")] + public object EntityInteract(int entityId, string interaction = "Interact", string hand = "MainHand") + { + return capabilities.InteractEntity(entityId, interaction, hand); + } + + [McpServerTool(Name = "mcc_block_scan"), Description("Scan nearby blocks around player location.")] + public object BlockScan(int radius = 3, int maxCount = 200, string? materialFilter = null) + { + return capabilities.ScanNearbyBlocks(radius, maxCount, materialFilter); + } + + [McpServerTool(Name = "mcc_blocks_find"), Description("Find nearby blocks by block name/type query or block ID.")] + public object BlocksFind(string? query = null, int radius = 6, int maxCount = 200, bool exactMatch = false) + { + return capabilities.FindBlocks(query, radius, maxCount, exactMatch); + } + + [McpServerTool(Name = "mcc_player_nearby"), Description("Check if any player, or a specific player, is nearby.")] + public object PlayerNearby(string? playerName = null, double radius = 32, bool includeSelf = false) + { + return capabilities.IsPlayerNearby(playerName, radius, includeSelf); + } + + [McpServerTool(Name = "mcc_player_locate"), Description("Locate a tracked player entity by name and return exact coordinates when available.")] + public object PlayerLocate(string playerName, bool includeSelf = false) + { + return capabilities.LocatePlayer(playerName, includeSelf); + } + + [McpServerTool(Name = "mcc_move_to"), Description("Request movement/pathing to a world coordinate and verify arrival.")] + public object MoveTo(double x, double y, double z, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) + { + return capabilities.MoveTo(x, y, z, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeoutMs); + } + + [McpServerTool(Name = "mcc_move_to_player"), Description("Locate a tracked player entity, request movement/pathing, and verify arrival.")] + public object MoveToPlayer(string playerName, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) + { + return capabilities.MoveToPlayer(playerName, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeoutMs); + } + + [McpServerTool(Name = "mcc_look_at"), Description("Rotate player view toward world coordinates.")] + public object LookAt(double x, double y, double z) + { + return capabilities.LookAt(x, y, z); + } + + [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_window_action"), Description("Perform a window action on an inventory slot.")] + public object InventoryWindowAction(int inventoryId, int slotId, [Description("WindowActionType enum name, e.g. LeftClick or ShiftClick.")] string actionType) + { + return capabilities.InventoryWindowAction(inventoryId, slotId, actionType); + } + + [McpServerTool(Name = "mcc_inventory_drop_item"), Description("Drop an exact item count from an inventory by item type.")] + public object InventoryDropItem( + [Description("Item type enum name (e.g. Diamond).")] string itemType, + [Description("Exact number of items to drop.")] int count, + [Description("Inventory ID. 0 is the player inventory.")] int inventoryId = 0, + [Description("Prefer dropping from larger stacks first when true.")] bool preferStack = false) + { + return capabilities.DropInventoryItem(itemType, count, inventoryId, preferStack); + } + + [McpServerTool(Name = "mcc_entities_query"), Description("Query tracked entities.")] + public object EntitiesQuery([Description("Maximum entities to return.")] int maxCount = 50) + { + return capabilities.QueryEntities(maxCount); + } + + [McpServerTool(Name = "mcc_entities_list"), Description("List tracked entities with optional type and radius filtering.")] + public object EntitiesList(int maxCount = 100, string? typeFilter = null, double radius = 0) + { + return capabilities.ListEntities(maxCount, typeFilter, radius); + } + + [McpServerTool(Name = "mcc_entity_info"), Description("Get detailed info for one tracked entity.")] + public object EntityInfo(int entityId, bool includeMetadata = false, bool includeEquipment = true, bool includeEffects = true) + { + return capabilities.GetEntityInfo(entityId, includeMetadata, includeEquipment, includeEffects); + } + + [McpServerTool(Name = "mcc_world_block_at"), Description("Get block information at world coordinates.")] + public object WorldBlockAt(int x, int y, int z) + { + return capabilities.GetWorldBlockAt(x, y, z); + } +} diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index 5df50933..dd843035 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -39,6 +39,8 @@ + + diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 6d91740b..b4d32711 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -936,4 +936,46 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be Yggdrasil authlib multi-user selection. + + Host an embedded MCP server while connected to Minecraft. Disabled by default. + + + Embedded MCP HTTP transport settings. + + + Enable or disable MCP tool categories. + + + Enable the built-in embedded MCP server bot. Server starts only after game join and stops on disconnect. + + + IP/host to bind the embedded MCP HTTP listener to. Default is loopback only. + + + TCP port for the embedded MCP HTTP listener. + + + Route prefix where MCP endpoints are exposed. + + + Require Bearer token authentication for MCP endpoint requests. + + + Environment variable name containing the MCP auth token when auth is required. + + + Allow session and status inspection tools. + + + Allow chat and internal command tools. + + + Allow movement and view-control tools. + + + Allow inventory read and action tools. + + + Allow entity and world inspection tools. + diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 3a2c4bc9..dc8c3e4a 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -6799,5 +6799,59 @@ namespace MinecraftClient { return ResourceManager.GetString("tui.crafting.grid", resourceCulture); } } + + /// + /// Looks up a localized string similar to Starting embedded MCP server.... + /// + internal static string bot_mcpserver_starting { + get { + return ResourceManager.GetString("bot.mcpserver.starting", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Embedded MCP server started on {0}. + /// + internal static string bot_mcpserver_started { + get { + return ResourceManager.GetString("bot.mcpserver.started", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to start embedded MCP server: {0}. + /// + internal static string bot_mcpserver_start_failed { + get { + return ResourceManager.GetString("bot.mcpserver.start_failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Embedded MCP auth token is required but environment variable {0} is empty.. + /// + internal static string bot_mcpserver_missing_auth_token { + get { + return ResourceManager.GetString("bot.mcpserver.missing_auth_token", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Embedded MCP server stopped.. + /// + internal static string bot_mcpserver_stopped { + get { + return ResourceManager.GetString("bot.mcpserver.stopped", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to stop embedded MCP server cleanly: {0}. + /// + internal static string bot_mcpserver_stop_failed { + get { + return ResourceManager.GetString("bot.mcpserver.stop_failed", resourceCulture); + } + } } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 68202894..33e965af 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2392,4 +2392,25 @@ see item details. Crafting - \ No newline at end of file + + McpServer + + + Starting embedded MCP server... + + + Embedded MCP server started on {0} + + + Failed to start embedded MCP server: {0} + + + Embedded MCP auth token is required but environment variable {0} is empty. + + + Embedded MCP server stopped. + + + Failed to stop embedded MCP server cleanly: {0} + + diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index e77514da..b3fc1e1b 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -1755,6 +1755,17 @@ namespace MinecraftClient get { return ChatBots.DiscordRpc.Config; } set { ChatBots.DiscordRpc.Config = value; ChatBots.DiscordRpc.Config.OnSettingUpdate(); } } + + [TomlPrecedingComment("$ChatBot.McpServer$")] + public ChatBots.McpServer.Configs McpServer + { + get { return ChatBots.McpServer.Config; } + set + { + ChatBots.McpServer.Config = value ?? new ChatBots.McpServer.Configs(); + ChatBots.McpServer.Config.OnSettingUpdate(); + } + } } } From 7f9023e7bb58ea02ff345e5df2330df8f01cb55f Mon Sep 17 00:00:00 2001 From: Anon Date: Sat, 28 Mar 2026 04:12:37 +0100 Subject: [PATCH 02/13] More tools, added debug tools, fixed issues with some tools. --- .../MccMcpSampleClient.csproj | 14 + DebugTools/MccMcpSampleClient/Program.cs | 211 ++++ .../MccMcpStdioHarness.csproj | 18 + DebugTools/MccMcpStdioHarness/Program.cs | 469 +++++++ .../MccMcpWebPlayground.csproj | 14 + DebugTools/MccMcpWebPlayground/Program.cs | 1116 ++++++++++++++++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 8 + .../MccMcpWebPlayground/appsettings.json | 9 + .../MccMcpWebPlayground/wwwroot/index.html | 1117 +++++++++++++++++ MinecraftClient/McClient.cs | 158 +++ MinecraftClient/Mcp/IMccMcpCapabilities.cs | 7 + MinecraftClient/Mcp/MccMcpCapabilities.cs | 826 +++++++++++- MinecraftClient/Mcp/MccMcpToolSet.cs | 42 + .../Protocol/Handlers/Protocol18.cs | 38 +- .../Protocol/IMinecraftComHandler.cs | 7 + 16 files changed, 4005 insertions(+), 72 deletions(-) create mode 100644 DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj create mode 100644 DebugTools/MccMcpSampleClient/Program.cs create mode 100644 DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj create mode 100644 DebugTools/MccMcpStdioHarness/Program.cs create mode 100644 DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj create mode 100644 DebugTools/MccMcpWebPlayground/Program.cs create mode 100644 DebugTools/MccMcpWebPlayground/Properties/launchSettings.json create mode 100644 DebugTools/MccMcpWebPlayground/appsettings.Development.json create mode 100644 DebugTools/MccMcpWebPlayground/appsettings.json create mode 100644 DebugTools/MccMcpWebPlayground/wwwroot/index.html diff --git a/DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj b/DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj new file mode 100644 index 00000000..0edf5faa --- /dev/null +++ b/DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/DebugTools/MccMcpSampleClient/Program.cs b/DebugTools/MccMcpSampleClient/Program.cs new file mode 100644 index 00000000..591cc52c --- /dev/null +++ b/DebugTools/MccMcpSampleClient/Program.cs @@ -0,0 +1,211 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +string endpoint = Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp"; +string model = "minimax/minimax-m2.7"; +bool useStdio = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_USE_STDIO"), "1", StringComparison.Ordinal); +string? openRouterApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); +string openRouterBaseUrl = Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1"; +string? mcpAuthToken = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); + +await using McpClient client = useStdio + ? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions())) + : await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken) + ? null + : new Dictionary { ["Authorization"] = $"Bearer {mcpAuthToken}" } + })); + +var executed = new List(); + +CallToolResult sessionStatus = await CallAndStore("mcc_session_status"); +await CallAndStore("mcc_players_list"); +await CallAndStore("mcc_send_chat", new Dictionary { ["text"] = "/say mcp_full_sweep" }); +await CallAndStore("mcc_run_internal_command", new Dictionary { ["command"] = "debug state" }); + +(double lookX, double lookY, double lookZ) = GetLookTarget(sessionStatus); +await CallAndStore("mcc_look_at", new Dictionary { ["x"] = lookX, ["y"] = lookY, ["z"] = lookZ }); +await CallAndStore("mcc_move_to", new Dictionary { ["x"] = lookX, ["y"] = lookY, ["z"] = lookZ, ["timeoutMs"] = 2000 }); + +CallToolResult inventorySnapshot = await CallAndStore("mcc_inventory_snapshot", new Dictionary { ["inventoryId"] = 0 }); +int actionSlot = GetInventoryActionSlot(inventorySnapshot); +await CallAndStore("mcc_inventory_window_action", new Dictionary { ["inventoryId"] = 0, ["slotId"] = actionSlot, ["actionType"] = "LeftClick" }); + +await CallAndStore("mcc_entities_query", new Dictionary { ["maxCount"] = 20 }); +CallToolResult entitiesList = await CallAndStore("mcc_entities_list", new Dictionary { ["maxCount"] = 20 }); +int? firstEntityId = GetFirstEntityId(entitiesList); +if (firstEntityId.HasValue) +{ + await CallAndStore("mcc_entity_info", new Dictionary + { + ["entityId"] = firstEntityId.Value, + ["includeMetadata"] = false, + ["includeEquipment"] = true, + ["includeEffects"] = true + }); +} +await CallAndStore("mcc_blocks_find", new Dictionary { ["query"] = "Grass", ["radius"] = 6, ["maxCount"] = 50 }); +await CallAndStore("mcc_player_nearby", new Dictionary { ["radius"] = 48.0, ["includeSelf"] = false }); +await CallAndStore("mcc_world_block_at", new Dictionary { ["x"] = 0, ["y"] = 80, ["z"] = 0 }); + +string evidenceJson = JsonSerializer.Serialize(executed, new JsonSerializerOptions { WriteIndented = true }); +Console.WriteLine(evidenceJson); + +if (!useStdio && !string.IsNullOrWhiteSpace(openRouterApiKey)) +{ + using HttpClient http = new(); + http.BaseAddress = new Uri(openRouterBaseUrl.TrimEnd('/') + "/"); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", openRouterApiKey); + http.DefaultRequestHeaders.Add("HTTP-Referer", "https://localhost/mcc-mcp-sample"); + http.DefaultRequestHeaders.Add("X-Title", "MCC MCP Sample Client"); + + var payload = new + { + model, + messages = new object[] + { + new { role = "system", content = "Summarize the MCP tool execution output briefly." }, + new { role = "user", content = evidenceJson } + } + }; + + HttpResponseMessage response = await http.PostAsync( + "chat/completions", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + string body = await response.Content.ReadAsStringAsync(); + Console.WriteLine(body); +} + +async Task CallAndStore(string toolName, IReadOnlyDictionary? args = null) +{ + CallToolResult result = await client.CallToolAsync(toolName, args); + executed.Add(new + { + tool = toolName, + arguments = args, + isError = result.IsError, + result = result + }); + return result; +} + +static (double x, double y, double z) GetLookTarget(CallToolResult sessionStatus) +{ + JsonElement? data = TryReadData(sessionStatus); + if (data is JsonElement jsonData && + jsonData.TryGetProperty("location", out JsonElement location) && + TryReadDouble(location, "x", out double x) && + TryReadDouble(location, "y", out double y) && + TryReadDouble(location, "z", out double z)) + { + return (x, y, z); + } + + return (0.5, 80.0, 0.5); +} + +static int GetInventoryActionSlot(CallToolResult inventorySnapshot) +{ + JsonElement? data = TryReadData(inventorySnapshot); + if (data is JsonElement jsonData && + jsonData.TryGetProperty("slots", out JsonElement slots) && + slots.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement slot in slots.EnumerateArray()) + { + if (TryReadInt(slot, "slot", out int slotId)) + return slotId; + } + } + + return 0; +} + +static int? GetFirstEntityId(CallToolResult entitiesList) +{ + JsonElement? data = TryReadData(entitiesList); + if (data is not JsonElement jsonData) + return null; + + if (!jsonData.TryGetProperty("entities", out JsonElement entities) + || entities.ValueKind != JsonValueKind.Array + || entities.GetArrayLength() == 0) + { + return null; + } + + JsonElement first = entities[0]; + if (TryReadInt(first, "id", out int entityId)) + return entityId; + + return null; +} + +static JsonElement? TryReadData(CallToolResult result) +{ + if (result.Content is null) + return null; + + foreach (ContentBlock content in result.Content) + { + if (content is TextContentBlock text && + !string.IsNullOrWhiteSpace(text.Text)) + { + using JsonDocument doc = JsonDocument.Parse(text.Text); + if (doc.RootElement.TryGetProperty("data", out JsonElement data)) + return data.Clone(); + } + } + + return null; +} + +static bool TryReadDouble(JsonElement element, string property, out double value) +{ + value = 0; + return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetDouble(out value); +} + +static bool TryReadInt(JsonElement element, string property, out int value) +{ + value = 0; + return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetInt32(out value); +} + +static StdioClientTransportOptions CreateStdioOptions() +{ + string? stdioBin = Environment.GetEnvironmentVariable("MCC_MCP_STDIO_BIN"); + if (!string.IsNullOrWhiteSpace(stdioBin)) + { + return new StdioClientTransportOptions + { + Name = "MCC MCP Stdio Harness", + Command = stdioBin, + Arguments = [], + ShutdownTimeout = TimeSpan.FromSeconds(5) + }; + } + + return new StdioClientTransportOptions + { + Name = "MCC MCP Stdio Harness", + Command = "dotnet", + Arguments = + [ + "run", + "--project", + "DebugTools/MccMcpStdioHarness", + "-c", + "Release", + "--no-build" + ], + ShutdownTimeout = TimeSpan.FromSeconds(5) + }; +} diff --git a/DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj b/DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj new file mode 100644 index 00000000..2261edaa --- /dev/null +++ b/DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/DebugTools/MccMcpStdioHarness/Program.cs b/DebugTools/MccMcpStdioHarness/Program.cs new file mode 100644 index 00000000..d105f12f --- /dev/null +++ b/DebugTools/MccMcpStdioHarness/Program.cs @@ -0,0 +1,469 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MinecraftClient.Mcp; +using ModelContextProtocol.Server; + +var builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddConsole(options => +{ + options.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services.AddSingleton(); +builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); + +internal sealed class DeterministicCapabilities : IMccMcpCapabilities +{ + private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + + public MccMcpResult GetSessionStatus() => + MccMcpResult.Ok(new + { + connected = true, + host = "deterministic.local", + port = 25565, + username = "HarnessBot", + location = new { x = C(0.5), y = C(80.0), z = C(0.5) } + }); + + public MccMcpResult GetServerInfo() => + MccMcpResult.Ok(new + { + host = "deterministic.local", + port = 25565, + tps = 20.0 + }); + + public MccMcpResult GetPlayerState() => + MccMcpResult.Ok(new + { + nickname = "HarnessBot", + username = "HarnessBot", + health = 20.0f, + saturation = 20, + gamemode = 1, + currentSlot = 1, + yaw = 0.0f, + pitch = 0.0f, + location = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + effects = new object[0] + }); + + public MccMcpResult GetPlayersList() => + MccMcpResult.Ok(new + { + players = new[] { "HarnessBot", "PlayerOne" } + }); + + public MccMcpResult GetChatHistory(int maxCount, bool includeJson) => + MccMcpResult.Ok(new + { + count = 2, + entries = new object[] + { + new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-10), kind = "chat", text = " hello", sender = "PlayerOne", message = "hello", json = includeJson ? "{}" : null }, + new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-5), kind = "system", text = "HarnessBot joined the game", sender = (string?)null, message = (string?)null, json = includeJson ? "{}" : null } + } + }); + + public MccMcpResult GetInternalCommands() => + MccMcpResult.Ok(new + { + count = 4, + commands = new[] + { + new { name = "debug", usage = "debug [on|off|state]", description = "Toggle debug or print state." }, + new { name = "move", usage = "move ", description = "Move to location." }, + new { name = "useitem", usage = "useitem [x] [y] [z]", description = "Use current held item." }, + new { name = "dig", usage = "dig [duration]", description = "Dig block at location." } + } + }); + + public MccMcpResult GetMaterialsList(string? filter, int maxCount) => + MccMcpResult.Ok(new + { + total = 3, + count = 3, + filter, + materials = new[] + { + new { name = "Air", typeLabel = "Air" }, + new { name = "GrassBlock", typeLabel = "Grass Block" }, + new { name = "OakLog", typeLabel = "Oak Log" } + } + }); + + public MccMcpResult GetBlockTypesList(string? filter, int maxCount) => + MccMcpResult.Ok(new + { + total = 3, + count = 3, + filter, + blockTypes = new[] + { + new { name = "Air", typeLabel = "Air" }, + new { name = "GrassBlock", typeLabel = "Grass Block" }, + new { name = "OakLog", typeLabel = "Oak Log" } + } + }); + + public MccMcpResult GetEntityTypesList(string? filter, int maxCount) => + MccMcpResult.Ok(new + { + total = 3, + count = 3, + filter, + entityTypes = new[] + { + new { name = "Player", typeLabel = "Player" }, + new { name = "Item", typeLabel = "Item" }, + new { name = "Villager", typeLabel = "Villager" } + } + }); + + public MccMcpResult SendChat(string text) => + MccMcpResult.Ok(new { echoed = text }); + + public MccMcpResult QuitClient() => + MccMcpResult.Ok(new { quitting = true }); + + public MccMcpResult RunInternalCommand(string command) => + MccMcpResult.Ok(new { command, status = "Done", output = "deterministic" }); + + public MccMcpResult UseItemOnHand() => + MccMcpResult.Ok(new { success = true, action = "use_item_on_hand" }); + + public MccMcpResult ChangeHotbarSlot(int slot) => + MccMcpResult.Ok(new { success = true, slot }); + + public MccMcpResult UseItemOnBlock(double x, double y, double z) => + MccMcpResult.Ok(new { success = true, x = C(x), y = C(y), z = C(z), action = "useitem" }); + + public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds) => + MccMcpResult.Ok(new + { + success = true, + target = new { x = C(x), y = C(y), z = C(z) }, + beforeBlock = new { material = "OakLog", typeLabel = "Oak Log", blockId = 137, blockMeta = 0 }, + afterBlock = new { material = "Air", typeLabel = "Air", blockId = 0, blockMeta = 0 }, + commandAccepted = true, + changed = true, + destroyed = true, + attempts = 1, + attemptedDurationsSeconds = new[] { durationSeconds > 0 ? durationSeconds : 1.5 }, + distance = 1.5, + playerLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) } + }); + + public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) => + MccMcpResult.Ok(new { success = true, x, y, z, face, hand, lookAtBlock, action = "place_block" }); + + public MccMcpResult InteractEntity(int entityId, string interaction, string hand) => + MccMcpResult.Ok(new { success = true, entityId, interaction, hand }); + + public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) => + MccMcpResult.Ok(new + { + center = new { x = 0, y = 79, z = 0 }, + radius, + count = 1, + blocks = new[] + { + new { x = 0, y = 79, z = 0, material = materialFilter ?? "GrassBlock", blockId = 9, blockMeta = 0, distance = 0.0 } + } + }); + + public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch) => + MccMcpResult.Ok(new + { + center = new { x = 0, y = 79, z = 0 }, + radius, + query, + exactMatch, + count = 2, + blocks = new object[] + { + new { x = 1, y = 79, z = 0, material = "GrassBlock", typeLabel = "Grass Block", blockId = 9, blockMeta = 0, distance = 1.0 }, + new { x = 2, y = 79, z = 0, material = "Dirt", typeLabel = "Dirt", blockId = 10, blockMeta = 0, distance = 2.0 } + } + }); + + public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) => + MccMcpResult.Ok(new + { + radius, + playerName, + includeSelf, + anyNearby = true, + count = 1, + players = new object[] + { + new + { + entityId = 1, + uuid = Guid.Empty, + name = "PlayerOne", + customName = (string?)null, + x = C(3.5), + y = C(80.0), + z = C(0.5), + distance = 3.0, + latency = 5 + } + } + }); + + public MccMcpResult LocatePlayer(string playerName, bool includeSelf) => + MccMcpResult.Ok(new + { + playerName, + matchedName = "PlayerOne", + entityId = 1, + uuid = Guid.Empty, + x = C(3.5), + y = C(80.0), + z = C(0.5), + distance = 3.0 + }); + + public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) => + MccMcpResult.Ok(new + { + reachable = true, + exactReachable = true, + target = new { x = C(x), y = C(y), z = C(z) }, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalWaypoint = new { x = C(x), y = C(y), z = C(z) }, + finalDistance = 0.0, + waypointCount = 4, + allowUnsafe, + maxOffset, + minOffset, + timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs + }); + + public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) => + MccMcpResult.Ok(new + { + pathFound = true, + arrived = true, + tolerance = 1.5, + verifyWaitMs = 250, + target = new { x = C(x), y = C(y), z = C(z) }, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalLocation = new { x = C(x), y = C(y), z = C(z) }, + finalDistance = 0.0, + distanceMoved = 3.0, + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }); + + public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) => + MccMcpResult.Ok(new + { + pathFound = true, + arrived = true, + tolerance = 1.5, + verifyWaitMs = 250, + target = new + { + playerName = "PlayerOne", + entityId = 1, + x = C(3.5), + y = C(80.0), + z = C(0.5) + }, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalLocation = new { x = C(3.5), y = C(80.0), z = C(0.5) }, + finalDistance = 0.0, + distanceMoved = 3.0, + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }); + + public MccMcpResult LookAt(double x, double y, double z) => + MccMcpResult.Ok(new { looked = true, x = C(x), y = C(y), z = C(z) }); + + public MccMcpResult GetInventorySnapshot(int inventoryId) => + MccMcpResult.Ok(new + { + id = inventoryId, + slots = new[] + { + new { slot = 0, type = "Stone", count = 64 } + } + }); + + public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) => + MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType }); + + public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack) => + MccMcpResult.Ok(new + { + success = true, + itemType, + requestedCount = count, + droppedCount = count, + beforeCount = 64, + afterCount = Math.Max(0, 64 - count), + inventoryId, + touchedSlots = new[] { 36 }, + preferStack + }); + + public MccMcpResult QueryEntities(int maxCount) => + MccMcpResult.Ok(new + { + count = 1, + entities = new[] + { + new { id = 1, type = "Player", x = C(0.5), y = C(80.0), z = C(0.5) } + } + }); + + public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius) => + MccMcpResult.Ok(new + { + totalTracked = 1, + count = 1, + entities = new[] + { + new + { + id = 1, + type = "Player", + typeLabel = "Player", + uuid = Guid.Empty, + name = "HarnessBot", + customName = (string?)null, + x = C(0.5), + y = C(80.0), + z = C(0.5), + distance = 0.0, + health = 20.0f, + pose = "Standing", + latency = 5 + } + } + }); + + public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects) => + MccMcpResult.Ok(new + { + id = entityId, + type = "Player", + typeLabel = "Player", + uuid = Guid.Empty, + name = "HarnessBot", + customName = (string?)null, + customNameVisible = false, + x = C(0.5), + y = C(80.0), + z = C(0.5), + yaw = 0.0f, + pitch = 0.0f, + health = 20.0f, + pose = "Standing", + latency = 5, + objectData = -1, + metadata = includeMetadata ? new { flags = 0 } : null, + equipment = includeEquipment ? new[] { new { slot = 0, type = "Stone", count = 1 } } : null, + activeEffects = includeEffects ? new object[0] : null + }); + + public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) => + MccMcpResult.Ok(new + { + text, + exactMatch, + radius, + includeBackText, + count = 1, + signs = new[] + { + new + { + x = 2, + y = 80, + z = 1, + material = "OakSign", + typeLabel = "Oak Sign", + distance = 1.8, + isWaxed = false, + frontText = new[] { "home", "storage" }, + backText = includeBackText ? new[] { "north wall" } : Array.Empty(), + matchedLines = new[] { text } + } + } + }); + + public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) => + MccMcpResult.Ok(new + { + itemType = itemType ?? "OakLog", + radius, + count = 1, + items = new[] + { + new + { + entityId = 99, + itemType = "OakLog", + typeLabel = "Oak Log", + count = 3, + x = C(2.5), + y = C(80.0), + z = C(1.5), + distance = 2.24 + } + } + }); + + public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) => + MccMcpResult.Ok(new + { + itemType, + radius, + maxItems, + allowUnsafe, + timeoutMs = timeoutMs <= 0 ? 2500 : timeoutMs, + attempted = 1, + successfulPickups = 1, + collectedCount = 3, + initialInventoryCount = 0, + finalInventoryCount = 3, + remainingNearby = 0, + attempts = new object[] + { + new + { + entityId = 99, + itemType, + typeLabel = "Oak Log", + expectedCount = 3, + target = new { x = C(2.5), y = C(80.0), z = C(1.5) }, + pathFound = true, + arrived = true, + entityGone = true, + inventoryDelta = 3, + startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }, + finalLocation = new { x = C(2.5), y = C(80.0), z = C(1.5) }, + finalDistance = 0.0 + } + } + }); + + public MccMcpResult GetWorldBlockAt(int x, int y, int z) => + MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 }); +} diff --git a/DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj b/DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj new file mode 100644 index 00000000..0ee15b7a --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/DebugTools/MccMcpWebPlayground/Program.cs b/DebugTools/MccMcpWebPlayground/Program.cs new file mode 100644 index 00000000..e2131393 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Program.cs @@ -0,0 +1,1116 @@ +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddHttpClient("openrouter"); + +var app = builder.Build(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +const string AgentSystemPrompt = """ +You are an agent controlling Minecraft Console Client (MCC) through MCP tools. +Use a plan-execute-verify loop. + +Operating mode +- For simple social turns like "hello" or "thanks", do not waste tool calls. Finish directly unless MCC state is required. +- For MCC questions and actions, think in steps and use tools to gather evidence before you finish. +- Never output plain assistant text before calling agent_finish(answer). + +Planning policy +- If the task is multi-step or physical, first decompose it into a short internal plan. +- Prefer the smallest plan that can succeed. +- For long or branchy tasks, keep a short checklist and update it as you go. +- Default sequence: + 1) inspect current state + 2) locate the target + 3) move into a valid position if needed + 4) perform the action + 5) verify with fresh tool calls + 6) call agent_finish(answer) +- If a step fails, revise the plan using the latest observation. Do not blindly repeat the same failing action. + +Todo policy +- Use todo_write, todo_read, and todo_list for tasks with 4 or more steps, retries, or branching verification. +- Keep todos short, concrete, and action-oriented. +- Update todo status as facts change. +- Todo state is request-scoped for the current chat request only. +- Skip todo tools for simple one-step tasks. + +Tool-use policy +- Use MCP tools for MCC/game-state questions and actions. +- Prefer the most direct high-signal tool first. +- If a tool result says success=false or includes an errorCode, treat that as a failed observation even if the transport call itself succeeded. +- Do not guess tool arguments repeatedly. If a tool returns invalid_args: + - simplify to the minimum required arguments, + - try at most one nearby variant, + - or switch to a broader inspection tool. +- Avoid long speculative tool chains. + +Verification policy +- Never claim success from intent alone. +- Never claim movement succeeded just because a move command was accepted. Check arrived or a fresh location result. +- Never claim an item was collected unless inventory or nearby entity state changed. +- Never claim blocks were removed unless block/world search results changed. +- If evidence is partial, say it is partial. +- If the request cannot be completed, say exactly what was verified and what remains unverified. + +Action-specific guidance +- Move or approach: + - locate the target, + - choose a reachable nearby standing position when exact occupancy is risky, + - move, + - verify arrival before finishing. +- Dig or collect: + - locate the blocks, + - move next to them if needed, + - dig in a sensible order, + - re-check remaining blocks, + - re-check inventory or nearby item entities before finishing. +- Search: + - start with the most direct search tool, + - use the user's requested radius when supported, + - if a query fails, simplify it instead of trying many near-duplicates. + +Good examples +1) User: "Pick up those logs." + Good: + - if the task looks long, write a short todo list + - find the logs + - move next to them + - dig them + - verify the logs are gone or reduced + - verify inventory increased + - then finish +2) User: "Is Zarko near you?" + Good: + - call a nearby-player tool + - report the matched player and distance + - then finish +3) User: "Hello" + Good: + - finish with a short greeting + - no MCP tools + +Wrong examples +1) Wrong: + - inventory did not change + - blocks may still exist + - but you still say "I picked them up" +2) Wrong: + - move returns pathFound=true but arrived=false + - and you still say "I walked there" +3) Wrong: + - a tool returns invalid_args several times + - and you keep guessing similar argument combinations +4) Wrong: + - you write assistant prose before agent_finish(answer) + +Finish rules +- Complete only by calling agent_finish(answer). +- The final answer must be natural language for a human and include exactly: + Reasoning: + - brief bullets with the important verified observations + Answer: + - direct user-facing result with uncertainty stated when relevant +"""; + +const string BudgetReminderPrompt = """ +Budget is nearly exhausted. +Use the strongest verified evidence you already have. +Do not start speculative new branches. +If the task is complete or partially complete, call agent_finish(answer) now and clearly distinguish verified facts from unverified assumptions. +Do not output plain assistant text before finishing. +"""; + +app.MapGet("/api/health", () => Results.Ok(new { ok = true })); +app.MapGet("/api/config", () => +{ + return Results.Ok(new + { + model = GetModel(), + openRouterBaseUrl = GetOpenRouterBaseUrl(), + mcpEndpoint = GetMcpEndpoint(), + hasApiKey = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")) + }); +}); + +app.MapPost("/api/chat/stream", async (ChatStreamRequest request, IHttpClientFactory httpClientFactory, HttpContext context, CancellationToken cancellationToken) => +{ + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + + try + { + string? apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + await WriteEvent(context.Response, "error", new { message = "OPENROUTER_API_KEY is not set." }, cancellationToken); + return; + } + + List messages = BuildMessages(request.Messages); + if (messages.Count == 0) + { + await WriteEvent(context.Response, "error", new { message = "No messages provided." }, cancellationToken); + return; + } + + string model = GetModel(); + int maxIterations = GetBoundedInt("MCC_WEB_MAX_ITERATIONS", 24, 4, 80); + int maxToolCalls = GetBoundedInt("MCC_WEB_MAX_TOOL_CALLS", 80, 4, 256); + TimeSpan maxWallTime = TimeSpan.FromSeconds(GetBoundedInt("MCC_WEB_MAX_SECONDS", 120, 10, 300)); + + await using McpClient mcp = await CreateMcpClientAsync(cancellationToken); + IList mcpTools = await mcp.ListToolsAsync(cancellationToken: cancellationToken); + Dictionary mcpToolsByName = mcpTools + .ToDictionary(tool => tool.Name, StringComparer.OrdinalIgnoreCase); + + object[] openRouterTools = + [ + .. mcpTools.Select(ToOpenRouterTool), + BuildTodoWriteToolSchema(), + BuildTodoReadToolSchema(), + BuildTodoListToolSchema(), + BuildAgentFinishToolSchema() + ]; + + using HttpClient openRouter = httpClientFactory.CreateClient("openrouter"); + openRouter.BaseAddress = new Uri(GetOpenRouterBaseUrl().TrimEnd('/') + "/"); + openRouter.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + openRouter.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground"); + openRouter.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground"); + + Stopwatch wallClock = Stopwatch.StartNew(); + int toolCallCount = 0; + bool reminderInjected = false; + string? finalAnswer = null; + List observations = new(); + Dictionary todos = new(StringComparer.OrdinalIgnoreCase); + int nextTodoOrder = 0; + + for (int iteration = 1; iteration <= maxIterations && !cancellationToken.IsCancellationRequested; iteration++) + { + if (!reminderInjected && ShouldInjectReminder(iteration, maxIterations, toolCallCount, maxToolCalls, wallClock.Elapsed, maxWallTime)) + { + messages.Add(new Dictionary + { + ["role"] = "system", + ["content"] = BudgetReminderPrompt + }); + reminderInjected = true; + } + + if (wallClock.Elapsed >= maxWallTime || toolCallCount >= maxToolCalls) + break; + + JsonElement choiceMessage = await RequestToolIterationAsync(openRouter, model, messages, openRouterTools, context.Response, cancellationToken); + if (choiceMessage.ValueKind == JsonValueKind.Undefined) + return; + + string assistantContent = choiceMessage.TryGetProperty("content", out JsonElement contentElement) + ? contentElement.GetString() ?? string.Empty + : string.Empty; + + if (choiceMessage.TryGetProperty("tool_calls", out JsonElement toolCallsElement) + && toolCallsElement.ValueKind == JsonValueKind.Array + && toolCallsElement.GetArrayLength() > 0) + { + List toolCallsForHistory = new(); + List toolMessages = new(); + bool stopLoop = false; + + foreach (JsonElement toolCall in toolCallsElement.EnumerateArray()) + { + if (!TryReadToolCall(toolCall, out string callId, out string toolName, out string argumentsRaw)) + continue; + + toolCallsForHistory.Add(new Dictionary + { + ["id"] = callId, + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = toolName, + ["arguments"] = argumentsRaw + } + }); + + await WriteEvent(context.Response, "tool_call", new + { + id = callId, + name = toolName, + arguments = argumentsRaw + }, cancellationToken); + + if (TryHandleLocalToolCall(toolName, argumentsRaw, todos, ref nextTodoOrder, out bool localIsError, out string localResultText, out string? completedAnswer)) + { + await WriteEvent(context.Response, "tool_result", new + { + id = callId, + name = toolName, + isError = localIsError, + content = localResultText + }, cancellationToken); + + toolMessages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = localResultText + }); + + toolCallCount++; + observations.Add(SummarizeObservation(toolName, localResultText, localIsError)); + + if (completedAnswer is not null) + { + finalAnswer = EnsureFinalAnswerFormat(completedAnswer, observations); + stopLoop = true; + break; + } + + continue; + } + + if (!mcpToolsByName.ContainsKey(toolName)) + { + string resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "unknown_tool", + message = $"Unknown tool '{toolName}'." + }); + await WriteEvent(context.Response, "tool_result", new + { + id = callId, + name = toolName, + isError = true, + content = resultText + }, cancellationToken); + + observations.Add($"Tool {toolName} was rejected because it is unknown."); + toolMessages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = resultText + }); + continue; + } + + if (toolCallCount >= maxToolCalls) + { + stopLoop = true; + break; + } + + bool isError = false; + string toolResultText; + try + { + Dictionary arguments = ParseArguments(argumentsRaw); + CallToolResult toolResult = await mcp.CallToolAsync(toolName, arguments, cancellationToken: cancellationToken); + toolResultText = ReadToolResultText(toolResult); + isError = toolResult.IsError == true || InferStructuredToolError(toolResultText); + } + catch (Exception ex) + { + isError = true; + toolResultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "tool_call_failed", + message = ex.Message + }); + } + + toolCallCount++; + observations.Add(SummarizeObservation(toolName, toolResultText, isError)); + await WriteEvent(context.Response, "tool_result", new + { + id = callId, + name = toolName, + isError, + content = toolResultText + }, cancellationToken); + + toolMessages.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = toolResultText + }); + } + + messages.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = assistantContent, + ["tool_calls"] = toolCallsForHistory + }); + foreach (object toolMessage in toolMessages) + messages.Add(toolMessage); + + if (finalAnswer is not null || stopLoop) + break; + + continue; + } + + if (!string.IsNullOrWhiteSpace(assistantContent)) + observations.Add($"Model attempted direct text before finishing: {Truncate(assistantContent, 140)}"); + + messages.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = assistantContent + }); + messages.Add(new Dictionary + { + ["role"] = "system", + ["content"] = "Do not return assistant prose yet. Continue with tool calls and end only by calling agent_finish(answer)." + }); + } + + finalAnswer ??= BuildForcedFinalAnswer(observations, toolCallCount, wallClock.Elapsed, maxIterations, maxToolCalls, maxWallTime); + await StreamFinalAnswer(context.Response, finalAnswer, cancellationToken); + } + catch (OperationCanceledException) + { + await WriteEvent(context.Response, "error", new { message = "Request cancelled." }, CancellationToken.None); + } + catch (Exception ex) + { + await WriteEvent(context.Response, "error", new + { + message = "Unhandled server error.", + detail = ex.Message + }, CancellationToken.None); + } +}); + +app.Run(); + +static string GetModel() +{ + return Environment.GetEnvironmentVariable("OPENROUTER_MODEL") ?? "minimax/minimax-m2.7"; +} + +static string GetOpenRouterBaseUrl() +{ + return Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1"; +} + +static string GetMcpEndpoint() +{ + return Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp"; +} + +static async Task CreateMcpClientAsync(CancellationToken cancellationToken) +{ + string endpoint = GetMcpEndpoint(); + string? token = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); + + return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + AdditionalHeaders = string.IsNullOrWhiteSpace(token) + ? null + : new Dictionary { ["Authorization"] = $"Bearer {token}" } + }), cancellationToken: cancellationToken); +} + +List BuildMessages(List? incoming) +{ + List messages = + [ + new Dictionary + { + ["role"] = "system", + ["content"] = AgentSystemPrompt + } + ]; + + if (incoming is null) + return messages; + + foreach (ChatMessage message in incoming) + { + if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content)) + continue; + + string role = message.Role.Trim().ToLowerInvariant(); + if (role is not ("system" or "user" or "assistant")) + continue; + + messages.Add(new Dictionary + { + ["role"] = role, + ["content"] = message.Content + }); + } + + return messages; +} + +static object ToOpenRouterTool(McpClientTool tool) +{ + JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject() + }; + + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["parameters"] = parameters + } + }; +} + +static object BuildAgentFinishToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "agent_finish", + ["description"] = "Finalize the response to the user after all required tool calls and verification are done.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["answer"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Final natural-language response for the user." + } + }, + ["required"] = new[] { "answer" }, + ["additionalProperties"] = false + } + } + }; +} + +static object BuildTodoWriteToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "todo_write", + ["description"] = "Create or update a short request-scoped todo item for complex task tracking.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Stable todo identifier, for example move_to_logs or verify_inventory." + }, + ["content"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Short actionable todo text. Required when creating a new item." + }, + ["status"] = new Dictionary + { + ["type"] = "string", + ["description"] = "One of pending, in_progress, completed, blocked, cancelled." + }, + ["notes"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Optional brief note with the latest observation." + } + }, + ["required"] = new[] { "id" }, + ["additionalProperties"] = false + } + } + }; +} + +static object BuildTodoReadToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "todo_read", + ["description"] = "Read one request-scoped todo item by id.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Todo identifier." + } + }, + ["required"] = new[] { "id" }, + ["additionalProperties"] = false + } + } + }; +} + +static object BuildTodoListToolSchema() +{ + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "todo_list", + ["description"] = "List all request-scoped todo items in creation order.", + ["parameters"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary(), + ["additionalProperties"] = false + } + } + }; +} + +static bool TryHandleLocalToolCall( + string toolName, + string argumentsRaw, + Dictionary todos, + ref int nextTodoOrder, + out bool isError, + out string resultText, + out string? completedAnswer) +{ + isError = false; + resultText = string.Empty; + completedAnswer = null; + + if (toolName.Equals("agent_finish", StringComparison.OrdinalIgnoreCase)) + { + completedAnswer = ParseAgentFinishAnswer(argumentsRaw); + resultText = JsonSerializer.Serialize(new + { + success = true, + finished = true + }); + return true; + } + + if (toolName.Equals("todo_list", StringComparison.OrdinalIgnoreCase)) + { + resultText = JsonSerializer.Serialize(new + { + success = true, + data = new + { + count = todos.Count, + items = todos.Values + .OrderBy(item => item.Order) + .Select(ToTodoDto) + .ToArray() + } + }); + return true; + } + + Dictionary arguments = ParseArguments(argumentsRaw); + if (toolName.Equals("todo_read", StringComparison.OrdinalIgnoreCase)) + { + string? id = ReadOptionalStringArgument(arguments, "id"); + if (string.IsNullOrWhiteSpace(id)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "todo_read requires a non-empty id." + }); + return true; + } + + if (!todos.TryGetValue(id, out TodoEntry? item)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_state", + message = $"Todo '{id}' does not exist." + }); + return true; + } + + resultText = JsonSerializer.Serialize(new + { + success = true, + data = new + { + item = ToTodoDto(item) + } + }); + return true; + } + + if (!toolName.Equals("todo_write", StringComparison.OrdinalIgnoreCase)) + return false; + + string? todoId = ReadOptionalStringArgument(arguments, "id"); + if (string.IsNullOrWhiteSpace(todoId)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "todo_write requires a non-empty id." + }); + return true; + } + + todos.TryGetValue(todoId, out TodoEntry? existingItem); + string? rawContent = ReadOptionalStringArgument(arguments, "content"); + string content = string.IsNullOrWhiteSpace(rawContent) + ? existingItem?.Content ?? string.Empty + : rawContent.Trim(); + if (content.Length == 0) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "todo_write requires content when creating a new item." + }); + return true; + } + + string requestedStatus = ReadOptionalStringArgument(arguments, "status") ?? existingItem?.Status ?? "pending"; + if (!TryNormalizeTodoStatus(requestedStatus, out string normalizedStatus)) + { + isError = true; + resultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_args", + message = "Invalid todo status.", + data = new + { + status = requestedStatus, + allowed = GetTodoStatusValues() + } + }); + return true; + } + + string? notes = ReadOptionalStringArgument(arguments, "notes") ?? existingItem?.Notes; + TodoEntry entry = existingItem ?? new TodoEntry + { + Id = todoId, + Order = ++nextTodoOrder + }; + entry.Content = content; + entry.Status = normalizedStatus; + entry.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); + todos[todoId] = entry; + + resultText = JsonSerializer.Serialize(new + { + success = true, + data = new + { + item = ToTodoDto(entry), + totalCount = todos.Count + } + }); + return true; +} + +static async Task RequestToolIterationAsync( + HttpClient openRouter, + string model, + List messages, + object[] tools, + HttpResponse response, + CancellationToken cancellationToken) +{ + var payload = new Dictionary + { + ["model"] = model, + ["messages"] = messages, + ["tools"] = tools, + ["tool_choice"] = "auto" + }; + + using HttpResponseMessage completion = await openRouter.PostAsync( + "chat/completions", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), + cancellationToken); + + string body = await completion.Content.ReadAsStringAsync(cancellationToken); + if (!completion.IsSuccessStatusCode) + { + await WriteEvent(response, "error", new + { + message = "OpenRouter request failed.", + statusCode = (int)completion.StatusCode, + body + }, cancellationToken); + return default; + } + + using JsonDocument doc = JsonDocument.Parse(body); + if (!TryGetFirstChoiceMessage(doc.RootElement, out JsonElement message)) + { + await WriteEvent(response, "error", new { message = "No completion choice returned by OpenRouter." }, cancellationToken); + return default; + } + + return message.Clone(); +} + +static bool TryReadToolCall(JsonElement toolCall, out string id, out string name, out string arguments) +{ + id = string.Empty; + name = string.Empty; + arguments = "{}"; + + if (!toolCall.TryGetProperty("id", out JsonElement idElement) + || !toolCall.TryGetProperty("function", out JsonElement functionElement) + || !functionElement.TryGetProperty("name", out JsonElement nameElement)) + { + return false; + } + + id = idElement.GetString() ?? string.Empty; + name = nameElement.GetString() ?? string.Empty; + arguments = functionElement.TryGetProperty("arguments", out JsonElement argsElement) + ? argsElement.GetString() ?? "{}" + : "{}"; + return true; +} + +static bool TryGetFirstChoiceMessage(JsonElement root, out JsonElement message) +{ + message = default; + if (!root.TryGetProperty("choices", out JsonElement choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0) + { + return false; + } + + JsonElement first = choices[0]; + return first.TryGetProperty("message", out message); +} + +static Dictionary ParseArguments(string raw) +{ + try + { + using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return new Dictionary(); + + Dictionary parsed = new(); + foreach (JsonProperty property in doc.RootElement.EnumerateObject()) + parsed[property.Name] = ConvertJsonElement(property.Value); + return parsed; + } + catch + { + return new Dictionary(); + } +} + +static string? ReadOptionalStringArgument(Dictionary arguments, string key) +{ + if (!arguments.TryGetValue(key, out object? value) || value is null) + return null; + + return value switch + { + string text => text.Trim(), + _ => Convert.ToString(value)?.Trim() + }; +} + +static object? ConvertJsonElement(JsonElement element) +{ + return element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => element.TryGetInt64(out long i64) + ? i64 + : element.TryGetDouble(out double d) ? d : element.GetRawText(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToArray(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(prop => prop.Name, prop => ConvertJsonElement(prop.Value)), + _ => element.GetRawText() + }; +} + +static string ReadToolResultText(CallToolResult result) +{ + if (result.Content is null) + return result.IsError == true ? "{\"success\":false}" : "{\"success\":true}"; + + StringBuilder sb = new(); + foreach (ContentBlock block in result.Content) + { + if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text)) + { + if (sb.Length > 0) + sb.Append('\n'); + sb.Append(text.Text); + } + } + + if (sb.Length > 0) + return sb.ToString(); + + return JsonSerializer.Serialize(new { isError = result.IsError }); +} + +static bool InferStructuredToolError(string toolResultText) +{ + try + { + using JsonDocument doc = JsonDocument.Parse(toolResultText); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + + if (doc.RootElement.TryGetProperty("success", out JsonElement successElement) + && successElement.ValueKind == JsonValueKind.False) + { + return true; + } + + return doc.RootElement.TryGetProperty("errorCode", out JsonElement errorCodeElement) + && errorCodeElement.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(errorCodeElement.GetString()); + } + catch + { + return false; + } +} + +static bool TryNormalizeTodoStatus(string rawStatus, out string normalizedStatus) +{ + normalizedStatus = rawStatus.Trim().ToLowerInvariant(); + return normalizedStatus is "pending" or "in_progress" or "completed" or "blocked" or "cancelled"; +} + +static string[] GetTodoStatusValues() +{ + return ["pending", "in_progress", "completed", "blocked", "cancelled"]; +} + +static object ToTodoDto(TodoEntry item) +{ + return new + { + id = item.Id, + content = item.Content, + status = item.Status, + notes = item.Notes, + order = item.Order + }; +} + +static string ParseAgentFinishAnswer(string argumentsRaw) +{ + try + { + using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsRaw) ? "{}" : argumentsRaw); + if (doc.RootElement.TryGetProperty("answer", out JsonElement answerElement) + && answerElement.ValueKind == JsonValueKind.String) + { + string answer = answerElement.GetString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(answer)) + return answer.Trim(); + } + } + catch + { + // ignore and use fallback below + } + + return """ +Reasoning: +- The model requested completion without a textual payload. +- Returning a safe fallback response. + +Answer: +I completed the requested tool workflow but did not receive a final textual answer payload. +"""; +} + +static string EnsureFinalAnswerFormat(string text, IReadOnlyList observations) +{ + string trimmed = text.Trim(); + if (trimmed.Length == 0) + trimmed = "I completed the tool workflow but produced no textual output."; + + bool hasReasoning = trimmed.Contains("Reasoning:", StringComparison.OrdinalIgnoreCase); + bool hasAnswer = trimmed.Contains("Answer:", StringComparison.OrdinalIgnoreCase); + if (hasReasoning && hasAnswer) + return trimmed; + + string[] latestObservations = observations + .TakeLast(3) + .ToArray(); + if (latestObservations.Length == 0) + latestObservations = ["Tool-assisted reasoning completed."]; + + string observationBullets = string.Join('\n', latestObservations.Select(observation => $"- {observation}")); + return $""" +Reasoning: +{observationBullets} +- Final response generated after tool execution and verification. + +Answer: +{trimmed} +"""; +} + +static bool ShouldInjectReminder(int iteration, int maxIterations, int toolCallCount, int maxToolCalls, TimeSpan elapsed, TimeSpan maxWallTime) +{ + return iteration >= maxIterations - 2 + || toolCallCount >= maxToolCalls - 4 + || elapsed >= maxWallTime - TimeSpan.FromSeconds(10); +} + +static string BuildForcedFinalAnswer( + IReadOnlyList observations, + int toolCalls, + TimeSpan elapsed, + int maxIterations, + int maxToolCalls, + TimeSpan maxWallTime) +{ + string lastObservation = observations.Count > 0 ? observations[^1] : "No tool observation was captured."; + return $""" +Reasoning: +- The agent loop reached its safety budget before `agent_finish` was called. +- Last observation: {lastObservation} +- Budget usage: toolCalls={toolCalls}/{maxToolCalls}, elapsed={elapsed.TotalSeconds:F1}s/{maxWallTime.TotalSeconds:F1}s, maxIterations={maxIterations}. + +Answer: +I could not complete this request within the configured tool budget. Ask me to retry and I will continue with a fresh loop. +"""; +} + +static string SummarizeObservation(string toolName, string toolResultText, bool isError) +{ + string status = isError ? "error" : "ok"; + return $"{toolName} => {status}: {Truncate(toolResultText.Replace('\n', ' '), 180)}"; +} + +static string Truncate(string text, int maxLength) +{ + if (string.IsNullOrEmpty(text) || text.Length <= maxLength) + return text; + return text[..maxLength] + "..."; +} + +static async Task StreamFinalAnswer(HttpResponse response, string finalText, CancellationToken cancellationToken) +{ + string text = finalText.Trim(); + if (text.Length == 0) + text = "I completed the request but no final text was generated."; + + MatchCollection tokens = Regex.Matches(text, @"\S+\s*", RegexOptions.CultureInvariant); + if (tokens.Count == 0) + { + await WriteEvent(response, "token", new { text }, cancellationToken); + await WriteEvent(response, "final", new { text }, cancellationToken); + return; + } + + const int wordsPerChunk = 10; + StringBuilder chunk = new(); + int words = 0; + + foreach (Match token in tokens.Cast()) + { + chunk.Append(token.Value); + words++; + if (words >= wordsPerChunk) + { + await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); + chunk.Clear(); + words = 0; + } + } + + if (chunk.Length > 0) + await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); + + await WriteEvent(response, "final", new { text }, cancellationToken); +} + +static int GetBoundedInt(string envName, int fallback, int min, int max) +{ + string? raw = Environment.GetEnvironmentVariable(envName); + if (!int.TryParse(raw, out int parsed)) + return fallback; + return Math.Clamp(parsed, min, max); +} + +static async Task WriteEvent(HttpResponse response, string eventName, object payload, CancellationToken cancellationToken) +{ + string json = JsonSerializer.Serialize(payload); + await response.WriteAsync($"event: {eventName}\n", cancellationToken); + await response.WriteAsync($"data: {json}\n\n", cancellationToken); + await response.Body.FlushAsync(cancellationToken); +} + +public sealed class ChatStreamRequest +{ + public List? Messages { get; set; } +} + +public sealed class ChatMessage +{ + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} + +public sealed class TodoEntry +{ + public required string Id { get; init; } + public required int Order { get; init; } + public string Content { get; set; } = string.Empty; + public string Status { get; set; } = "pending"; + public string? Notes { get; set; } +} diff --git a/DebugTools/MccMcpWebPlayground/Properties/launchSettings.json b/DebugTools/MccMcpWebPlayground/Properties/launchSettings.json new file mode 100644 index 00000000..3701e48f --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5295", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7104;http://localhost:5295", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DebugTools/MccMcpWebPlayground/appsettings.Development.json b/DebugTools/MccMcpWebPlayground/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DebugTools/MccMcpWebPlayground/appsettings.json b/DebugTools/MccMcpWebPlayground/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DebugTools/MccMcpWebPlayground/wwwroot/index.html b/DebugTools/MccMcpWebPlayground/wwwroot/index.html new file mode 100644 index 00000000..68ae6c67 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/wwwroot/index.html @@ -0,0 +1,1117 @@ + + + + + + MCC MCP Live Playground + + + + + + + + + +
+
+
+

MCC MCP Playground

+
+
+
Booting…
+ +
+
+ + +
+ +
+
+ Chat +
+ +
+
+
+
+ + + +

No messages yet.
Ask the LLM to control MCC via MCP.

+
+
+ Thinking +
+ +
+
+
+
+ + +
+
+ + + + + Tool Events + +
+ + +
+
+
+
+ + + +

No tool events yet.

+
+
+
+ + + +
+ + +
+
+ + +
+ +
+ + + + \ No newline at end of file diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index cb6b169b..d64ef70a 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -113,6 +113,8 @@ namespace MinecraftClient // Entity handling private readonly Dictionary entities = new(); + private readonly Lock signDataLock = new(); + private readonly Dictionary<(int x, int y, int z), (string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)> knownSigns = new(); // server TPS private long lastAge = 0; @@ -166,6 +168,21 @@ namespace MinecraftClient public void GetCookie(string key, out byte[]? data) => Cookies.TryGetValue(key, out data); public void SetCookie(string key, byte[] data) => Cookies[key] = data; public void DeleteCookie(string key) => Cookies.Remove(key, out var data); + public (Location location, string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)[] GetKnownSigns() + { + lock (signDataLock) + { + return knownSigns + .Select(pair => ( + location: new Location(pair.Key.x, pair.Key.y, pair.Key.z), + material: pair.Value.material, + typeLabel: pair.Value.typeLabel, + frontText: (string[])pair.Value.frontText.Clone(), + backText: (string[])pair.Value.backText.Clone(), + isWaxed: pair.Value.isWaxed)) + .ToArray(); + } + } TcpClient client = null!; IMinecraftCom handler = null!; @@ -478,6 +495,7 @@ namespace MinecraftClient physicsInput.Reset(); world.Clear(); entities.Clear(); + ClearKnownSigns(); ClearInventories(); } @@ -763,6 +781,7 @@ namespace MinecraftClient handler.Dispose(); world.Clear(); + ClearKnownSigns(); if (timeoutdetector is not null) { @@ -2804,6 +2823,7 @@ namespace MinecraftClient } entities.Clear(); + ClearKnownSigns(); ClearInventories(); DispatchBotEvent(bot => bot.OnRespawn()); } @@ -4036,9 +4056,16 @@ namespace MinecraftClient public void OnBlockChange(Location location, Block block) { world.SetBlock(location, block); + if (!IsSignMaterial(block.Type)) + RemoveKnownSign(location); DispatchBotEvent(bot => bot.OnBlockChange(location, block)); } + public void OnBlockEntityData(Location location, Dictionary? nbt) + { + UpdateKnownSign(location, nbt); + } + /// /// Called when "AutoComplete" completes. /// @@ -4068,6 +4095,137 @@ namespace MinecraftClient return handler.ClickContainerButton(windowId, buttonId); } + private void ClearKnownSigns() + { + lock (signDataLock) + { + knownSigns.Clear(); + } + } + + private void RemoveKnownSign(Location location) + { + var key = ToBlockKey(location); + lock (signDataLock) + { + knownSigns.Remove(key); + } + } + + private void UpdateKnownSign(Location location, Dictionary? nbt) + { + var key = ToBlockKey(location); + var block = world.GetBlock(new Location(key.x, key.y, key.z)); + if (!IsSignMaterial(block.Type) || !TryExtractSignText(nbt, out string[] frontText, out string[] backText, out bool isWaxed)) + { + lock (signDataLock) + { + knownSigns.Remove(key); + } + + return; + } + + lock (signDataLock) + { + knownSigns[key] = (block.Type.ToString(), block.GetTypeString(), frontText, backText, isWaxed); + } + } + + private static bool TryExtractSignText(Dictionary? nbt, out string[] frontText, out string[] backText, out bool isWaxed) + { + frontText = ExtractSignLines(nbt, "front_text"); + backText = ExtractSignLines(nbt, "back_text"); + if (frontText.Length == 0 && backText.Length == 0) + frontText = ExtractLegacySignLines(nbt); + + isWaxed = nbt is not null + && nbt.TryGetValue("is_waxed", out object? waxedValue) + && waxedValue is bool waxed + && waxed; + return frontText.Length > 0 || backText.Length > 0; + } + + private static string[] ExtractSignLines(Dictionary? nbt, string sideKey) + { + if (nbt is null + || !nbt.TryGetValue(sideKey, out object? sideValue) + || sideValue is not Dictionary sideData + || !sideData.TryGetValue("messages", out object? messagesValue) + || messagesValue is not object[] messages) + { + return []; + } + + return messages + .Take(4) + .Select(ConvertSignMessage) + .ToArray(); + } + + private static string[] ExtractLegacySignLines(Dictionary? nbt) + { + if (nbt is null) + return []; + + List lines = new(4); + for (int i = 1; i <= 4; i++) + { + if (nbt.TryGetValue($"Text{i}", out object? value)) + lines.Add(ConvertSignMessage(value)); + } + + return lines.ToArray(); + } + + private static string ConvertSignMessage(object? value) + { + try + { + return value switch + { + null => string.Empty, + string text => ParseMaybeJsonText(text), + Dictionary nbt => ChatParser.ParseText(nbt), + object[] items => string.Concat(items.Select(ConvertSignMessage)), + _ => value.ToString() ?? string.Empty + }; + } + catch + { + return value?.ToString() ?? string.Empty; + } + } + + private static string ParseMaybeJsonText(string text) + { + string trimmed = text.Trim(); + if ((trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal)) + || (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal))) + { + try + { + return ChatParser.ParseText(trimmed); + } + catch + { + } + } + + return text; + } + + private static bool IsSignMaterial(Material material) + { + return material.ToString().Contains("Sign", StringComparison.Ordinal); + } + + private static (int x, int y, int z) ToBlockKey(Location location) + { + Location blockLocation = location.ToFloor(); + return ((int)blockLocation.X, (int)blockLocation.Y, (int)blockLocation.Z); + } + #endregion } } diff --git a/MinecraftClient/Mcp/IMccMcpCapabilities.cs b/MinecraftClient/Mcp/IMccMcpCapabilities.cs index cae36647..6d5b118a 100644 --- a/MinecraftClient/Mcp/IMccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/IMccMcpCapabilities.cs @@ -8,6 +8,9 @@ public interface IMccMcpCapabilities MccMcpResult GetPlayersList(); MccMcpResult GetChatHistory(int maxCount, bool includeJson); MccMcpResult GetInternalCommands(); + MccMcpResult GetMaterialsList(string? filter, int maxCount); + MccMcpResult GetBlockTypesList(string? filter, int maxCount); + MccMcpResult GetEntityTypesList(string? filter, int maxCount); MccMcpResult SendChat(string text); MccMcpResult QuitClient(); MccMcpResult RunInternalCommand(string command); @@ -21,6 +24,7 @@ public interface IMccMcpCapabilities MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch); MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf); MccMcpResult LocatePlayer(string playerName, bool includeSelf); + MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs); MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs); MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs); MccMcpResult LookAt(double x, double y, double z); @@ -30,5 +34,8 @@ public interface IMccMcpCapabilities MccMcpResult QueryEntities(int maxCount); MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius); MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects); + MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText); + MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount); + MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs); MccMcpResult GetWorldBlockAt(int x, int y, int z); } diff --git a/MinecraftClient/Mcp/MccMcpCapabilities.cs b/MinecraftClient/Mcp/MccMcpCapabilities.cs index 097db7d2..055786a6 100644 --- a/MinecraftClient/Mcp/MccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/MccMcpCapabilities.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using MinecraftClient.CommandHandler; using MinecraftClient.Inventory; using MinecraftClient.Mapping; +using MinecraftClient.Protocol.Message; using MinecraftClient.Scripting; namespace MinecraftClient.Mcp; @@ -14,13 +15,22 @@ namespace MinecraftClient.Mcp; public sealed class MccMcpCapabilities : IMccMcpCapabilities { private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase; + private static readonly double[] s_defaultDigAttemptDurations = [1.5, 3.0, 5.0]; private const int CoordinateRoundingPrecision = 2; private const double SelfEntityDistanceThreshold = 0.2; + private const int MaxBlockScanRadius = 12; + private const int MaxBlockFindRadius = 32; + private const double DigReachDistance = 5.0; + private const double DigReachDistanceSquared = DigReachDistance * DigReachDistance; + private const int DefaultPathQueryTimeoutMs = 5000; + private const int MinPathQueryTimeoutMs = 250; + private const int MaxPathQueryTimeoutMs = 15000; private const int DefaultArrivalWaitMs = 3500; private const int MinArrivalWaitMs = 250; private const int MaxArrivalWaitMs = 15000; private const double DefaultArrivalTolerance = 1.5; private const int ArrivalPollIntervalMs = 125; + private const int MaxBlockVerifyWaitMs = 12000; private sealed class InternalCommandInfo { @@ -42,6 +52,18 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities public required int Latency { get; init; } } + private sealed class NearbyItemSnapshot + { + public required int EntityId { get; init; } + public required ItemType ItemType { get; init; } + public required string TypeLabel { get; init; } + public required int Count { get; init; } + public required double X { get; init; } + public required double Y { get; init; } + public required double Z { get; init; } + public required double Distance { get; init; } + } + private readonly Func togglesProvider; public MccMcpCapabilities(Func togglesProvider) @@ -226,6 +248,96 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult GetMaterialsList(string? filter, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim(); + Material[] allMaterials = Enum.GetValues(); + var materials = allMaterials + .Select(material => new + { + name = material.ToString(), + typeLabel = GetMaterialTypeLabel(material) + }) + .Where(material => normalizedFilter is null + || TextMatchesFilter(material.name, normalizedFilter) + || TextMatchesFilter(material.typeLabel, normalizedFilter)) + .OrderBy(material => material.name, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + total = allMaterials.Length, + count = materials.Length, + filter = normalizedFilter, + materials + }); + } + + public MccMcpResult GetBlockTypesList(string? filter, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim(); + Material[] allMaterials = Enum.GetValues(); + var blockTypes = allMaterials + .Select(material => new + { + name = material.ToString(), + typeLabel = GetMaterialTypeLabel(material) + }) + .Where(blockType => normalizedFilter is null + || TextMatchesFilter(blockType.name, normalizedFilter) + || TextMatchesFilter(blockType.typeLabel, normalizedFilter)) + .OrderBy(blockType => blockType.name, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + total = allMaterials.Length, + count = blockTypes.Length, + filter = normalizedFilter, + blockTypes + }); + } + + public MccMcpResult GetEntityTypesList(string? filter, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + int limit = Math.Clamp(maxCount, 1, 5000); + string? normalizedFilter = string.IsNullOrWhiteSpace(filter) ? null : filter.Trim(); + EntityType[] allEntityTypes = Enum.GetValues(); + var entityTypes = allEntityTypes + .Select(entityType => new + { + name = entityType.ToString(), + typeLabel = Entity.GetTypeString(entityType) + }) + .Where(entityType => normalizedFilter is null + || TextMatchesFilter(entityType.name, normalizedFilter) + || TextMatchesFilter(entityType.typeLabel, normalizedFilter)) + .OrderBy(entityType => entityType.name, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .ToArray(); + + return MccMcpResult.Ok(new + { + total = allEntityTypes.Length, + count = entityTypes.Length, + filter = normalizedFilter, + entityTypes + }); + } + public MccMcpResult SendChat(string text) { if (!IsCategoryEnabled(t => t.ChatAndCommands)) @@ -343,7 +455,13 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("capability_disabled"); if (durationSeconds < 0) - return MccMcpResult.Fail("invalid_args"); + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "durationSeconds", + min = 0 + }); + } McClient? client = GetClient(); if (client is null) @@ -352,13 +470,74 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!client.GetTerrainEnabled()) return MccMcpResult.Fail("feature_disabled"); - string sx = x.ToString(CultureInfo.InvariantCulture); - string sy = y.ToString(CultureInfo.InvariantCulture); - string sz = z.ToString(CultureInfo.InvariantCulture); - string command = durationSeconds > 0 - ? $"dig {sx} {sy} {sz} {durationSeconds.ToString(CultureInfo.InvariantCulture)}" - : $"dig {sx} {sy} {sz}"; - return ExecuteInternalCommand(client, command); + Location target = ToBlockLocation(x, y, z); + Location currentLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + Location eyesLocation = currentLocation.EyesLocation(); + Location centeredTarget = target.ToCenter(); + Block beforeBlock = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target)); + if (beforeBlock.Type == Material.Air) + { + return MccMcpResult.Fail("invalid_state", data: new + { + target = ToCoordinate(target), + beforeBlock = ToBlockState(beforeBlock) + }); + } + + double distance = eyesLocation.Distance(centeredTarget); + if (distance > DigReachDistance) + { + return MccMcpResult.Fail("action_incomplete", data: new + { + reason = "too_far", + target = ToCoordinate(target), + playerLocation = ToCoordinate(currentLocation), + distance, + maxReach = DigReachDistance, + beforeBlock = ToBlockState(beforeBlock) + }); + } + + double[] attemptDurations = GetDigAttemptDurations(durationSeconds); + List attemptedDurations = new(); + Block afterBlock = beforeBlock; + bool changed = false; + bool commandAccepted = false; + + foreach (double attemptDuration in attemptDurations) + { + attemptedDurations.Add(attemptDuration); + bool accepted = client.InvokeOnMainThread(() => client.DigBlock(target, Direction.Down, duration: attemptDuration)); + commandAccepted |= accepted; + if (!accepted) + continue; + + if (WaitForBlockChange(client, target, beforeBlock, GetDigVerifyWaitMs(attemptDuration), out afterBlock)) + { + changed = true; + break; + } + } + + afterBlock = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target)); + object resultData = new + { + success = changed, + target = ToCoordinate(target), + beforeBlock = ToBlockState(beforeBlock), + afterBlock = ToBlockState(afterBlock), + commandAccepted, + changed, + destroyed = changed && afterBlock.Type == Material.Air, + attempts = attemptedDurations.Count, + attemptedDurationsSeconds = attemptedDurations.ToArray(), + distance, + playerLocation = ToCoordinate(currentLocation) + }; + + return changed + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); } public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) @@ -411,8 +590,15 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!IsCategoryEnabled(t => t.EntityWorld)) return MccMcpResult.Fail("capability_disabled"); - if (radius is < 1 or > 8) - return MccMcpResult.Fail("invalid_args"); + if (radius is < 1 or > MaxBlockScanRadius) + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "radius", + min = 1, + max = MaxBlockScanRadius + }); + } McClient? client = GetClient(); if (client is null) @@ -444,8 +630,13 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities continue; string material = block.Type.ToString(); - if (filter is not null && !material.Contains(filter, StringComparison.OrdinalIgnoreCase)) + string typeLabel = block.GetTypeString(); + if (filter is not null + && !TextMatchesFilter(material, filter) + && !TextMatchesFilter(typeLabel, filter)) + { continue; + } double dx = x + 0.5 - playerLocation.X; double dy = y + 0.5 - playerLocation.Y; @@ -456,6 +647,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities y, z, material, + typeLabel, blockId = block.BlockId, blockMeta = block.BlockMeta, distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) @@ -479,8 +671,15 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!IsCategoryEnabled(t => t.EntityWorld)) return MccMcpResult.Fail("capability_disabled"); - if (radius is < 1 or > 16) - return MccMcpResult.Fail("invalid_args"); + if (radius is < 1 or > MaxBlockFindRadius) + { + return MccMcpResult.Fail("invalid_args", data: new + { + parameter = "radius", + min = 1, + max = MaxBlockFindRadius + }); + } McClient? client = GetClient(); if (client is null) @@ -558,6 +757,61 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) + { + if (!IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs + }); + } + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + Location goal = new(x, y, z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + World world = client.InvokeOnMainThread(client.GetWorld); + int effectiveTimeoutMs = GetPathQueryTimeoutMs(timeoutMs); + Queue? path = Movement.CalculatePath( + world, + startLocation, + goal, + allowUnsafe, + maxOffset, + minOffset, + TimeSpan.FromMilliseconds(effectiveTimeoutMs)); + Location? finalWaypoint = path?.LastOrDefault(); + double? finalDistance = finalWaypoint is Location waypoint + ? GetDistance(waypoint, goal) + : null; + + return MccMcpResult.Ok(new + { + reachable = path is not null, + exactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(), + target = ToCoordinate(goal), + startLocation = ToCoordinate(startLocation), + finalWaypoint = finalWaypoint is Location finalLocation ? ToCoordinate(finalLocation) : null, + finalDistance, + waypointCount = path?.Count ?? 0, + allowUnsafe, + maxOffset, + minOffset, + timeoutMs = effectiveTimeoutMs + }); + } + public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) { if (!IsCategoryEnabled(t => t.EntityWorld)) @@ -672,6 +926,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (!IsCategoryEnabled(t => t.Movement)) return MccMcpResult.Fail("capability_disabled"); + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs + }); + } + McClient? client = GetClient(); if (client is null) return NotConnected(); @@ -680,6 +944,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("feature_disabled"); Location goal = new(x, y, z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout)); @@ -687,16 +952,28 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities double tolerance = GetArrivalTolerance(maxOffset, minOffset); Location? finalLocation = null; bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); - - return MccMcpResult.Ok(new + finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation); + object resultData = new { pathFound, arrived, tolerance, verifyWaitMs, target = ToCoordinate(goal), - finalLocation = finalLocation is Location location ? ToCoordinate(location) : null - }); + startLocation = ToCoordinate(startLocation), + finalLocation = ToCoordinate(finalLocation.Value), + finalDistance = GetDistance(finalLocation.Value, goal), + distanceMoved = GetDistance(startLocation, finalLocation.Value), + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }; + + return pathFound && arrived + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); } public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) @@ -707,6 +984,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities if (string.IsNullOrWhiteSpace(playerName)) return MccMcpResult.Fail("invalid_args"); + if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0) + { + return MccMcpResult.Fail("invalid_args", data: new + { + maxOffset, + minOffset, + timeoutMs + }); + } + McClient? client = GetClient(); if (client is null) return NotConnected(); @@ -718,53 +1005,68 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return MccMcpResult.Fail("feature_disabled"); string nameFilter = playerName.Trim(); - return client.InvokeOnMainThread(() => + NearbyPlayerSnapshot? target = client.InvokeOnMainThread(() => { List trackedPlayers = BuildTrackedPlayerSnapshots(client, includeSelf: false); - NearbyPlayerSnapshot? target = trackedPlayers + return trackedPlayers .Where(player => PlayerNameMatches(player, nameFilter)) .OrderBy(player => player.Distance) .FirstOrDefault(); - - if (target is null) - { - return MccMcpResult.Fail("invalid_state", data: new - { - playerName = nameFilter, - trackedPlayers = trackedPlayers - .Select(player => player.Name) - .OfType() - .Distinct(NameComparer) - .ToArray() - }); - } - - Location goal = new(target.X, target.Y, target.Z); - TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; - bool pathFound = client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout); - - int verifyWaitMs = GetArrivalWaitMs(timeoutMs); - double tolerance = GetArrivalTolerance(maxOffset, minOffset); - Location? finalLocation = null; - bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); - - return MccMcpResult.Ok(new - { - pathFound, - arrived, - tolerance, - verifyWaitMs, - target = new - { - playerName = target.Name, - entityId = target.EntityId, - x = RoundCoordinate(target.X), - y = RoundCoordinate(target.Y), - z = RoundCoordinate(target.Z) - }, - finalLocation = finalLocation is Location location ? ToCoordinate(location) : null - }); }); + + if (target is null) + { + string[] trackedPlayers = client.InvokeOnMainThread(() => BuildTrackedPlayerSnapshots(client, includeSelf: false) + .Select(player => player.Name) + .OfType() + .Distinct(NameComparer) + .ToArray()); + return MccMcpResult.Fail("invalid_state", data: new + { + playerName = nameFilter, + trackedPlayers + }); + } + + Location goal = new(target.X, target.Y, target.Z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + TimeSpan? timeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; + bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(goal, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout)); + + int verifyWaitMs = GetArrivalWaitMs(timeoutMs); + double tolerance = GetArrivalTolerance(maxOffset, minOffset); + Location? finalLocation = null; + bool arrived = pathFound && WaitForArrival(client, goal, verifyWaitMs, tolerance, out finalLocation); + finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation); + + object resultData = new + { + pathFound, + arrived, + tolerance, + verifyWaitMs, + target = new + { + playerName = target.Name, + entityId = target.EntityId, + x = RoundCoordinate(target.X), + y = RoundCoordinate(target.Y), + z = RoundCoordinate(target.Z) + }, + startLocation = ToCoordinate(startLocation), + finalLocation = ToCoordinate(finalLocation.Value), + finalDistance = GetDistance(finalLocation.Value, goal), + distanceMoved = GetDistance(startLocation, finalLocation.Value), + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeoutMs + }; + + return pathFound && arrived + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); } public MccMcpResult LookAt(double x, double y, double z) @@ -1168,6 +1470,237 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(text) || radius is < 1 or > MaxBlockFindRadius) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + string filter = text.Trim(); + int limit = Math.Clamp(maxCount, 1, 500); + + return client.InvokeOnMainThread(() => + { + Location playerLocation = client.GetCurrentLocation(); + World world = client.GetWorld(); + var signs = client.GetKnownSigns() + .Select(sign => + { + double dx = sign.location.X + 0.5 - playerLocation.X; + double dy = sign.location.Y + 0.5 - playerLocation.Y; + double dz = sign.location.Z + 0.5 - playerLocation.Z; + return new + { + sign, + distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) + }; + }) + .Where(entry => entry.distance <= radius) + .Where(entry => IsSignMaterial(world.GetBlock(entry.sign.location).Type)) + .Select(entry => + { + string[] frontText = entry.sign.frontText.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray(); + string[] backText = includeBackText + ? entry.sign.backText.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray() + : []; + string[] matchedLines = frontText + .Concat(backText) + .Where(line => exactMatch ? TextEqualsFilter(line, filter) : TextMatchesFilter(line, filter)) + .Distinct(NameComparer) + .ToArray(); + + return new + { + entry.sign, + entry.distance, + frontText, + backText, + matchedLines + }; + }) + .Where(entry => entry.matchedLines.Length > 0) + .OrderBy(entry => entry.distance) + .Take(limit) + .Select(entry => new + { + x = (int)Math.Floor(entry.sign.location.X), + y = (int)Math.Floor(entry.sign.location.Y), + z = (int)Math.Floor(entry.sign.location.Z), + material = entry.sign.material, + typeLabel = entry.sign.typeLabel, + distance = entry.distance, + isWaxed = entry.sign.isWaxed, + frontText = entry.frontText, + backText = entry.backText, + matchedLines = entry.matchedLines + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + text = filter, + exactMatch, + radius, + includeBackText, + count = signs.Length, + signs + }); + }); + } + + public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) + { + if (!IsCategoryEnabled(t => t.EntityWorld)) + return MccMcpResult.Fail("capability_disabled"); + + if (radius <= 0 || radius > 1024) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + ItemType? parsedItemType = null; + string? itemTypeFilter = null; + if (!string.IsNullOrWhiteSpace(itemType)) + { + itemTypeFilter = itemType.Trim(); + if (!TryParseItemType(itemTypeFilter, out ItemType resolvedType)) + return MccMcpResult.Fail("invalid_args"); + parsedItemType = resolvedType; + } + + int limit = Math.Clamp(maxCount, 1, 500); + return client.InvokeOnMainThread(() => + { + NearbyItemSnapshot[] items = BuildNearbyItemSnapshots(client, parsedItemType, radius, limit); + return MccMcpResult.Ok(new + { + itemType = parsedItemType?.ToString() ?? itemTypeFilter, + radius, + count = items.Length, + items = items.Select(item => new + { + entityId = item.EntityId, + itemType = item.ItemType.ToString(), + typeLabel = item.TypeLabel, + count = item.Count, + x = RoundCoordinate(item.X), + y = RoundCoordinate(item.Y), + z = RoundCoordinate(item.Z), + distance = item.Distance + }).ToArray() + }); + }); + } + + public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) + { + if (!IsCategoryEnabled(t => t.EntityWorld) || !IsCategoryEnabled(t => t.Movement)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(itemType) || radius <= 0 || radius > 1024 || maxItems < 1 || timeoutMs < 0) + return MccMcpResult.Fail("invalid_args"); + + if (!TryParseItemType(itemType.Trim(), out ItemType parsedItemType)) + return MccMcpResult.Fail("invalid_args"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetTerrainEnabled() || !client.GetEntityHandlingEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + int limit = Math.Clamp(maxItems, 1, 50); + NearbyItemSnapshot[] targets = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, limit)); + if (targets.Length == 0) + { + return MccMcpResult.Fail("invalid_state", data: new + { + itemType = parsedItemType.ToString(), + radius, + maxItems = limit + }); + } + + bool inventoryEnabled = client.GetInventoryEnabled(); + int beforeCount = inventoryEnabled ? client.InvokeOnMainThread(() => GetInventoryItemCount(client, parsedItemType)) : 0; + int initialCount = beforeCount; + int verifyWaitMs = timeoutMs > 0 ? Math.Clamp(timeoutMs, MinArrivalWaitMs, MaxArrivalWaitMs) : 2500; + List attempts = new(targets.Length); + int successfulPickups = 0; + + foreach (NearbyItemSnapshot target in targets) + { + Location targetLocation = new(target.X, target.Y, target.Z); + Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation); + TimeSpan? moveTimeout = timeoutMs > 0 ? TimeSpan.FromMilliseconds(timeoutMs) : null; + bool pathFound = client.InvokeOnMainThread(() => client.MoveTo(targetLocation, allowUnsafe, false, 0, 0, moveTimeout)); + Location? finalLocation = null; + bool arrived = pathFound && WaitForArrival(client, targetLocation, verifyWaitMs, 2.0, out finalLocation); + finalLocation ??= client.InvokeOnMainThread(client.GetCurrentLocation); + bool entityGone = WaitForEntityRemoval(client, target.EntityId, verifyWaitMs); + int afterCount = inventoryEnabled ? client.InvokeOnMainThread(() => GetInventoryItemCount(client, parsedItemType)) : beforeCount; + int inventoryDelta = inventoryEnabled ? Math.Max(0, afterCount - beforeCount) : 0; + bool pickedUp = entityGone || inventoryDelta > 0; + if (pickedUp) + successfulPickups++; + + attempts.Add(new + { + entityId = target.EntityId, + itemType = target.ItemType.ToString(), + typeLabel = target.TypeLabel, + expectedCount = target.Count, + target = ToCoordinate(target.X, target.Y, target.Z), + pathFound, + arrived, + entityGone, + inventoryDelta, + startLocation = ToCoordinate(startLocation), + finalLocation = ToCoordinate(finalLocation.Value), + finalDistance = GetDistance(finalLocation.Value, targetLocation) + }); + + beforeCount = afterCount; + } + + int remainingNearby = client.InvokeOnMainThread(() => BuildNearbyItemSnapshots(client, parsedItemType, radius, 1000).Length); + int collectedCount = inventoryEnabled ? Math.Max(0, beforeCount - initialCount) : successfulPickups; + object resultData = new + { + itemType = parsedItemType.ToString(), + radius, + maxItems = limit, + allowUnsafe, + timeoutMs = verifyWaitMs, + attempted = attempts.Count, + successfulPickups, + collectedCount, + initialInventoryCount = inventoryEnabled ? (int?)initialCount : null, + finalInventoryCount = inventoryEnabled ? (int?)beforeCount : null, + remainingNearby, + attempts = attempts.ToArray() + }; + + return successfulPickups > 0 || collectedCount > 0 + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); + } + public MccMcpResult GetWorldBlockAt(int x, int y, int z) { if (!IsCategoryEnabled(t => t.EntityWorld)) @@ -1436,6 +1969,112 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return Math.Max(DefaultArrivalTolerance, toleranceFromOffset); } + private static int GetPathQueryTimeoutMs(int timeoutMs) + { + if (timeoutMs <= 0) + return DefaultPathQueryTimeoutMs; + return Math.Clamp(timeoutMs, MinPathQueryTimeoutMs, MaxPathQueryTimeoutMs); + } + + private static bool WaitForBlockChange(McClient client, Location target, Block beforeBlock, int waitMs, out Block afterBlock) + { + afterBlock = beforeBlock; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + Block current = client.InvokeOnMainThread(() => client.GetWorld().GetBlock(target)); + afterBlock = current; + if (!AreEquivalentBlocks(current, beforeBlock)) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static bool AreEquivalentBlocks(Block left, Block right) + { + return left.BlockId == right.BlockId + && left.BlockMeta == right.BlockMeta + && left.Type == right.Type; + } + + private static double[] GetDigAttemptDurations(double durationSeconds) + { + if (durationSeconds > 0) + return [durationSeconds]; + return s_defaultDigAttemptDurations; + } + + private static int GetDigVerifyWaitMs(double durationSeconds) + { + int waitMs = (int)Math.Ceiling(durationSeconds * 1000) + 2000; + return Math.Clamp(waitMs, 1500, MaxBlockVerifyWaitMs); + } + + private static bool AreValidPathOffsets(int maxOffset, int minOffset) + { + return maxOffset >= 0 && minOffset >= 0 && minOffset <= maxOffset; + } + + private static NearbyItemSnapshot[] BuildNearbyItemSnapshots(McClient client, ItemType? itemType, double radius, int maxCount) + { + Location playerLocation = client.GetCurrentLocation(); + return client.GetEntities().Values + .Where(entity => entity.Type == EntityType.Item && !entity.Item.IsEmpty) + .Where(entity => !itemType.HasValue || entity.Item.Type == itemType.Value) + .Select(entity => + { + double dx = entity.Location.X - playerLocation.X; + double dy = entity.Location.Y - playerLocation.Y; + double dz = entity.Location.Z - playerLocation.Z; + return new NearbyItemSnapshot + { + EntityId = entity.ID, + ItemType = entity.Item.Type, + TypeLabel = entity.Item.GetTypeString(), + Count = entity.Item.Count, + X = entity.Location.X, + Y = entity.Location.Y, + Z = entity.Location.Z, + Distance = Math.Sqrt(dx * dx + dy * dy + dz * dz) + }; + }) + .Where(item => item.Distance <= radius) + .OrderBy(item => item.Distance) + .Take(maxCount) + .ToArray(); + } + + private static bool WaitForEntityRemoval(McClient client, int entityId, int waitMs) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + bool exists = client.InvokeOnMainThread(() => client.GetEntities().ContainsKey(entityId)); + if (!exists) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static int GetInventoryItemCount(McClient client, ItemType itemType) + { + Container? inventory = client.GetInventory(0); + if (inventory is null) + return 0; + + return inventory.Items.Values + .Where(item => item.Type == itemType) + .Sum(item => item.Count); + } + private static object ToCoordinate(Location location) { return ToCoordinate(location.X, location.Y, location.Z); @@ -1456,6 +2095,51 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return Math.Round(value, CoordinateRoundingPrecision, MidpointRounding.AwayFromZero); } + private static Location ToBlockLocation(double x, double y, double z) + { + return new Location(Math.Floor(x), Math.Floor(y), Math.Floor(z)); + } + + private static object ToBlockState(Block block) + { + return new + { + material = block.Type.ToString(), + typeLabel = block.GetTypeString(), + blockId = block.BlockId, + blockMeta = block.BlockMeta + }; + } + + private static string GetMaterialTypeLabel(Material material) + { + string key = "block.minecraft." + ToTranslationKey(material.ToString()); + string? translation = ChatParser.TranslateString(key); + return string.IsNullOrEmpty(translation) ? material.ToString() : translation; + } + + private static string ToTranslationKey(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + List chars = new(value.Length * 2); + for (int i = 0; i < value.Length; i++) + { + char current = value[i]; + if (char.IsUpper(current) && i > 0 && (char.IsLower(value[i - 1]) || char.IsDigit(value[i - 1]))) + chars.Add('_'); + chars.Add(char.ToLowerInvariant(current)); + } + + return new string(chars.ToArray()); + } + + private static bool IsSignMaterial(Material material) + { + return material.ToString().Contains("Sign", StringComparison.Ordinal); + } + private static string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary uuidToName) { if (!string.IsNullOrWhiteSpace(entity.Name)) @@ -1492,12 +2176,30 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities string typeLabel = block.GetTypeString(); if (exactMatch) { - return material.Equals(filter, StringComparison.OrdinalIgnoreCase) - || typeLabel.Equals(filter, StringComparison.OrdinalIgnoreCase); + return TextEqualsFilter(material, filter) + || TextEqualsFilter(typeLabel, filter); } - return material.Contains(filter, StringComparison.OrdinalIgnoreCase) - || typeLabel.Contains(filter, StringComparison.OrdinalIgnoreCase); + return TextMatchesFilter(material, filter) + || TextMatchesFilter(typeLabel, filter); + } + + private static bool TextEqualsFilter(string text, string filter) + { + return text.Equals(filter, StringComparison.OrdinalIgnoreCase) + || NormalizeToken(text) == NormalizeToken(filter); + } + + private static bool TextMatchesFilter(string text, string filter) + { + if (text.Contains(filter, StringComparison.OrdinalIgnoreCase)) + return true; + + string normalizedFilter = NormalizeToken(filter); + if (normalizedFilter.Length == 0) + return false; + + return NormalizeToken(text).Contains(normalizedFilter, StringComparison.Ordinal); } private static void ParseBlockQuery(string? query, out int? blockId, out int? blockMeta) diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs index 6eb41caa..d0d0307b 100644 --- a/MinecraftClient/Mcp/MccMcpToolSet.cs +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -49,6 +49,24 @@ public sealed class MccMcpToolSet return capabilities.GetInternalCommands(); } + [McpServerTool(Name = "mcc_materials_list"), Description("List known MCC material names with optional filtering.")] + public object MaterialsList(string? filter = null, int maxCount = 500) + { + return capabilities.GetMaterialsList(filter, maxCount); + } + + [McpServerTool(Name = "mcc_block_types_list"), Description("List known MCC block type names with optional filtering.")] + public object BlockTypesList(string? filter = null, int maxCount = 500) + { + return capabilities.GetBlockTypesList(filter, maxCount); + } + + [McpServerTool(Name = "mcc_entity_types_list"), Description("List known MCC entity type names with optional filtering.")] + public object EntityTypesList(string? filter = null, int maxCount = 500) + { + return capabilities.GetEntityTypesList(filter, maxCount); + } + [McpServerTool(Name = "mcc_send_chat"), Description("Send chat text or slash-command to the connected Minecraft server.")] public object SendChat([Description("Text to send to server chat.")] string text) { @@ -127,6 +145,12 @@ public sealed class MccMcpToolSet return capabilities.LocatePlayer(playerName, includeSelf); } + [McpServerTool(Name = "mcc_can_reach_position"), Description("Check whether MCC can currently path to a world coordinate without moving there.")] + public object CanReachPosition(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) + { + return capabilities.CanReachPosition(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs); + } + [McpServerTool(Name = "mcc_move_to"), Description("Request movement/pathing to a world coordinate and verify arrival.")] public object MoveTo(double x, double y, double z, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0) { @@ -185,6 +209,24 @@ public sealed class MccMcpToolSet return capabilities.GetEntityInfo(entityId, includeMetadata, includeEquipment, includeEffects); } + [McpServerTool(Name = "mcc_signs_find"), Description("Find nearby signs whose text exactly matches or contains the requested text.")] + public object SignsFind(string text, bool exactMatch = false, int radius = 16, int maxCount = 50, bool includeBackText = true) + { + return capabilities.FindSigns(text, exactMatch, radius, maxCount, includeBackText); + } + + [McpServerTool(Name = "mcc_items_list"), Description("List nearby dropped item entities with optional item type filtering.")] + public object ItemsList(string? itemType = null, double radius = 32, int maxCount = 100) + { + return capabilities.ListItemEntities(itemType, radius, maxCount); + } + + [McpServerTool(Name = "mcc_items_pickup"), Description("Move to and pick up nearby dropped items of a given item type.")] + public object ItemsPickup(string itemType, double radius = 32, int maxItems = 20, bool allowUnsafe = false, int timeoutMs = 0) + { + return capabilities.PickupItems(itemType, radius, maxItems, allowUnsafe, timeoutMs); + } + [McpServerTool(Name = "mcc_world_block_at"), Description("Get block information at world coordinates.")] public object WorldBlockAt(int x, int y, int z) { diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index b6cdcd05..a36eaf72 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -1569,6 +1569,7 @@ namespace MinecraftClient.Protocol.Handlers var dataSize = dataTypes.ReadNextVarInt(packetData); // Size pTerrain.ProcessChunkColumnData(chunkX, chunkZ, verticalStripBitmask, packetData); + ProcessChunkBlockEntityData(chunkX, chunkZ, packetData); Interlocked.Decrement(ref handler.GetWorld().chunkLoadNotCompleted); // Block Entity data: ignored @@ -2957,17 +2958,16 @@ namespace MinecraftClient.Protocol.Handlers // TODO: Use break; + case PacketTypesIn.BlockEntityData: + if (handler.GetTerrainEnabled() && protocolVersion >= MC_1_17_Version) + { + var location_ = dataTypes.ReadNextLocation(packetData); + dataTypes.ReadNextVarInt(packetData); // Block entity type registry id + var nbt = dataTypes.ReadNextNbt(packetData); + handler.OnBlockEntityData(location_, nbt); + } - // Temporarily disabled until I find a fix - /*case PacketTypesIn.BlockEntityData: - var location_ = dataTypes.ReadNextLocation(packetData); - var type_ = dataTypes.ReadNextInt(packetData); - var nbt = dataTypes.ReadNextNbt(packetData); - var nbtJson = JsonConvert.SerializeObject(nbt["messages"]); - - //log.Info($"BLOCK ENTITY DATA -> {location_.ToString()} [{type_}] -> NBT: {nbtJson}"); - - break;*/ + break; case PacketTypesIn.SetTickingState: dataTypes.ReadNextFloat(packetData); @@ -3162,6 +3162,24 @@ namespace MinecraftClient.Protocol.Handlers SendPacket(packetPalette.GetOutgoingIdByType(packet), packetData); } + private void ProcessChunkBlockEntityData(int chunkX, int chunkZ, Queue packetData) + { + if (protocolVersion < MC_1_17_Version || packetData.Count == 0) + return; + + int blockEntityCount = dataTypes.ReadNextVarInt(packetData); + for (int i = 0; i < blockEntityCount; i++) + { + int packedXZ = dataTypes.ReadNextByte(packetData); + int y = dataTypes.ReadNextShort(packetData); + dataTypes.ReadNextVarInt(packetData); // Block entity type registry id + Dictionary? nbt = dataTypes.ReadNextNbt(packetData); + int blockX = chunkX * Chunk.SizeX + ((packedXZ >> 4) & 0x0F); + int blockZ = chunkZ * Chunk.SizeZ + (packedXZ & 0x0F); + handler.OnBlockEntityData(new Location(blockX, y, blockZ), nbt); + } + } + /// /// Send a configuration packet to the server. Packet ID, compression, and encryption will be handled automatically. /// diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index 94fe0590..85618c07 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -508,6 +508,13 @@ namespace MinecraftClient.Protocol /// The block public void OnBlockChange(Location location, Block block); + /// + /// Called when block entity update data is received for a loaded block. + /// + /// The block location. + /// The block entity NBT payload. + public void OnBlockEntityData(Location location, Dictionary? nbt); + /// /// Called when "AutoComplete" completes. /// From cf382122e9dcc4f0a3fe71cad3d253608119d8f9 Mon Sep 17 00:00:00 2001 From: Anon Date: Sat, 28 Mar 2026 16:44:22 +0100 Subject: [PATCH 03/13] Added inventory manipulation to the MCP, improved the test harness --- DebugTools/MccMcpStdioHarness/Program.cs | 73 ++ DebugTools/MccMcpWebPlayground/Program.cs | 31 +- MinecraftClient/Mcp/IMccMcpCapabilities.cs | 5 + MinecraftClient/Mcp/MccMcpCapabilities.cs | 808 +++++++++++++++++++++ MinecraftClient/Mcp/MccMcpToolSet.cs | 38 + 5 files changed, 948 insertions(+), 7 deletions(-) diff --git a/DebugTools/MccMcpStdioHarness/Program.cs b/DebugTools/MccMcpStdioHarness/Program.cs index d105f12f..69f2488a 100644 --- a/DebugTools/MccMcpStdioHarness/Program.cs +++ b/DebugTools/MccMcpStdioHarness/Program.cs @@ -295,16 +295,53 @@ 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 ListInventories() => + MccMcpResult.Ok(new + { + count = 2, + inventories = new object[] + { + new { id = 0, type = "PlayerInventory", title = "Player Inventory", slotCount = 46, nonEmptySlots = 1, active = false }, + new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2, active = true } + } + }); + public MccMcpResult GetInventorySnapshot(int inventoryId) => MccMcpResult.Ok(new { id = inventoryId, + type = inventoryId == 0 ? "PlayerInventory" : "Generic_9x3", + title = inventoryId == 0 ? "Player Inventory" : "Chest", + slotCount = inventoryId == 0 ? 46 : 63, slots = new[] { new { slot = 0, type = "Stone", count = 64 } } }); + public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent) => + MccMcpResult.Ok(new + { + success = true, + openAccepted = true, + opened = true, + timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs, + x, + y, + z, + 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 + { + success = true, + closed = true, + inventoryId = inventoryId <= 0 ? 1 : inventoryId, + timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs + }); + public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) => MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType }); @@ -322,6 +359,42 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities preferStack }); + public MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) => + MccMcpResult.Ok(new + { + success = true, + direction = "deposit", + itemType, + requestedCount = count, + movedCount = count, + beforePlayerCount = 64, + afterPlayerCount = Math.Max(0, 64 - count), + beforeContainerCount = 0, + afterContainerCount = count, + inventoryId = inventoryId <= 0 ? 1 : inventoryId, + containerType = "Generic_9x3", + touchedSourceSlots = new[] { 36 }, + touchedTargetSlots = new[] { 0 } + }); + + public MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) => + MccMcpResult.Ok(new + { + success = true, + direction = "withdraw", + itemType, + requestedCount = count, + movedCount = count, + beforePlayerCount = 0, + afterPlayerCount = count, + beforeContainerCount = 64, + afterContainerCount = Math.Max(0, 64 - count), + inventoryId = inventoryId <= 0 ? 1 : inventoryId, + containerType = "Generic_9x3", + touchedSourceSlots = new[] { 0 }, + touchedTargetSlots = new[] { 36 } + }); + public MccMcpResult QueryEntities(int maxCount) => MccMcpResult.Ok(new { diff --git a/DebugTools/MccMcpWebPlayground/Program.cs b/DebugTools/MccMcpWebPlayground/Program.cs index e2131393..9b25f0ca 100644 --- a/DebugTools/MccMcpWebPlayground/Program.cs +++ b/DebugTools/MccMcpWebPlayground/Program.cs @@ -8,7 +8,10 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient("openrouter"); +builder.Services.AddHttpClient("openrouter", client => +{ + client.Timeout = TimeSpan.FromMinutes(15); +}); var app = builder.Build(); app.UseDefaultFiles(); @@ -46,6 +49,7 @@ Todo policy Tool-use policy - Use MCP tools for MCC/game-state questions and actions. - Prefer the most direct high-signal tool first. +- Prefer structured inventory/container tools over raw window-click tools for chest or container management. - If a tool result says success=false or includes an errorCode, treat that as a failed observation even if the transport call itself succeeded. - Do not guess tool arguments repeatedly. If a tool returns invalid_args: - simplify to the minimum required arguments, @@ -73,6 +77,12 @@ Action-specific guidance - dig in a sensible order, - re-check remaining blocks, - re-check inventory or nearby item entities before finishing. +- Container inventory: + - locate the target container block, + - open the container first, + - inspect player and container inventory state, + - use structured deposit or withdraw tools instead of raw window clicks, + - verify both player and container counts changed before finishing. - Search: - start with the most direct search tool, - use the user's requested radius when supported, @@ -97,6 +107,13 @@ Good examples Good: - finish with a short greeting - no MCP tools +4) User: "Put 5 diamonds in the chest." + Good: + - open the chest + - inspect inventory state + - deposit exactly 5 diamonds + - verify the chest count increased and player count decreased by 5 + - then finish Wrong examples 1) Wrong: @@ -165,9 +182,9 @@ app.MapPost("/api/chat/stream", async (ChatStreamRequest request, IHttpClientFac } string model = GetModel(); - int maxIterations = GetBoundedInt("MCC_WEB_MAX_ITERATIONS", 24, 4, 80); - int maxToolCalls = GetBoundedInt("MCC_WEB_MAX_TOOL_CALLS", 80, 4, 256); - TimeSpan maxWallTime = TimeSpan.FromSeconds(GetBoundedInt("MCC_WEB_MAX_SECONDS", 120, 10, 300)); + int maxIterations = GetBoundedInt("MCC_WEB_MAX_ITERATIONS", 96, 4, 256); + int maxToolCalls = GetBoundedInt("MCC_WEB_MAX_TOOL_CALLS", 320, 4, 1024); + TimeSpan maxWallTime = TimeSpan.FromSeconds(GetBoundedInt("MCC_WEB_MAX_SECONDS", 900, 10, 3600)); await using McpClient mcp = await CreateMcpClientAsync(cancellationToken); IList mcpTools = await mcp.ListToolsAsync(cancellationToken: cancellationToken); @@ -1005,9 +1022,9 @@ Answer: static bool ShouldInjectReminder(int iteration, int maxIterations, int toolCallCount, int maxToolCalls, TimeSpan elapsed, TimeSpan maxWallTime) { - return iteration >= maxIterations - 2 - || toolCallCount >= maxToolCalls - 4 - || elapsed >= maxWallTime - TimeSpan.FromSeconds(10); + return iteration >= maxIterations - 6 + || toolCallCount >= maxToolCalls - 12 + || elapsed >= maxWallTime - TimeSpan.FromSeconds(45); } static string BuildForcedFinalAnswer( diff --git a/MinecraftClient/Mcp/IMccMcpCapabilities.cs b/MinecraftClient/Mcp/IMccMcpCapabilities.cs index 6d5b118a..0e460a9e 100644 --- a/MinecraftClient/Mcp/IMccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/IMccMcpCapabilities.cs @@ -28,9 +28,14 @@ public interface IMccMcpCapabilities 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 ListInventories(); MccMcpResult GetInventorySnapshot(int inventoryId); + 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); MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack); + MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack); + MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack); MccMcpResult QueryEntities(int maxCount); MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius); MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects); diff --git a/MinecraftClient/Mcp/MccMcpCapabilities.cs b/MinecraftClient/Mcp/MccMcpCapabilities.cs index 055786a6..907b8b66 100644 --- a/MinecraftClient/Mcp/MccMcpCapabilities.cs +++ b/MinecraftClient/Mcp/MccMcpCapabilities.cs @@ -31,6 +31,10 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities private const double DefaultArrivalTolerance = 1.5; private const int ArrivalPollIntervalMs = 125; private const int MaxBlockVerifyWaitMs = 12000; + private const int DefaultContainerWaitMs = 5000; + private const int MinContainerWaitMs = 250; + private const int MaxContainerWaitMs = 20000; + private const int DefaultInventoryActionWaitMs = 3500; private sealed class InternalCommandInfo { @@ -64,6 +68,12 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities public required double Distance { get; init; } } + private enum InventoryTransferDirection + { + Deposit, + Withdraw + } + private readonly Func togglesProvider; public MccMcpCapabilities(Func togglesProvider) @@ -1122,6 +1132,120 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult ListInventories() + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + return client.InvokeOnMainThread(() => + { + var inventories = client.GetInventories() + .OrderBy(entry => entry.Key) + .Select(entry => new + { + id = entry.Key, + type = entry.Value.Type.ToString(), + title = entry.Value.Title, + slotCount = entry.Value.Type.SlotCount(), + nonEmptySlots = entry.Value.Items.Count, + active = entry.Key > 0 && entry.Key == GetActiveContainerId(client) + }) + .ToArray(); + + return MccMcpResult.Ok(new + { + count = inventories.Length, + inventories + }); + }); + } + + public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled() || !client.GetTerrainEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + Location location = new(x, y, z); + int waitMs = GetContainerWaitMs(timeoutMs); + (Block block, int activeContainerId) state = client.InvokeOnMainThread(() => + { + Block block = client.GetWorld().GetBlock(location); + return (block, GetActiveContainerId(client)); + }); + + if (!IsInteractableContainerMaterial(state.block.Type)) + { + return MccMcpResult.Fail("invalid_state", data: new + { + x, + y, + z, + block = ToBlockState(state.block), + activeContainerId = state.activeContainerId + }); + } + + return OpenContainerCore(client, location, state.block, state.activeContainerId, waitMs, closeCurrent); + } + + public MccMcpResult CloseContainer(int inventoryId, int timeoutMs) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + McClient? client = GetClient(); + if (client is null) + return NotConnected(); + + if (!client.GetInventoryEnabled()) + return MccMcpResult.Fail("feature_disabled"); + + int waitMs = GetContainerWaitMs(timeoutMs); + int resolvedInventoryId = client.InvokeOnMainThread(() => ResolveContainerInventoryId(client, inventoryId)); + if (resolvedInventoryId <= 0) + { + if (inventoryId < 0) + { + return MccMcpResult.Ok(new + { + success = true, + closed = false + }); + } + + return MccMcpResult.Fail("invalid_state", data: new { inventoryId }); + } + + bool closeAccepted = client.CloseInventory(resolvedInventoryId); + bool closed = closeAccepted && WaitForContainerClose(client, resolvedInventoryId, waitMs); + var resultData = new + { + success = closeAccepted && closed, + closeAccepted, + closed, + inventoryId = resolvedInventoryId, + timeoutMs = waitMs + }; + + return closeAccepted && closed + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); + } + public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) { if (!IsCategoryEnabled(t => t.Inventory)) @@ -1274,6 +1398,16 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + public MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) + { + return TransferContainerItem(itemType, count, inventoryId, preferLargestStack, InventoryTransferDirection.Deposit); + } + + public MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) + { + return TransferContainerItem(itemType, count, inventoryId, preferLargestStack, InventoryTransferDirection.Withdraw); + } + public MccMcpResult QueryEntities(int maxCount) { if (!IsCategoryEnabled(t => t.EntityWorld)) @@ -1729,6 +1863,312 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + private static MccMcpResult OpenContainerCore(McClient client, Location location, Block block, int activeContainerId, int waitMs, bool closeCurrent) + { + if (activeContainerId > 0) + { + if (!closeCurrent) + { + return MccMcpResult.Fail("invalid_state", data: new + { + reason = "container_already_open", + activeContainerId, + x = location.X, + y = location.Y, + z = location.Z, + block = ToBlockState(block) + }); + } + + bool closeAccepted = client.CloseInventory(activeContainerId); + bool closed = closeAccepted && WaitForContainerClose(client, activeContainerId, waitMs); + if (!closeAccepted || !closed) + { + return MccMcpResult.Fail("action_incomplete", data: new + { + action = "close_previous_container", + activeContainerId, + closeAccepted, + closed, + timeoutMs = waitMs + }); + } + } + + HashSet beforeIds = client.InvokeOnMainThread(() => client.GetInventories().Keys.Where(id => id > 0).ToHashSet()); + int openedInventoryId = 0; + Container? openedInventory = null; + bool openAccepted = client.InvokeOnMainThread(() => client.PlaceBlock(location, Direction.Down, Hand.MainHand, lookAtBlock: true)); + bool opened = openAccepted && WaitForContainerOpen(client, beforeIds, waitMs, out openedInventoryId, out openedInventory); + var resultData = new + { + success = openAccepted && opened && openedInventory is not null, + openAccepted, + opened, + timeoutMs = waitMs, + x = location.X, + y = location.Y, + z = location.Z, + block = ToBlockState(block), + inventory = openedInventory is null + ? null + : new + { + id = openedInventoryId, + type = openedInventory.Type.ToString(), + title = openedInventory.Title, + slotCount = openedInventory.Type.SlotCount(), + nonEmptySlots = openedInventory.Items.Count + } + }; + + return openAccepted && opened && openedInventory is not null + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); + } + + private MccMcpResult TransferContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack, InventoryTransferDirection direction) + { + if (!IsCategoryEnabled(t => t.Inventory)) + return MccMcpResult.Fail("capability_disabled"); + + if (string.IsNullOrWhiteSpace(itemType) || count <= 0) + 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() + }); + } + + if (TryGetCursorItem(client, out Item? cursorItem)) + { + return MccMcpResult.Fail("invalid_state", data: new + { + reason = "cursor_item_present", + cursor = new { type = cursorItem!.Type.ToString(), count = cursorItem.Count } + }); + } + + int resolvedInventoryId = client.InvokeOnMainThread(() => ResolveContainerInventoryId(client, inventoryId)); + if (resolvedInventoryId <= 0) + { + return MccMcpResult.Fail("invalid_state", data: new + { + inventoryId + }); + } + + Container? initialInventory = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId)); + if (initialInventory is null) + return MccMcpResult.Fail("invalid_state", data: new { inventoryId = resolvedInventoryId }); + + if (!TryGetContainerSlotRanges(initialInventory.Type, out int containerStart, out int containerEnd, out int playerStart, out int playerEnd)) + { + return MccMcpResult.Fail("invalid_state", data: new + { + reason = "unsupported_container_type", + inventoryId = resolvedInventoryId, + type = initialInventory.Type.ToString() + }); + } + + int sourceStart = direction == InventoryTransferDirection.Deposit ? playerStart : containerStart; + int sourceEnd = direction == InventoryTransferDirection.Deposit ? playerEnd : containerEnd; + int targetStart = direction == InventoryTransferDirection.Deposit ? containerStart : playerStart; + int targetEnd = direction == InventoryTransferDirection.Deposit ? containerEnd : playerEnd; + + int beforePlayerCount = CountItemInRange(initialInventory, parsedItemType, playerStart, playerEnd); + int beforeContainerCount = CountItemInRange(initialInventory, parsedItemType, containerStart, containerEnd); + int availableCount = CountItemInRange(initialInventory, parsedItemType, sourceStart, sourceEnd); + if (availableCount < count) + { + return MccMcpResult.Fail("invalid_state", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + availableCount, + inventoryId = resolvedInventoryId, + direction = direction.ToString() + }); + } + + int remaining = count; + List touchedSourceSlots = new(); + List touchedTargetSlots = new(); + + while (remaining > 0) + { + Container? inventory = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId)); + if (inventory is null) + return MccMcpResult.Fail("invalid_state", data: new { inventoryId = resolvedInventoryId }); + + if (TryGetCursorItem(client, out cursorItem)) + { + return MccMcpResult.Fail("invalid_state", data: new + { + reason = "cursor_item_present_mid_transfer", + cursor = new { type = cursorItem!.Type.ToString(), count = cursorItem.Count } + }); + } + + var sourceSlots = GetOrderedItemSlots(inventory, parsedItemType, sourceStart, sourceEnd, preferLargestStack); + if (sourceSlots.Length == 0) + break; + + int beforeSourceCount = CountItemInRange(inventory, parsedItemType, sourceStart, sourceEnd); + int beforeTargetCount = CountItemInRange(inventory, parsedItemType, targetStart, targetEnd); + (int slot, int sourceCount) = sourceSlots[0]; + touchedSourceSlots.Add(slot); + + int movedCount; + List usedTargetSlots = new(); + if (sourceCount <= remaining || direction == InventoryTransferDirection.Withdraw) + { + if (!client.DoWindowAction(resolvedInventoryId, slot, WindowActionType.ShiftClick)) + { + return MccMcpResult.Fail("action_failed", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + remainingCount = remaining, + inventoryId = resolvedInventoryId, + sourceSlot = slot, + direction = direction.ToString() + }); + } + + if (direction == InventoryTransferDirection.Withdraw) + { + if (!WaitForRangeCount(client, resolvedInventoryId, parsedItemType, sourceStart, sourceEnd, countAfterShift => countAfterShift < beforeSourceCount, DefaultInventoryActionWaitMs, out Container? afterShift, out int afterSourceCount)) + { + afterShift = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId)); + afterSourceCount = afterShift is null ? beforeSourceCount : CountItemInRange(afterShift, parsedItemType, sourceStart, sourceEnd); + } + + movedCount = beforeSourceCount - afterSourceCount; + } + else + { + if (!WaitForRangeCount(client, resolvedInventoryId, parsedItemType, targetStart, targetEnd, countAfterShift => countAfterShift > beforeTargetCount, DefaultInventoryActionWaitMs, out Container? afterShift, out int afterTargetCount)) + { + afterShift = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId)); + afterTargetCount = afterShift is null ? beforeTargetCount : CountItemInRange(afterShift, parsedItemType, targetStart, targetEnd); + } + + movedCount = afterTargetCount - beforeTargetCount; + } + + if (direction == InventoryTransferDirection.Withdraw && movedCount > remaining) + { + int excessCount = movedCount - remaining; + MccMcpResult returnExcess = TransferContainerItem(parsedItemType.ToString(), excessCount, resolvedInventoryId, preferLargestStack, InventoryTransferDirection.Deposit); + if (!returnExcess.Success) + { + return MccMcpResult.Fail("action_incomplete", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + remainingCount = remaining, + inventoryId = resolvedInventoryId, + sourceSlot = slot, + direction = direction.ToString(), + excessCount, + returnExcess + }); + } + + movedCount -= excessCount; + } + } + else + { + movedCount = TransferPartialFromSlot( + client, + resolvedInventoryId, + slot, + parsedItemType, + remaining, + sourceStart, + sourceEnd, + targetStart, + targetEnd, + usedTargetSlots); + } + + if (movedCount <= 0) + { + Container? afterFailure = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId)); + return MccMcpResult.Fail("action_incomplete", data: new + { + itemType = parsedItemType.ToString(), + requestedCount = count, + remainingCount = remaining, + inventoryId = resolvedInventoryId, + sourceSlot = slot, + direction = direction.ToString(), + playerCount = afterFailure is null ? 0 : CountItemInRange(afterFailure, parsedItemType, playerStart, playerEnd), + containerCount = afterFailure is null ? 0 : CountItemInRange(afterFailure, parsedItemType, containerStart, containerEnd) + }); + } + + remaining -= movedCount; + touchedTargetSlots.AddRange(usedTargetSlots); + } + + Container? finalInventory = client.InvokeOnMainThread(() => client.GetInventory(resolvedInventoryId)); + if (finalInventory is null) + return MccMcpResult.Fail("invalid_state", data: new { inventoryId = resolvedInventoryId }); + + int afterPlayerCount = CountItemInRange(finalInventory, parsedItemType, playerStart, playerEnd); + int afterContainerCount = CountItemInRange(finalInventory, parsedItemType, containerStart, containerEnd); + int playerDelta = afterPlayerCount - beforePlayerCount; + int containerDelta = afterContainerCount - beforeContainerCount; + int movedTotal = direction == InventoryTransferDirection.Deposit + ? afterContainerCount - beforeContainerCount + : beforeContainerCount - afterContainerCount; + bool countsVerified = direction == InventoryTransferDirection.Deposit + ? containerDelta == count + : containerDelta == -count; + bool playerCountsMatchExpected = direction == InventoryTransferDirection.Deposit + ? playerDelta == -count + : playerDelta == count; + bool succeeded = remaining == 0 && countsVerified; + var resultData = new + { + success = succeeded, + direction = direction.ToString().ToLowerInvariant(), + itemType = parsedItemType.ToString(), + requestedCount = count, + movedCount = movedTotal, + beforePlayerCount, + afterPlayerCount, + beforeContainerCount, + afterContainerCount, + playerDelta, + containerDelta, + playerCountsMatchExpected, + verificationBasis = "container_delta", + inventoryId = resolvedInventoryId, + containerType = finalInventory.Type.ToString(), + touchedSourceSlots = touchedSourceSlots.Distinct().OrderBy(slot => slot).ToArray(), + touchedTargetSlots = touchedTargetSlots.Distinct().OrderBy(slot => slot).ToArray() + }; + + return succeeded + ? MccMcpResult.Ok(resultData) + : MccMcpResult.Fail("action_incomplete", data: resultData); + } + private static MccMcpResult ExecuteInternalCommand(McClient client, string command) { return client.InvokeOnMainThread(() => @@ -1744,6 +2184,359 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities }); } + private static bool TryGetCursorItem(McClient client, out Item? cursorItem) + { + cursorItem = client.InvokeOnMainThread(() => + { + Container? playerInventory = client.GetInventory(0); + return playerInventory is not null && playerInventory.Items.TryGetValue(-1, out Item? item) ? item : null; + }); + return cursorItem is not null; + } + + private static int ResolveContainerInventoryId(McClient client, int inventoryId) + { + if (inventoryId > 0) + { + Container? inventory = client.GetInventory(inventoryId); + return inventory is not null && inventoryId != 0 ? inventoryId : 0; + } + + return GetActiveContainerId(client); + } + + private static int GetActiveContainerId(McClient client) + { + return client.GetInventories().Keys.Where(id => id > 0).DefaultIfEmpty(0).Max(); + } + + private static bool WaitForContainerOpen(McClient client, ISet beforeIds, int waitMs, out int inventoryId, out Container? inventory) + { + inventoryId = 0; + inventory = null; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + (int activeId, Container? activeInventory) state = client.InvokeOnMainThread(() => + { + int activeId = GetActiveContainerId(client); + Container? activeInventory = activeId > 0 ? client.GetInventory(activeId) : null; + return (activeId, activeInventory); + }); + + if (state.activeId > 0 && (!beforeIds.Contains(state.activeId) || beforeIds.Count == 0) && state.activeInventory is not null) + { + inventoryId = state.activeId; + inventory = state.activeInventory; + return true; + } + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static bool WaitForContainerClose(McClient client, int inventoryId, int waitMs) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + bool stillOpen = client.InvokeOnMainThread(() => client.GetInventories().ContainsKey(inventoryId)); + if (!stillOpen) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static int GetContainerWaitMs(int timeoutMs) + { + if (timeoutMs <= 0) + return DefaultContainerWaitMs; + return Math.Clamp(timeoutMs, MinContainerWaitMs, MaxContainerWaitMs); + } + + private static bool TryGetContainerSlotRanges(ContainerType type, out int containerStart, out int containerEnd, out int playerStart, out int playerEnd) + { + containerStart = 0; + containerEnd = -1; + playerStart = 0; + playerEnd = -1; + + int containerSlots = type switch + { + ContainerType.Generic_9x1 => 9, + ContainerType.Generic_9x2 => 18, + ContainerType.Generic_9x3 => 27, + ContainerType.Generic_9x4 => 36, + ContainerType.Generic_9x5 => 45, + ContainerType.Generic_9x6 => 54, + ContainerType.Generic_3x3 => 9, + ContainerType.Hopper => 5, + ContainerType.ShulkerBox => 27, + ContainerType.Furnace or ContainerType.BlastFurnace or ContainerType.Smoker => 3, + ContainerType.Crafter => 9, + _ => -1 + }; + + if (containerSlots <= 0) + return false; + + int slotCount = type.SlotCount(); + if (slotCount <= containerSlots) + return false; + + containerEnd = containerSlots - 1; + playerStart = containerSlots; + playerEnd = slotCount - 1; + return true; + } + + private static int CountItemInRange(Container inventory, ItemType itemType, int startSlot, int endSlot) + { + return inventory.Items + .Where(entry => entry.Key >= startSlot && entry.Key <= endSlot) + .Where(entry => entry.Value.Type == itemType) + .Sum(entry => entry.Value.Count); + } + + private static (int slot, int count)[] GetOrderedItemSlots(Container inventory, ItemType itemType, int startSlot, int endSlot, bool preferLargestStack) + { + var query = inventory.Items + .Where(entry => entry.Key >= startSlot && entry.Key <= endSlot) + .Where(entry => entry.Value.Type == itemType && entry.Value.Count > 0) + .Select(entry => (slot: entry.Key, count: entry.Value.Count)); + + return (preferLargestStack + ? query.OrderByDescending(entry => entry.count).ThenBy(entry => entry.slot) + : query.OrderBy(entry => entry.count).ThenBy(entry => entry.slot)) + .ToArray(); + } + + private static int TransferPartialFromSlot(McClient client, int inventoryId, int sourceSlot, ItemType itemType, int requestedCount, int sourceStart, int sourceEnd, int targetStart, int targetEnd, List touchedTargetSlots) + { + Container? inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId)); + if (inventory is null || !inventory.Items.TryGetValue(sourceSlot, out Item? sourceItem) || sourceItem.Count <= 0) + return 0; + + int amountToMove = Math.Min(requestedCount, sourceItem.Count); + if (!client.DoWindowAction(inventoryId, sourceSlot, WindowActionType.LeftClick)) + return 0; + + if (!WaitForCursorItem(client, itemType, DefaultInventoryActionWaitMs, out _)) + return 0; + + int moved = 0; + while (moved < amountToMove) + { + inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId)); + if (inventory is null || !TryGetCursorItem(client, out Item? cursorItem) || cursorItem is null || cursorItem.Type != itemType) + break; + + if (!TryFindTransferTargetSlot(inventory, itemType, targetStart, targetEnd, out int targetSlot, out int capacity)) + break; + + int step = Math.Min(amountToMove - moved, Math.Min(capacity, cursorItem.Count)); + int beforeTargetCount = GetSlotItemCount(inventory, targetSlot, itemType); + int beforeCursorCount = cursorItem.Count; + if (step <= 0 || !PlaceItemsFromCursor(client, inventoryId, targetSlot, step)) + break; + + if (!WaitForPlacement(client, inventoryId, targetSlot, itemType, beforeTargetCount, beforeCursorCount, step)) + break; + + touchedTargetSlots.Add(targetSlot); + moved += step; + } + + if (TryGetCursorItem(client, out Item? remainingCursor) && remainingCursor is not null && remainingCursor.Count > 0) + { + inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId)); + if (inventory is null) + return 0; + + int returnSlot = GetReturnSlot(inventory, itemType, sourceStart, sourceEnd, sourceSlot); + if (!client.DoWindowAction(inventoryId, returnSlot, WindowActionType.LeftClick)) + return 0; + + if (!WaitForCursorClear(client, DefaultInventoryActionWaitMs)) + return 0; + } + + return TryGetCursorItem(client, out _) + ? 0 + : moved; + } + + private static bool TryFindTransferTargetSlot(Container inventory, ItemType itemType, int startSlot, int endSlot, out int targetSlot, out int capacity) + { + int maxStack = itemType.StackCount(); + for (int slot = startSlot; slot <= endSlot; slot++) + { + if (inventory.Items.TryGetValue(slot, out Item? item) && item.Type == itemType && item.Count < maxStack) + { + targetSlot = slot; + capacity = maxStack - item.Count; + return true; + } + } + + for (int slot = startSlot; slot <= endSlot; slot++) + { + if (!inventory.Items.ContainsKey(slot)) + { + targetSlot = slot; + capacity = maxStack; + return true; + } + } + + targetSlot = -1; + capacity = 0; + return false; + } + + private static bool PlaceItemsFromCursor(McClient client, int inventoryId, int targetSlot, int count) + { + if (count <= 0 || !TryGetCursorItem(client, out Item? cursorItem) || cursorItem is null) + return false; + + if (count == cursorItem.Count) + return client.DoWindowAction(inventoryId, targetSlot, WindowActionType.LeftClick); + + for (int i = 0; i < count; i++) + { + if (!client.DoWindowAction(inventoryId, targetSlot, WindowActionType.RightClick)) + return false; + } + + return true; + } + + private static int GetReturnSlot(Container inventory, ItemType itemType, int startSlot, int endSlot, int originalSourceSlot) + { + if (originalSourceSlot != 0) + return originalSourceSlot; + + int maxStack = itemType.StackCount(); + for (int slot = startSlot; slot <= endSlot; slot++) + { + if (slot == 0) + continue; + + if (inventory.Items.TryGetValue(slot, out Item? item) && item.Type == itemType && item.Count < maxStack) + return slot; + } + + for (int slot = startSlot; slot <= endSlot; slot++) + { + if (slot == 0) + continue; + + if (!inventory.Items.ContainsKey(slot)) + return slot; + } + + return originalSourceSlot; + } + + private static int GetSlotItemCount(Container inventory, int slot, ItemType itemType) + { + return inventory.Items.TryGetValue(slot, out Item? item) && item.Type == itemType ? item.Count : 0; + } + + private static bool WaitForCursorItem(McClient client, ItemType itemType, int waitMs, out Item? cursorItem) + { + cursorItem = null; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + if (TryGetCursorItem(client, out cursorItem) && cursorItem is not null && cursorItem.Type == itemType) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static bool WaitForCursorClear(McClient client, int waitMs) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + if (!TryGetCursorItem(client, out _)) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static bool WaitForPlacement(McClient client, int inventoryId, int targetSlot, ItemType itemType, int beforeTargetCount, int beforeCursorCount, int placedCount) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(DefaultInventoryActionWaitMs); + while (true) + { + bool targetUpdated = false; + bool cursorUpdated = false; + + Container? inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId)); + if (inventory is not null) + { + int currentTargetCount = GetSlotItemCount(inventory, targetSlot, itemType); + targetUpdated = currentTargetCount >= beforeTargetCount + placedCount; + } + + if (placedCount >= beforeCursorCount) + { + cursorUpdated = !TryGetCursorItem(client, out _); + } + else if (TryGetCursorItem(client, out Item? cursorItem) && cursorItem is not null && cursorItem.Type == itemType) + { + cursorUpdated = cursorItem.Count <= beforeCursorCount - placedCount; + } + + if (targetUpdated && cursorUpdated) + return true; + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + + private static bool WaitForRangeCount(McClient client, int inventoryId, ItemType itemType, int startSlot, int endSlot, Func predicate, int waitMs, out Container? inventory, out int itemCount) + { + inventory = null; + itemCount = 0; + DateTime deadline = DateTime.UtcNow.AddMilliseconds(waitMs); + while (true) + { + inventory = client.InvokeOnMainThread(() => client.GetInventory(inventoryId)); + if (inventory is not null) + { + itemCount = CountItemInRange(inventory, itemType, startSlot, endSlot); + if (predicate(itemCount)) + return true; + } + + if (DateTime.UtcNow >= deadline) + return false; + + Thread.Sleep(ArrivalPollIntervalMs); + } + } + private static List BuildTrackedPlayerSnapshots(McClient client, bool includeSelf) { Location playerLocation = client.GetCurrentLocation(); @@ -2140,6 +2933,21 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities return material.ToString().Contains("Sign", StringComparison.Ordinal); } + private static bool IsInteractableContainerMaterial(Material material) + { + string name = material.ToString(); + return name.Contains("Chest", StringComparison.Ordinal) + || name.Contains("Barrel", StringComparison.Ordinal) + || name.Contains("ShulkerBox", StringComparison.Ordinal) + || name.Contains("Hopper", StringComparison.Ordinal) + || name.Contains("Dispenser", StringComparison.Ordinal) + || name.Contains("Dropper", StringComparison.Ordinal) + || name.Contains("Furnace", StringComparison.Ordinal) + || name.Contains("Smoker", StringComparison.Ordinal) + || name.Contains("BlastFurnace", StringComparison.Ordinal) + || name.Contains("Crafter", StringComparison.Ordinal); + } + private static string? ResolvePlayerEntityName(Entity entity, IReadOnlyDictionary uuidToName) { if (!string.IsNullOrWhiteSpace(entity.Name)) diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs index d0d0307b..38c01973 100644 --- a/MinecraftClient/Mcp/MccMcpToolSet.cs +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -175,6 +175,24 @@ public sealed class MccMcpToolSet return capabilities.GetInventorySnapshot(inventoryId); } + [McpServerTool(Name = "mcc_inventories_list"), Description("List currently open inventories and containers known to MCC.")] + public object InventoriesList() + { + return capabilities.ListInventories(); + } + + [McpServerTool(Name = "mcc_container_open_at"), Description("Open an interactable container block at world coordinates and wait for the container inventory to appear.")] + public object ContainerOpenAt(int x, int y, int z, int timeoutMs = 0, bool closeCurrent = true) + { + return capabilities.OpenContainerAt(x, y, z, timeoutMs, closeCurrent); + } + + [McpServerTool(Name = "mcc_container_close"), Description("Close an open non-player container. Use inventoryId=-1 to close the active container.")] + public object ContainerClose([Description("Container inventory ID, or -1 for the active non-player container.")] int inventoryId = -1, int timeoutMs = 0) + { + return capabilities.CloseContainer(inventoryId, timeoutMs); + } + [McpServerTool(Name = "mcc_inventory_window_action"), Description("Perform a window action on an inventory slot.")] public object InventoryWindowAction(int inventoryId, int slotId, [Description("WindowActionType enum name, e.g. LeftClick or ShiftClick.")] string actionType) { @@ -191,6 +209,26 @@ public sealed class MccMcpToolSet return capabilities.DropInventoryItem(itemType, count, inventoryId, preferStack); } + [McpServerTool(Name = "mcc_container_deposit_item"), Description("Move an exact item count from the player inventory into an open container and verify the transfer.")] + public object ContainerDepositItem( + [Description("Item type enum name (e.g. Diamond).")] string itemType, + [Description("Exact number of items to move into the container.")] int count, + [Description("Container inventory ID, or -1 for the active non-player container.")] int inventoryId = -1, + [Description("Prefer larger source stacks first when true.")] bool preferLargestStack = true) + { + return capabilities.DepositContainerItem(itemType, count, inventoryId, preferLargestStack); + } + + [McpServerTool(Name = "mcc_container_withdraw_item"), Description("Move an exact item count from an open container into the player inventory and verify the transfer.")] + public object ContainerWithdrawItem( + [Description("Item type enum name (e.g. Diamond).")] string itemType, + [Description("Exact number of items to move into the player inventory.")] int count, + [Description("Container inventory ID, or -1 for the active non-player container.")] int inventoryId = -1, + [Description("Prefer larger source stacks first when true.")] bool preferLargestStack = true) + { + return capabilities.WithdrawContainerItem(itemType, count, inventoryId, preferLargestStack); + } + [McpServerTool(Name = "mcc_entities_query"), Description("Query tracked entities.")] public object EntitiesQuery([Description("Maximum entities to return.")] int maxCount = 50) { From 7b415d5388511c1f556134d78f5daadc22a5a39d Mon Sep 17 00:00:00 2001 From: Anon Date: Sun, 29 Mar 2026 20:57:16 +0200 Subject: [PATCH 04/13] Added a Skill for MCP --- .skills/mcc-mcp-operator/SKILL.md | 94 +++++++ DebugTools/MccMcpStdioHarness/Program.cs | 5 +- MinecraftClient/Mcp/MccEmbeddedMcpHost.cs | 4 +- MinecraftClient/Mcp/MccMcpGuidanceProvider.cs | 236 ++++++++++++++++++ MinecraftClient/Mcp/MccMcpPromptSet.cs | 20 ++ MinecraftClient/Mcp/MccMcpToolSet.cs | 10 +- MinecraftClient/MinecraftClient.csproj | 1 + 7 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 .skills/mcc-mcp-operator/SKILL.md create mode 100644 MinecraftClient/Mcp/MccMcpGuidanceProvider.cs create mode 100644 MinecraftClient/Mcp/MccMcpPromptSet.cs diff --git a/.skills/mcc-mcp-operator/SKILL.md b/.skills/mcc-mcp-operator/SKILL.md new file mode 100644 index 00000000..2e2e734a --- /dev/null +++ b/.skills/mcc-mcp-operator/SKILL.md @@ -0,0 +1,94 @@ +--- +name: mcc-mcp-operator +description: Operate Minecraft Console Client through the built-in MCP server. Use this whenever the user wants an agent to inspect MCC state, move, search the world, interact with players or entities, dig, pick up items, manage containers, or carry out Minecraft tasks through MCP tools, even if they do not explicitly say "use MCP" or "control MCC". Prefer this skill over ad hoc tool guessing for agentic MCC and Minecraft control work. +--- + +# MCC MCP Operator + +Use the MCC MCP toolset as the source of truth for game state and action results. +Do not guess what happened from intent alone. + +## Operating Loop + +1. Inspect the current situation before acting. +2. Make the shortest plan that can succeed. +3. Use the smallest set of high-signal tools needed to act. +4. Verify the outcome with fresh tool calls. +5. Report only what is verified, and clearly label anything inferred or still unknown. + +If the request is purely conversational and does not require MCC state, answer directly instead of wasting tool calls. + +## Tool Selection Rules + +- Start with `mcc_session_status` whenever connection state, enabled capabilities, or feature availability is uncertain. +- Prefer direct inspection tools such as `mcc_player_state`, `mcc_players_list`, `mcc_entities_list`, `mcc_blocks_find`, `mcc_items_list`, and `mcc_inventory_snapshot` before taking physical actions. +- Prefer purpose-built action tools over low-level escape hatches. +- Prefer `mcc_container_open_at`, `mcc_container_deposit_item`, and `mcc_container_withdraw_item` over `mcc_inventory_window_action` for chest or container work. +- Use `mcc_can_reach_position` or a locating tool before pathing when reachability is uncertain. +- Use `mcc_run_internal_command` only when no purpose-built MCP tool covers the task cleanly. +- Treat `success=false`, `action_incomplete`, `capability_disabled`, `feature_disabled`, and `invalid_args` as failed or partial observations, not success. +- After `invalid_args`, simplify the call and try at most one nearby variant. Do not spam near-duplicate guesses. + +## Verification Rules + +- Movement is not complete just because a move request was accepted. Confirm `arrived=true` or verify the new location with a fresh state read. +- Digging is not complete just because `mcc_dig_block` was invoked. Re-check the target block or nearby block search results. +- Item pickup is not complete just because the bot moved over an item. Re-check inventory state or nearby dropped-item entities. +- Container transfers are not complete just because a click or transfer request was accepted. Verify the resulting counts after the transfer. +- Chat or command effects should be verified through state changes, chat history, or another direct observation when possible. +- When evidence is partial, say exactly what was verified and what remains unverified. + +## Best Practices + +- Query first, act second, verify third. +- Keep plans short and concrete. Long speculative tool chains usually make the result worse. +- Prefer high-signal tools that answer the real question directly. +- Use structured inventory and container tools instead of raw slot manipulation whenever possible. +- Do not claim success from acceptance alone. Always pair actions with a follow-up observation. +- Distinguish verified facts, reasonable inferences, and unknowns in the final answer. +- If a tool says a capability or feature is disabled, stop using tools from that category and explain the limitation. +- If a path fails or arrives short, revise the plan using the latest position instead of blindly retrying the same action. +- Use `mcc_quit_client` to stop MCC. Do not send bare `quit` or `exit` through chat. +- Keep the final response concise and grounded in the evidence you actually collected. + +## Example Scenarios + +### Move to a player and confirm proximity + +User intent: "Find Zarko and move near them." + +Good flow: +- call `mcc_player_locate` or `mcc_players_list` to confirm the player is known +- if needed, call `mcc_can_reach_position` for the target area +- call `mcc_move_to_player` +- verify `arrived=true` or confirm the new position with `mcc_player_state` +- report whether proximity was verified or only partially achieved + +### Open a chest, move an exact item count, and verify the result + +User intent: "Put 5 diamonds in the chest at 11000 64 11021." + +Good flow: +- call `mcc_container_open_at` +- inspect current state with `mcc_inventory_snapshot` if item availability is unclear +- call `mcc_container_deposit_item` or `mcc_container_withdraw_item` +- verify the resulting counts from the transfer result and, when useful, a fresh inventory snapshot +- report the exact verified delta, not just that the action was attempted + +### Collect nearby dropped items or dig target blocks and verify the outcome + +User intent: "Pick up nearby apples" or "Break those logs and collect them." + +Good flow: +- call `mcc_items_list` or `mcc_blocks_find` to locate the target +- move only if the target is not already reachable from the current position +- call `mcc_items_pickup` for dropped items, or `mcc_dig_block` in a sensible order for blocks +- verify the result with `mcc_items_list`, `mcc_inventory_snapshot`, or a fresh block query +- if the result is partial, say what changed and what still remains + +## Output Style + +- Lead with the outcome the user cares about. +- Include the small set of observations that justify the answer. +- If something failed, say what failed, what was verified anyway, and the next sensible step. +- Do not embellish uncertain results. diff --git a/DebugTools/MccMcpStdioHarness/Program.cs b/DebugTools/MccMcpStdioHarness/Program.cs index 69f2488a..441f7dac 100644 --- a/DebugTools/MccMcpStdioHarness/Program.cs +++ b/DebugTools/MccMcpStdioHarness/Program.cs @@ -10,10 +10,13 @@ builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace; }); +builder.Services.AddSingleton(new MccMcpConfig()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithStdioServerTransport() - .WithTools(); + .WithTools() + .WithPrompts(); await builder.Build().RunAsync(); diff --git a/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs b/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs index cf481d97..a40b7183 100644 --- a/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs +++ b/MinecraftClient/Mcp/MccEmbeddedMcpHost.cs @@ -66,9 +66,11 @@ public sealed class MccEmbeddedMcpHost builder.Logging.AddFilter(_ => false); builder.Services.AddSingleton(capabilities); builder.Services.AddSingleton(config); + builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithHttpTransport() - .WithTools(); + .WithTools() + .WithPrompts(); builder.WebHost.UseUrls($"http://{bindHost}:{config.Transport.Port}"); WebApplication builtApp = builder.Build(); diff --git a/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs b/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs new file mode 100644 index 00000000..285a6d31 --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json.Serialization; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpGuidanceProvider +{ + private const string EmbeddedSkillResourceSuffix = "MccMcpOperatorSkill.md"; + private const string BestPracticesHeading = "## Best Practices"; + private const string ExampleScenariosHeading = "## Example Scenarios"; + + private readonly MccMcpConfig config; + private readonly Lazy guidanceDocument; + + public MccMcpGuidanceProvider(MccMcpConfig config) + { + this.config = config; + guidanceDocument = new Lazy(LoadGuidanceDocument); + } + + public string SkillName => "mcc-mcp-operator"; + + public string GetSystemPrompt() + { + GuidanceDocument document = guidanceDocument.Value; + MccMcpAgentCapabilityStatus capabilityStatus = BuildCapabilityStatus(); + StringBuilder builder = new(); + builder.AppendLine("You are an external agent controlling Minecraft Console Client (MCC) through its built-in MCP server."); + builder.AppendLine("Use the following operator guide as your system prompt. Treat the capability snapshot as authoritative and do not invent unsupported actions."); + builder.AppendLine(); + builder.AppendLine(document.BodyMarkdown); + builder.AppendLine(); + builder.AppendLine("Current capability snapshot"); + builder.AppendLine($"- sessionStatus: {FormatCapability(capabilityStatus.SessionStatus)}"); + builder.AppendLine($"- chatAndCommands: {FormatCapability(capabilityStatus.ChatAndCommands)}"); + builder.AppendLine($"- movement: {FormatCapability(capabilityStatus.Movement)}"); + builder.AppendLine($"- inventory: {FormatCapability(capabilityStatus.Inventory)}"); + builder.AppendLine($"- entityWorld: {FormatCapability(capabilityStatus.EntityWorld)}"); + return builder.ToString().Trim(); + } + + public MccMcpAgentGuidancePayload GetToolPayload() + { + GuidanceDocument document = guidanceDocument.Value; + return new MccMcpAgentGuidancePayload + { + SkillName = SkillName, + SkillMarkdown = document.SkillMarkdown, + SystemPrompt = GetSystemPrompt(), + BestPractices = document.BestPractices, + ExampleScenarios = document.ExampleScenarios, + CapabilityStatus = BuildCapabilityStatus() + }; + } + + private GuidanceDocument LoadGuidanceDocument() + { + Assembly assembly = typeof(MccMcpGuidanceProvider).Assembly; + string resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(name => name.EndsWith(EmbeddedSkillResourceSuffix, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Embedded MCP skill resource '{EmbeddedSkillResourceSuffix}' was not found."); + + using Stream? stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + throw new InvalidOperationException($"Embedded MCP skill resource '{resourceName}' could not be opened."); + + using StreamReader reader = new(stream, Encoding.UTF8); + string skillMarkdown = reader.ReadToEnd(); + string bodyMarkdown = StripFrontmatter(skillMarkdown); + string bestPracticesSection = ExtractSection(bodyMarkdown, BestPracticesHeading); + string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading); + + return new GuidanceDocument( + skillMarkdown.Replace("\r\n", "\n").Trim(), + bodyMarkdown, + ExtractBulletList(bestPracticesSection), + ExtractExampleScenarios(exampleScenariosSection)); + } + + private MccMcpAgentCapabilityStatus BuildCapabilityStatus() + { + return new MccMcpAgentCapabilityStatus + { + SessionStatus = config.Capabilities.SessionStatus, + ChatAndCommands = config.Capabilities.ChatAndCommands, + Movement = config.Capabilities.Movement, + Inventory = config.Capabilities.Inventory, + EntityWorld = config.Capabilities.EntityWorld + }; + } + + private static string StripFrontmatter(string markdown) + { + string normalized = markdown.Replace("\r\n", "\n"); + if (!normalized.StartsWith("---\n", StringComparison.Ordinal)) + return normalized.Trim(); + + int endOfFrontmatter = normalized.IndexOf("\n---\n", 4, StringComparison.Ordinal); + if (endOfFrontmatter < 0) + return normalized.Trim(); + + return normalized[(endOfFrontmatter + 5)..].Trim(); + } + + private static string ExtractSection(string markdownBody, string heading) + { + int headingIndex = markdownBody.IndexOf(heading, StringComparison.Ordinal); + if (headingIndex < 0) + return string.Empty; + + int sectionStart = headingIndex + heading.Length; + int nextHeading = markdownBody.IndexOf("\n## ", sectionStart, StringComparison.Ordinal); + string section = nextHeading >= 0 + ? markdownBody[sectionStart..nextHeading] + : markdownBody[sectionStart..]; + + return section.Trim(); + } + + private static string[] ExtractBulletList(string section) + { + return section + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(line => line.StartsWith("- ", StringComparison.Ordinal)) + .Select(line => line[2..].Trim()) + .Where(line => line.Length > 0) + .ToArray(); + } + + private static MccMcpAgentScenario[] ExtractExampleScenarios(string section) + { + if (string.IsNullOrWhiteSpace(section)) + return []; + + List scenarios = []; + string? currentTitle = null; + List currentBodyLines = []; + + foreach (string rawLine in section.Split('\n')) + { + string line = rawLine.TrimEnd(); + if (line.StartsWith("### ", StringComparison.Ordinal)) + { + AddScenario(scenarios, currentTitle, currentBodyLines); + currentTitle = line[4..].Trim(); + currentBodyLines = []; + continue; + } + + if (currentTitle is not null) + currentBodyLines.Add(line); + } + + AddScenario(scenarios, currentTitle, currentBodyLines); + return scenarios.ToArray(); + } + + private static void AddScenario(List scenarios, string? title, List bodyLines) + { + if (string.IsNullOrWhiteSpace(title)) + return; + + string guidance = string.Join('\n', bodyLines) + .Trim(); + + scenarios.Add(new MccMcpAgentScenario + { + Title = title, + Guidance = guidance + }); + } + + private static string FormatCapability(bool enabled) + { + return enabled ? "enabled" : "disabled"; + } + + private sealed record GuidanceDocument( + string SkillMarkdown, + string BodyMarkdown, + string[] BestPractices, + MccMcpAgentScenario[] ExampleScenarios); +} + +public sealed class MccMcpAgentGuidancePayload +{ + [JsonPropertyName("skillName")] + public string SkillName { get; init; } = string.Empty; + + [JsonPropertyName("skillMarkdown")] + public string SkillMarkdown { get; init; } = string.Empty; + + [JsonPropertyName("systemPrompt")] + public string SystemPrompt { get; init; } = string.Empty; + + [JsonPropertyName("bestPractices")] + public string[] BestPractices { get; init; } = []; + + [JsonPropertyName("exampleScenarios")] + public MccMcpAgentScenario[] ExampleScenarios { get; init; } = []; + + [JsonPropertyName("capabilityStatus")] + public MccMcpAgentCapabilityStatus CapabilityStatus { get; init; } = new(); +} + +public sealed class MccMcpAgentScenario +{ + [JsonPropertyName("title")] + public string Title { get; init; } = string.Empty; + + [JsonPropertyName("guidance")] + public string Guidance { get; init; } = string.Empty; +} + +public sealed class MccMcpAgentCapabilityStatus +{ + [JsonPropertyName("sessionStatus")] + public bool SessionStatus { get; init; } + + [JsonPropertyName("chatAndCommands")] + public bool ChatAndCommands { get; init; } + + [JsonPropertyName("movement")] + public bool Movement { get; init; } + + [JsonPropertyName("inventory")] + public bool Inventory { get; init; } + + [JsonPropertyName("entityWorld")] + public bool EntityWorld { get; init; } +} diff --git a/MinecraftClient/Mcp/MccMcpPromptSet.cs b/MinecraftClient/Mcp/MccMcpPromptSet.cs new file mode 100644 index 00000000..564e6f7f --- /dev/null +++ b/MinecraftClient/Mcp/MccMcpPromptSet.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace MinecraftClient.Mcp; + +public sealed class MccMcpPromptSet +{ + private readonly MccMcpGuidanceProvider guidanceProvider; + + public MccMcpPromptSet(MccMcpGuidanceProvider guidanceProvider) + { + this.guidanceProvider = guidanceProvider; + } + + [McpServerPrompt(Name = "mcc_operator_guide"), Description("Get the canonical MCC operator guidance prompt for external agents using this MCP server.")] + public string OperatorGuide() + { + return guidanceProvider.GetSystemPrompt(); + } +} diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs index 38c01973..64dc2789 100644 --- a/MinecraftClient/Mcp/MccMcpToolSet.cs +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -7,10 +7,12 @@ namespace MinecraftClient.Mcp; public sealed class MccMcpToolSet { private readonly IMccMcpCapabilities capabilities; + private readonly MccMcpGuidanceProvider guidanceProvider; - public MccMcpToolSet(IMccMcpCapabilities capabilities) + public MccMcpToolSet(IMccMcpCapabilities capabilities, MccMcpGuidanceProvider guidanceProvider) { this.capabilities = capabilities; + this.guidanceProvider = guidanceProvider; } [McpServerTool(Name = "mcc_session_status"), Description("Get current MCC session and feature status.")] @@ -49,6 +51,12 @@ public sealed class MccMcpToolSet return capabilities.GetInternalCommands(); } + [McpServerTool(Name = "mcc_agent_guidance"), Description("Get the canonical MCC operator guidance bundle for external agents using this MCP server.")] + public object AgentGuidance() + { + return guidanceProvider.GetToolPayload(); + } + [McpServerTool(Name = "mcc_materials_list"), Description("List known MCC material names with optional filtering.")] public object MaterialsList(string? filter = null, int maxCount = 500) { diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index dd843035..68b31912 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -20,6 +20,7 @@ + From 968800b95a5dedbff31168da8c049931bf3510ef Mon Sep 17 00:00:00 2001 From: Anon Date: Mon, 30 Mar 2026 23:14:28 +0200 Subject: [PATCH 05/13] Added a bunch of new useful MCP Tools --- DebugTools/MccMcpSampleClient/Program.cs | 828 +++++++++++++--- DebugTools/MccMcpStdioHarness/Program.cs | 467 +++++++++- MinecraftClient/ChatBots/McpServer.cs | 133 ++- MinecraftClient/Mcp/IMccMcpCapabilities.cs | 20 + MinecraftClient/Mcp/MccMcpCapabilities.cs | 882 ++++++++++++++++++ MinecraftClient/Mcp/MccMcpRecentEventStore.cs | 75 ++ .../Mcp/MccMcpRuntimeStateStore.cs | 70 ++ MinecraftClient/Mcp/MccMcpToolSet.cs | 120 +++ MinecraftClient/Program.cs | 13 +- 9 files changed, 2453 insertions(+), 155 deletions(-) create mode 100644 MinecraftClient/Mcp/MccMcpRecentEventStore.cs create mode 100644 MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs 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 From c3c57c058a16846d737063bbe7de35405b224589 Mon Sep 17 00:00:00 2001 From: Anon Date: Mon, 30 Mar 2026 23:21:36 +0200 Subject: [PATCH 06/13] Moved the operator prompt from a skill to an embedded resourcce --- MinecraftClient/Mcp/MccMcpGuidanceProvider.cs | 32 +++++++++--------- MinecraftClient/Mcp/MccMcpPromptSet.cs | 4 +-- MinecraftClient/Mcp/MccMcpToolSet.cs | 2 +- .../Mcp/Prompts/MccMcpOperatorPrompt.md | 33 +++++++++++-------- MinecraftClient/MinecraftClient.csproj | 2 +- 5 files changed, 40 insertions(+), 33 deletions(-) rename .skills/mcc-mcp-operator/SKILL.md => MinecraftClient/Mcp/Prompts/MccMcpOperatorPrompt.md (62%) diff --git a/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs b/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs index 285a6d31..232c1bd1 100644 --- a/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs +++ b/MinecraftClient/Mcp/MccMcpGuidanceProvider.cs @@ -10,7 +10,7 @@ namespace MinecraftClient.Mcp; public sealed class MccMcpGuidanceProvider { - private const string EmbeddedSkillResourceSuffix = "MccMcpOperatorSkill.md"; + private const string EmbeddedPromptResourceSuffix = "MccMcpOperatorPrompt.md"; private const string BestPracticesHeading = "## Best Practices"; private const string ExampleScenariosHeading = "## Example Scenarios"; @@ -23,7 +23,7 @@ public sealed class MccMcpGuidanceProvider guidanceDocument = new Lazy(LoadGuidanceDocument); } - public string SkillName => "mcc-mcp-operator"; + public string PromptName => "mcc_operator_prompt"; public string GetSystemPrompt() { @@ -31,7 +31,7 @@ public sealed class MccMcpGuidanceProvider MccMcpAgentCapabilityStatus capabilityStatus = BuildCapabilityStatus(); StringBuilder builder = new(); builder.AppendLine("You are an external agent controlling Minecraft Console Client (MCC) through its built-in MCP server."); - builder.AppendLine("Use the following operator guide as your system prompt. Treat the capability snapshot as authoritative and do not invent unsupported actions."); + builder.AppendLine("Use the following MCP Operator Prompt as your system prompt. Treat the capability snapshot as authoritative and do not invent unsupported actions."); builder.AppendLine(); builder.AppendLine(document.BodyMarkdown); builder.AppendLine(); @@ -49,8 +49,8 @@ public sealed class MccMcpGuidanceProvider GuidanceDocument document = guidanceDocument.Value; return new MccMcpAgentGuidancePayload { - SkillName = SkillName, - SkillMarkdown = document.SkillMarkdown, + PromptName = PromptName, + PromptMarkdown = document.PromptMarkdown, SystemPrompt = GetSystemPrompt(), BestPractices = document.BestPractices, ExampleScenarios = document.ExampleScenarios, @@ -62,21 +62,21 @@ public sealed class MccMcpGuidanceProvider { Assembly assembly = typeof(MccMcpGuidanceProvider).Assembly; string resourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(name => name.EndsWith(EmbeddedSkillResourceSuffix, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"Embedded MCP skill resource '{EmbeddedSkillResourceSuffix}' was not found."); + .FirstOrDefault(name => name.EndsWith(EmbeddedPromptResourceSuffix, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Embedded MCP operator prompt resource '{EmbeddedPromptResourceSuffix}' was not found."); using Stream? stream = assembly.GetManifestResourceStream(resourceName); if (stream is null) - throw new InvalidOperationException($"Embedded MCP skill resource '{resourceName}' could not be opened."); + throw new InvalidOperationException($"Embedded MCP operator prompt resource '{resourceName}' could not be opened."); using StreamReader reader = new(stream, Encoding.UTF8); - string skillMarkdown = reader.ReadToEnd(); - string bodyMarkdown = StripFrontmatter(skillMarkdown); + string promptMarkdown = reader.ReadToEnd(); + string bodyMarkdown = StripFrontmatter(promptMarkdown); string bestPracticesSection = ExtractSection(bodyMarkdown, BestPracticesHeading); string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading); return new GuidanceDocument( - skillMarkdown.Replace("\r\n", "\n").Trim(), + promptMarkdown.Replace("\r\n", "\n").Trim(), bodyMarkdown, ExtractBulletList(bestPracticesSection), ExtractExampleScenarios(exampleScenariosSection)); @@ -181,7 +181,7 @@ public sealed class MccMcpGuidanceProvider } private sealed record GuidanceDocument( - string SkillMarkdown, + string PromptMarkdown, string BodyMarkdown, string[] BestPractices, MccMcpAgentScenario[] ExampleScenarios); @@ -189,11 +189,11 @@ public sealed class MccMcpGuidanceProvider public sealed class MccMcpAgentGuidancePayload { - [JsonPropertyName("skillName")] - public string SkillName { get; init; } = string.Empty; + [JsonPropertyName("promptName")] + public string PromptName { get; init; } = string.Empty; - [JsonPropertyName("skillMarkdown")] - public string SkillMarkdown { get; init; } = string.Empty; + [JsonPropertyName("promptMarkdown")] + public string PromptMarkdown { get; init; } = string.Empty; [JsonPropertyName("systemPrompt")] public string SystemPrompt { get; init; } = string.Empty; diff --git a/MinecraftClient/Mcp/MccMcpPromptSet.cs b/MinecraftClient/Mcp/MccMcpPromptSet.cs index 564e6f7f..970528a6 100644 --- a/MinecraftClient/Mcp/MccMcpPromptSet.cs +++ b/MinecraftClient/Mcp/MccMcpPromptSet.cs @@ -12,8 +12,8 @@ public sealed class MccMcpPromptSet this.guidanceProvider = guidanceProvider; } - [McpServerPrompt(Name = "mcc_operator_guide"), Description("Get the canonical MCC operator guidance prompt for external agents using this MCP server.")] - public string OperatorGuide() + [McpServerPrompt(Name = "mcc_operator_prompt"), Description("Get the canonical MCC MCP Operator Prompt for external agents using this MCP server.")] + public string OperatorPrompt() { return guidanceProvider.GetSystemPrompt(); } diff --git a/MinecraftClient/Mcp/MccMcpToolSet.cs b/MinecraftClient/Mcp/MccMcpToolSet.cs index 8da6b413..84305e34 100644 --- a/MinecraftClient/Mcp/MccMcpToolSet.cs +++ b/MinecraftClient/Mcp/MccMcpToolSet.cs @@ -105,7 +105,7 @@ public sealed class MccMcpToolSet return capabilities.GetInternalCommands(); } - [McpServerTool(Name = "mcc_agent_guidance"), Description("Get the canonical MCC operator guidance bundle for external agents using this MCP server.")] + [McpServerTool(Name = "mcc_agent_guidance"), Description("Get the canonical MCC MCP Operator Prompt bundle for external agents using this MCP server.")] public object AgentGuidance() { return guidanceProvider.GetToolPayload(); diff --git a/.skills/mcc-mcp-operator/SKILL.md b/MinecraftClient/Mcp/Prompts/MccMcpOperatorPrompt.md similarity index 62% rename from .skills/mcc-mcp-operator/SKILL.md rename to MinecraftClient/Mcp/Prompts/MccMcpOperatorPrompt.md index 2e2e734a..b2dabd79 100644 --- a/.skills/mcc-mcp-operator/SKILL.md +++ b/MinecraftClient/Mcp/Prompts/MccMcpOperatorPrompt.md @@ -1,9 +1,4 @@ ---- -name: mcc-mcp-operator -description: Operate Minecraft Console Client through the built-in MCP server. Use this whenever the user wants an agent to inspect MCC state, move, search the world, interact with players or entities, dig, pick up items, manage containers, or carry out Minecraft tasks through MCP tools, even if they do not explicitly say "use MCP" or "control MCC". Prefer this skill over ad hoc tool guessing for agentic MCC and Minecraft control work. ---- - -# MCC MCP Operator +# MCC MCP Operator Prompt Use the MCC MCP toolset as the source of truth for game state and action results. Do not guess what happened from intent alone. @@ -21,20 +16,31 @@ If the request is purely conversational and does not require MCC state, answer d ## Tool Selection Rules - Start with `mcc_session_status` whenever connection state, enabled capabilities, or feature availability is uncertain. -- Prefer direct inspection tools such as `mcc_player_state`, `mcc_players_list`, `mcc_entities_list`, `mcc_blocks_find`, `mcc_items_list`, and `mcc_inventory_snapshot` before taking physical actions. +- Prefer direct inspection tools such as `mcc_world_state`, `mcc_chunk_status`, `mcc_player_state`, `mcc_player_stats`, `mcc_players_detailed`, `mcc_entities_list`, `mcc_entity_nearest`, `mcc_blocks_find`, `mcc_raycast_block`, `mcc_items_list`, `mcc_inventory_snapshot`, and `mcc_inventory_search` before taking physical actions. - Prefer purpose-built action tools over low-level escape hatches. - Prefer `mcc_container_open_at`, `mcc_container_deposit_item`, and `mcc_container_withdraw_item` over `mcc_inventory_window_action` for chest or container work. -- Use `mcc_can_reach_position` or a locating tool before pathing when reachability is uncertain. +- Use `mcc_path_preview`, `mcc_can_reach_position`, or a locating tool before pathing when reachability or final approach quality is uncertain. +- Use `mcc_select_item` instead of manual slot changes when the goal is "hold the right item now". +- Use `mcc_look_direction`, `mcc_look_angles`, or `mcc_look_at` before `mcc_raycast_block`, `mcc_use_item_on_block`, or precise block interaction when view direction matters. +- Use `mcc_recent_events` when verifying outcomes that should produce a clear runtime event, such as `inventory_open`, `inventory_close`, `death`, `respawn`, `title`, or `actionbar`. +- Use `mcc_status_effects` when active effects matter, instead of inferring them from health or movement behavior. +- Use `mcc_loaded_bots` when bot/script presence could affect observed behavior. - Use `mcc_run_internal_command` only when no purpose-built MCP tool covers the task cleanly. - Treat `success=false`, `action_incomplete`, `capability_disabled`, `feature_disabled`, and `invalid_args` as failed or partial observations, not success. - After `invalid_args`, simplify the call and try at most one nearby variant. Do not spam near-duplicate guesses. ## Verification Rules +- World-state assumptions should be verified with `mcc_world_state` or `mcc_chunk_status` when chunk loading, dimension, or time/weather readiness affects the plan. - Movement is not complete just because a move request was accepted. Confirm `arrived=true` or verify the new location with a fresh state read. +- A path preview is not proof of arrival. Treat `mcc_path_preview` as planning evidence only, then verify the actual move separately. - Digging is not complete just because `mcc_dig_block` was invoked. Re-check the target block or nearby block search results. +- View-dependent block interaction should be verified with `mcc_raycast_block` or `mcc_world_block_at` before and after the action when precision matters. - Item pickup is not complete just because the bot moved over an item. Re-check inventory state or nearby dropped-item entities. +- Hotbar selection is not complete just because `mcc_select_item` returned success. Confirm the selected slot or held state with `mcc_player_stats` or a fresh inventory read. - Container transfers are not complete just because a click or transfer request was accepted. Verify the resulting counts after the transfer. +- Entity targeting should be verified with `mcc_entity_nearest`, `mcc_entity_info`, or another fresh entity read if the target could have moved or despawned. +- Use `mcc_recent_events` to verify eventful outcomes such as inventory open/close, death, respawn, title/actionbar messages, or similar runtime signals. - Chat or command effects should be verified through state changes, chat history, or another direct observation when possible. - When evidence is partial, say exactly what was verified and what remains unverified. @@ -43,6 +49,7 @@ If the request is purely conversational and does not require MCC state, answer d - Query first, act second, verify third. - Keep plans short and concrete. Long speculative tool chains usually make the result worse. - Prefer high-signal tools that answer the real question directly. +- Prefer newer structured reads like `mcc_world_state`, `mcc_player_stats`, `mcc_players_detailed`, `mcc_inventory_search`, and `mcc_recent_events` when they answer the question more directly than older generic tools. - Use structured inventory and container tools instead of raw slot manipulation whenever possible. - Do not claim success from acceptance alone. Always pair actions with a follow-up observation. - Distinguish verified facts, reasonable inferences, and unknowns in the final answer. @@ -59,9 +66,9 @@ User intent: "Find Zarko and move near them." Good flow: - call `mcc_player_locate` or `mcc_players_list` to confirm the player is known -- if needed, call `mcc_can_reach_position` for the target area +- if needed, call `mcc_players_detailed` for exact coordinates and `mcc_path_preview` or `mcc_can_reach_position` for the target area - call `mcc_move_to_player` -- verify `arrived=true` or confirm the new position with `mcc_player_state` +- verify `arrived=true` or confirm the new position with `mcc_player_stats` - report whether proximity was verified or only partially achieved ### Open a chest, move an exact item count, and verify the result @@ -70,9 +77,9 @@ User intent: "Put 5 diamonds in the chest at 11000 64 11021." Good flow: - call `mcc_container_open_at` -- inspect current state with `mcc_inventory_snapshot` if item availability is unclear +- inspect current state with `mcc_inventory_search` or `mcc_inventory_snapshot` if item availability is unclear - call `mcc_container_deposit_item` or `mcc_container_withdraw_item` -- verify the resulting counts from the transfer result and, when useful, a fresh inventory snapshot +- verify the resulting counts from the transfer result and, when useful, a fresh inventory snapshot or `mcc_recent_events` - report the exact verified delta, not just that the action was attempted ### Collect nearby dropped items or dig target blocks and verify the outcome @@ -80,7 +87,7 @@ Good flow: User intent: "Pick up nearby apples" or "Break those logs and collect them." Good flow: -- call `mcc_items_list` or `mcc_blocks_find` to locate the target +- call `mcc_items_list`, `mcc_blocks_find`, or `mcc_raycast_block` to locate the target - move only if the target is not already reachable from the current position - call `mcc_items_pickup` for dropped items, or `mcc_dig_block` in a sensible order for blocks - verify the result with `mcc_items_list`, `mcc_inventory_snapshot`, or a fresh block query diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index 68b31912..4fdf6c94 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -20,7 +20,7 @@ - + From ee6eb84bd881b7fdfd3aaaa09fa2b68753f5c773 Mon Sep 17 00:00:00 2001 From: milutinke Date: Wed, 1 Apr 2026 12:49:07 +0200 Subject: [PATCH 07/13] Improved the Web Based Harness --- .../Api/MccPlaygroundEndpoints.cs | 36 + .../Contracts/MccContracts.cs | 94 ++ .../Harness/MccAgentRunService.cs | 852 ++++++++++++ .../Harness/MccContextCompressor.cs | 21 + .../Harness/MccFinalizer.cs | 221 ++++ .../Harness/MccGuidanceSource.cs | 63 + .../Harness/MccPromptComposer.cs | 86 ++ .../Harness/MccRunState.cs | 103 ++ .../Harness/MccToolPolicy.cs | 133 ++ .../Harness/MccWebHarnessOptions.cs | 58 + .../Mcp/MccMcpSessionFactory.cs | 200 +++ .../OpenRouter/OpenRouterChatClient.cs | 120 ++ DebugTools/MccMcpWebPlayground/Program.cs | 1149 +---------------- .../appsettings.Development.json | 6 + .../MccMcpWebPlayground/appsettings.json | 15 + DebugTools/MccMcpWebPlayground/wwwroot/app.js | 310 +++++ .../MccMcpWebPlayground/wwwroot/index.html | 1120 +--------------- .../MccMcpWebPlayground/wwwroot/site.css | 383 ++++++ MinecraftClient/Mcp/MccMcpGuidanceProvider.cs | 31 +- 19 files changed, 2799 insertions(+), 2202 deletions(-) create mode 100644 DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs create mode 100644 DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs create mode 100644 DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs create mode 100644 DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs create mode 100644 DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs create mode 100644 DebugTools/MccMcpWebPlayground/wwwroot/app.js create mode 100644 DebugTools/MccMcpWebPlayground/wwwroot/site.css diff --git a/DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs b/DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs new file mode 100644 index 00000000..ec3e0f83 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs @@ -0,0 +1,36 @@ +using DebugTools.MccMcpWebPlayground.Contracts; +using DebugTools.MccMcpWebPlayground.Harness; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace DebugTools.MccMcpWebPlayground.Api; + +public static class MccPlaygroundEndpoints +{ + public static IEndpointRouteBuilder MapMccPlaygroundEndpoints(this IEndpointRouteBuilder endpoints) + { + RouteGroupBuilder api = endpoints.MapGroup("/api"); + + api.MapGet("/health", () => Results.Ok(new { ok = true })); + + api.MapGet("/config", (IOptions options) => + { + MccWebHarnessOptions harnessOptions = options.Value; + return Results.Ok(new MccConfigResponse( + Model: harnessOptions.ResolveModel(), + OpenRouterBaseUrl: harnessOptions.ResolveOpenRouterBaseUrl(), + McpEndpoint: harnessOptions.ResolveMcpEndpoint(), + HasApiKey: harnessOptions.HasApiKeyConfigured(), + ExposeInventoryWindowAction: harnessOptions.ExposeInventoryWindowAction, + ExposeInternalCommandTool: harnessOptions.ExposeInternalCommandTool)); + }); + + api.MapPost("/chat/stream", (ChatStreamRequest request, IMccAgentRunService runService, HttpContext httpContext, CancellationToken cancellationToken) => + { + return TypedResults.ServerSentEvents(runService.StreamAsync(request, httpContext, cancellationToken)); + }) + .WithRequestTimeout("mcc-stream"); + + return endpoints; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs b/DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs new file mode 100644 index 00000000..2257e1b1 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; + +namespace DebugTools.MccMcpWebPlayground.Contracts; + +public sealed class ChatStreamRequest +{ + public List? Messages { get; set; } +} + +public sealed class ChatMessage +{ + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; +} + +public sealed record MccConfigResponse( + string? Model, + string OpenRouterBaseUrl, + string McpEndpoint, + bool HasApiKey, + bool ExposeInventoryWindowAction, + bool ExposeInternalCommandTool); + +public sealed record MccStreamEnvelope(string RunId, long Sequence, string Kind, object Data); + +public sealed record MccRunStartedData(string Model, string McpEndpoint, DateTimeOffset StartedAtUtc); + +public sealed record MccGuidanceLoadedData( + string SourceTool, + string CanonicalPromptName, + string GuidanceVersion, + MccCapabilityStatus CapabilityStatus); + +public sealed record MccStateSummaryData( + int TurnCount, + int ToolCallCount, + bool SoftFinish, + int DirectAnswerAttempts, + IReadOnlyList OpenVerification, + IReadOnlyList RecentEvidence, + string? CompactionSummary); + +public sealed record MccToolCalledData(string CallId, string Name, string ArgumentsJson, bool Advanced, bool Sensitive); + +public sealed record MccToolResultData( + string CallId, + string Name, + bool IsError, + bool Success, + string? ErrorCode, + string Summary, + string RawText, + string EvidenceId); + +public sealed record MccVerificationEventData(string ObligationId, string ToolName, string Kind, string Description); + +public sealed record MccBudgetData( + int TurnCount, + int MaxTurns, + int ToolCallCount, + int MaxToolCalls, + double ElapsedSeconds, + int MaxWallClockSeconds); + +public sealed record MccErrorData(string Code, string Message, string? Detail = null); + +public sealed record MccFinalPayload( + string Status, + string Headline, + string AnswerMarkdown, + IReadOnlyList VerifiedFacts, + IReadOnlyList OpenIssues, + IReadOnlyList EvidenceIds, + string? NextAction); + +public sealed record MccSubmitFinalArgs( + string Status, + string Headline, + string AnswerMarkdown, + IReadOnlyList VerifiedFacts, + IReadOnlyList OpenIssues, + IReadOnlyList EvidenceIds, + string? NextAction); + +public sealed record MccCapabilityStatus( + [property: JsonPropertyName("sessionStatus")] bool SessionStatus, + [property: JsonPropertyName("chatAndCommands")] bool ChatAndCommands, + [property: JsonPropertyName("movement")] bool Movement, + [property: JsonPropertyName("inventory")] bool Inventory, + [property: JsonPropertyName("entityWorld")] bool EntityWorld); + +public sealed record MccEvidenceView(string Id, string ToolName, string Summary, bool IsError); + +public sealed record MccVerificationObligationView(string Id, string ToolName, string Kind, string Description); diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs b/DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs new file mode 100644 index 00000000..33259389 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs @@ -0,0 +1,852 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; +using DebugTools.MccMcpWebPlayground.Contracts; +using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp; +using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace DebugTools.MccMcpWebPlayground.Harness; + +public interface IMccAgentRunService +{ + IAsyncEnumerable> StreamAsync(ChatStreamRequest request, HttpContext httpContext, CancellationToken cancellationToken); +} + +public sealed class MccAgentRunService : IMccAgentRunService +{ + private readonly MccMcpSessionFactory sessionFactory; + private readonly MccGuidanceSource guidanceSource; + private readonly MccPromptComposer promptComposer; + private readonly MccContextCompressor contextCompressor; + private readonly MccFinalizer finalizer; + private readonly OpenRouterChatClient openRouterChatClient; + private readonly MccWebHarnessOptions options; + + public MccAgentRunService( + MccMcpSessionFactory sessionFactory, + MccGuidanceSource guidanceSource, + MccPromptComposer promptComposer, + MccContextCompressor contextCompressor, + MccFinalizer finalizer, + OpenRouterChatClient openRouterChatClient, + IOptions options) + { + this.sessionFactory = sessionFactory; + this.guidanceSource = guidanceSource; + this.promptComposer = promptComposer; + this.contextCompressor = contextCompressor; + this.finalizer = finalizer; + this.openRouterChatClient = openRouterChatClient; + this.options = options.Value; + } + + public async IAsyncEnumerable> StreamAsync( + ChatStreamRequest request, + HttpContext httpContext, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, httpContext.RequestAborted); + CancellationToken linkedToken = linkedCts.Token; + + string runId = Guid.NewGuid().ToString("n"); + long sequence = 0; + + string? model = options.ResolveModel(); + if (string.IsNullOrWhiteSpace(model)) + { + yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_MODEL or MccWebHarness:Model must be configured.")); + yield break; + } + + if (!options.HasApiKeyConfigured()) + { + yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_API_KEY is not set.")); + yield break; + } + + List baseConversationMessages = NormalizeConversation(request.Messages); + string userRequest = ExtractUserRequest(request.Messages); + if (string.IsNullOrWhiteSpace(userRequest)) + { + yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("invalid_request", "No user message was provided.")); + yield break; + } + + await using McpClient client = await sessionFactory.CreateAsync(linkedToken); + MccGuidanceBundle guidance = await guidanceSource.LoadAsync(client, linkedToken); + + MccRunState runState = new() + { + RunId = runId, + UserRequest = userRequest, + BaseConversationMessages = baseConversationMessages, + ConfiguredModel = model, + Guidance = guidance + }; + + yield return CreateEvent(runId, ref sequence, "run_started", new MccRunStartedData(model, options.ResolveMcpEndpoint(), runState.StartedAtUtc)); + yield return CreateEvent(runId, ref sequence, "guidance_loaded", new MccGuidanceLoadedData( + guidance.SourceToolName, + guidance.CanonicalPromptName, + guidance.GuidanceVersion, + guidance.CapabilityStatus)); + + IList tools = await client.ListToolsAsync(cancellationToken: linkedToken); + MccToolCatalog catalog = MccToolPolicy.BuildCatalog(tools, options, finalizer.BuildSubmitToolSchema()); + + while (!linkedToken.IsCancellationRequested) + { + runState.TurnCount++; + contextCompressor.CompactIfNeeded(runState); + yield return CreateEvent(runId, ref sequence, "state_summary", BuildStateSummary(runState, options)); + + if (runState.IsSoftFinish(options, DateTimeOffset.UtcNow)) + { + yield return CreateEvent(runId, ref sequence, "budget", BuildBudgetData(runState)); + } + + if (runState.IsHardStop(options, DateTimeOffset.UtcNow)) + break; + + MccModelTurn? turn = null; + Exception? providerException = null; + try + { + turn = await openRouterChatClient.CreateTurnAsync( + promptComposer.Compose(runState), + catalog.ModelVisibleTools, + options, + linkedToken); + } + catch (Exception ex) + { + providerException = ex; + } + + if (providerException is not null || turn is null) + { + yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("provider_error", "OpenRouter request failed.", providerException?.Message)); + yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options)); + yield break; + } + + runState.RoutedModel = turn.ModelId; + runState.RoutedProvider = turn.RoutedProvider; + + if (turn.ToolCalls.Count == 0) + { + runState.DirectAnswerAttempts++; + string content = string.IsNullOrWhiteSpace(turn.AssistantContent) ? "(empty assistant turn)" : turn.AssistantContent.Trim(); + runState.ToolConversationMessages.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = content + }); + + if (runState.DirectAnswerAttempts >= 4) + { + yield return CreateEvent(runId, ref sequence, "error", new MccErrorData( + "model_protocol_error", + "The model kept returning plain assistant text instead of using tools or mcc_submit_final.", + content)); + yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options)); + yield break; + } + + runState.ToolConversationMessages.Add(new Dictionary + { + ["role"] = "user", + ["content"] = "The previous plain assistant text was not accepted by this harness. On your next turn, you must either call the relevant MCC tools or call mcc_submit_final. Do not answer with plain assistant text again." + }); + continue; + } + + Dictionary assistantMessage = new() + { + ["role"] = "assistant", + ["content"] = turn.AssistantContent, + ["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary + { + ["id"] = call.CallId, + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = call.Name, + ["arguments"] = call.ArgumentsJson + } + }).ToArray() + }; + runState.ToolConversationMessages.Add(assistantMessage); + + foreach (MccModelToolCall toolCall in turn.ToolCalls) + { + MccToolProfile profile = MccToolPolicy.GetProfile(toolCall.Name); + yield return CreateEvent(runId, ref sequence, "tool_called", new MccToolCalledData( + toolCall.CallId, + toolCall.Name, + toolCall.ArgumentsJson, + profile.Risk == MccToolRisk.EscapeHatch, + profile.Risk == MccToolRisk.Sensitive)); + + if (toolCall.Name.Equals("mcc_submit_final", StringComparison.OrdinalIgnoreCase)) + { + MccFinalizationValidation validation = finalizer.Validate(runState, toolCall.ArgumentsJson); + if (validation.Accepted) + { + yield return CreateEvent(runId, ref sequence, "final", validation.Payload!); + yield break; + } + + string localResultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "invalid_final_submission", + message = validation.ErrorText + }); + runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText)); + yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData( + toolCall.CallId, + toolCall.Name, + IsError: true, + Success: false, + ErrorCode: "invalid_final_submission", + Summary: validation.ErrorText ?? "Invalid final submission.", + RawText: localResultText, + EvidenceId: string.Empty)); + continue; + } + + if (MccToolPolicy.RequiresExplicitUserIntent(toolCall.Name) && !MccToolPolicy.HasExplicitUserIntent(runState.UserRequest, toolCall.Name)) + { + string localResultText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "explicit_user_intent_required", + message = $"Tool '{toolCall.Name}' requires explicit user intent." + }); + runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText)); + yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData( + toolCall.CallId, + toolCall.Name, + IsError: true, + Success: false, + ErrorCode: "explicit_user_intent_required", + Summary: $"Tool '{toolCall.Name}' requires explicit user intent.", + RawText: localResultText, + EvidenceId: string.Empty)); + continue; + } + + if (!catalog.ToolsByName.TryGetValue(toolCall.Name, out MccToolCatalogEntry? entry)) + { + string unknownToolText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "unknown_tool", + message = $"Unknown tool '{toolCall.Name}'." + }); + runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, unknownToolText)); + yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData( + toolCall.CallId, + toolCall.Name, + IsError: true, + Success: false, + ErrorCode: "unknown_tool", + Summary: $"Unknown tool '{toolCall.Name}'.", + RawText: unknownToolText, + EvidenceId: string.Empty)); + continue; + } + + CallToolResult? result = null; + Exception? toolException = null; + try + { + Dictionary arguments = MccJsonArguments.Parse(toolCall.ArgumentsJson); + result = await client.CallToolAsync(toolCall.Name, arguments, cancellationToken: linkedToken); + } + catch (Exception ex) + { + toolException = ex; + } + + if (toolException is not null || result is null) + { + string failedText = JsonSerializer.Serialize(new + { + success = false, + errorCode = "tool_call_failed", + message = toolException?.Message + }); + runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, failedText)); + yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData( + toolCall.CallId, + toolCall.Name, + IsError: true, + Success: false, + ErrorCode: "tool_call_failed", + Summary: toolException?.Message ?? "Tool call failed.", + RawText: failedText, + EvidenceId: string.Empty)); + continue; + } + + runState.ToolCallCount++; + MccNormalizedToolResult normalized = MccMcpJson.Normalize(result); + MccEvidenceRecord evidence = CreateEvidence(runState, toolCall.Name, normalized); + runState.Evidence.Add(evidence); + runState.ToolExecutions.Add(new MccToolExecutionRecord + { + CallId = toolCall.CallId, + ToolName = toolCall.Name, + ArgumentsJson = toolCall.ArgumentsJson, + Evidence = evidence + }); + + runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, normalized.Text)); + + foreach (MccVerificationObligation obligation in CreateObligations(runState, evidence, toolCall.ArgumentsJson)) + { + runState.VerificationObligations.Add(obligation); + yield return CreateEvent(runId, ref sequence, "verification_required", new MccVerificationEventData( + obligation.Id, + obligation.ToolName, + obligation.Kind, + obligation.Description)); + + if (obligation.Cleared) + { + yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData( + obligation.Id, + obligation.ToolName, + obligation.Kind, + obligation.Description)); + } + } + + foreach (MccVerificationObligation cleared in TryClearObligationsFromEvidence(runState, evidence)) + { + yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData( + cleared.Id, + cleared.ToolName, + cleared.Kind, + cleared.Description)); + } + + yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData( + toolCall.CallId, + toolCall.Name, + evidence.IsError, + evidence.Success, + evidence.ErrorCode, + evidence.Summary, + evidence.RawText, + evidence.Id)); + } + } + + yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options)); + } + + private static List NormalizeConversation(List? incoming) + { + List messages = []; + if (incoming is null) + return messages; + + foreach (ChatMessage message in incoming) + { + if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content)) + continue; + + string role = message.Role.Trim().ToLowerInvariant(); + if (role is not ("user" or "assistant" or "system")) + continue; + + messages.Add(new Dictionary + { + ["role"] = role, + ["content"] = message.Content.Trim() + }); + } + + return messages; + } + + private static string ExtractUserRequest(List? incoming) + { + return incoming? + .LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(message.Content)) + ?.Content + ?.Trim() + ?? string.Empty; + } + + private static Dictionary BuildToolMessage(string callId, string content) + { + return new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = callId, + ["content"] = content + }; + } + + private static MccStateSummaryData BuildStateSummary(MccRunState runState, MccWebHarnessOptions options) + { + return new MccStateSummaryData( + TurnCount: runState.TurnCount, + ToolCallCount: runState.ToolCallCount, + SoftFinish: runState.IsSoftFinish(options, DateTimeOffset.UtcNow), + DirectAnswerAttempts: runState.DirectAnswerAttempts, + OpenVerification: runState.OpenObligations + .Select(obligation => new MccVerificationObligationView(obligation.Id, obligation.ToolName, obligation.Kind, obligation.Description)) + .ToArray(), + RecentEvidence: runState.Evidence + .TakeLast(6) + .Select(evidence => new MccEvidenceView(evidence.Id, evidence.ToolName, evidence.Summary, evidence.IsError)) + .ToArray(), + CompactionSummary: runState.CompactionSummary); + } + + private MccBudgetData BuildBudgetData(MccRunState runState) + { + return new MccBudgetData( + TurnCount: runState.TurnCount, + MaxTurns: options.MaxTurns, + ToolCallCount: runState.ToolCallCount, + MaxToolCalls: options.MaxToolCalls, + ElapsedSeconds: (DateTimeOffset.UtcNow - runState.StartedAtUtc).TotalSeconds, + MaxWallClockSeconds: options.MaxWallClockSeconds); + } + + private static MccEvidenceRecord CreateEvidence(MccRunState runState, string toolName, MccNormalizedToolResult result) + { + string summary = SummarizeEvidence(toolName, result); + return new MccEvidenceRecord + { + Id = runState.NextEvidenceId(), + ToolName = toolName, + Summary = summary, + RawText = result.Text, + IsError = result.IsError, + Success = result.Success, + ErrorCode = result.ErrorCode, + Root = result.Root, + Data = result.Data + }; + } + + private static string SummarizeEvidence(string toolName, MccNormalizedToolResult result) + { + if (result.Data is JsonElement data) + { + if ((toolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) || toolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)) + && TryReadBool(data, "arrived", out bool arrived)) + { + return arrived + ? $"movement verified; arrived={arrived}" + : $"movement not yet verified; arrived={arrived}"; + } + + if (toolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase)) + { + bool destroyed = TryReadBool(data, "destroyed", out bool destroyedValue) && destroyedValue; + bool changed = TryReadBool(data, "changed", out bool changedValue) && changedValue; + return $"dig result changed={changed} destroyed={destroyed}"; + } + + if (toolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase)) + { + int successful = TryReadInt(data, "successfulPickups", out int successfulValue) ? successfulValue : 0; + int collected = TryReadInt(data, "collectedCount", out int collectedValue) ? collectedValue : 0; + return $"pickup result successfulPickups={successful} collectedCount={collected}"; + } + + if (toolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase) + && TryReadBool(data, "opened", out bool opened)) + { + return $"container open result opened={opened}"; + } + + if (toolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item") + { + int moved = TryReadInt(data, "movedCount", out int movedValue) + ? movedValue + : TryReadInt(data, "droppedCount", out int droppedValue) ? droppedValue : 0; + return $"{toolName} movedCount={moved}"; + } + } + + string prefix = result.IsError ? "error" : "ok"; + return $"{prefix}: {Truncate(result.Text.Replace('\n', ' '), 180)}"; + } + + private List CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson) + { + List obligations = []; + JsonElement metadata = ParseArgumentsToJson(argumentsJson); + + if (evidence.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase)) + { + MccVerificationObligation obligation = new() + { + Id = runState.NextObligationId(), + ToolName = evidence.ToolName, + Kind = "movement", + Description = "Verify final player location for the requested move target.", + SourceEvidenceId = evidence.Id, + Metadata = BuildMoveMetadata(evidence, metadata), + Cleared = IsMovementVerified(evidence) + }; + obligations.Add(obligation); + return obligations; + } + + if (evidence.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)) + { + MccVerificationObligation obligation = new() + { + Id = runState.NextObligationId(), + ToolName = evidence.ToolName, + Kind = "movement", + Description = "Verify final proximity to the requested player target.", + SourceEvidenceId = evidence.Id, + Metadata = BuildMoveToPlayerMetadata(evidence, metadata), + Cleared = IsMovementVerified(evidence) + }; + obligations.Add(obligation); + return obligations; + } + + if (evidence.ToolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase)) + { + obligations.Add(new MccVerificationObligation + { + Id = runState.NextObligationId(), + ToolName = evidence.ToolName, + Kind = "container", + Description = "Verify that the target container is open and active.", + SourceEvidenceId = evidence.Id, + Metadata = null, + Cleared = IsContainerOpenVerified(evidence) + }); + return obligations; + } + + if (evidence.ToolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item") + { + obligations.Add(new MccVerificationObligation + { + Id = runState.NextObligationId(), + ToolName = evidence.ToolName, + Kind = "inventory", + Description = "Verify the requested inventory delta.", + SourceEvidenceId = evidence.Id, + Metadata = evidence.Data, + Cleared = IsInventoryVerified(evidence) + }); + return obligations; + } + + if (evidence.ToolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase)) + { + obligations.Add(new MccVerificationObligation + { + Id = runState.NextObligationId(), + ToolName = evidence.ToolName, + Kind = "pickup", + Description = "Verify that the requested dropped items were picked up.", + SourceEvidenceId = evidence.Id, + Metadata = evidence.Data, + Cleared = IsPickupVerified(evidence) + }); + return obligations; + } + + if (evidence.ToolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase)) + { + obligations.Add(new MccVerificationObligation + { + Id = runState.NextObligationId(), + ToolName = evidence.ToolName, + Kind = "block_change", + Description = "Verify that the target block changed state after digging.", + SourceEvidenceId = evidence.Id, + Metadata = evidence.Data, + Cleared = IsDigVerified(evidence) + }); + } + + return obligations; + } + + private List TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence) + { + List cleared = []; + foreach (MccVerificationObligation obligation in runState.OpenObligations) + { + if (obligation.Cleared) + continue; + + if (obligation.Kind == "movement" && TryClearMovementObligation(obligation, evidence)) + { + obligation.Cleared = true; + obligation.ClearedByEvidenceId = evidence.Id; + cleared.Add(obligation); + continue; + } + + if (obligation.Kind == "block_change" && TryClearDigObligation(obligation, evidence)) + { + obligation.Cleared = true; + obligation.ClearedByEvidenceId = evidence.Id; + cleared.Add(obligation); + } + } + + return cleared; + } + + private static bool TryClearMovementObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence) + { + if (evidence.ToolName.Equals("mcc_player_state", StringComparison.OrdinalIgnoreCase) + && evidence.Data is JsonElement data + && data.TryGetProperty("location", out JsonElement location) + && obligation.Metadata is JsonElement metadata) + { + if (obligation.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) + && metadata.TryGetProperty("x", out JsonElement targetX) + && metadata.TryGetProperty("y", out JsonElement targetY) + && metadata.TryGetProperty("z", out JsonElement targetZ)) + { + double tolerance = metadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 1.5; + return TryReadDouble(location, "x", out double x) + && TryReadDouble(location, "y", out double y) + && TryReadDouble(location, "z", out double z) + && Distance(x, y, z, targetX.GetDouble(), targetY.GetDouble(), targetZ.GetDouble()) <= tolerance; + } + } + + if (evidence.ToolName.Equals("mcc_player_locate", StringComparison.OrdinalIgnoreCase) + && obligation.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase) + && evidence.Data is JsonElement playerData + && obligation.Metadata is JsonElement playerMetadata) + { + string? expectedName = playerMetadata.TryGetProperty("playerName", out JsonElement nameElement) ? nameElement.GetString() : null; + string? matchedName = playerData.TryGetProperty("matchedName", out JsonElement matchedNameElement) ? matchedNameElement.GetString() : null; + if (!string.IsNullOrWhiteSpace(expectedName) && !string.Equals(expectedName, matchedName, StringComparison.OrdinalIgnoreCase)) + return false; + + if (TryReadDouble(playerData, "distance", out double distance)) + { + double tolerance = playerMetadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 2.0; + return distance <= tolerance; + } + } + + return false; + } + + private static bool TryClearDigObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence) + { + if (!evidence.ToolName.Equals("mcc_world_block_at", StringComparison.OrdinalIgnoreCase) + || evidence.Data is not JsonElement data + || obligation.Metadata is not JsonElement metadata) + { + return false; + } + + if (!metadata.TryGetProperty("target", out JsonElement target) + || !TryReadDouble(target, "x", out double x) + || !TryReadDouble(target, "y", out double y) + || !TryReadDouble(target, "z", out double z)) + { + return false; + } + + return TryReadInt(data, "x", out int blockX) + && TryReadInt(data, "y", out int blockY) + && TryReadInt(data, "z", out int blockZ) + && Math.Abs(blockX - x) < 0.5 + && Math.Abs(blockY - y) < 0.5 + && Math.Abs(blockZ - z) < 0.5 + && data.TryGetProperty("block", out JsonElement block) + && block.TryGetProperty("material", out JsonElement material) + && !string.Equals(material.GetString(), "Air", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsMovementVerified(MccEvidenceRecord evidence) + { + if (evidence.Data is not JsonElement data) + return false; + + if (TryReadBool(data, "arrived", out bool arrived) && arrived) + return true; + + if (TryReadDouble(data, "finalDistance", out double finalDistance)) + { + double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5; + return finalDistance <= tolerance; + } + + return false; + } + + private static bool IsContainerOpenVerified(MccEvidenceRecord evidence) + { + return evidence.Data is JsonElement data + && TryReadBool(data, "opened", out bool opened) + && opened; + } + + private static bool IsInventoryVerified(MccEvidenceRecord evidence) + { + if (evidence.Data is not JsonElement data) + return false; + + if (TryReadInt(data, "requestedCount", out int requestedCount) + && TryReadInt(data, "movedCount", out int movedCount)) + { + return movedCount == requestedCount; + } + + if (TryReadInt(data, "requestedCount", out requestedCount) + && TryReadInt(data, "droppedCount", out int droppedCount)) + { + return droppedCount == requestedCount; + } + + return evidence.Success; + } + + private static bool IsPickupVerified(MccEvidenceRecord evidence) + { + if (evidence.Data is not JsonElement data) + return false; + + return (TryReadInt(data, "successfulPickups", out int successfulPickups) && successfulPickups > 0) + || (TryReadInt(data, "collectedCount", out int collectedCount) && collectedCount > 0); + } + + private static bool IsDigVerified(MccEvidenceRecord evidence) + { + if (evidence.Data is not JsonElement data) + return false; + + return (TryReadBool(data, "destroyed", out bool destroyed) && destroyed) + || (TryReadBool(data, "changed", out bool changed) && changed); + } + + private static JsonElement? BuildMoveMetadata(MccEvidenceRecord evidence, JsonElement arguments) + { + if (evidence.Data is not JsonElement data) + return null; + + double x = TryReadDoubleFromArguments(arguments, "x", out double targetX) + ? targetX + : data.TryGetProperty("target", out JsonElement target) && TryReadDouble(target, "x", out double fromDataX) ? fromDataX : 0; + double y = TryReadDoubleFromArguments(arguments, "y", out double targetY) + ? targetY + : data.TryGetProperty("target", out target) && TryReadDouble(target, "y", out double fromDataY) ? fromDataY : 0; + double z = TryReadDoubleFromArguments(arguments, "z", out double targetZ) + ? targetZ + : data.TryGetProperty("target", out target) && TryReadDouble(target, "z", out double fromDataZ) ? fromDataZ : 0; + double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5; + + return JsonSerializer.SerializeToElement(new + { + x, + y, + z, + tolerance + }); + } + + private static JsonElement? BuildMoveToPlayerMetadata(MccEvidenceRecord evidence, JsonElement arguments) + { + string? playerName = arguments.TryGetProperty("playerName", out JsonElement property) ? property.GetString() : null; + double tolerance = evidence.Data is JsonElement data && TryReadDouble(data, "tolerance", out double tol) ? tol : 2.0; + return JsonSerializer.SerializeToElement(new + { + playerName, + tolerance + }); + } + + private static JsonElement ParseArgumentsToJson(string argumentsJson) + { + try + { + using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + return document.RootElement.Clone(); + } + catch + { + using JsonDocument document = JsonDocument.Parse("{}"); + return document.RootElement.Clone(); + } + } + + private static bool TryReadBool(JsonElement element, string propertyName, out bool value) + { + value = false; + return element.TryGetProperty(propertyName, out JsonElement property) + && property.ValueKind is JsonValueKind.True or JsonValueKind.False + && ((value = property.GetBoolean()) || !value || true); + } + + private static bool TryReadInt(JsonElement element, string propertyName, out int value) + { + value = 0; + return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetInt32(out value); + } + + private static bool TryReadDouble(JsonElement element, string propertyName, out double value) + { + value = 0; + return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetDouble(out value); + } + + private static bool TryReadDoubleFromArguments(JsonElement element, string propertyName, out double value) + { + value = 0; + if (!element.TryGetProperty(propertyName, out JsonElement property)) + return false; + + return property.ValueKind == JsonValueKind.Number + ? property.TryGetDouble(out value) + : property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), out value); + } + + private static double Distance(double x1, double y1, double z1, double x2, double y2, double z2) + { + double dx = x1 - x2; + double dy = y1 - y2; + double dz = z1 - z2; + return Math.Sqrt(dx * dx + dy * dy + dz * dz); + } + + private static string Truncate(string text, int maxLength) + { + return string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "..."; + } + + private static SseItem CreateEvent(string runId, ref long sequence, string kind, T data) + { + sequence++; + return new SseItem( + new MccStreamEnvelope(runId, sequence, kind, data!), + kind) + { + EventId = sequence.ToString(CultureInfo.InvariantCulture) + }; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs b/DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs new file mode 100644 index 00000000..1d131bda --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccContextCompressor.cs @@ -0,0 +1,21 @@ +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccContextCompressor +{ + public void CompactIfNeeded(MccRunState runState) + { + if (runState.Evidence.Count <= 6) + return; + + IReadOnlyList olderEvidence = runState.Evidence + .Take(Math.Max(0, runState.Evidence.Count - 6)) + .ToArray(); + + if (olderEvidence.Count == 0) + return; + + runState.CompactionSummary = string.Join('\n', olderEvidence + .TakeLast(8) + .Select(record => $"- {record.Id} {record.ToolName}: {record.Summary}")); + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs b/DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs new file mode 100644 index 00000000..88978bc6 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs @@ -0,0 +1,221 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using DebugTools.MccMcpWebPlayground.Contracts; + +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccFinalizer +{ + private static readonly string[] AllowedStatuses = ["completed", "partial", "blocked", "clarification_needed", "failed"]; + + public object BuildSubmitToolSchema() + { + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = "mcc_submit_final", + ["description"] = "Submit the final result for this MCC run. Use completed only when no required verification obligations remain open.", + ["parameters"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = false, + ["properties"] = new JsonObject + { + ["status"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray(AllowedStatuses.Select(status => JsonValue.Create(status)).ToArray()) + }, + ["headline"] = new JsonObject { ["type"] = "string" }, + ["answerMarkdown"] = new JsonObject { ["type"] = "string" }, + ["verifiedFacts"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" } + }, + ["openIssues"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" } + }, + ["evidenceIds"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" } + }, + ["nextAction"] = new JsonObject + { + ["type"] = new JsonArray("string", "null") + } + }, + ["required"] = new JsonArray("status", "headline", "answerMarkdown", "verifiedFacts", "openIssues", "evidenceIds", "nextAction") + } + } + }; + } + + public MccFinalizationValidation Validate(MccRunState runState, string argumentsJson) + { + try + { + using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + JsonElement root = document.RootElement; + MccSubmitFinalArgs submission = new( + Status: ReadRequiredString(root, "status"), + Headline: ReadRequiredString(root, "headline"), + AnswerMarkdown: ReadRequiredString(root, "answerMarkdown"), + VerifiedFacts: ReadStringArray(root, "verifiedFacts"), + OpenIssues: ReadStringArray(root, "openIssues"), + EvidenceIds: ReadStringArray(root, "evidenceIds"), + NextAction: ReadNullableString(root, "nextAction")); + + string normalizedStatus = submission.Status.Trim().ToLowerInvariant(); + if (!AllowedStatuses.Contains(normalizedStatus, StringComparer.Ordinal)) + return MccFinalizationValidation.Reject("Invalid final status."); + + if (string.IsNullOrWhiteSpace(submission.Headline) || string.IsNullOrWhiteSpace(submission.AnswerMarkdown)) + return MccFinalizationValidation.Reject("headline and answerMarkdown are required."); + + Dictionary evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase); + Dictionary evidenceAliasByCallId = new(StringComparer.OrdinalIgnoreCase); + foreach (MccToolExecutionRecord execution in runState.ToolExecutions) + { + evidenceAliasByCallId[execution.CallId] = execution.Evidence.Id; + + int suffixSeparator = execution.CallId.LastIndexOf('_'); + if (suffixSeparator >= 0 && suffixSeparator < execution.CallId.Length - 1) + evidenceAliasByCallId[execution.CallId[(suffixSeparator + 1)..]] = execution.Evidence.Id; + } + + List normalizedEvidenceIds = []; + foreach (string evidenceId in submission.EvidenceIds) + { + string normalizedEvidenceId = evidenceAliasByCallId.TryGetValue(evidenceId, out string? mappedEvidenceId) + ? mappedEvidenceId + : evidenceId; + + if (!evidenceById.ContainsKey(normalizedEvidenceId)) + return MccFinalizationValidation.Reject($"Unknown evidence id '{evidenceId}'."); + + if (!normalizedEvidenceIds.Contains(normalizedEvidenceId, StringComparer.OrdinalIgnoreCase)) + normalizedEvidenceIds.Add(normalizedEvidenceId); + } + + if (normalizedStatus == "completed" && runState.OpenObligations.Count > 0) + return MccFinalizationValidation.Reject("completed is invalid while verification obligations remain open."); + + if (!AreVerifiedFactsGrounded(submission.VerifiedFacts, normalizedEvidenceIds, evidenceById)) + return MccFinalizationValidation.Reject("verifiedFacts must be grounded in the referenced evidence."); + + return MccFinalizationValidation.Accept(new MccFinalPayload( + normalizedStatus, + submission.Headline.Trim(), + submission.AnswerMarkdown.Trim(), + submission.VerifiedFacts, + submission.OpenIssues, + normalizedEvidenceIds, + string.IsNullOrWhiteSpace(submission.NextAction) ? null : submission.NextAction.Trim())); + } + catch (Exception ex) + { + return MccFinalizationValidation.Reject($"Invalid mcc_submit_final payload: {ex.Message}"); + } + } + + public MccFinalPayload BuildHardStopResult(MccRunState runState, MccWebHarnessOptions options) + { + IReadOnlyList openIssues = runState.OpenObligations.Count > 0 + ? runState.OpenObligations.Select(obligation => obligation.Description).ToArray() + : ["The harness reached its execution budget before the run was explicitly finalized."]; + + IReadOnlyList evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray(); + IReadOnlyList verifiedFacts = runState.Evidence + .TakeLast(4) + .Where(record => record.Success) + .Select(record => record.Summary) + .ToArray(); + + return new MccFinalPayload( + Status: runState.OpenObligations.Count > 0 ? "partial" : "blocked", + Headline: "Run stopped before explicit completion", + AnswerMarkdown: "I could not finish the request within the current harness budget. I am returning the strongest verified state captured so far.", + VerifiedFacts: verifiedFacts, + OpenIssues: openIssues, + EvidenceIds: evidenceIds, + NextAction: "Retry with a fresh run if you want me to continue from the latest verified state."); + } + + private static bool AreVerifiedFactsGrounded( + IReadOnlyList verifiedFacts, + IReadOnlyList evidenceIds, + IReadOnlyDictionary evidenceById) + { + if (verifiedFacts.Count == 0) + return true; + + if (evidenceIds.Count == 0) + return false; + + string evidenceCorpus = string.Join(' ', evidenceIds + .Where(evidenceById.ContainsKey) + .Select(id => evidenceById[id].Summary)) + .ToLowerInvariant(); + + foreach (string fact in verifiedFacts) + { + HashSet factTokens = fact.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(token => token.Trim().Trim(',', '.', ':', ';', '!', '?', '"', '\'')) + .Where(token => token.Length >= 4) + .Select(token => token.ToLowerInvariant()) + .ToHashSet(StringComparer.Ordinal); + + if (factTokens.Count == 0) + continue; + + int matches = factTokens.Count(token => evidenceCorpus.Contains(token, StringComparison.Ordinal)); + if (matches < Math.Min(2, factTokens.Count)) + return false; + } + + return true; + } + + private static string ReadRequiredString(JsonElement root, string propertyName) + { + string? value = ReadNullableString(root, propertyName); + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException($"{propertyName} is required."); + + return value.Trim(); + } + + private static string? ReadNullableString(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out JsonElement property)) + return null; + + return property.ValueKind == JsonValueKind.Null ? null : property.GetString(); + } + + private static string[] ReadStringArray(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out JsonElement property) || property.ValueKind != JsonValueKind.Array) + return []; + + return property.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Cast() + .ToArray(); + } +} + +public sealed record MccFinalizationValidation(bool Accepted, string? ErrorText, MccFinalPayload? Payload) +{ + public static MccFinalizationValidation Accept(MccFinalPayload payload) => new(true, null, payload); + + public static MccFinalizationValidation Reject(string errorText) => new(false, errorText, null); +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs b/DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs new file mode 100644 index 00000000..a302e3f0 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using DebugTools.MccMcpWebPlayground.Contracts; +using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccGuidanceSource +{ + public const string SourceToolName = "mcc_agent_guidance"; + public const string CanonicalPromptName = "mcc_operator_guide"; + + public async Task LoadAsync(McpClient client, CancellationToken cancellationToken) + { + CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary(), cancellationToken: cancellationToken); + MccNormalizedToolResult normalized = MccMcpJson.Normalize(result); + JsonElement data = normalized.Data ?? throw new InvalidOperationException("mcc_agent_guidance did not return data."); + + string[] bestPractices = ReadStringArray(data, "bestPractices"); + string[] exampleTitles = data.TryGetProperty("exampleScenarios", out JsonElement examples) + && examples.ValueKind == JsonValueKind.Array + ? examples.EnumerateArray() + .Select(example => example.TryGetProperty("title", out JsonElement title) ? title.GetString() : null) + .Where(title => !string.IsNullOrWhiteSpace(title)) + .Cast() + .ToArray() + : []; + + MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson) + ? JsonSerializer.Deserialize(capabilityJson.GetRawText()) ?? new MccCapabilityStatus(false, false, false, false, false) + : new MccCapabilityStatus(false, false, false, false, false); + + return new MccGuidanceBundle( + SourceToolName, + CanonicalPromptName, + SkillName: ReadString(data, "skillName") ?? "mcc-mcp-operator", + GuidanceVersion: ReadString(data, "guidanceVersion") ?? "unknown", + SystemPrompt: ReadString(data, "systemPrompt") ?? throw new InvalidOperationException("mcc_agent_guidance did not return systemPrompt."), + BestPractices: bestPractices, + ExampleScenarioTitles: exampleTitles, + CapabilityStatus: capabilityStatus); + } + + private static string? ReadString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static string[] ReadStringArray(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Array + ? property.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Cast() + .ToArray() + : []; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs b/DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs new file mode 100644 index 00000000..b9cb4650 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs @@ -0,0 +1,86 @@ +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccPromptComposer +{ + private const string HarnessContract = """ +You are operating Minecraft Console Client through MCC MCP tools. + +Rules: +- Use tool results and the run-state summary as the source of truth. +- Execute tools sequentially. +- End the run only with mcc_submit_final. +- status=completed is valid only when no required verification obligations remain open. +- If the task is blocked or partial, say exactly what is verified and what remains unverified. +- Do not repeat the same failing stateful action with the same arguments. +- mcc_quit_client requires explicit user intent. +- Prefer structured high-level tools. Avoid escape hatches unless they are explicitly exposed and necessary. +"""; + + public List Compose(MccRunState runState) + { + List messages = + [ + BuildSystemMessage(HarnessContract), + BuildSystemMessage(runState.Guidance.SystemPrompt), + BuildSystemMessage(BuildStateSummary(runState)), + .. runState.BaseConversationMessages + ]; + + if (!string.IsNullOrWhiteSpace(runState.CompactionSummary)) + { + messages.Add(BuildSystemMessage($""" +Older verified evidence summary +{runState.CompactionSummary} +""")); + } + + foreach (object message in runState.ToolConversationMessages.TakeLast(12)) + messages.Add(message); + + return messages; + } + + private static Dictionary BuildSystemMessage(string text) + { + return new Dictionary + { + ["role"] = "system", + ["content"] = text + }; + } + + private static string BuildStateSummary(MccRunState runState) + { + string evidence = runState.Evidence.Count == 0 + ? "- none yet" + : string.Join('\n', runState.Evidence.TakeLast(6).Select(record => + $"- {record.Id} {record.ToolName}: {record.Summary}")); + + string obligations = runState.OpenObligations.Count == 0 + ? "- none" + : string.Join('\n', runState.OpenObligations.Select(obligation => + $"- {obligation.Id} {obligation.ToolName}/{obligation.Kind}: {obligation.Description}")); + + string bestPractices = runState.Guidance.BestPractices.Length == 0 + ? "- use verified MCC state before claiming success" + : string.Join('\n', runState.Guidance.BestPractices.Take(4).Select(item => $"- {item}")); + + return $""" +Current run state +- turnCount: {runState.TurnCount} +- toolCallCount: {runState.ToolCallCount} +- directAnswerAttempts: {runState.DirectAnswerAttempts} +- routedModel: {runState.RoutedModel ?? runState.ConfiguredModel} +- routedProvider: {runState.RoutedProvider ?? "unknown"} + +Outstanding verification +{obligations} + +Recent evidence +{evidence} + +Guidance highlights +{bestPractices} +"""; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs b/DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs new file mode 100644 index 00000000..f226c8b3 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using DebugTools.MccMcpWebPlayground.Contracts; + +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccRunState +{ + private int evidenceCounter; + private int obligationCounter; + + public required string RunId { get; init; } + public required string UserRequest { get; init; } + public required List BaseConversationMessages { get; init; } + public required string ConfiguredModel { get; init; } + public required MccGuidanceBundle Guidance { get; init; } + public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow; + + public List ToolConversationMessages { get; } = []; + public List Evidence { get; } = []; + public List ToolExecutions { get; } = []; + public List VerificationObligations { get; } = []; + public string? CompactionSummary { get; set; } + public string? RoutedModel { get; set; } + public string? RoutedProvider { get; set; } + public int TurnCount { get; set; } + public int ToolCallCount { get; set; } + public int DirectAnswerAttempts { get; set; } + + public string NextEvidenceId() => $"e{++evidenceCounter:0000}"; + + public string NextObligationId() => $"v{++obligationCounter:0000}"; + + public bool IsSoftFinish(MccWebHarnessOptions options, DateTimeOffset nowUtc) + { + TimeSpan elapsed = nowUtc - StartedAtUtc; + return (options.MaxTurns - TurnCount) <= options.SoftFinishRemainingTurns + || (options.MaxToolCalls - ToolCallCount) <= options.SoftFinishRemainingToolCalls + || (options.MaxWallClockSeconds - (int)elapsed.TotalSeconds) <= options.SoftFinishRemainingSeconds; + } + + public bool IsHardStop(MccWebHarnessOptions options, DateTimeOffset nowUtc) + { + TimeSpan elapsed = nowUtc - StartedAtUtc; + return TurnCount >= options.MaxTurns + || ToolCallCount >= options.MaxToolCalls + || elapsed.TotalSeconds >= options.MaxWallClockSeconds; + } + + public IReadOnlyList OpenObligations => + VerificationObligations.Where(obligation => !obligation.Cleared).ToArray(); +} + +public sealed record MccGuidanceBundle( + string SourceToolName, + string CanonicalPromptName, + string SkillName, + string GuidanceVersion, + string SystemPrompt, + string[] BestPractices, + string[] ExampleScenarioTitles, + MccCapabilityStatus CapabilityStatus); + +public sealed class MccEvidenceRecord +{ + public required string Id { get; init; } + public required string ToolName { get; init; } + public required string Summary { get; init; } + public required string RawText { get; init; } + public required bool IsError { get; init; } + public required bool Success { get; init; } + public string? ErrorCode { get; init; } + public JsonElement? Root { get; init; } + public JsonElement? Data { get; init; } +} + +public sealed class MccToolExecutionRecord +{ + public required string CallId { get; init; } + public required string ToolName { get; init; } + public required string ArgumentsJson { get; init; } + public required MccEvidenceRecord Evidence { get; init; } +} + +public sealed class MccVerificationObligation +{ + public required string Id { get; init; } + public required string ToolName { get; init; } + public required string Kind { get; init; } + public required string Description { get; init; } + public required string SourceEvidenceId { get; init; } + public JsonElement? Metadata { get; init; } + public bool Cleared { get; set; } + public string? ClearedByEvidenceId { get; set; } +} + +public sealed record MccNormalizedToolResult( + string Text, + bool IsError, + bool Success, + string? ErrorCode, + string? Message, + JsonElement? Root, + JsonElement? Data); diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs b/DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs new file mode 100644 index 00000000..e8a9bd77 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs @@ -0,0 +1,133 @@ +using System.Collections.Frozen; +using System.Text.Json.Nodes; +using ModelContextProtocol.Client; + +namespace DebugTools.MccMcpWebPlayground.Harness; + +public enum MccToolRisk +{ + ReadOnly, + Stateful, + Sensitive, + EscapeHatch +} + +public sealed record MccToolProfile( + string Name, + MccToolRisk Risk, + bool VisibleByDefault, + bool RequiresExplicitUserIntent); + +public sealed record MccToolCatalogEntry(McpClientTool Tool, MccToolProfile Profile); + +public sealed class MccToolCatalog +{ + public required Dictionary ToolsByName { get; init; } + public required IReadOnlyList ModelVisibleTools { get; init; } +} + +public static class MccToolPolicy +{ + private static readonly FrozenDictionary Profiles = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["mcc_agent_guidance"] = new("mcc_agent_guidance", MccToolRisk.ReadOnly, false, false), + ["mcc_inventory_window_action"] = new("mcc_inventory_window_action", MccToolRisk.EscapeHatch, false, false), + ["mcc_run_internal_command"] = new("mcc_run_internal_command", MccToolRisk.EscapeHatch, false, false), + ["mcc_quit_client"] = new("mcc_quit_client", MccToolRisk.Sensitive, true, true) + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + public static MccToolProfile GetProfile(string toolName) + { + return Profiles.TryGetValue(toolName, out MccToolProfile? profile) + ? profile + : new MccToolProfile(toolName, MccToolRisk.Stateful, true, false); + } + + public static MccToolCatalog BuildCatalog(IList tools, MccWebHarnessOptions options, object submitFinalTool) + { + Dictionary toolsByName = tools.ToDictionary( + tool => tool.Name, + tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)), + StringComparer.OrdinalIgnoreCase); + + List visibleTools = []; + foreach (MccToolCatalogEntry entry in toolsByName.Values.OrderBy(entry => entry.Tool.Name, StringComparer.OrdinalIgnoreCase)) + { + if (!IsVisible(entry.Profile, options)) + continue; + + visibleTools.Add(ToOpenRouterTool(entry.Tool, entry.Profile)); + } + + visibleTools.Add(submitFinalTool); + + return new MccToolCatalog + { + ToolsByName = toolsByName, + ModelVisibleTools = visibleTools + }; + } + + public static bool RequiresExplicitUserIntent(string toolName) + { + return GetProfile(toolName).RequiresExplicitUserIntent; + } + + public static bool HasExplicitUserIntent(string userRequest, string toolName) + { + if (!RequiresExplicitUserIntent(toolName)) + return true; + + string request = userRequest.Trim().ToLowerInvariant(); + return toolName.Equals("mcc_quit_client", StringComparison.OrdinalIgnoreCase) + && (request.Contains("quit mcc", StringComparison.Ordinal) + || request.Contains("close mcc", StringComparison.Ordinal) + || request.Contains("stop mcc", StringComparison.Ordinal) + || request.Contains("exit mcc", StringComparison.Ordinal) + || request.Contains("quit the client", StringComparison.Ordinal) + || request.Contains("stop the client", StringComparison.Ordinal)); + } + + private static bool IsVisible(MccToolProfile profile, MccWebHarnessOptions options) + { + if (!profile.VisibleByDefault) + { + if (profile.Name.Equals("mcc_inventory_window_action", StringComparison.OrdinalIgnoreCase)) + return options.ExposeInventoryWindowAction; + + if (profile.Name.Equals("mcc_run_internal_command", StringComparison.OrdinalIgnoreCase)) + return options.ExposeInternalCommandTool; + + return false; + } + + return true; + } + + private static object ToOpenRouterTool(McpClientTool tool, MccToolProfile profile) + { + JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject() + }; + + string description = tool.Description ?? string.Empty; + if (profile.Risk == MccToolRisk.Sensitive) + description = $"{description} Requires explicit user intent."; + else if (profile.Risk == MccToolRisk.EscapeHatch) + description = $"{description} Advanced escape hatch; prefer higher-level tools first."; + + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = tool.Name, + ["description"] = description, + ["parameters"] = parameters + } + }; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs b/DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs new file mode 100644 index 00000000..0855c471 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Harness/MccWebHarnessOptions.cs @@ -0,0 +1,58 @@ +namespace DebugTools.MccMcpWebPlayground.Harness; + +public sealed class MccWebHarnessOptions +{ + public const string SectionName = "MccWebHarness"; + + public string? Model { get; set; } + public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1"; + public string McpEndpoint { get; set; } = "http://127.0.0.1:33333/mcp"; + public int MaxTurns { get; set; } = 48; + public int MaxToolCalls { get; set; } = 120; + public int MaxWallClockSeconds { get; set; } = 240; + public int SoftFinishRemainingTurns { get; set; } = 3; + public int SoftFinishRemainingToolCalls { get; set; } = 8; + public int SoftFinishRemainingSeconds { get; set; } = 30; + public bool RequireProviderParameters { get; set; } = true; + public bool AllowFallbacks { get; set; } + public bool DisableParallelToolCalls { get; set; } = true; + public bool ExposeInventoryWindowAction { get; set; } + public bool ExposeInternalCommandTool { get; set; } + + public string? ResolveModel() + { + return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_MODEL"), Model); + } + + public string ResolveOpenRouterBaseUrl() + { + return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL"), OpenRouterBaseUrl) + ?? "https://openrouter.ai/api/v1"; + } + + public string ResolveMcpEndpoint() + { + return FirstNonEmpty(Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT"), McpEndpoint) + ?? "http://127.0.0.1:33333/mcp"; + } + + public string? ResolveMcpAuthToken() + { + return Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); + } + + public string? ResolveApiKey() + { + return Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); + } + + public bool HasApiKeyConfigured() + { + return !string.IsNullOrWhiteSpace(ResolveApiKey()); + } + + private static string? FirstNonEmpty(params string?[] candidates) + { + return candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate))?.Trim(); + } +} diff --git a/DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs b/DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs new file mode 100644 index 00000000..8775bb2c --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Infrastructure/Mcp/MccMcpSessionFactory.cs @@ -0,0 +1,200 @@ +using System.Text; +using System.Text.Json; +using System.Reflection; +using DebugTools.MccMcpWebPlayground.Harness; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace DebugTools.MccMcpWebPlayground.Infrastructure.Mcp; + +public sealed class MccMcpSessionFactory +{ + private readonly MccWebHarnessOptions options; + + public MccMcpSessionFactory(IOptions options) + { + this.options = options.Value; + } + + public async Task CreateAsync(CancellationToken cancellationToken) + { + string endpoint = options.ResolveMcpEndpoint(); + string? token = options.ResolveMcpAuthToken(); + + return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.AutoDetect, + AdditionalHeaders = string.IsNullOrWhiteSpace(token) + ? null + : new Dictionary + { + ["Authorization"] = $"Bearer {token}" + } + }), cancellationToken: cancellationToken); + } +} + +public static class MccMcpJson +{ + public static MccNormalizedToolResult Normalize(CallToolResult result) + { + JsonElement? structuredRoot = TryReadStructuredContent(result); + string text = ReadToolResultText(result, structuredRoot); + try + { + using JsonDocument document = JsonDocument.Parse(text); + JsonElement parsedRoot = document.RootElement.Clone(); + JsonElement root = ShouldPreferStructuredRoot(parsedRoot, structuredRoot) + ? structuredRoot!.Value + : parsedRoot; + JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement) + ? dataElement.Clone() + : ShouldTreatRootAsData(root) ? root.Clone() : structuredRoot; + bool success = root.TryGetProperty("success", out JsonElement successElement) + ? successElement.ValueKind != JsonValueKind.False + : result.IsError != true; + string? errorCode = root.TryGetProperty("errorCode", out JsonElement errorCodeElement) && errorCodeElement.ValueKind == JsonValueKind.String + ? errorCodeElement.GetString() + : null; + string? message = root.TryGetProperty("message", out JsonElement messageElement) && messageElement.ValueKind == JsonValueKind.String + ? messageElement.GetString() + : null; + bool isError = result.IsError == true || !success || !string.IsNullOrWhiteSpace(errorCode); + + return new MccNormalizedToolResult(text, isError, success, errorCode, message, root, data); + } + catch + { + bool isError = result.IsError == true; + return new MccNormalizedToolResult(text, isError, !isError, null, null, structuredRoot, structuredRoot); + } + } + + private static string ReadToolResultText(CallToolResult result, JsonElement? structuredRoot) + { + if (result.Content is null) + return structuredRoot?.GetRawText() ?? (result.IsError == true ? "{\"success\":false}" : "{\"success\":true}"); + + StringBuilder builder = new(); + foreach (ContentBlock block in result.Content) + { + if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text)) + { + if (builder.Length > 0) + builder.Append('\n'); + builder.Append(text.Text); + } + } + + return builder.Length > 0 + ? builder.ToString() + : structuredRoot?.GetRawText() + ?? JsonSerializer.Serialize(new { success = result.IsError != true, isError = result.IsError }); + } + + private static JsonElement? TryReadStructuredContent(CallToolResult result) + { + PropertyInfo? property = typeof(CallToolResult).GetProperty("StructuredContent", BindingFlags.Instance | BindingFlags.Public); + if (property?.GetValue(result) is not { } value) + return null; + + return value switch + { + JsonElement json when json.ValueKind != JsonValueKind.Undefined && json.ValueKind != JsonValueKind.Null => json.Clone(), + JsonDocument document => document.RootElement.Clone(), + string text when !string.IsNullOrWhiteSpace(text) => TryParseJson(text), + _ => TrySerializeToJson(value) + }; + } + + private static JsonElement? TrySerializeToJson(object value) + { + try + { + return JsonSerializer.SerializeToElement(value); + } + catch + { + return null; + } + } + + private static JsonElement? TryParseJson(string text) + { + try + { + using JsonDocument document = JsonDocument.Parse(text); + return document.RootElement.Clone(); + } + catch + { + return null; + } + } + + private static bool ShouldPreferStructuredRoot(JsonElement parsedRoot, JsonElement? structuredRoot) + { + if (structuredRoot is null) + return false; + + if (parsedRoot.ValueKind != JsonValueKind.Object) + return true; + + return !parsedRoot.EnumerateObject().Any(property => + !property.NameEquals("success") && + !property.NameEquals("isError")); + } + + private static bool ShouldTreatRootAsData(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + return false; + + return root.EnumerateObject().Any(property => + !property.NameEquals("success") && + !property.NameEquals("isError") && + !property.NameEquals("errorCode") && + !property.NameEquals("message")); + } +} + +public static class MccJsonArguments +{ + public static Dictionary Parse(string rawJson) + { + try + { + using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return new Dictionary(); + + Dictionary values = new(StringComparer.OrdinalIgnoreCase); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + values[property.Name] = Convert(property.Value); + return values; + } + catch + { + return new Dictionary(); + } + } + + private static object? Convert(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => element.TryGetInt64(out long i64) + ? i64 + : element.TryGetDouble(out double d) ? d : element.GetRawText(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Array => element.EnumerateArray().Select(Convert).ToArray(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Convert(property.Value)), + _ => element.GetRawText() + }; + } +} diff --git a/DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs b/DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs new file mode 100644 index 00000000..24d9a080 --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/Infrastructure/OpenRouter/OpenRouterChatClient.cs @@ -0,0 +1,120 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using DebugTools.MccMcpWebPlayground.Harness; + +namespace DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter; + +public sealed class OpenRouterChatClient +{ + private readonly IHttpClientFactory httpClientFactory; + + public OpenRouterChatClient(IHttpClientFactory httpClientFactory) + { + this.httpClientFactory = httpClientFactory; + } + + public async Task CreateTurnAsync( + List messages, + IReadOnlyList tools, + MccWebHarnessOptions options, + CancellationToken cancellationToken) + { + string apiKey = options.ResolveApiKey() ?? throw new InvalidOperationException("OPENROUTER_API_KEY is not configured."); + string model = options.ResolveModel() ?? throw new InvalidOperationException("Model is not configured."); + + using HttpClient client = httpClientFactory.CreateClient("openrouter"); + client.BaseAddress = new Uri(options.ResolveOpenRouterBaseUrl().TrimEnd('/') + "/"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + client.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground"); + + Dictionary payload = new() + { + ["model"] = model, + ["messages"] = messages, + ["tools"] = tools, + ["tool_choice"] = "auto", + ["provider"] = new Dictionary + { + ["allow_fallbacks"] = options.AllowFallbacks, + ["require_parameters"] = options.RequireProviderParameters + } + }; + + if (ShouldSendParallelToolCallsParameter(model)) + payload["parallel_tool_calls"] = !options.DisableParallelToolCalls; + + using HttpResponseMessage response = await client.PostAsync( + "chat/completions", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), + cancellationToken); + + string body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"OpenRouter returned HTTP {(int)response.StatusCode}: {body}"); + + using JsonDocument document = JsonDocument.Parse(body); + if (!document.RootElement.TryGetProperty("choices", out JsonElement choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0) + { + throw new InvalidOperationException("OpenRouter did not return any choices."); + } + + JsonElement message = choices[0].GetProperty("message"); + string assistantContent = message.TryGetProperty("content", out JsonElement contentElement) + ? contentElement.GetString() ?? string.Empty + : string.Empty; + + List toolCalls = []; + if (message.TryGetProperty("tool_calls", out JsonElement toolCallsElement) && toolCallsElement.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement toolCall in toolCallsElement.EnumerateArray()) + { + if (!toolCall.TryGetProperty("id", out JsonElement idElement) + || !toolCall.TryGetProperty("function", out JsonElement functionElement) + || !functionElement.TryGetProperty("name", out JsonElement nameElement)) + { + continue; + } + + toolCalls.Add(new MccModelToolCall( + CallId: idElement.GetString() ?? Guid.NewGuid().ToString("n"), + Name: nameElement.GetString() ?? string.Empty, + ArgumentsJson: functionElement.TryGetProperty("arguments", out JsonElement argumentsElement) + ? argumentsElement.GetString() ?? "{}" + : "{}")); + } + } + + string modelId = document.RootElement.TryGetProperty("model", out JsonElement modelElement) + ? modelElement.GetString() ?? model + : model; + + string? routedProvider = response.Headers.TryGetValues("x-openrouter-provider", out IEnumerable? providerValues) + ? providerValues.FirstOrDefault() + : null; + + return new MccModelTurn(modelId, routedProvider, assistantContent, toolCalls); + } + + private static bool ShouldSendParallelToolCallsParameter(string model) + { + // Some OpenRouter model families reject tool-enabled requests when the parallel_tool_calls + // parameter is present at all, even if it is explicitly set to false. The harness still + // executes all returned tool calls sequentially, so omitting the transport hint for those + // families preserves the intended runtime behavior while keeping the stricter flag for + // compatible models. + return !model.StartsWith("minimax/", StringComparison.OrdinalIgnoreCase) + && !model.StartsWith("google/gemini-", StringComparison.OrdinalIgnoreCase); + } +} + +public sealed record MccModelTurn( + string ModelId, + string? RoutedProvider, + string AssistantContent, + IReadOnlyList ToolCalls); + +public sealed record MccModelToolCall(string CallId, string Name, string ArgumentsJson); diff --git a/DebugTools/MccMcpWebPlayground/Program.cs b/DebugTools/MccMcpWebPlayground/Program.cs index 9b25f0ca..17060301 100644 --- a/DebugTools/MccMcpWebPlayground/Program.cs +++ b/DebugTools/MccMcpWebPlayground/Program.cs @@ -1,1133 +1,40 @@ -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; +using DebugTools.MccMcpWebPlayground.Api; +using DebugTools.MccMcpWebPlayground.Harness; +using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp; +using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter; +using Microsoft.AspNetCore.Http.Timeouts; var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(MccWebHarnessOptions.SectionName)); + +builder.Services.AddRequestTimeouts(options => +{ + options.AddPolicy("mcc-stream", new RequestTimeoutPolicy + { + Timeout = TimeSpan.FromMinutes(10) + }); +}); + builder.Services.AddHttpClient("openrouter", client => { client.Timeout = TimeSpan.FromMinutes(15); }); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + var app = builder.Build(); + +app.UseRequestTimeouts(); app.UseDefaultFiles(); app.UseStaticFiles(); - -const string AgentSystemPrompt = """ -You are an agent controlling Minecraft Console Client (MCC) through MCP tools. -Use a plan-execute-verify loop. - -Operating mode -- For simple social turns like "hello" or "thanks", do not waste tool calls. Finish directly unless MCC state is required. -- For MCC questions and actions, think in steps and use tools to gather evidence before you finish. -- Never output plain assistant text before calling agent_finish(answer). - -Planning policy -- If the task is multi-step or physical, first decompose it into a short internal plan. -- Prefer the smallest plan that can succeed. -- For long or branchy tasks, keep a short checklist and update it as you go. -- Default sequence: - 1) inspect current state - 2) locate the target - 3) move into a valid position if needed - 4) perform the action - 5) verify with fresh tool calls - 6) call agent_finish(answer) -- If a step fails, revise the plan using the latest observation. Do not blindly repeat the same failing action. - -Todo policy -- Use todo_write, todo_read, and todo_list for tasks with 4 or more steps, retries, or branching verification. -- Keep todos short, concrete, and action-oriented. -- Update todo status as facts change. -- Todo state is request-scoped for the current chat request only. -- Skip todo tools for simple one-step tasks. - -Tool-use policy -- Use MCP tools for MCC/game-state questions and actions. -- Prefer the most direct high-signal tool first. -- Prefer structured inventory/container tools over raw window-click tools for chest or container management. -- If a tool result says success=false or includes an errorCode, treat that as a failed observation even if the transport call itself succeeded. -- Do not guess tool arguments repeatedly. If a tool returns invalid_args: - - simplify to the minimum required arguments, - - try at most one nearby variant, - - or switch to a broader inspection tool. -- Avoid long speculative tool chains. - -Verification policy -- Never claim success from intent alone. -- Never claim movement succeeded just because a move command was accepted. Check arrived or a fresh location result. -- Never claim an item was collected unless inventory or nearby entity state changed. -- Never claim blocks were removed unless block/world search results changed. -- If evidence is partial, say it is partial. -- If the request cannot be completed, say exactly what was verified and what remains unverified. - -Action-specific guidance -- Move or approach: - - locate the target, - - choose a reachable nearby standing position when exact occupancy is risky, - - move, - - verify arrival before finishing. -- Dig or collect: - - locate the blocks, - - move next to them if needed, - - dig in a sensible order, - - re-check remaining blocks, - - re-check inventory or nearby item entities before finishing. -- Container inventory: - - locate the target container block, - - open the container first, - - inspect player and container inventory state, - - use structured deposit or withdraw tools instead of raw window clicks, - - verify both player and container counts changed before finishing. -- Search: - - start with the most direct search tool, - - use the user's requested radius when supported, - - if a query fails, simplify it instead of trying many near-duplicates. - -Good examples -1) User: "Pick up those logs." - Good: - - if the task looks long, write a short todo list - - find the logs - - move next to them - - dig them - - verify the logs are gone or reduced - - verify inventory increased - - then finish -2) User: "Is Zarko near you?" - Good: - - call a nearby-player tool - - report the matched player and distance - - then finish -3) User: "Hello" - Good: - - finish with a short greeting - - no MCP tools -4) User: "Put 5 diamonds in the chest." - Good: - - open the chest - - inspect inventory state - - deposit exactly 5 diamonds - - verify the chest count increased and player count decreased by 5 - - then finish - -Wrong examples -1) Wrong: - - inventory did not change - - blocks may still exist - - but you still say "I picked them up" -2) Wrong: - - move returns pathFound=true but arrived=false - - and you still say "I walked there" -3) Wrong: - - a tool returns invalid_args several times - - and you keep guessing similar argument combinations -4) Wrong: - - you write assistant prose before agent_finish(answer) - -Finish rules -- Complete only by calling agent_finish(answer). -- The final answer must be natural language for a human and include exactly: - Reasoning: - - brief bullets with the important verified observations - Answer: - - direct user-facing result with uncertainty stated when relevant -"""; - -const string BudgetReminderPrompt = """ -Budget is nearly exhausted. -Use the strongest verified evidence you already have. -Do not start speculative new branches. -If the task is complete or partially complete, call agent_finish(answer) now and clearly distinguish verified facts from unverified assumptions. -Do not output plain assistant text before finishing. -"""; - -app.MapGet("/api/health", () => Results.Ok(new { ok = true })); -app.MapGet("/api/config", () => -{ - return Results.Ok(new - { - model = GetModel(), - openRouterBaseUrl = GetOpenRouterBaseUrl(), - mcpEndpoint = GetMcpEndpoint(), - hasApiKey = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")) - }); -}); - -app.MapPost("/api/chat/stream", async (ChatStreamRequest request, IHttpClientFactory httpClientFactory, HttpContext context, CancellationToken cancellationToken) => -{ - context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "text/event-stream"; - context.Response.Headers.CacheControl = "no-cache"; - context.Response.Headers["X-Accel-Buffering"] = "no"; - - try - { - string? apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); - if (string.IsNullOrWhiteSpace(apiKey)) - { - await WriteEvent(context.Response, "error", new { message = "OPENROUTER_API_KEY is not set." }, cancellationToken); - return; - } - - List messages = BuildMessages(request.Messages); - if (messages.Count == 0) - { - await WriteEvent(context.Response, "error", new { message = "No messages provided." }, cancellationToken); - return; - } - - string model = GetModel(); - int maxIterations = GetBoundedInt("MCC_WEB_MAX_ITERATIONS", 96, 4, 256); - int maxToolCalls = GetBoundedInt("MCC_WEB_MAX_TOOL_CALLS", 320, 4, 1024); - TimeSpan maxWallTime = TimeSpan.FromSeconds(GetBoundedInt("MCC_WEB_MAX_SECONDS", 900, 10, 3600)); - - await using McpClient mcp = await CreateMcpClientAsync(cancellationToken); - IList mcpTools = await mcp.ListToolsAsync(cancellationToken: cancellationToken); - Dictionary mcpToolsByName = mcpTools - .ToDictionary(tool => tool.Name, StringComparer.OrdinalIgnoreCase); - - object[] openRouterTools = - [ - .. mcpTools.Select(ToOpenRouterTool), - BuildTodoWriteToolSchema(), - BuildTodoReadToolSchema(), - BuildTodoListToolSchema(), - BuildAgentFinishToolSchema() - ]; - - using HttpClient openRouter = httpClientFactory.CreateClient("openrouter"); - openRouter.BaseAddress = new Uri(GetOpenRouterBaseUrl().TrimEnd('/') + "/"); - openRouter.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - openRouter.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground"); - openRouter.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground"); - - Stopwatch wallClock = Stopwatch.StartNew(); - int toolCallCount = 0; - bool reminderInjected = false; - string? finalAnswer = null; - List observations = new(); - Dictionary todos = new(StringComparer.OrdinalIgnoreCase); - int nextTodoOrder = 0; - - for (int iteration = 1; iteration <= maxIterations && !cancellationToken.IsCancellationRequested; iteration++) - { - if (!reminderInjected && ShouldInjectReminder(iteration, maxIterations, toolCallCount, maxToolCalls, wallClock.Elapsed, maxWallTime)) - { - messages.Add(new Dictionary - { - ["role"] = "system", - ["content"] = BudgetReminderPrompt - }); - reminderInjected = true; - } - - if (wallClock.Elapsed >= maxWallTime || toolCallCount >= maxToolCalls) - break; - - JsonElement choiceMessage = await RequestToolIterationAsync(openRouter, model, messages, openRouterTools, context.Response, cancellationToken); - if (choiceMessage.ValueKind == JsonValueKind.Undefined) - return; - - string assistantContent = choiceMessage.TryGetProperty("content", out JsonElement contentElement) - ? contentElement.GetString() ?? string.Empty - : string.Empty; - - if (choiceMessage.TryGetProperty("tool_calls", out JsonElement toolCallsElement) - && toolCallsElement.ValueKind == JsonValueKind.Array - && toolCallsElement.GetArrayLength() > 0) - { - List toolCallsForHistory = new(); - List toolMessages = new(); - bool stopLoop = false; - - foreach (JsonElement toolCall in toolCallsElement.EnumerateArray()) - { - if (!TryReadToolCall(toolCall, out string callId, out string toolName, out string argumentsRaw)) - continue; - - toolCallsForHistory.Add(new Dictionary - { - ["id"] = callId, - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = toolName, - ["arguments"] = argumentsRaw - } - }); - - await WriteEvent(context.Response, "tool_call", new - { - id = callId, - name = toolName, - arguments = argumentsRaw - }, cancellationToken); - - if (TryHandleLocalToolCall(toolName, argumentsRaw, todos, ref nextTodoOrder, out bool localIsError, out string localResultText, out string? completedAnswer)) - { - await WriteEvent(context.Response, "tool_result", new - { - id = callId, - name = toolName, - isError = localIsError, - content = localResultText - }, cancellationToken); - - toolMessages.Add(new Dictionary - { - ["role"] = "tool", - ["tool_call_id"] = callId, - ["content"] = localResultText - }); - - toolCallCount++; - observations.Add(SummarizeObservation(toolName, localResultText, localIsError)); - - if (completedAnswer is not null) - { - finalAnswer = EnsureFinalAnswerFormat(completedAnswer, observations); - stopLoop = true; - break; - } - - continue; - } - - if (!mcpToolsByName.ContainsKey(toolName)) - { - string resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "unknown_tool", - message = $"Unknown tool '{toolName}'." - }); - await WriteEvent(context.Response, "tool_result", new - { - id = callId, - name = toolName, - isError = true, - content = resultText - }, cancellationToken); - - observations.Add($"Tool {toolName} was rejected because it is unknown."); - toolMessages.Add(new Dictionary - { - ["role"] = "tool", - ["tool_call_id"] = callId, - ["content"] = resultText - }); - continue; - } - - if (toolCallCount >= maxToolCalls) - { - stopLoop = true; - break; - } - - bool isError = false; - string toolResultText; - try - { - Dictionary arguments = ParseArguments(argumentsRaw); - CallToolResult toolResult = await mcp.CallToolAsync(toolName, arguments, cancellationToken: cancellationToken); - toolResultText = ReadToolResultText(toolResult); - isError = toolResult.IsError == true || InferStructuredToolError(toolResultText); - } - catch (Exception ex) - { - isError = true; - toolResultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "tool_call_failed", - message = ex.Message - }); - } - - toolCallCount++; - observations.Add(SummarizeObservation(toolName, toolResultText, isError)); - await WriteEvent(context.Response, "tool_result", new - { - id = callId, - name = toolName, - isError, - content = toolResultText - }, cancellationToken); - - toolMessages.Add(new Dictionary - { - ["role"] = "tool", - ["tool_call_id"] = callId, - ["content"] = toolResultText - }); - } - - messages.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = assistantContent, - ["tool_calls"] = toolCallsForHistory - }); - foreach (object toolMessage in toolMessages) - messages.Add(toolMessage); - - if (finalAnswer is not null || stopLoop) - break; - - continue; - } - - if (!string.IsNullOrWhiteSpace(assistantContent)) - observations.Add($"Model attempted direct text before finishing: {Truncate(assistantContent, 140)}"); - - messages.Add(new Dictionary - { - ["role"] = "assistant", - ["content"] = assistantContent - }); - messages.Add(new Dictionary - { - ["role"] = "system", - ["content"] = "Do not return assistant prose yet. Continue with tool calls and end only by calling agent_finish(answer)." - }); - } - - finalAnswer ??= BuildForcedFinalAnswer(observations, toolCallCount, wallClock.Elapsed, maxIterations, maxToolCalls, maxWallTime); - await StreamFinalAnswer(context.Response, finalAnswer, cancellationToken); - } - catch (OperationCanceledException) - { - await WriteEvent(context.Response, "error", new { message = "Request cancelled." }, CancellationToken.None); - } - catch (Exception ex) - { - await WriteEvent(context.Response, "error", new - { - message = "Unhandled server error.", - detail = ex.Message - }, CancellationToken.None); - } -}); +app.MapMccPlaygroundEndpoints(); app.Run(); - -static string GetModel() -{ - return Environment.GetEnvironmentVariable("OPENROUTER_MODEL") ?? "minimax/minimax-m2.7"; -} - -static string GetOpenRouterBaseUrl() -{ - return Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1"; -} - -static string GetMcpEndpoint() -{ - return Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp"; -} - -static async Task CreateMcpClientAsync(CancellationToken cancellationToken) -{ - string endpoint = GetMcpEndpoint(); - string? token = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN"); - - return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions - { - Endpoint = new Uri(endpoint), - TransportMode = HttpTransportMode.AutoDetect, - AdditionalHeaders = string.IsNullOrWhiteSpace(token) - ? null - : new Dictionary { ["Authorization"] = $"Bearer {token}" } - }), cancellationToken: cancellationToken); -} - -List BuildMessages(List? incoming) -{ - List messages = - [ - new Dictionary - { - ["role"] = "system", - ["content"] = AgentSystemPrompt - } - ]; - - if (incoming is null) - return messages; - - foreach (ChatMessage message in incoming) - { - if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content)) - continue; - - string role = message.Role.Trim().ToLowerInvariant(); - if (role is not ("system" or "user" or "assistant")) - continue; - - messages.Add(new Dictionary - { - ["role"] = role, - ["content"] = message.Content - }); - } - - return messages; -} - -static object ToOpenRouterTool(McpClientTool tool) -{ - JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject() - }; - - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = tool.Name, - ["description"] = tool.Description, - ["parameters"] = parameters - } - }; -} - -static object BuildAgentFinishToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "agent_finish", - ["description"] = "Finalize the response to the user after all required tool calls and verification are done.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["answer"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Final natural-language response for the user." - } - }, - ["required"] = new[] { "answer" }, - ["additionalProperties"] = false - } - } - }; -} - -static object BuildTodoWriteToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "todo_write", - ["description"] = "Create or update a short request-scoped todo item for complex task tracking.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["id"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Stable todo identifier, for example move_to_logs or verify_inventory." - }, - ["content"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Short actionable todo text. Required when creating a new item." - }, - ["status"] = new Dictionary - { - ["type"] = "string", - ["description"] = "One of pending, in_progress, completed, blocked, cancelled." - }, - ["notes"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Optional brief note with the latest observation." - } - }, - ["required"] = new[] { "id" }, - ["additionalProperties"] = false - } - } - }; -} - -static object BuildTodoReadToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "todo_read", - ["description"] = "Read one request-scoped todo item by id.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["id"] = new Dictionary - { - ["type"] = "string", - ["description"] = "Todo identifier." - } - }, - ["required"] = new[] { "id" }, - ["additionalProperties"] = false - } - } - }; -} - -static object BuildTodoListToolSchema() -{ - return new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = "todo_list", - ["description"] = "List all request-scoped todo items in creation order.", - ["parameters"] = new Dictionary - { - ["type"] = "object", - ["properties"] = new Dictionary(), - ["additionalProperties"] = false - } - } - }; -} - -static bool TryHandleLocalToolCall( - string toolName, - string argumentsRaw, - Dictionary todos, - ref int nextTodoOrder, - out bool isError, - out string resultText, - out string? completedAnswer) -{ - isError = false; - resultText = string.Empty; - completedAnswer = null; - - if (toolName.Equals("agent_finish", StringComparison.OrdinalIgnoreCase)) - { - completedAnswer = ParseAgentFinishAnswer(argumentsRaw); - resultText = JsonSerializer.Serialize(new - { - success = true, - finished = true - }); - return true; - } - - if (toolName.Equals("todo_list", StringComparison.OrdinalIgnoreCase)) - { - resultText = JsonSerializer.Serialize(new - { - success = true, - data = new - { - count = todos.Count, - items = todos.Values - .OrderBy(item => item.Order) - .Select(ToTodoDto) - .ToArray() - } - }); - return true; - } - - Dictionary arguments = ParseArguments(argumentsRaw); - if (toolName.Equals("todo_read", StringComparison.OrdinalIgnoreCase)) - { - string? id = ReadOptionalStringArgument(arguments, "id"); - if (string.IsNullOrWhiteSpace(id)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "todo_read requires a non-empty id." - }); - return true; - } - - if (!todos.TryGetValue(id, out TodoEntry? item)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_state", - message = $"Todo '{id}' does not exist." - }); - return true; - } - - resultText = JsonSerializer.Serialize(new - { - success = true, - data = new - { - item = ToTodoDto(item) - } - }); - return true; - } - - if (!toolName.Equals("todo_write", StringComparison.OrdinalIgnoreCase)) - return false; - - string? todoId = ReadOptionalStringArgument(arguments, "id"); - if (string.IsNullOrWhiteSpace(todoId)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "todo_write requires a non-empty id." - }); - return true; - } - - todos.TryGetValue(todoId, out TodoEntry? existingItem); - string? rawContent = ReadOptionalStringArgument(arguments, "content"); - string content = string.IsNullOrWhiteSpace(rawContent) - ? existingItem?.Content ?? string.Empty - : rawContent.Trim(); - if (content.Length == 0) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "todo_write requires content when creating a new item." - }); - return true; - } - - string requestedStatus = ReadOptionalStringArgument(arguments, "status") ?? existingItem?.Status ?? "pending"; - if (!TryNormalizeTodoStatus(requestedStatus, out string normalizedStatus)) - { - isError = true; - resultText = JsonSerializer.Serialize(new - { - success = false, - errorCode = "invalid_args", - message = "Invalid todo status.", - data = new - { - status = requestedStatus, - allowed = GetTodoStatusValues() - } - }); - return true; - } - - string? notes = ReadOptionalStringArgument(arguments, "notes") ?? existingItem?.Notes; - TodoEntry entry = existingItem ?? new TodoEntry - { - Id = todoId, - Order = ++nextTodoOrder - }; - entry.Content = content; - entry.Status = normalizedStatus; - entry.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); - todos[todoId] = entry; - - resultText = JsonSerializer.Serialize(new - { - success = true, - data = new - { - item = ToTodoDto(entry), - totalCount = todos.Count - } - }); - return true; -} - -static async Task RequestToolIterationAsync( - HttpClient openRouter, - string model, - List messages, - object[] tools, - HttpResponse response, - CancellationToken cancellationToken) -{ - var payload = new Dictionary - { - ["model"] = model, - ["messages"] = messages, - ["tools"] = tools, - ["tool_choice"] = "auto" - }; - - using HttpResponseMessage completion = await openRouter.PostAsync( - "chat/completions", - new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), - cancellationToken); - - string body = await completion.Content.ReadAsStringAsync(cancellationToken); - if (!completion.IsSuccessStatusCode) - { - await WriteEvent(response, "error", new - { - message = "OpenRouter request failed.", - statusCode = (int)completion.StatusCode, - body - }, cancellationToken); - return default; - } - - using JsonDocument doc = JsonDocument.Parse(body); - if (!TryGetFirstChoiceMessage(doc.RootElement, out JsonElement message)) - { - await WriteEvent(response, "error", new { message = "No completion choice returned by OpenRouter." }, cancellationToken); - return default; - } - - return message.Clone(); -} - -static bool TryReadToolCall(JsonElement toolCall, out string id, out string name, out string arguments) -{ - id = string.Empty; - name = string.Empty; - arguments = "{}"; - - if (!toolCall.TryGetProperty("id", out JsonElement idElement) - || !toolCall.TryGetProperty("function", out JsonElement functionElement) - || !functionElement.TryGetProperty("name", out JsonElement nameElement)) - { - return false; - } - - id = idElement.GetString() ?? string.Empty; - name = nameElement.GetString() ?? string.Empty; - arguments = functionElement.TryGetProperty("arguments", out JsonElement argsElement) - ? argsElement.GetString() ?? "{}" - : "{}"; - return true; -} - -static bool TryGetFirstChoiceMessage(JsonElement root, out JsonElement message) -{ - message = default; - if (!root.TryGetProperty("choices", out JsonElement choices) - || choices.ValueKind != JsonValueKind.Array - || choices.GetArrayLength() == 0) - { - return false; - } - - JsonElement first = choices[0]; - return first.TryGetProperty("message", out message); -} - -static Dictionary ParseArguments(string raw) -{ - try - { - using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(raw) ? "{}" : raw); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return new Dictionary(); - - Dictionary parsed = new(); - foreach (JsonProperty property in doc.RootElement.EnumerateObject()) - parsed[property.Name] = ConvertJsonElement(property.Value); - return parsed; - } - catch - { - return new Dictionary(); - } -} - -static string? ReadOptionalStringArgument(Dictionary arguments, string key) -{ - if (!arguments.TryGetValue(key, out object? value) || value is null) - return null; - - return value switch - { - string text => text.Trim(), - _ => Convert.ToString(value)?.Trim() - }; -} - -static object? ConvertJsonElement(JsonElement element) -{ - return element.ValueKind switch - { - JsonValueKind.Null => null, - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Number => element.TryGetInt64(out long i64) - ? i64 - : element.TryGetDouble(out double d) ? d : element.GetRawText(), - JsonValueKind.String => element.GetString(), - JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToArray(), - JsonValueKind.Object => element.EnumerateObject().ToDictionary(prop => prop.Name, prop => ConvertJsonElement(prop.Value)), - _ => element.GetRawText() - }; -} - -static string ReadToolResultText(CallToolResult result) -{ - if (result.Content is null) - return result.IsError == true ? "{\"success\":false}" : "{\"success\":true}"; - - StringBuilder sb = new(); - foreach (ContentBlock block in result.Content) - { - if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text)) - { - if (sb.Length > 0) - sb.Append('\n'); - sb.Append(text.Text); - } - } - - if (sb.Length > 0) - return sb.ToString(); - - return JsonSerializer.Serialize(new { isError = result.IsError }); -} - -static bool InferStructuredToolError(string toolResultText) -{ - try - { - using JsonDocument doc = JsonDocument.Parse(toolResultText); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return false; - - if (doc.RootElement.TryGetProperty("success", out JsonElement successElement) - && successElement.ValueKind == JsonValueKind.False) - { - return true; - } - - return doc.RootElement.TryGetProperty("errorCode", out JsonElement errorCodeElement) - && errorCodeElement.ValueKind == JsonValueKind.String - && !string.IsNullOrWhiteSpace(errorCodeElement.GetString()); - } - catch - { - return false; - } -} - -static bool TryNormalizeTodoStatus(string rawStatus, out string normalizedStatus) -{ - normalizedStatus = rawStatus.Trim().ToLowerInvariant(); - return normalizedStatus is "pending" or "in_progress" or "completed" or "blocked" or "cancelled"; -} - -static string[] GetTodoStatusValues() -{ - return ["pending", "in_progress", "completed", "blocked", "cancelled"]; -} - -static object ToTodoDto(TodoEntry item) -{ - return new - { - id = item.Id, - content = item.Content, - status = item.Status, - notes = item.Notes, - order = item.Order - }; -} - -static string ParseAgentFinishAnswer(string argumentsRaw) -{ - try - { - using JsonDocument doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsRaw) ? "{}" : argumentsRaw); - if (doc.RootElement.TryGetProperty("answer", out JsonElement answerElement) - && answerElement.ValueKind == JsonValueKind.String) - { - string answer = answerElement.GetString() ?? string.Empty; - if (!string.IsNullOrWhiteSpace(answer)) - return answer.Trim(); - } - } - catch - { - // ignore and use fallback below - } - - return """ -Reasoning: -- The model requested completion without a textual payload. -- Returning a safe fallback response. - -Answer: -I completed the requested tool workflow but did not receive a final textual answer payload. -"""; -} - -static string EnsureFinalAnswerFormat(string text, IReadOnlyList observations) -{ - string trimmed = text.Trim(); - if (trimmed.Length == 0) - trimmed = "I completed the tool workflow but produced no textual output."; - - bool hasReasoning = trimmed.Contains("Reasoning:", StringComparison.OrdinalIgnoreCase); - bool hasAnswer = trimmed.Contains("Answer:", StringComparison.OrdinalIgnoreCase); - if (hasReasoning && hasAnswer) - return trimmed; - - string[] latestObservations = observations - .TakeLast(3) - .ToArray(); - if (latestObservations.Length == 0) - latestObservations = ["Tool-assisted reasoning completed."]; - - string observationBullets = string.Join('\n', latestObservations.Select(observation => $"- {observation}")); - return $""" -Reasoning: -{observationBullets} -- Final response generated after tool execution and verification. - -Answer: -{trimmed} -"""; -} - -static bool ShouldInjectReminder(int iteration, int maxIterations, int toolCallCount, int maxToolCalls, TimeSpan elapsed, TimeSpan maxWallTime) -{ - return iteration >= maxIterations - 6 - || toolCallCount >= maxToolCalls - 12 - || elapsed >= maxWallTime - TimeSpan.FromSeconds(45); -} - -static string BuildForcedFinalAnswer( - IReadOnlyList observations, - int toolCalls, - TimeSpan elapsed, - int maxIterations, - int maxToolCalls, - TimeSpan maxWallTime) -{ - string lastObservation = observations.Count > 0 ? observations[^1] : "No tool observation was captured."; - return $""" -Reasoning: -- The agent loop reached its safety budget before `agent_finish` was called. -- Last observation: {lastObservation} -- Budget usage: toolCalls={toolCalls}/{maxToolCalls}, elapsed={elapsed.TotalSeconds:F1}s/{maxWallTime.TotalSeconds:F1}s, maxIterations={maxIterations}. - -Answer: -I could not complete this request within the configured tool budget. Ask me to retry and I will continue with a fresh loop. -"""; -} - -static string SummarizeObservation(string toolName, string toolResultText, bool isError) -{ - string status = isError ? "error" : "ok"; - return $"{toolName} => {status}: {Truncate(toolResultText.Replace('\n', ' '), 180)}"; -} - -static string Truncate(string text, int maxLength) -{ - if (string.IsNullOrEmpty(text) || text.Length <= maxLength) - return text; - return text[..maxLength] + "..."; -} - -static async Task StreamFinalAnswer(HttpResponse response, string finalText, CancellationToken cancellationToken) -{ - string text = finalText.Trim(); - if (text.Length == 0) - text = "I completed the request but no final text was generated."; - - MatchCollection tokens = Regex.Matches(text, @"\S+\s*", RegexOptions.CultureInvariant); - if (tokens.Count == 0) - { - await WriteEvent(response, "token", new { text }, cancellationToken); - await WriteEvent(response, "final", new { text }, cancellationToken); - return; - } - - const int wordsPerChunk = 10; - StringBuilder chunk = new(); - int words = 0; - - foreach (Match token in tokens.Cast()) - { - chunk.Append(token.Value); - words++; - if (words >= wordsPerChunk) - { - await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); - chunk.Clear(); - words = 0; - } - } - - if (chunk.Length > 0) - await WriteEvent(response, "token", new { text = chunk.ToString() }, cancellationToken); - - await WriteEvent(response, "final", new { text }, cancellationToken); -} - -static int GetBoundedInt(string envName, int fallback, int min, int max) -{ - string? raw = Environment.GetEnvironmentVariable(envName); - if (!int.TryParse(raw, out int parsed)) - return fallback; - return Math.Clamp(parsed, min, max); -} - -static async Task WriteEvent(HttpResponse response, string eventName, object payload, CancellationToken cancellationToken) -{ - string json = JsonSerializer.Serialize(payload); - await response.WriteAsync($"event: {eventName}\n", cancellationToken); - await response.WriteAsync($"data: {json}\n\n", cancellationToken); - await response.Body.FlushAsync(cancellationToken); -} - -public sealed class ChatStreamRequest -{ - public List? Messages { get; set; } -} - -public sealed class ChatMessage -{ - public string Role { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; -} - -public sealed class TodoEntry -{ - public required string Id { get; init; } - public required int Order { get; init; } - public string Content { get; set; } = string.Empty; - public string Status { get; set; } = "pending"; - public string? Notes { get; set; } -} diff --git a/DebugTools/MccMcpWebPlayground/appsettings.Development.json b/DebugTools/MccMcpWebPlayground/appsettings.Development.json index 0c208ae9..6cde4d27 100644 --- a/DebugTools/MccMcpWebPlayground/appsettings.Development.json +++ b/DebugTools/MccMcpWebPlayground/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "MccWebHarness": { + "AllowFallbacks": false, + "DisableParallelToolCalls": true, + "ExposeInventoryWindowAction": false, + "ExposeInternalCommandTool": false } } diff --git a/DebugTools/MccMcpWebPlayground/appsettings.json b/DebugTools/MccMcpWebPlayground/appsettings.json index 10f68b8c..869684a5 100644 --- a/DebugTools/MccMcpWebPlayground/appsettings.json +++ b/DebugTools/MccMcpWebPlayground/appsettings.json @@ -5,5 +5,20 @@ "Microsoft.AspNetCore": "Warning" } }, + "MccWebHarness": { + "OpenRouterBaseUrl": "https://openrouter.ai/api/v1", + "McpEndpoint": "http://127.0.0.1:33333/mcp", + "MaxTurns": 48, + "MaxToolCalls": 120, + "MaxWallClockSeconds": 240, + "SoftFinishRemainingTurns": 3, + "SoftFinishRemainingToolCalls": 8, + "SoftFinishRemainingSeconds": 30, + "RequireProviderParameters": true, + "AllowFallbacks": false, + "DisableParallelToolCalls": true, + "ExposeInventoryWindowAction": false, + "ExposeInternalCommandTool": false + }, "AllowedHosts": "*" } diff --git a/DebugTools/MccMcpWebPlayground/wwwroot/app.js b/DebugTools/MccMcpWebPlayground/wwwroot/app.js new file mode 100644 index 00000000..14cd9c0e --- /dev/null +++ b/DebugTools/MccMcpWebPlayground/wwwroot/app.js @@ -0,0 +1,310 @@ +const html = document.documentElement; +const statusEl = document.getElementById("status"); +const sendBtn = document.getElementById("send"); +const stopBtn = document.getElementById("stop"); +const clearBtn = document.getElementById("clear"); +const clearChatBtn = document.getElementById("clear-chat-btn"); +const clearToolsBtn = document.getElementById("clear-tools-btn"); +const promptEl = document.getElementById("prompt"); +const chatEl = document.getElementById("chat"); +const toolsEl = document.getElementById("tools"); +const emptyStateEl = document.getElementById("empty-state"); +const toolsEmptyStateEl = document.getElementById("tools-empty-state"); +const typingIndicatorEl = document.getElementById("typing-indicator"); +const themeToggleBtn = document.getElementById("theme-toggle"); +const themeToggleIconEl = document.getElementById("theme-toggle-icon"); + +let history = []; +let activeAssistantBody = null; +let abortController = null; + +stopBtn.disabled = true; + +loadTheme(); +loadConfig(); + +themeToggleBtn.addEventListener("click", () => { + const next = html.getAttribute("data-theme") === "dark" ? "light" : "dark"; + setTheme(next); +}); + +sendBtn.addEventListener("click", sendPrompt); +stopBtn.addEventListener("click", () => abortController?.abort()); + +clearBtn.addEventListener("click", () => { + history = []; + removeAllMessages(); + removeAllTimelineEvents(); + promptEl.value = ""; + activeAssistantBody = null; + updateEmptyStates(); +}); + +clearChatBtn.addEventListener("click", () => { + history = []; + removeAllMessages(); + promptEl.value = ""; + activeAssistantBody = null; + updateEmptyStates(); +}); + +clearToolsBtn.addEventListener("click", () => { + removeAllTimelineEvents(); + updateEmptyStates(); +}); + +promptEl.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + sendPrompt(); + } +}); + +async function loadConfig() { + try { + const response = await fetch("/api/config"); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const config = await response.json(); + const modelLabel = config.model ? config.model : "Model not configured"; + statusEl.textContent = config.hasApiKey ? modelLabel : `${modelLabel} / missing OPENROUTER_API_KEY`; + } catch (error) { + statusEl.textContent = `Config error: ${error.message}`; + } +} + +async function sendPrompt() { + const prompt = promptEl.value.trim(); + if (!prompt || abortController) { + return; + } + + history.push({ role: "user", content: prompt }); + addMessage("user", prompt); + promptEl.value = ""; + activeAssistantBody = addMessage("assistant", ""); + setBusy(true); + + abortController = new AbortController(); + + try { + const response = await fetch("/api/chat/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: history }), + signal: abortController.signal + }); + + if (!response.ok || !response.body) { + throw new Error(`HTTP ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let finalAssistantText = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + buffer = parseSseChunk(buffer, (eventName, envelope) => { + addTimelineEvent(eventName, envelope); + + if (eventName === "error") { + const errorMessage = envelope.data?.message ?? "Unknown error"; + addMessage("error", errorMessage); + } + + if (eventName === "final") { + finalAssistantText = formatFinalText(envelope.data); + activeAssistantBody.textContent = finalAssistantText; + } + + if (eventName === "state_summary") { + const turnCount = envelope.data?.turnCount ?? "?"; + const toolCallCount = envelope.data?.toolCallCount ?? "?"; + statusEl.textContent = `Running turn ${turnCount}, tools ${toolCallCount}`; + } + }); + } + + if (finalAssistantText.trim().length > 0) { + history.push({ role: "assistant", content: finalAssistantText }); + } + } catch (error) { + if (error.name !== "AbortError") { + addMessage("error", `Request failed: ${error.message}`); + addTimelineEvent("error", { + kind: "error", + data: { + code: "request_failed", + message: error.message + } + }); + } + } finally { + abortController = null; + activeAssistantBody = null; + setBusy(false); + } +} + +function parseSseChunk(buffer, onEvent) { + let blockIndex; + while ((blockIndex = buffer.indexOf("\n\n")) >= 0) { + const rawBlock = buffer.slice(0, blockIndex); + buffer = buffer.slice(blockIndex + 2); + + let eventName = "message"; + let dataText = ""; + for (const line of rawBlock.split("\n")) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataText += line.slice(5).trim(); + } + } + + if (!dataText) { + continue; + } + + try { + onEvent(eventName, JSON.parse(dataText)); + } catch (error) { + onEvent("error", { + kind: "error", + data: { + code: "invalid_sse_payload", + message: "Failed to parse SSE payload.", + detail: dataText + } + }); + } + } + + return buffer; +} + +function addMessage(role, content) { + const wrapper = document.createElement("div"); + wrapper.className = `message ${role}`; + + const label = document.createElement("div"); + label.className = "message-label"; + label.textContent = role; + + const body = document.createElement("div"); + body.className = "message-body"; + body.textContent = content; + + wrapper.append(label, body); + chatEl.insertBefore(wrapper, typingIndicatorEl); + chatEl.scrollTop = chatEl.scrollHeight; + updateEmptyStates(); + return body; +} + +function addTimelineEvent(kind, envelope) { + const event = document.createElement("div"); + event.className = `timeline-event kind-${kind}`; + + const label = document.createElement("div"); + label.className = "timeline-label"; + label.textContent = kind.replaceAll("_", " "); + + const body = document.createElement("div"); + body.className = "timeline-body-text"; + body.textContent = JSON.stringify(envelope.data ?? envelope, null, 2); + + event.append(label, body); + toolsEl.appendChild(event); + toolsEl.scrollTop = toolsEl.scrollHeight; + updateEmptyStates(); +} + +function formatFinalText(data) { + if (!data) { + return "The run completed without a final payload."; + } + + const lines = []; + if (data.headline) { + lines.push(data.headline); + lines.push(""); + } + + if (data.answerMarkdown) { + lines.push(data.answerMarkdown); + } + + if (Array.isArray(data.verifiedFacts) && data.verifiedFacts.length > 0) { + lines.push(""); + lines.push("Verified facts:"); + for (const fact of data.verifiedFacts) { + lines.push(`- ${fact}`); + } + } + + if (Array.isArray(data.openIssues) && data.openIssues.length > 0) { + lines.push(""); + lines.push("Open issues:"); + for (const issue of data.openIssues) { + lines.push(`- ${issue}`); + } + } + + if (data.nextAction) { + lines.push(""); + lines.push(`Next action: ${data.nextAction}`); + } + + return lines.join("\n"); +} + +function setBusy(busy) { + sendBtn.disabled = busy; + stopBtn.disabled = !busy; + promptEl.disabled = busy; + typingIndicatorEl.classList.toggle("visible", busy); + statusEl.classList.toggle("busy", busy); + if (!busy) { + loadConfig(); + } else { + statusEl.textContent = "Streaming run..."; + } +} + +function removeAllMessages() { + for (const message of chatEl.querySelectorAll(".message")) { + message.remove(); + } +} + +function removeAllTimelineEvents() { + for (const event of toolsEl.querySelectorAll(".timeline-event")) { + event.remove(); + } +} + +function updateEmptyStates() { + emptyStateEl.style.display = chatEl.querySelectorAll(".message").length === 0 ? "" : "none"; + toolsEmptyStateEl.style.display = toolsEl.querySelectorAll(".timeline-event").length === 0 ? "" : "none"; +} + +function loadTheme() { + const theme = localStorage.getItem("mcc-playground-theme") || "dark"; + setTheme(theme); +} + +function setTheme(theme) { + html.setAttribute("data-theme", theme); + themeToggleIconEl.textContent = theme === "dark" ? "◎" : "◐"; + localStorage.setItem("mcc-playground-theme", theme); +} diff --git a/DebugTools/MccMcpWebPlayground/wwwroot/index.html b/DebugTools/MccMcpWebPlayground/wwwroot/index.html index 68ae6c67..71a9d992 100644 --- a/DebugTools/MccMcpWebPlayground/wwwroot/index.html +++ b/DebugTools/MccMcpWebPlayground/wwwroot/index.html @@ -3,1115 +3,77 @@ - MCC MCP Live Playground + MCC MCP Playground - - + + - - - - -
-
+
+
-

MCC MCP Playground

+
+
MCC MCP Playground
+
Canonical guidance bootstrap, typed run state, verified completion
+
-
-
Booting…
-
- -
- -
+
+
- Chat +

Conversation

- +
-
-
- - - -

No messages yet.
Ask the LLM to control MCC via MCP.

+
+
+

No messages yet.

+

Ask the harness to inspect or act through MCC's MCP server.

-
- Thinking -
- -
+
+
- -
+
- - - - - Tool Events - +

Run Timeline

- - +
-
-
- - - -

No tool events yet.

+
+
+

No run events yet.

+

Typed SSE events will appear here as the harness runs.

- - -
- -