More tools, added debug tools, fixed issues with some tools.

This commit is contained in:
Anon 2026-03-28 04:12:37 +01:00
parent 79b091cab6
commit 7f9023e7bb
16 changed files with 4005 additions and 72 deletions

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
</ItemGroup>
</Project>

View file

@ -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<string, string> { ["Authorization"] = $"Bearer {mcpAuthToken}" }
}));
var executed = new List<object>();
CallToolResult sessionStatus = await CallAndStore("mcc_session_status");
await CallAndStore("mcc_players_list");
await CallAndStore("mcc_send_chat", new Dictionary<string, object?> { ["text"] = "/say mcp_full_sweep" });
await CallAndStore("mcc_run_internal_command", new Dictionary<string, object?> { ["command"] = "debug state" });
(double lookX, double lookY, double lookZ) = GetLookTarget(sessionStatus);
await CallAndStore("mcc_look_at", new Dictionary<string, object?> { ["x"] = lookX, ["y"] = lookY, ["z"] = lookZ });
await CallAndStore("mcc_move_to", new Dictionary<string, object?> { ["x"] = lookX, ["y"] = lookY, ["z"] = lookZ, ["timeoutMs"] = 2000 });
CallToolResult inventorySnapshot = await CallAndStore("mcc_inventory_snapshot", new Dictionary<string, object?> { ["inventoryId"] = 0 });
int actionSlot = GetInventoryActionSlot(inventorySnapshot);
await CallAndStore("mcc_inventory_window_action", new Dictionary<string, object?> { ["inventoryId"] = 0, ["slotId"] = actionSlot, ["actionType"] = "LeftClick" });
await CallAndStore("mcc_entities_query", new Dictionary<string, object?> { ["maxCount"] = 20 });
CallToolResult entitiesList = await CallAndStore("mcc_entities_list", new Dictionary<string, object?> { ["maxCount"] = 20 });
int? firstEntityId = GetFirstEntityId(entitiesList);
if (firstEntityId.HasValue)
{
await CallAndStore("mcc_entity_info", new Dictionary<string, object?>
{
["entityId"] = firstEntityId.Value,
["includeMetadata"] = false,
["includeEquipment"] = true,
["includeEffects"] = true
});
}
await CallAndStore("mcc_blocks_find", new Dictionary<string, object?> { ["query"] = "Grass", ["radius"] = 6, ["maxCount"] = 50 });
await CallAndStore("mcc_player_nearby", new Dictionary<string, object?> { ["radius"] = 48.0, ["includeSelf"] = false });
await CallAndStore("mcc_world_block_at", new Dictionary<string, object?> { ["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<CallToolResult> CallAndStore(string toolName, IReadOnlyDictionary<string, object?>? 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)
};
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\MinecraftClient\MinecraftClient.csproj" />
</ItemGroup>
</Project>

View file

@ -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<IMccMcpCapabilities, DeterministicCapabilities>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<MccMcpToolSet>();
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 = "<PlayerOne> 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 <x> <y> <z>", description = "Move to location." },
new { name = "useitem", usage = "useitem [x] [y] [z]", description = "Use current held item." },
new { name = "dig", usage = "dig <x> <y> <z> [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<string>(),
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 });
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
</ItemGroup>
</Project>

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,8 @@ namespace MinecraftClient
// Entity handling
private readonly Dictionary<int, Entity> 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<string, object>? nbt)
{
UpdateKnownSign(location, nbt);
}
/// <summary>
/// Called when "AutoComplete" completes.
/// </summary>
@ -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<string, object>? 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<string, object>? 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<string, object>? nbt, string sideKey)
{
if (nbt is null
|| !nbt.TryGetValue(sideKey, out object? sideValue)
|| sideValue is not Dictionary<string, object> 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<string, object>? nbt)
{
if (nbt is null)
return [];
List<string> 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<string, object> 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
}
}

View file

@ -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);
}

View file

@ -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<MccMcpCapabilityToggles> togglesProvider;
public MccMcpCapabilities(Func<MccMcpCapabilityToggles> 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<Material>();
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<Material>();
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<EntityType>();
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<double> 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<Location>? 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<NearbyPlayerSnapshot> 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<string>()
.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<string>()
.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<object> 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<char> 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<string, string> 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)

View file

@ -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)
{

View file

@ -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<byte> 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<string, object>? 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);
}
}
/// <summary>
/// Send a configuration packet to the server. Packet ID, compression, and encryption will be handled automatically.
/// </summary>

View file

@ -508,6 +508,13 @@ namespace MinecraftClient.Protocol
/// <param name="block">The block</param>
public void OnBlockChange(Location location, Block block);
/// <summary>
/// Called when block entity update data is received for a loaded block.
/// </summary>
/// <param name="location">The block location.</param>
/// <param name="nbt">The block entity NBT payload.</param>
public void OnBlockEntityData(Location location, Dictionary<string, object>? nbt);
/// <summary>
/// Called when "AutoComplete" completes.
/// </summary>