Added a bunch of new useful MCP Tools

This commit is contained in:
Anon 2026-03-30 23:14:28 +02:00
parent 7b415d5388
commit 968800b95a
9 changed files with 2453 additions and 155 deletions

View file

@ -1,182 +1,733 @@
using System.Net.Http.Headers;
using System.Text;
using System.Diagnostics;
using System.Text.Json;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
string endpoint = Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp";
string model = "minimax/minimax-m2.7";
bool useStdio = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_USE_STDIO"), "1", StringComparison.Ordinal);
string? openRouterApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
string openRouterBaseUrl = Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL") ?? "https://openrouter.ai/api/v1";
string? mcpAuthToken = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
await using McpClient client = useStdio
? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions()))
: await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken)
? null
: new Dictionary<string, string> { ["Authorization"] = $"Bearer {mcpAuthToken}" }
}));
string repoRoot = FindRepoRoot();
string rconScript = Path.Combine(repoRoot, "tools", "mc-rcon.sh");
string rconPort = Environment.GetEnvironmentVariable("MCC_RCON_PORT") ?? "25575";
string rconPassword = Environment.GetEnvironmentVariable("MCC_RCON_PASSWORD") ?? "test123";
bool skipSetup = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_SKIP_SETUP"), "1", StringComparison.Ordinal);
bool runLocalSetup = !useStdio && !skipSetup && IsLocalEndpoint(endpoint) && File.Exists(rconScript);
var executed = new List<object>();
var checks = new List<string>();
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)
try
{
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[]
await using McpClient client = useStdio
? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions()))
: await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
new { role = "system", content = "Summarize the MCP tool execution output briefly." },
new { role = "user", content = evidenceJson }
}
};
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken)
? null
: new Dictionary<string, string> { ["Authorization"] = $"Bearer {mcpAuthToken}" }
}));
HttpResponseMessage response = await http.PostAsync(
"chat/completions",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
ToolEnvelope initialWorldState = await CallSuccessAsync(client, executed, "mcc_world_state");
JsonElement initialWorldData = RequireData(initialWorldState);
string botName = ReadString(initialWorldData, "username") ?? "CursorBot";
Coordinate initialLocation = ReadCoordinate(initialWorldData, "location");
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
long setupBaseline = 0;
if (runLocalSetup)
{
ToolEnvelope baselineEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = 0L,
["maxCount"] = 1
});
setupBaseline = ReadInt64(RequireData(baselineEvents), "latestId");
await PrepareWorldAsync(rconScript, rconPort, rconPassword, botName, initialLocation);
await Task.Delay(1500);
}
ToolEnvelope worldState = await WaitForPredicateAsync(
client,
executed,
"mcc_world_state",
null,
envelope =>
{
if (!envelope.Success || envelope.Data is not JsonElement data)
return false;
return data.TryGetProperty("loadedChunkCount", out JsonElement loaded)
&& loaded.TryGetInt32(out int loadedChunkCount)
&& loadedChunkCount >= 0
&& HasNonNullProperty(data, "worldAge")
&& HasNonNullProperty(data, "timeOfDay");
},
"mcc_world_state never reported chunk/time state.");
JsonElement worldData = RequireData(worldState);
Coordinate worldLocation = ReadCoordinate(worldData, "location");
string dimension = RequireString(worldData, "dimension");
int loadedChunkCount = ReadInt32(worldData, "loadedChunkCount");
int pendingChunkCount = ReadInt32(worldData, "pendingChunkCount");
int totalChunkCount = ReadInt32(worldData, "totalChunkCount");
double loadRatio = ReadDouble(worldData, "loadRatio");
_ = RequireString(worldData, "host");
_ = ReadInt32(worldData, "port");
_ = RequireString(worldData, "username");
_ = ReadInt32(worldData, "protocol");
_ = ReadDouble(worldData, "tps");
Ensure(!string.IsNullOrWhiteSpace(dimension), "mcc_world_state returned an empty dimension.");
Ensure(loadedChunkCount + pendingChunkCount == totalChunkCount, "mcc_world_state chunk counters are inconsistent.");
Ensure(loadRatio is >= 0 and <= 1, "mcc_world_state loadRatio is out of range.");
Ensure(HasNonNullProperty(worldData, "worldAge"), "mcc_world_state.worldAge is null.");
Ensure(HasNonNullProperty(worldData, "timeOfDay"), "mcc_world_state.timeOfDay is null.");
if (runLocalSetup || useStdio)
{
Ensure(HasNonNullProperty(worldData, "rainLevel"), "mcc_world_state.rainLevel is null after setup.");
Ensure(HasNonNullProperty(worldData, "thunderLevel"), "mcc_world_state.thunderLevel is null after setup.");
}
checks.Add("mcc_world_state");
ToolEnvelope chunkStatus = await CallSuccessAsync(client, executed, "mcc_chunk_status");
JsonElement chunkData = RequireData(chunkStatus);
JsonElement chunk = RequireProperty(chunkData, "chunk");
_ = ReadInt32(chunk, "x");
_ = ReadInt32(chunk, "z");
Ensure(ReadBoolean(chunkData, "loaded"), "mcc_chunk_status reported the current chunk as unloaded.");
Ensure(ReadInt32(chunkData, "loadedChunkCount") + ReadInt32(chunkData, "pendingChunkCount") == ReadInt32(chunkData, "totalChunkCount"),
"mcc_chunk_status chunk counters are inconsistent.");
checks.Add("mcc_chunk_status");
await CallSuccessAsync(client, executed, "mcc_look_direction", new Dictionary<string, object?> { ["direction"] = "Down" });
ToolEnvelope raycast = await CallSuccessAsync(client, executed, "mcc_raycast_block", new Dictionary<string, object?>
{
["maxDistance"] = 8.0,
["includeNeighbors"] = true
});
JsonElement raycastData = RequireData(raycast);
Ensure(ReadBoolean(raycastData, "hit"), "mcc_raycast_block did not report a hit after looking down.");
JsonElement raycastBlock = RequireProperty(raycastData, "block");
Ensure(!string.Equals(RequireString(raycastBlock, "material"), "Air", StringComparison.OrdinalIgnoreCase),
"mcc_raycast_block hit Air instead of a solid block.");
Ensure(RequireProperty(raycastData, "neighbors").ValueKind == JsonValueKind.Object,
"mcc_raycast_block did not include neighbors when requested.");
checks.Add("mcc_raycast_block");
ToolEnvelope pathPreview = await CallSuccessAsync(client, executed, "mcc_path_preview", new Dictionary<string, object?>
{
["x"] = Math.Floor(worldLocation.X) + 2,
["y"] = worldLocation.Y,
["z"] = Math.Floor(worldLocation.Z),
["allowUnsafe"] = false,
["timeoutMs"] = 2000,
["maxWaypoints"] = 32
});
JsonElement pathData = RequireData(pathPreview);
Ensure(ReadBoolean(pathData, "pathFound"), "mcc_path_preview did not find a path to a nearby target.");
Ensure(RequireProperty(pathData, "waypoints").GetArrayLength() > 0, "mcc_path_preview returned no waypoints.");
checks.Add("mcc_path_preview");
ToolEnvelope stoneSearch = await WaitForPredicateAsync(
client,
executed,
"mcc_inventory_search",
new Dictionary<string, object?>
{
["query"] = "Stone",
["maxCount"] = 20,
["exactMatch"] = true,
["includeContainers"] = false
},
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
"mcc_inventory_search never found Stone in the player inventory.");
Ensure(ContainsItemType(RequireData(stoneSearch), "Stone"), "mcc_inventory_search results did not include Stone.");
ToolEnvelope swordSearch = await WaitForPredicateAsync(
client,
executed,
"mcc_inventory_search",
new Dictionary<string, object?>
{
["query"] = "DiamondSword",
["maxCount"] = 20,
["exactMatch"] = true,
["includeContainers"] = false
},
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
"mcc_inventory_search never found DiamondSword in the player inventory.");
Ensure(ContainsItemType(RequireData(swordSearch), "DiamondSword"), "mcc_inventory_search results did not include DiamondSword.");
checks.Add("mcc_inventory_search");
ToolEnvelope selectItem = await CallSuccessAsync(client, executed, "mcc_select_item", new Dictionary<string, object?>
{
["itemType"] = "DiamondSword",
["preferLowestSlot"] = true
});
JsonElement selectData = RequireData(selectItem);
int selectedSlot = ReadInt32(selectData, "selectedSlot");
ToolEnvelope playerStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
JsonElement playerStatsData = RequireData(playerStats);
Ensure(ReadInt32(playerStatsData, "currentSlot") == selectedSlot, "mcc_select_item did not update mcc_player_stats.currentSlot.");
_ = ReadInt32(playerStatsData, "playerEntityId");
_ = ReadInt32(playerStatsData, "level");
_ = ReadInt32(playerStatsData, "totalExperience");
_ = ReadCoordinate(playerStatsData, "location");
checks.Add("mcc_select_item");
checks.Add("mcc_player_stats");
ToolEnvelope playersDetailed = await CallSuccessAsync(client, executed, "mcc_players_detailed", new Dictionary<string, object?>
{
["includeSelf"] = true,
["includeCoordinates"] = true
});
JsonElement playersData = RequireData(playersDetailed);
JsonElement selfPlayer = FindPlayer(RequireProperty(playersData, "players"), botName);
_ = RequireString(selfPlayer, "uuid");
_ = ReadInt32(selfPlayer, "ping");
_ = ReadInt32(selfPlayer, "entityId");
_ = ReadDouble(selfPlayer, "x");
_ = ReadDouble(selfPlayer, "y");
_ = ReadDouble(selfPlayer, "z");
checks.Add("mcc_players_detailed");
ToolEnvelope statusEffects = await CallSuccessAsync(client, executed, "mcc_status_effects");
Ensure(RequireProperty(RequireData(statusEffects), "effects").ValueKind == JsonValueKind.Array,
"mcc_status_effects.effects is not an array.");
checks.Add("mcc_status_effects");
ToolEnvelope animation = await CallSuccessAsync(client, executed, "mcc_animation", new Dictionary<string, object?>
{
["hand"] = "MainHand"
});
Ensure(ReadBoolean(RequireData(animation), "success"), "mcc_animation did not report success.");
ToolEnvelope sneakOn = await CallSuccessAsync(client, executed, "mcc_toggle_sneak", new Dictionary<string, object?> { ["enabled"] = true });
Ensure(ReadBoolean(RequireData(sneakOn), "enabled"), "mcc_toggle_sneak(true) did not report enabled=true.");
ToolEnvelope sprintOn = await CallSuccessAsync(client, executed, "mcc_toggle_sprint", new Dictionary<string, object?> { ["enabled"] = true });
Ensure(ReadBoolean(RequireData(sprintOn), "enabled"), "mcc_toggle_sprint(true) did not report enabled=true.");
await CallSuccessAsync(client, executed, "mcc_look_angles", new Dictionary<string, object?>
{
["yaw"] = 45.0f,
["pitch"] = -15.0f
});
ToolEnvelope updatedStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
JsonElement updatedStatsData = RequireData(updatedStats);
Ensure(Math.Abs(ReadDouble(updatedStatsData, "yaw") - 45.0) < 0.01, "mcc_look_angles did not update yaw.");
Ensure(Math.Abs(ReadDouble(updatedStatsData, "pitch") - (-15.0)) < 0.01, "mcc_look_angles did not update pitch.");
checks.Add("mcc_animation");
checks.Add("mcc_toggle_sneak");
checks.Add("mcc_toggle_sprint");
checks.Add("mcc_look_direction");
checks.Add("mcc_look_angles");
ToolEnvelope nearestEntity = await WaitForPredicateAsync(
client,
executed,
"mcc_entity_nearest",
new Dictionary<string, object?>
{
["typeFilter"] = "ArmorStand",
["radius"] = 16.0,
["includePlayers"] = false
},
envelope => envelope.Success,
"mcc_entity_nearest never found a nearby ArmorStand.");
JsonElement nearestData = RequireData(nearestEntity);
int entityId = ReadInt32(nearestData, "id");
Ensure(string.Equals(RequireString(nearestData, "type"), "ArmorStand", StringComparison.OrdinalIgnoreCase),
"mcc_entity_nearest did not return an ArmorStand.");
ToolEnvelope attackEntity = await CallSuccessAsync(client, executed, "mcc_entity_attack", new Dictionary<string, object?>
{
["entityId"] = entityId
});
Ensure(ReadBoolean(RequireData(attackEntity), "success"), "mcc_entity_attack did not report success.");
checks.Add("mcc_entity_nearest");
checks.Add("mcc_entity_attack");
long recentSetupAfterId = runLocalSetup ? setupBaseline : 0;
if (runLocalSetup || useStdio)
{
ToolEnvelope setupEvents = await WaitForRecentEventTypesAsync(
client,
executed,
recentSetupAfterId,
"weather_rain",
"title",
"actionbar");
JsonElement setupEventsData = RequireData(setupEvents);
Ensure(GetEventTypes(setupEventsData).Contains("weather_rain", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include weather_rain.");
Ensure(GetEventTypes(setupEventsData).Contains("title", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include title.");
Ensure(GetEventTypes(setupEventsData).Contains("actionbar", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include actionbar.");
}
ToolEnvelope actionbarEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = 0L,
["maxCount"] = 20,
["typeFilter"] = "actionbar"
});
JsonElement actionbarData = RequireData(actionbarEvents);
Ensure(ReadInt32(actionbarData, "count") > 0, "mcc_recent_events typeFilter=actionbar returned no events.");
Ensure(AllEventsMatchType(actionbarData, "actionbar"), "mcc_recent_events typeFilter returned mixed event types.");
long inventoryBaseline = ReadInt64(actionbarData, "latestId");
int chestX = (int)Math.Floor(worldLocation.X) + 2;
int chestY = (int)Math.Floor(worldLocation.Y);
int chestZ = (int)Math.Floor(worldLocation.Z);
ToolEnvelope openContainer = await WaitForPredicateAsync(
client,
executed,
"mcc_container_open_at",
new Dictionary<string, object?>
{
["x"] = chestX,
["y"] = chestY,
["z"] = chestZ,
["timeoutMs"] = 3000,
["closeCurrent"] = true
},
envelope => envelope.Success,
"mcc_open_container_at never opened the nearby chest.");
JsonElement inventoryInfo = RequireProperty(RequireData(openContainer), "inventory");
int openedInventoryId = ReadInt32(inventoryInfo, "id");
ToolEnvelope closeContainer = await CallSuccessAsync(client, executed, "mcc_container_close", new Dictionary<string, object?>
{
["inventoryId"] = openedInventoryId,
["timeoutMs"] = 3000
});
Ensure(ReadBoolean(RequireData(closeContainer), "closed"), "mcc_close_container did not close the chest.");
ToolEnvelope inventoryEvents = await WaitForRecentEventTypesAsync(
client,
executed,
inventoryBaseline,
"inventory_open",
"inventory_close");
JsonElement inventoryEventsData = RequireData(inventoryEvents);
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_open", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_open.");
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_close", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_close.");
checks.Add("mcc_recent_events");
if (runLocalSetup)
{
long deathBaseline = ReadInt64(inventoryEventsData, "latestId");
await RunRconCommandAsync(rconScript, rconPort, rconPassword, $"kill {botName}");
ToolEnvelope deathEvents = await WaitForRecentEventTypesAsync(client, executed, deathBaseline, "death");
Ensure(GetEventTypes(RequireData(deathEvents)).Contains("death", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported death after the RCON kill.");
long respawnBaseline = ReadInt64(RequireData(deathEvents), "latestId");
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported respawn after mcc_respawn.");
checks.Add("mcc_respawn");
}
else
{
long respawnBaseline = ReadInt64(RequireData(inventoryEvents), "latestId");
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported respawn after mcc_respawn.");
checks.Add("mcc_respawn");
}
ToolEnvelope loadedBots = await CallSuccessAsync(client, executed, "mcc_loaded_bots");
JsonElement bots = RequireProperty(RequireData(loadedBots), "bots");
Ensure(ContainsBot(bots, "McpServer"), "mcc_loaded_bots did not include McpServer.");
checks.Add("mcc_loaded_bots");
ToolEnvelope disconnect = await CallSuccessAsync(client, executed, "mcc_disconnect");
Ensure(ReadBoolean(RequireData(disconnect), "disconnecting"), "mcc_disconnect did not report disconnecting=true.");
checks.Add("mcc_disconnect");
if (!useStdio)
{
await AssertDisconnectStopsEndpointAsync(client, executed);
}
Console.WriteLine(JsonSerializer.Serialize(new
{
success = true,
endpoint,
useStdio,
runLocalSetup,
checks,
executed
}, new JsonSerializerOptions { WriteIndented = true }));
Environment.ExitCode = 0;
}
catch (Exception ex)
{
Console.WriteLine(JsonSerializer.Serialize(new
{
success = false,
endpoint,
useStdio,
runLocalSetup,
error = ex.Message,
checks,
executed
}, new JsonSerializerOptions { WriteIndented = true }));
Environment.ExitCode = 1;
}
async Task<CallToolResult> CallAndStore(string toolName, IReadOnlyDictionary<string, object?>? args = null)
static async Task PrepareWorldAsync(string rconScript, string rconPort, string rconPassword, string botName, Coordinate location)
{
string[] commands =
[
$"op {botName}",
$"gamemode creative {botName}",
$"tp {botName} 0 80 0",
$"item replace entity {botName} hotbar.0 with minecraft:stone 32",
$"item replace entity {botName} hotbar.1 with minecraft:diamond_sword 1",
$"execute as {botName} at @s run setblock ~2 ~ ~ minecraft:chest",
$"execute as {botName} at @s run summon minecraft:armor_stand ~2 ~ ~1",
"weather clear",
"weather rain",
$"title {botName} title {{\"text\":\"mcp_title\"}}",
$"title {botName} actionbar {{\"text\":\"mcp_actionbar\"}}"
];
foreach (string command in commands)
{
await RunRconCommandAsync(rconScript, rconPort, rconPassword, command);
}
}
static async Task RunRconCommandAsync(string rconScript, string rconPort, string rconPassword, string command)
{
ProcessStartInfo startInfo = new("bash")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
startInfo.ArgumentList.Add(rconScript);
startInfo.ArgumentList.Add(command);
startInfo.ArgumentList.Add(rconPort);
startInfo.ArgumentList.Add(rconPassword);
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mc-rcon.sh.");
string stdout = await process.StandardOutput.ReadToEndAsync();
string stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"RCON command failed ({command}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}");
}
}
static async Task<ToolEnvelope> CallSuccessAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args = null)
{
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
if (!envelope.Success)
{
throw new InvalidOperationException(
$"{toolName} failed with errorCode={envelope.ErrorCode ?? "<null>"} message={envelope.Message ?? "<null>"}.");
}
return envelope;
}
static async Task<ToolEnvelope> WaitForPredicateAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args,
Func<ToolEnvelope, bool> predicate,
string failureMessage,
int maxAttempts = 12,
int delayMs = 400)
{
ToolEnvelope? lastEnvelope = null;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
lastEnvelope = envelope;
if (predicate(envelope))
return envelope;
await Task.Delay(delayMs);
}
throw new InvalidOperationException(
$"{failureMessage} Last result: success={lastEnvelope?.Success}, errorCode={lastEnvelope?.ErrorCode ?? "<null>"}.");
}
static async Task<ToolEnvelope> WaitForRecentEventTypesAsync(
McpClient client,
List<object> executed,
long afterId,
params string[] expectedTypes)
{
HashSet<string> expected = expectedTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
ToolEnvelope? lastEnvelope = null;
for (int attempt = 0; attempt < 12; attempt++)
{
ToolEnvelope envelope = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = afterId,
["maxCount"] = 100
});
lastEnvelope = envelope;
JsonElement data = RequireData(envelope);
HashSet<string> actual = GetEventTypes(data);
if (expected.All(actual.Contains))
return envelope;
await Task.Delay(400);
}
throw new InvalidOperationException(
$"mcc_recent_events never reported: {string.Join(", ", expectedTypes)} after event id {afterId}. Last latestId={ReadInt64(RequireData(lastEnvelope!), "latestId")}.");
}
static async Task<ToolEnvelope> CallToolAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args = null)
{
CallToolResult result = await client.CallToolAsync(toolName, args);
string responseJson = ExtractResponseJson(result);
JsonElement root = JsonDocument.Parse(responseJson).RootElement.Clone();
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement) ? dataElement.Clone() : null;
bool success = root.TryGetProperty("success", out JsonElement successElement)
&& successElement.ValueKind == JsonValueKind.True;
string? errorCode = ReadString(root, "errorCode");
string? message = ReadString(root, "message");
executed.Add(new
{
tool = toolName,
arguments = args,
isError = result.IsError,
result = result
success,
errorCode,
message,
response = root
});
return result;
return new ToolEnvelope(toolName, result.IsError ?? false, success, errorCode, message, root, data);
}
static (double x, double y, double z) GetLookTarget(CallToolResult sessionStatus)
static async Task AssertDisconnectStopsEndpointAsync(McpClient client, List<object> executed)
{
JsonElement? data = TryReadData(sessionStatus);
if (data is JsonElement jsonData &&
jsonData.TryGetProperty("location", out JsonElement location) &&
TryReadDouble(location, "x", out double x) &&
TryReadDouble(location, "y", out double y) &&
TryReadDouble(location, "z", out double z))
for (int attempt = 0; attempt < 15; attempt++)
{
return (x, y, z);
}
return (0.5, 80.0, 0.5);
}
static int GetInventoryActionSlot(CallToolResult inventorySnapshot)
{
JsonElement? data = TryReadData(inventorySnapshot);
if (data is JsonElement jsonData &&
jsonData.TryGetProperty("slots", out JsonElement slots) &&
slots.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement slot in slots.EnumerateArray())
try
{
if (TryReadInt(slot, "slot", out int slotId))
return slotId;
await CallToolAsync(client, executed, "mcc_world_state");
}
catch
{
return;
}
await Task.Delay(300);
}
return 0;
throw new InvalidOperationException("The MCP endpoint still responded after mcc_disconnect.");
}
static int? GetFirstEntityId(CallToolResult entitiesList)
{
JsonElement? data = TryReadData(entitiesList);
if (data is not JsonElement jsonData)
return null;
if (!jsonData.TryGetProperty("entities", out JsonElement entities)
|| entities.ValueKind != JsonValueKind.Array
|| entities.GetArrayLength() == 0)
{
return null;
}
JsonElement first = entities[0];
if (TryReadInt(first, "id", out int entityId))
return entityId;
return null;
}
static JsonElement? TryReadData(CallToolResult result)
static string ExtractResponseJson(CallToolResult result)
{
if (result.Content is null)
return null;
throw new InvalidOperationException("Tool response did not contain any content blocks.");
foreach (ContentBlock content in result.Content)
{
if (content is TextContentBlock text &&
!string.IsNullOrWhiteSpace(text.Text))
{
using JsonDocument doc = JsonDocument.Parse(text.Text);
if (doc.RootElement.TryGetProperty("data", out JsonElement data))
return data.Clone();
}
if (content is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
return text.Text;
}
return null;
throw new InvalidOperationException("Tool response did not contain a text payload.");
}
static bool TryReadDouble(JsonElement element, string property, out double value)
static JsonElement RequireData(ToolEnvelope envelope)
{
value = 0;
return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetDouble(out value);
if (envelope.Data is JsonElement data)
return data;
throw new InvalidOperationException($"{envelope.ToolName} returned no data payload.");
}
static bool TryReadInt(JsonElement element, string property, out int value)
static JsonElement RequireProperty(JsonElement element, string propertyName)
{
value = 0;
return element.TryGetProperty(property, out JsonElement prop) && prop.TryGetInt32(out value);
if (element.TryGetProperty(propertyName, out JsonElement property))
return property;
throw new InvalidOperationException($"Missing required property '{propertyName}'.");
}
static string RequireString(JsonElement element, string propertyName)
{
string? value = ReadString(element, propertyName);
if (!string.IsNullOrWhiteSpace(value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is missing or empty.");
}
static string? ReadString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
static int ReadInt32(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetInt32(out int value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not an Int32.");
}
static long ReadInt64(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetInt64(out long value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not an Int64.");
}
static double ReadDouble(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetDouble(out double value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not a Double.");
}
static bool ReadBoolean(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => throw new InvalidOperationException($"Property '{propertyName}' is not a Boolean.")
};
}
static bool HasNonNullProperty(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind != JsonValueKind.Null;
}
static Coordinate ReadCoordinate(JsonElement element, string propertyName)
{
JsonElement coordinate = RequireProperty(element, propertyName);
return new Coordinate(
ReadDouble(coordinate, "x"),
ReadDouble(coordinate, "y"),
ReadDouble(coordinate, "z"));
}
static JsonElement FindPlayer(JsonElement players, string playerName)
{
foreach (JsonElement player in players.EnumerateArray())
{
string? name = ReadString(player, "name");
if (string.Equals(name, playerName, StringComparison.OrdinalIgnoreCase))
return player;
}
throw new InvalidOperationException($"Could not find player '{playerName}' in mcc_players_detailed.");
}
static bool ContainsItemType(JsonElement searchData, string itemType)
{
JsonElement matches = RequireProperty(searchData, "matches");
foreach (JsonElement match in matches.EnumerateArray())
{
if (string.Equals(ReadString(match, "itemType"), itemType, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
static bool ContainsBot(JsonElement bots, string botName)
{
foreach (JsonElement bot in bots.EnumerateArray())
{
if (string.Equals(ReadString(bot, "name"), botName, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
static HashSet<string> GetEventTypes(JsonElement recentEventsData)
{
JsonElement events = RequireProperty(recentEventsData, "events");
return events.EnumerateArray()
.Select(entry => RequireString(entry, "type"))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
static bool AllEventsMatchType(JsonElement recentEventsData, string type)
{
JsonElement events = RequireProperty(recentEventsData, "events");
foreach (JsonElement entry in events.EnumerateArray())
{
if (!string.Equals(RequireString(entry, "type"), type, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
static void Ensure(bool condition, string message)
{
if (!condition)
throw new InvalidOperationException(message);
}
static bool IsLocalEndpoint(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
return false;
return string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "::1", StringComparison.OrdinalIgnoreCase);
}
static string FindRepoRoot()
{
string current = Directory.GetCurrentDirectory();
DirectoryInfo? directory = new(current);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "MinecraftClient.sln")))
return directory.FullName;
directory = directory.Parent;
}
return current;
}
static StdioClientTransportOptions CreateStdioOptions()
@ -209,3 +760,14 @@ static StdioClientTransportOptions CreateStdioOptions()
ShutdownTimeout = TimeSpan.FromSeconds(5)
};
}
internal readonly record struct Coordinate(double X, double Y, double Z);
internal sealed record ToolEnvelope(
string ToolName,
bool IsError,
bool Success,
string? ErrorCode,
string? Message,
JsonElement Root,
JsonElement? Data);

View file

@ -24,14 +24,36 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
{
private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
private readonly List<RecentEvent> recentEvents = [];
private long nextEventId = 1;
private double playerX = C(0.5);
private double playerY = C(80.0);
private double playerZ = C(0.5);
private float yaw;
private float pitch;
private int currentSlot = 1;
private bool sneaking;
private bool sprinting;
private float health = 20.0f;
private bool disconnecting;
public DeterministicCapabilities()
{
AddRecentEvent("player_join", new { name = "HarnessBot" });
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest" });
AddRecentEvent("weather_rain", new { level = 1.0 });
AddRecentEvent("title", new { text = "mcp_title" });
AddRecentEvent("actionbar", new { text = "mcp_actionbar" });
}
public MccMcpResult GetSessionStatus() =>
MccMcpResult.Ok(new
{
connected = true,
connected = !disconnecting,
host = "deterministic.local",
port = 25565,
username = "HarnessBot",
location = new { x = C(0.5), y = C(80.0), z = C(0.5) }
location = new { x = playerX, y = playerY, z = playerZ }
});
public MccMcpResult GetServerInfo() =>
@ -47,22 +69,222 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
{
nickname = "HarnessBot",
username = "HarnessBot",
health = 20.0f,
health,
saturation = 20,
gamemode = 1,
currentSlot = 1,
yaw = 0.0f,
pitch = 0.0f,
location = new { x = C(0.5), y = C(80.0), z = C(0.5) },
currentSlot,
yaw,
pitch,
location = new { x = playerX, y = playerY, z = playerZ },
effects = new object[0]
});
public MccMcpResult GetWorldState() =>
MccMcpResult.Ok(new
{
connected = !disconnecting,
host = "deterministic.local",
port = 25565,
username = "HarnessBot",
protocol = 769,
terrainEnabled = true,
inventoryEnabled = true,
entityHandlingEnabled = true,
location = new { x = playerX, y = playerY, z = playerZ },
tps = 20.0,
dimension = "minecraft:overworld",
loadedChunkCount = 9,
pendingChunkCount = 0,
totalChunkCount = 9,
loadRatio = 1.0,
worldAge = 12000L,
timeOfDay = 6000L,
rainLevel = 1.0,
thunderLevel = 0.0
});
public MccMcpResult GetChunkStatus(double? x, double? y, double? z)
{
double resolvedX = x ?? playerX;
double resolvedY = y ?? playerY;
double resolvedZ = z ?? playerZ;
int chunkX = (int)Math.Floor(resolvedX) >> 4;
int chunkZ = (int)Math.Floor(resolvedZ) >> 4;
return MccMcpResult.Ok(new
{
location = new { x = C(resolvedX), y = C(resolvedY), z = C(resolvedZ) },
chunk = new { x = chunkX, z = chunkZ },
loaded = true,
fullyLoaded = true,
loadedChunkCount = 9,
pendingChunkCount = 0,
totalChunkCount = 9,
loadRatio = 1.0
});
}
public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors)
{
object? neighbors = includeNeighbors
? new
{
north = new { x = 0, y = 79, z = -1, material = "Air", typeLabel = "Air" },
south = new { x = 0, y = 79, z = 1, material = "Air", typeLabel = "Air" },
east = new { x = 1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
west = new { x = -1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
above = new { x = 0, y = 80, z = 0, material = "Air", typeLabel = "Air" },
below = new { x = 0, y = 78, z = 0, material = "Stone", typeLabel = "Stone" }
}
: null;
return MccMcpResult.Ok(new
{
hit = true,
maxDistance,
playerLocation = new { x = playerX, y = playerY, z = playerZ },
eyeLocation = new { x = playerX, y = C(playerY + 1.62), z = playerZ },
location = new { x = 0, y = 79, z = 0 },
block = new { material = "Stone", typeLabel = "Stone", blockId = 1, blockMeta = 0 },
distance = 1.12,
eyeDistance = 2.03,
neighbors
});
}
public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints)
{
object[] waypoints =
[
new { x = playerX, y = playerY, z = playerZ },
new { x = C((playerX + x) / 2), y = C((playerY + y) / 2), z = C((playerZ + z) / 2) },
new { x = C(x), y = C(y), z = C(z) }
];
return MccMcpResult.Ok(new
{
pathFound = true,
exactReachable = true,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = playerX, y = playerY, z = playerZ },
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
waypointCount = waypoints.Length,
truncated = waypoints.Length > Math.Max(1, maxWaypoints),
waypoints = waypoints.Take(Math.Max(1, maxWaypoints)).ToArray(),
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
}
public MccMcpResult GetPlayersList() =>
MccMcpResult.Ok(new
{
players = new[] { "HarnessBot", "PlayerOne" }
});
public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates)
{
List<object> players = [];
if (includeSelf)
{
players.Add(new
{
name = "HarnessBot",
uuid = Guid.Parse("11111111-1111-1111-1111-111111111111"),
ping = 5,
gamemode = 1,
listed = true,
displayName = "HarnessBot",
entityId = 1,
x = includeCoordinates ? playerX : (double?)null,
y = includeCoordinates ? playerY : (double?)null,
z = includeCoordinates ? playerZ : (double?)null
});
}
players.Add(new
{
name = "PlayerOne",
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
ping = 12,
gamemode = 1,
listed = true,
displayName = "PlayerOne",
entityId = 2,
x = includeCoordinates ? C(3.5) : (double?)null,
y = includeCoordinates ? C(80.0) : (double?)null,
z = includeCoordinates ? C(0.5) : (double?)null
});
return MccMcpResult.Ok(new
{
count = players.Count,
players = players.ToArray()
});
}
public MccMcpResult GetPlayerStats() =>
MccMcpResult.Ok(new
{
health,
saturation = 20,
level = 12,
totalExperience = 245,
gamemode = 1,
playerEntityId = 1,
currentSlot,
yaw,
pitch,
sneaking,
sprinting,
location = new { x = playerX, y = playerY, z = playerZ },
tps = 20.0
});
public MccMcpResult GetStatusEffects() =>
MccMcpResult.Ok(new
{
count = 0,
effects = Array.Empty<object>()
});
public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter)
{
RecentEvent[] events = recentEvents
.Where(e => e.Id > afterId)
.Where(e => string.IsNullOrWhiteSpace(typeFilter) || string.Equals(e.Type, typeFilter, StringComparison.OrdinalIgnoreCase))
.Take(Math.Max(1, maxCount))
.ToArray();
return MccMcpResult.Ok(new
{
afterId,
latestId = recentEvents.Count > 0 ? recentEvents[^1].Id : 0,
count = events.Length,
events = events.Select(e => new
{
id = e.Id,
timestampUtc = e.TimestampUtc,
type = e.Type,
data = e.Data
}).ToArray()
});
}
public MccMcpResult GetLoadedBots() =>
MccMcpResult.Ok(new
{
count = 2,
bots = new object[]
{
new { name = "McpServer", fullTypeName = "MinecraftClient.ChatBots.McpServer", isScript = false },
new { name = "HarnessScript", fullTypeName = "MinecraftClient.ChatBots.Script", isScript = true }
}
});
public MccMcpResult GetChatHistory(int maxCount, bool includeJson) =>
MccMcpResult.Ok(new
{
@ -135,14 +357,38 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
public MccMcpResult QuitClient() =>
MccMcpResult.Ok(new { quitting = true });
public MccMcpResult DisconnectClient()
{
disconnecting = true;
AddRecentEvent("disconnect", new { reason = "requested", message = "Disconnect requested by test client." });
return MccMcpResult.Ok(new { disconnecting = true });
}
public MccMcpResult RunInternalCommand(string command) =>
MccMcpResult.Ok(new { command, status = "Done", output = "deterministic" });
public MccMcpResult UseItemOnHand() =>
MccMcpResult.Ok(new { success = true, action = "use_item_on_hand" });
public MccMcpResult ChangeHotbarSlot(int slot) =>
MccMcpResult.Ok(new { success = true, slot });
public MccMcpResult ChangeHotbarSlot(int slot)
{
currentSlot = slot;
return MccMcpResult.Ok(new { success = true, slot });
}
public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot)
{
currentSlot = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 2 : 1;
return MccMcpResult.Ok(new
{
success = true,
itemType,
inventorySlot = currentSlot - 1,
selectedSlot = currentSlot,
count = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 1 : 32,
preferLowestSlot
});
}
public MccMcpResult UseItemOnBlock(double x, double y, double z) =>
MccMcpResult.Ok(new { success = true, x = C(x), y = C(y), z = C(z), action = "useitem" });
@ -169,6 +415,58 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
public MccMcpResult InteractEntity(int entityId, string interaction, string hand) =>
MccMcpResult.Ok(new { success = true, entityId, interaction, hand });
public MccMcpResult AttackEntity(int entityId) =>
MccMcpResult.Ok(new { success = true, entityId, interaction = "Attack" });
public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers)
{
bool wantsArmorStand = string.IsNullOrWhiteSpace(typeFilter)
|| string.Equals(typeFilter, "ArmorStand", StringComparison.OrdinalIgnoreCase)
|| string.Equals(typeFilter, "Armor Stand", StringComparison.OrdinalIgnoreCase);
if (wantsArmorStand && radius >= 4.0)
{
return MccMcpResult.Ok(new
{
id = 7,
type = "ArmorStand",
typeLabel = "Armor Stand",
uuid = Guid.Parse("33333333-3333-3333-3333-333333333333"),
name = "Armor Stand",
customName = (string?)null,
x = C(2.5),
y = C(80.0),
z = C(0.5),
distance = 2.0,
health = 20.0f,
pose = "Standing",
latency = 0
});
}
if (includePlayers && radius >= 3.0)
{
return MccMcpResult.Ok(new
{
id = 2,
type = "Player",
typeLabel = "Player",
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
name = string.IsNullOrWhiteSpace(nameFilter) ? "PlayerOne" : nameFilter,
customName = (string?)null,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0,
health = 20.0f,
pose = "Standing",
latency = 12
});
}
return MccMcpResult.Fail("invalid_state", data: new { typeFilter, nameFilter, radius, includePlayers });
}
public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) =>
MccMcpResult.Ok(new
{
@ -298,6 +596,61 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
public MccMcpResult LookAt(double x, double y, double z) =>
MccMcpResult.Ok(new { looked = true, x = C(x), y = C(y), z = C(z) });
public MccMcpResult LookDirection(string direction)
{
switch (direction.Trim().ToLowerInvariant())
{
case "up":
yaw = 0.0f;
pitch = -90.0f;
break;
case "down":
yaw = 0.0f;
pitch = 90.0f;
break;
case "north":
yaw = 180.0f;
pitch = 0.0f;
break;
case "south":
yaw = 0.0f;
pitch = 0.0f;
break;
case "east":
yaw = -90.0f;
pitch = 0.0f;
break;
case "west":
yaw = 90.0f;
pitch = 0.0f;
break;
}
return MccMcpResult.Ok(new { success = true, direction, yaw, pitch });
}
public MccMcpResult LookAngles(float yaw, float pitch)
{
this.yaw = yaw;
this.pitch = pitch;
return MccMcpResult.Ok(new { success = true, yaw, pitch });
}
public MccMcpResult PlayAnimation(string hand) =>
MccMcpResult.Ok(new { success = true, hand });
public MccMcpResult ToggleSneak(bool enabled)
{
sneaking = enabled;
return MccMcpResult.Ok(new { success = true, enabled = sneaking });
}
public MccMcpResult ToggleSprint(bool enabled)
{
sprinting = enabled;
return MccMcpResult.Ok(new { success = true, enabled = sprinting });
}
public MccMcpResult ListInventories() =>
MccMcpResult.Ok(new
{
@ -322,8 +675,73 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
}
});
public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent) =>
MccMcpResult.Ok(new
public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers)
{
List<object> matches = [];
if (query.Contains("stone", StringComparison.OrdinalIgnoreCase))
{
matches.Add(new
{
inventoryId = 0,
inventoryType = "PlayerInventory",
inventoryTitle = "Player Inventory",
slot = 0,
itemType = "Stone",
typeLabel = "Stone",
count = 32,
isPlayerInventory = true,
hotbarSlot = 1
});
}
if (query.Contains("diamond", StringComparison.OrdinalIgnoreCase) || query.Contains("sword", StringComparison.OrdinalIgnoreCase))
{
matches.Add(new
{
inventoryId = 0,
inventoryType = "PlayerInventory",
inventoryTitle = "Player Inventory",
slot = 1,
itemType = "DiamondSword",
typeLabel = "Diamond Sword",
count = 1,
isPlayerInventory = true,
hotbarSlot = 2
});
}
if (includeContainers)
{
matches.Add(new
{
inventoryId = 1,
inventoryType = "Generic_9x3",
inventoryTitle = "Chest",
slot = 0,
itemType = "Stone",
typeLabel = "Stone",
count = 16,
isPlayerInventory = false,
hotbarSlot = (int?)null
});
}
object[] result = matches.Take(Math.Max(1, maxCount)).ToArray();
return MccMcpResult.Ok(new
{
query,
exactMatch,
includeContainers,
count = result.Length,
matches = result
});
}
public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent)
{
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest", x, y, z });
return MccMcpResult.Ok(new
{
success = true,
openAccepted = true,
@ -335,15 +753,20 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
block = new { material = "Chest", typeLabel = "Chest", blockId = 0, blockMeta = 0 },
inventory = new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2 }
});
}
public MccMcpResult CloseContainer(int inventoryId, int timeoutMs) =>
MccMcpResult.Ok(new
public MccMcpResult CloseContainer(int inventoryId, int timeoutMs)
{
int resolvedInventoryId = inventoryId <= 0 ? 1 : inventoryId;
AddRecentEvent("inventory_close", new { inventoryId = resolvedInventoryId });
return MccMcpResult.Ok(new
{
success = true,
closed = true,
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
inventoryId = resolvedInventoryId,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
}
public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) =>
MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType });
@ -540,6 +963,22 @@ internal sealed class DeterministicCapabilities : IMccMcpCapabilities
}
});
public MccMcpResult Respawn()
{
health = 20.0f;
AddRecentEvent("respawn", new { location = new { x = playerX, y = playerY, z = playerZ } });
return MccMcpResult.Ok(new { success = true, respawned = true });
}
public MccMcpResult GetWorldBlockAt(int x, int y, int z) =>
MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 });
private void AddRecentEvent(string type, object? data)
{
recentEvents.Add(new RecentEvent(nextEventId++, DateTimeOffset.UtcNow, type, data));
if (recentEvents.Count > 100)
recentEvents.RemoveAt(0);
}
private sealed record RecentEvent(long Id, DateTimeOffset TimestampUtc, string Type, object? Data);
}