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(); + } + } } }