mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Added a bunch of new useful MCP Tools
This commit is contained in:
parent
7b415d5388
commit
968800b95a
9 changed files with 2453 additions and 155 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Mcp;
|
||||
using MinecraftClient.Scripting;
|
||||
using Tomlet.Attributes;
|
||||
|
|
@ -58,7 +59,7 @@ namespace MinecraftClient.ChatBots
|
|||
if (!Config.Enabled)
|
||||
return;
|
||||
|
||||
MccMcpChatHistoryStore.Clear();
|
||||
ClearStores();
|
||||
|
||||
MccMcpConfig mcpConfig = new()
|
||||
{
|
||||
|
|
@ -86,15 +87,20 @@ namespace MinecraftClient.ChatBots
|
|||
|
||||
public override bool OnDisconnect(DisconnectReason reason, string message)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("disconnect", new
|
||||
{
|
||||
reason = reason.ToString(),
|
||||
message
|
||||
});
|
||||
StopHost();
|
||||
MccMcpChatHistoryStore.Clear();
|
||||
ClearStores();
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void OnUnload()
|
||||
{
|
||||
StopHost();
|
||||
MccMcpChatHistoryStore.Clear();
|
||||
ClearStores();
|
||||
}
|
||||
|
||||
public override void GetText(string text, string? json)
|
||||
|
|
@ -133,6 +139,120 @@ namespace MinecraftClient.ChatBots
|
|||
});
|
||||
}
|
||||
|
||||
public override void OnTimeUpdate(long WorldAge, long TimeOfDay)
|
||||
{
|
||||
MccMcpRuntimeStateStore.SetTime(WorldAge, TimeOfDay);
|
||||
}
|
||||
|
||||
public override void OnRainLevelChange(float level)
|
||||
{
|
||||
MccMcpRuntimeStateStore.SetRainLevel(level);
|
||||
MccMcpRecentEventStore.Add("weather_rain", new { level });
|
||||
}
|
||||
|
||||
public override void OnThunderLevelChange(float level)
|
||||
{
|
||||
MccMcpRuntimeStateStore.SetThunderLevel(level);
|
||||
MccMcpRecentEventStore.Add("weather_thunder", new { level });
|
||||
}
|
||||
|
||||
public override void OnDeath()
|
||||
{
|
||||
MccMcpRecentEventStore.Add("death");
|
||||
}
|
||||
|
||||
public override void OnRespawn()
|
||||
{
|
||||
MccMcpRecentEventStore.Add("respawn");
|
||||
}
|
||||
|
||||
public override void OnPlayerJoin(Guid uuid, string name)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("player_join", new
|
||||
{
|
||||
uuid,
|
||||
name
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnPlayerLeave(Guid uuid, string? name)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("player_leave", new
|
||||
{
|
||||
uuid,
|
||||
name
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnInventoryOpen(int inventoryId)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("inventory_open", new { inventoryId });
|
||||
}
|
||||
|
||||
public override void OnInventoryClose(int inventoryId)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("inventory_close", new { inventoryId });
|
||||
}
|
||||
|
||||
public override void OnTitle(int action, string titletext, string subtitletext, string actionbartext, int fadein, int stay, int fadeout, string json)
|
||||
{
|
||||
if (action == 2)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("actionbar", new
|
||||
{
|
||||
action,
|
||||
text = actionbartext,
|
||||
fadein,
|
||||
stay,
|
||||
fadeout,
|
||||
json
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action is 0 or 1)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("title", new
|
||||
{
|
||||
action,
|
||||
titleText = titletext,
|
||||
subtitleText = subtitletext,
|
||||
fadein,
|
||||
stay,
|
||||
fadeout,
|
||||
json
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnBlockBreakAnimation(Entity entity, Location location, byte stage)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("block_break_animation", new
|
||||
{
|
||||
entityId = entity.ID,
|
||||
entityType = entity.Type.ToString(),
|
||||
stage,
|
||||
location = new
|
||||
{
|
||||
x = location.X,
|
||||
y = location.Y,
|
||||
z = location.Z
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnEntityAnimation(Entity entity, byte animation)
|
||||
{
|
||||
MccMcpRecentEventStore.Add("entity_animation", new
|
||||
{
|
||||
entityId = entity.ID,
|
||||
entityType = entity.Type.ToString(),
|
||||
animation,
|
||||
name = entity.Name,
|
||||
customName = entity.CustomName
|
||||
});
|
||||
}
|
||||
|
||||
private void StopHost()
|
||||
{
|
||||
if (host is null || !host.IsRunning)
|
||||
|
|
@ -143,5 +263,12 @@ namespace MinecraftClient.ChatBots
|
|||
else
|
||||
LogToConsole(string.Format(Translations.bot_mcpserver_stop_failed, error ?? "unknown"));
|
||||
}
|
||||
|
||||
private static void ClearStores()
|
||||
{
|
||||
MccMcpChatHistoryStore.Clear();
|
||||
MccMcpRuntimeStateStore.Clear();
|
||||
MccMcpRecentEventStore.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,16 @@ public interface IMccMcpCapabilities
|
|||
MccMcpResult GetSessionStatus();
|
||||
MccMcpResult GetServerInfo();
|
||||
MccMcpResult GetPlayerState();
|
||||
MccMcpResult GetWorldState();
|
||||
MccMcpResult GetChunkStatus(double? x, double? y, double? z);
|
||||
MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors);
|
||||
MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints);
|
||||
MccMcpResult GetPlayersList();
|
||||
MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates);
|
||||
MccMcpResult GetPlayerStats();
|
||||
MccMcpResult GetStatusEffects();
|
||||
MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter);
|
||||
MccMcpResult GetLoadedBots();
|
||||
MccMcpResult GetChatHistory(int maxCount, bool includeJson);
|
||||
MccMcpResult GetInternalCommands();
|
||||
MccMcpResult GetMaterialsList(string? filter, int maxCount);
|
||||
|
|
@ -13,23 +22,34 @@ public interface IMccMcpCapabilities
|
|||
MccMcpResult GetEntityTypesList(string? filter, int maxCount);
|
||||
MccMcpResult SendChat(string text);
|
||||
MccMcpResult QuitClient();
|
||||
MccMcpResult DisconnectClient();
|
||||
MccMcpResult Respawn();
|
||||
MccMcpResult RunInternalCommand(string command);
|
||||
MccMcpResult PlayAnimation(string hand);
|
||||
MccMcpResult ToggleSneak(bool enabled);
|
||||
MccMcpResult ToggleSprint(bool enabled);
|
||||
MccMcpResult UseItemOnHand();
|
||||
MccMcpResult ChangeHotbarSlot(int slot);
|
||||
MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot);
|
||||
MccMcpResult UseItemOnBlock(double x, double y, double z);
|
||||
MccMcpResult DigBlock(double x, double y, double z, double durationSeconds);
|
||||
MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock);
|
||||
MccMcpResult InteractEntity(int entityId, string interaction, string hand);
|
||||
MccMcpResult AttackEntity(int entityId);
|
||||
MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter);
|
||||
MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch);
|
||||
MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf);
|
||||
MccMcpResult LocatePlayer(string playerName, bool includeSelf);
|
||||
MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers);
|
||||
MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs);
|
||||
MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs);
|
||||
MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs);
|
||||
MccMcpResult LookAt(double x, double y, double z);
|
||||
MccMcpResult LookDirection(string direction);
|
||||
MccMcpResult LookAngles(float yaw, float pitch);
|
||||
MccMcpResult ListInventories();
|
||||
MccMcpResult GetInventorySnapshot(int inventoryId);
|
||||
MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers);
|
||||
MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent);
|
||||
MccMcpResult CloseContainer(int inventoryId, int timeoutMs);
|
||||
MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
|||
using MinecraftClient.CommandHandler;
|
||||
using MinecraftClient.Inventory;
|
||||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Protocol;
|
||||
using MinecraftClient.Protocol.Message;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
private const double SelfEntityDistanceThreshold = 0.2;
|
||||
private const int MaxBlockScanRadius = 12;
|
||||
private const int MaxBlockFindRadius = 32;
|
||||
private const double MaxRaycastDistance = 128.0;
|
||||
private const double DigReachDistance = 5.0;
|
||||
private const double DigReachDistanceSquared = DigReachDistance * DigReachDistance;
|
||||
private const int DefaultPathQueryTimeoutMs = 5000;
|
||||
|
|
@ -35,6 +37,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
private const int MinContainerWaitMs = 250;
|
||||
private const int MaxContainerWaitMs = 20000;
|
||||
private const int DefaultInventoryActionWaitMs = 3500;
|
||||
private const int MaxPathPreviewWaypoints = 1000;
|
||||
|
||||
private sealed class InternalCommandInfo
|
||||
{
|
||||
|
|
@ -174,6 +177,226 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetWorldState()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location location = client.GetCurrentLocation();
|
||||
World world = client.GetWorld();
|
||||
Dimension dimension = World.GetDimension();
|
||||
MccMcpRuntimeStateSnapshot runtimeState = MccMcpRuntimeStateStore.GetSnapshot();
|
||||
int totalChunkCount = world.chunkCnt;
|
||||
int pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted);
|
||||
int loadedChunkCount = GetLoadedChunkCount(world);
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
host = client.GetServerHost(),
|
||||
port = client.GetServerPort(),
|
||||
username = client.GetUsername(),
|
||||
protocol = client.GetProtocolVersion(),
|
||||
protocolVersion = client.GetProtocolVersion(),
|
||||
terrainEnabled = client.GetTerrainEnabled(),
|
||||
inventoryEnabled = client.GetInventoryEnabled(),
|
||||
entityEnabled = client.GetEntityHandlingEnabled(),
|
||||
entityHandlingEnabled = client.GetEntityHandlingEnabled(),
|
||||
location = ToCoordinate(location),
|
||||
tps = client.GetServerTPS(),
|
||||
dimension = dimension.Name,
|
||||
dimensionDetails = new
|
||||
{
|
||||
name = dimension.Name,
|
||||
minY = dimension.minY,
|
||||
maxY = dimension.maxY,
|
||||
height = dimension.height,
|
||||
logicalHeight = dimension.logicalHeight,
|
||||
coordinateScale = dimension.coordinateScale,
|
||||
hasSkylight = dimension.hasSkylight,
|
||||
hasCeiling = dimension.hasCeiling,
|
||||
fixedTime = dimension.fixedTime >= 0 ? dimension.fixedTime : (long?)null
|
||||
},
|
||||
loadedChunkCount,
|
||||
pendingChunkCount,
|
||||
totalChunkCount,
|
||||
loadRatio = GetChunkLoadRatio(world),
|
||||
worldAge = runtimeState.WorldAge,
|
||||
timeOfDay = runtimeState.TimeOfDay,
|
||||
rainLevel = runtimeState.RainLevel,
|
||||
thunderLevel = runtimeState.ThunderLevel
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetChunkStatus(double? x, double? y, double? z)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.EntityWorld))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (!HasCompleteCoordinateTriple(x, y, z))
|
||||
return MccMcpResult.Fail("invalid_args");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetTerrainEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location queryLocation = x.HasValue && y.HasValue && z.HasValue
|
||||
? new Location(x.Value, y.Value, z.Value)
|
||||
: client.GetCurrentLocation();
|
||||
|
||||
World world = client.GetWorld();
|
||||
ChunkColumn? chunkColumn = world.GetChunkColumn(queryLocation);
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
location = ToCoordinate(queryLocation),
|
||||
chunk = new
|
||||
{
|
||||
x = queryLocation.ChunkX,
|
||||
z = queryLocation.ChunkZ
|
||||
},
|
||||
chunkX = queryLocation.ChunkX,
|
||||
chunkZ = queryLocation.ChunkZ,
|
||||
loaded = chunkColumn is not null,
|
||||
fullyLoaded = chunkColumn?.FullyLoaded ?? false,
|
||||
loadedChunkCount = GetLoadedChunkCount(world),
|
||||
pendingChunkCount = Math.Max(0, world.chunkLoadNotCompleted),
|
||||
totalChunkCount = world.chunkCnt,
|
||||
loadRatio = GetChunkLoadRatio(world)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.EntityWorld))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (maxDistance <= 0 || maxDistance > MaxRaycastDistance)
|
||||
{
|
||||
return MccMcpResult.Fail("invalid_args", data: new
|
||||
{
|
||||
parameter = "maxDistance",
|
||||
minExclusive = 0,
|
||||
max = MaxRaycastDistance
|
||||
});
|
||||
}
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetTerrainEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location playerLocation = client.GetCurrentLocation();
|
||||
Location eyeLocation = playerLocation.EyesLocation();
|
||||
Tuple<bool, Location, Block> raycast = RaycastHelper.RaycastBlock(client, maxDistance, includeFluids: false);
|
||||
if (!raycast.Item1)
|
||||
{
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
hit = false,
|
||||
maxDistance,
|
||||
playerLocation = ToCoordinate(playerLocation),
|
||||
eyeLocation = ToCoordinate(eyeLocation),
|
||||
location = (object?)null,
|
||||
block = (object?)null,
|
||||
distance = (double?)null,
|
||||
eyeDistance = (double?)null,
|
||||
neighbors = (object?)null
|
||||
});
|
||||
}
|
||||
|
||||
Location blockLocation = raycast.Item2;
|
||||
Block block = raycast.Item3;
|
||||
Location targetCenter = blockLocation.ToCenter();
|
||||
object? neighbors = includeNeighbors ? GetNeighborBlockSnapshot(client.GetWorld(), blockLocation) : null;
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
hit = true,
|
||||
maxDistance,
|
||||
playerLocation = ToCoordinate(playerLocation),
|
||||
eyeLocation = ToCoordinate(eyeLocation),
|
||||
location = ToCoordinate(blockLocation),
|
||||
block = ToBlockState(block),
|
||||
distance = playerLocation.Distance(targetCenter),
|
||||
eyeDistance = eyeLocation.Distance(targetCenter),
|
||||
neighbors
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (!AreValidPathOffsets(maxOffset, minOffset) || timeoutMs < 0 || maxWaypoints <= 0)
|
||||
{
|
||||
return MccMcpResult.Fail("invalid_args", data: new
|
||||
{
|
||||
maxOffset,
|
||||
minOffset,
|
||||
timeoutMs,
|
||||
maxWaypoints
|
||||
});
|
||||
}
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetTerrainEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
Location goal = new(x, y, z);
|
||||
Location startLocation = client.InvokeOnMainThread(client.GetCurrentLocation);
|
||||
World world = client.InvokeOnMainThread(client.GetWorld);
|
||||
int effectiveTimeoutMs = GetPathQueryTimeoutMs(timeoutMs);
|
||||
int waypointLimit = Math.Clamp(maxWaypoints, 1, MaxPathPreviewWaypoints);
|
||||
Queue<Location>? path = Movement.CalculatePath(
|
||||
world,
|
||||
startLocation,
|
||||
goal,
|
||||
allowUnsafe,
|
||||
maxOffset,
|
||||
minOffset,
|
||||
TimeSpan.FromMilliseconds(effectiveTimeoutMs));
|
||||
Location[] waypoints = path?.Take(waypointLimit).ToArray() ?? [];
|
||||
Location? finalWaypoint = path is not null && path.Count > 0 ? path.Last() : null;
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
pathFound = path is not null,
|
||||
exactReachable = finalWaypoint is Location location && location.ToFloor() == goal.ToFloor(),
|
||||
target = ToCoordinate(goal),
|
||||
startLocation = ToCoordinate(startLocation),
|
||||
finalWaypoint = finalWaypoint is Location waypoint ? ToCoordinate(waypoint) : (object?)null,
|
||||
finalDistance = finalWaypoint is Location endWaypoint ? GetDistance(endWaypoint, goal) : (double?)null,
|
||||
waypointCount = path?.Count ?? 0,
|
||||
truncated = path is not null && path.Count > waypointLimit,
|
||||
waypoints = waypoints.Select(ToCoordinate).ToArray(),
|
||||
allowUnsafe,
|
||||
maxOffset,
|
||||
minOffset,
|
||||
timeoutMs = effectiveTimeoutMs
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetPlayersList()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
|
|
@ -189,6 +412,202 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
}));
|
||||
}
|
||||
|
||||
public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Dictionary<string, string> onlinePlayers = client.GetOnlinePlayersWithUUID();
|
||||
Dictionary<Guid, NearbyPlayerSnapshot>? trackedPlayers = client.GetEntityHandlingEnabled()
|
||||
? BuildTrackedPlayerSnapshots(client, includeSelf: true).ToDictionary(player => player.Uuid)
|
||||
: null;
|
||||
Guid selfUuid = client.GetUserUuid();
|
||||
string selfName = client.GetUsername();
|
||||
|
||||
var players = onlinePlayers
|
||||
.Select(pair =>
|
||||
{
|
||||
if (!Guid.TryParse(pair.Key, out Guid uuid))
|
||||
return null;
|
||||
|
||||
bool isSelf = uuid == selfUuid || NameComparer.Equals(pair.Value, selfName);
|
||||
if (!includeSelf && isSelf)
|
||||
return null;
|
||||
|
||||
PlayerInfo? playerInfo = client.GetPlayerInfo(uuid);
|
||||
NearbyPlayerSnapshot? trackedPlayer = trackedPlayers is not null
|
||||
&& trackedPlayers.TryGetValue(uuid, out NearbyPlayerSnapshot? resolvedTrackedPlayer)
|
||||
? resolvedTrackedPlayer
|
||||
: null;
|
||||
Location? selfLocation = isSelf ? client.GetCurrentLocation() : null;
|
||||
int? entityId = trackedPlayer?.EntityId ?? (isSelf ? client.GetPlayerEntityID() : null);
|
||||
double? x = includeCoordinates
|
||||
? trackedPlayer?.X is double trackedX ? RoundCoordinate(trackedX)
|
||||
: selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.X)
|
||||
: (double?)null
|
||||
: null;
|
||||
double? y = includeCoordinates
|
||||
? trackedPlayer?.Y is double trackedY ? RoundCoordinate(trackedY)
|
||||
: selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.Y)
|
||||
: (double?)null
|
||||
: null;
|
||||
double? z = includeCoordinates
|
||||
? trackedPlayer?.Z is double trackedZ ? RoundCoordinate(trackedZ)
|
||||
: selfLocation.HasValue ? RoundCoordinate(selfLocation.Value.Z)
|
||||
: (double?)null
|
||||
: null;
|
||||
|
||||
return new
|
||||
{
|
||||
name = playerInfo?.Name ?? pair.Value,
|
||||
uuid,
|
||||
ping = playerInfo?.Ping ?? trackedPlayer?.Latency ?? 0,
|
||||
gamemode = playerInfo?.Gamemode ?? -1,
|
||||
listed = playerInfo?.Listed ?? true,
|
||||
displayName = playerInfo?.DisplayName,
|
||||
entityId,
|
||||
x,
|
||||
y,
|
||||
z
|
||||
};
|
||||
})
|
||||
.Where(player => player is not null)
|
||||
.OrderBy(player => player!.name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
includeSelf,
|
||||
includeCoordinates,
|
||||
count = players.Length,
|
||||
players
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetPlayerStats()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location location = client.GetCurrentLocation();
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
username = client.GetUsername(),
|
||||
health = client.GetHealth(),
|
||||
saturation = client.GetSaturation(),
|
||||
level = client.GetLevel(),
|
||||
totalExperience = client.GetTotalExperience(),
|
||||
gamemode = client.GetGamemode(),
|
||||
playerEntityId = client.GetPlayerEntityID(),
|
||||
currentSlot = client.GetCurrentSlot() + 1,
|
||||
yaw = client.GetYaw(),
|
||||
pitch = client.GetPitch(),
|
||||
location = ToCoordinate(location),
|
||||
tps = client.GetServerTPS()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetStatusEffects()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
var effects = client.GetPlayerEffects()
|
||||
.Values
|
||||
.Where(effect => !effect.IsExpired)
|
||||
.OrderBy(effect => effect.Effect)
|
||||
.Select(effect => new
|
||||
{
|
||||
id = effect.Effect.ToString(),
|
||||
name = effect.GetDisplayName(),
|
||||
amplifier = effect.Amplifier,
|
||||
remainingSeconds = effect.RemainingSeconds,
|
||||
isInfinite = effect.IsInfinite
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
count = effects.Length,
|
||||
effects
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
MccMcpRecentEventEntry[] events = MccMcpRecentEventStore.GetAfter(afterId, maxCount, typeFilter);
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
afterId,
|
||||
latestId = MccMcpRecentEventStore.GetLatestId(),
|
||||
count = events.Length,
|
||||
events = events.Select(entry => new
|
||||
{
|
||||
id = entry.Id,
|
||||
timestampUtc = entry.TimestampUtc,
|
||||
type = entry.Type,
|
||||
data = entry.Data
|
||||
}).ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetLoadedBots()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
var bots = client.GetLoadedChatBots()
|
||||
.Select(bot => new
|
||||
{
|
||||
name = bot.GetType().Name,
|
||||
fullTypeName = bot.GetType().FullName,
|
||||
isScript = bot is MinecraftClient.ChatBots.Script
|
||||
})
|
||||
.OrderBy(bot => bot.name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
count = bots.Length,
|
||||
bots
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetChatHistory(int maxCount, bool includeJson)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.SessionStatus))
|
||||
|
|
@ -394,6 +813,48 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
return MccMcpResult.Ok(new { quitting = true });
|
||||
}
|
||||
|
||||
public MccMcpResult DisconnectClient()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.ChatAndCommands))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(150).ConfigureAwait(false);
|
||||
client.Disconnect();
|
||||
});
|
||||
|
||||
return MccMcpResult.Ok(new { disconnecting = true });
|
||||
}
|
||||
|
||||
public MccMcpResult Respawn()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.ChatAndCommands))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
float health = client.InvokeOnMainThread(client.GetHealth);
|
||||
if (health > 0)
|
||||
{
|
||||
return MccMcpResult.Fail("invalid_state", data: new
|
||||
{
|
||||
health
|
||||
});
|
||||
}
|
||||
|
||||
bool ok = client.InvokeOnMainThread(client.SendRespawnPacket);
|
||||
return ok
|
||||
? MccMcpResult.Ok(new { success = true })
|
||||
: MccMcpResult.Fail("action_failed", data: new { success = false });
|
||||
}
|
||||
|
||||
public MccMcpResult RunInternalCommand(string command)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.ChatAndCommands))
|
||||
|
|
@ -409,6 +870,67 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
return ExecuteInternalCommand(client, command.Trim());
|
||||
}
|
||||
|
||||
public MccMcpResult PlayAnimation(string hand)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(hand) || !Enum.TryParse(hand, true, out Hand parsedHand))
|
||||
return MccMcpResult.Fail("invalid_args");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
int animation = parsedHand == Hand.MainHand ? 1 : 0;
|
||||
bool ok = client.DoAnimation(animation);
|
||||
object resultData = new { success = ok, hand = parsedHand.ToString() };
|
||||
return ok
|
||||
? MccMcpResult.Ok(resultData)
|
||||
: MccMcpResult.Fail("action_failed", data: resultData);
|
||||
}
|
||||
|
||||
public MccMcpResult ToggleSneak(bool enabled)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
EntityActionType action = enabled ? EntityActionType.StartSneaking : EntityActionType.StopSneaking;
|
||||
bool ok = client.InvokeOnMainThread(() =>
|
||||
{
|
||||
bool actionResult = client.SendEntityAction(action);
|
||||
if (actionResult)
|
||||
client.IsSneaking = enabled;
|
||||
return actionResult;
|
||||
});
|
||||
|
||||
object resultData = new { success = ok, enabled };
|
||||
return ok
|
||||
? MccMcpResult.Ok(resultData)
|
||||
: MccMcpResult.Fail("action_failed", data: resultData);
|
||||
}
|
||||
|
||||
public MccMcpResult ToggleSprint(bool enabled)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
EntityActionType action = enabled ? EntityActionType.StartSprinting : EntityActionType.StopSprinting;
|
||||
bool ok = client.SendEntityAction(action);
|
||||
object resultData = new { success = ok, enabled };
|
||||
return ok
|
||||
? MccMcpResult.Ok(resultData)
|
||||
: MccMcpResult.Fail("action_failed", data: resultData);
|
||||
}
|
||||
|
||||
public MccMcpResult UseItemOnHand()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
|
|
@ -441,6 +963,77 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
return MccMcpResult.Ok(new { success = ok, slot });
|
||||
}
|
||||
|
||||
public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Inventory))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(itemType))
|
||||
return MccMcpResult.Fail("invalid_args");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetInventoryEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
if (!TryParseItemType(itemType, out ItemType parsedItemType))
|
||||
{
|
||||
return MccMcpResult.Fail("invalid_args", data: new
|
||||
{
|
||||
itemType = itemType.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Container? inventory = client.GetInventory(0);
|
||||
if (inventory is null)
|
||||
return MccMcpResult.Fail("invalid_state");
|
||||
|
||||
var matches = inventory.Items
|
||||
.Where(pair => pair.Value.Type == parsedItemType && pair.Value.Count > 0)
|
||||
.Select(pair =>
|
||||
{
|
||||
bool isHotbar = inventory.IsHotbar(pair.Key, out int hotbar);
|
||||
return new
|
||||
{
|
||||
inventorySlot = pair.Key,
|
||||
hotbar,
|
||||
isHotbar,
|
||||
count = pair.Value.Count
|
||||
};
|
||||
})
|
||||
.Where(match => match.isHotbar)
|
||||
.OrderBy(match => preferLowestSlot ? match.hotbar : -match.hotbar)
|
||||
.ToArray();
|
||||
|
||||
if (matches.Length == 0)
|
||||
{
|
||||
return MccMcpResult.Fail("invalid_state", data: new
|
||||
{
|
||||
itemType = parsedItemType.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
var selected = matches[0];
|
||||
bool ok = client.ChangeSlot((short)selected.hotbar);
|
||||
object resultData = new
|
||||
{
|
||||
success = ok,
|
||||
itemType = parsedItemType.ToString(),
|
||||
inventorySlot = selected.inventorySlot,
|
||||
selectedSlot = selected.hotbar + 1,
|
||||
count = selected.count
|
||||
};
|
||||
|
||||
return ok
|
||||
? MccMcpResult.Ok(resultData)
|
||||
: MccMcpResult.Fail("action_failed", data: resultData);
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult UseItemOnBlock(double x, double y, double z)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
|
|
@ -595,6 +1188,37 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
return MccMcpResult.Ok(new { success = ok, entityId, interaction = interactType.ToString(), hand = parsedHand.ToString() });
|
||||
}
|
||||
|
||||
public MccMcpResult AttackEntity(int entityId)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.EntityWorld))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetEntityHandlingEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
if (!client.GetEntities().ContainsKey(entityId))
|
||||
return MccMcpResult.Fail("invalid_state", data: new { entityId });
|
||||
|
||||
bool ok = client.InteractEntity(entityId, InteractType.Attack);
|
||||
object resultData = new
|
||||
{
|
||||
success = ok,
|
||||
entityId,
|
||||
interaction = InteractType.Attack.ToString()
|
||||
};
|
||||
|
||||
return ok
|
||||
? MccMcpResult.Ok(resultData)
|
||||
: MccMcpResult.Fail("action_failed", data: resultData);
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.EntityWorld))
|
||||
|
|
@ -931,6 +1555,86 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
});
|
||||
}
|
||||
|
||||
public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.EntityWorld))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (radius <= 0 || radius > 1024)
|
||||
return MccMcpResult.Fail("invalid_args");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetEntityHandlingEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
string? normalizedTypeFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim();
|
||||
string? normalizedNameFilter = string.IsNullOrWhiteSpace(nameFilter) ? null : nameFilter.Trim();
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location playerLocation = client.GetCurrentLocation();
|
||||
Dictionary<int, string?> playerNamesByEntityId = BuildTrackedPlayerSnapshots(client, includeSelf: true)
|
||||
.ToDictionary(player => player.EntityId, player => player.Name);
|
||||
|
||||
var nearest = client.GetEntities().Values
|
||||
.Where(entity => includePlayers || entity.Type != EntityType.Player)
|
||||
.Select(entity =>
|
||||
{
|
||||
double dx = entity.Location.X - playerLocation.X;
|
||||
double dy = entity.Location.Y - playerLocation.Y;
|
||||
double dz = entity.Location.Z - playerLocation.Z;
|
||||
string? resolvedName = entity.Type == EntityType.Player
|
||||
&& playerNamesByEntityId.TryGetValue(entity.ID, out string? mappedName)
|
||||
? mappedName
|
||||
: entity.Name;
|
||||
return new
|
||||
{
|
||||
entity,
|
||||
resolvedName,
|
||||
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
|
||||
};
|
||||
})
|
||||
.Where(item => item.distance <= radius)
|
||||
.Where(item => normalizedTypeFilter is null
|
||||
|| TextMatchesFilter(item.entity.Type.ToString(), normalizedTypeFilter)
|
||||
|| TextMatchesFilter(item.entity.GetTypeString(), normalizedTypeFilter))
|
||||
.Where(item => normalizedNameFilter is null || EntityNameMatches(item.resolvedName, item.entity.CustomName, normalizedNameFilter))
|
||||
.OrderBy(item => item.distance)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (nearest is null)
|
||||
{
|
||||
return MccMcpResult.Fail("invalid_state", data: new
|
||||
{
|
||||
typeFilter = normalizedTypeFilter,
|
||||
nameFilter = normalizedNameFilter,
|
||||
radius,
|
||||
includePlayers
|
||||
});
|
||||
}
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
id = nearest.entity.ID,
|
||||
type = nearest.entity.Type.ToString(),
|
||||
typeLabel = nearest.entity.GetTypeString(),
|
||||
uuid = nearest.entity.UUID,
|
||||
name = nearest.resolvedName,
|
||||
customName = nearest.entity.CustomName,
|
||||
x = RoundCoordinate(nearest.entity.Location.X),
|
||||
y = RoundCoordinate(nearest.entity.Location.Y),
|
||||
z = RoundCoordinate(nearest.entity.Location.Z),
|
||||
distance = nearest.distance,
|
||||
health = nearest.entity.Health,
|
||||
pose = nearest.entity.Pose.ToString(),
|
||||
latency = nearest.entity.Latency
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
|
|
@ -1096,6 +1800,60 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
return MccMcpResult.Ok();
|
||||
}
|
||||
|
||||
public MccMcpResult LookDirection(string direction)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(direction) || !Enum.TryParse(direction, true, out Direction parsedDirection) || !IsSupportedLookDirection(parsedDirection))
|
||||
return MccMcpResult.Fail("invalid_args");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetTerrainEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location current = client.GetCurrentLocation();
|
||||
client.UpdateLocation(current, parsedDirection);
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
direction = parsedDirection.ToString(),
|
||||
yaw = client.GetYaw(),
|
||||
pitch = client.GetPitch(),
|
||||
location = ToCoordinate(current)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult LookAngles(float yaw, float pitch)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Movement))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetTerrainEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
Location current = client.GetCurrentLocation();
|
||||
client.UpdateLocation(current, yaw, pitch);
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
yaw = client.GetYaw(),
|
||||
pitch = client.GetPitch(),
|
||||
location = ToCoordinate(current)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetInventorySnapshot(int inventoryId)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Inventory))
|
||||
|
|
@ -1132,6 +1890,69 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
});
|
||||
}
|
||||
|
||||
public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers)
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Inventory))
|
||||
return MccMcpResult.Fail("capability_disabled");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return MccMcpResult.Fail("invalid_args");
|
||||
|
||||
McClient? client = GetClient();
|
||||
if (client is null)
|
||||
return NotConnected();
|
||||
|
||||
if (!client.GetInventoryEnabled())
|
||||
return MccMcpResult.Fail("feature_disabled");
|
||||
|
||||
string normalizedQuery = query.Trim();
|
||||
ItemType? parsedItemType = exactMatch && TryParseItemType(normalizedQuery, out ItemType exactItemType)
|
||||
? exactItemType
|
||||
: null;
|
||||
int limit = Math.Clamp(maxCount, 1, 1000);
|
||||
|
||||
return client.InvokeOnMainThread(() =>
|
||||
{
|
||||
var matches = client.GetInventories()
|
||||
.Where(entry => includeContainers || entry.Key == 0)
|
||||
.OrderBy(entry => entry.Key)
|
||||
.SelectMany(entry =>
|
||||
{
|
||||
Container inventory = entry.Value;
|
||||
return inventory.Items
|
||||
.Where(pair => pair.Key >= 0 && pair.Value.Count > 0)
|
||||
.Where(pair => ItemMatches(pair.Value, normalizedQuery, exactMatch, parsedItemType))
|
||||
.Select(pair =>
|
||||
{
|
||||
bool isHotbar = inventory.IsHotbar(pair.Key, out int hotbar);
|
||||
return new
|
||||
{
|
||||
inventoryId = entry.Key,
|
||||
inventoryType = inventory.Type.ToString(),
|
||||
inventoryTitle = inventory.Title,
|
||||
slot = pair.Key,
|
||||
itemType = pair.Value.Type.ToString(),
|
||||
typeLabel = pair.Value.GetTypeString(),
|
||||
count = pair.Value.Count,
|
||||
isPlayerInventory = entry.Key == 0,
|
||||
hotbarSlot = isHotbar ? hotbar + 1 : (int?)null
|
||||
};
|
||||
});
|
||||
})
|
||||
.Take(limit)
|
||||
.ToArray();
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
query = normalizedQuery,
|
||||
exactMatch,
|
||||
includeContainers,
|
||||
count = matches.Length,
|
||||
matches
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult ListInventories()
|
||||
{
|
||||
if (!IsCategoryEnabled(t => t.Inventory))
|
||||
|
|
@ -2812,6 +3633,67 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
return maxOffset >= 0 && minOffset >= 0 && minOffset <= maxOffset;
|
||||
}
|
||||
|
||||
private static bool HasCompleteCoordinateTriple(double? x, double? y, double? z)
|
||||
{
|
||||
return x.HasValue == y.HasValue && y.HasValue == z.HasValue;
|
||||
}
|
||||
|
||||
private static int GetLoadedChunkCount(World world)
|
||||
{
|
||||
return Math.Max(0, world.chunkCnt - Math.Max(0, world.chunkLoadNotCompleted));
|
||||
}
|
||||
|
||||
private static double GetChunkLoadRatio(World world)
|
||||
{
|
||||
return world.chunkCnt > 0
|
||||
? GetLoadedChunkCount(world) / (double)world.chunkCnt
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
private static object GetNeighborBlockSnapshot(World world, Location location)
|
||||
{
|
||||
Location blockLocation = location.ToFloor();
|
||||
Location north = new(blockLocation.X, blockLocation.Y, blockLocation.Z - 1);
|
||||
Location south = new(blockLocation.X, blockLocation.Y, blockLocation.Z + 1);
|
||||
Location east = new(blockLocation.X + 1, blockLocation.Y, blockLocation.Z);
|
||||
Location west = new(blockLocation.X - 1, blockLocation.Y, blockLocation.Z);
|
||||
Location above = new(blockLocation.X, blockLocation.Y + 1, blockLocation.Z);
|
||||
Location below = new(blockLocation.X, blockLocation.Y - 1, blockLocation.Z);
|
||||
|
||||
return new
|
||||
{
|
||||
north = new { location = ToCoordinate(north), block = ToBlockState(world.GetBlock(north)) },
|
||||
south = new { location = ToCoordinate(south), block = ToBlockState(world.GetBlock(south)) },
|
||||
east = new { location = ToCoordinate(east), block = ToBlockState(world.GetBlock(east)) },
|
||||
west = new { location = ToCoordinate(west), block = ToBlockState(world.GetBlock(west)) },
|
||||
above = new { location = ToCoordinate(above), block = ToBlockState(world.GetBlock(above)) },
|
||||
below = new { location = ToCoordinate(below), block = ToBlockState(world.GetBlock(below)) }
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ItemMatches(Item item, string query, bool exactMatch, ItemType? exactItemType)
|
||||
{
|
||||
if (exactItemType.HasValue)
|
||||
return item.Type == exactItemType.Value;
|
||||
|
||||
string typeName = item.Type.ToString();
|
||||
string typeLabel = item.GetTypeString();
|
||||
return exactMatch
|
||||
? TextEqualsFilter(typeName, query) || TextEqualsFilter(typeLabel, query)
|
||||
: TextMatchesFilter(typeName, query) || TextMatchesFilter(typeLabel, query);
|
||||
}
|
||||
|
||||
private static bool EntityNameMatches(string? name, string? customName, string filter)
|
||||
{
|
||||
return (!string.IsNullOrWhiteSpace(name) && TextMatchesFilter(name, filter))
|
||||
|| (!string.IsNullOrWhiteSpace(customName) && TextMatchesFilter(customName, filter));
|
||||
}
|
||||
|
||||
private static bool IsSupportedLookDirection(Direction direction)
|
||||
{
|
||||
return direction is Direction.Up or Direction.Down or Direction.North or Direction.South or Direction.East or Direction.West;
|
||||
}
|
||||
|
||||
private static NearbyItemSnapshot[] BuildNearbyItemSnapshots(McClient client, ItemType? itemType, double radius, int maxCount)
|
||||
{
|
||||
Location playerLocation = client.GetCurrentLocation();
|
||||
|
|
|
|||
75
MinecraftClient/Mcp/MccMcpRecentEventStore.cs
Normal file
75
MinecraftClient/Mcp/MccMcpRecentEventStore.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpRecentEventEntry
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public object? Data { get; init; }
|
||||
}
|
||||
|
||||
public static class MccMcpRecentEventStore
|
||||
{
|
||||
private static readonly object historyLock = new();
|
||||
private static readonly List<MccMcpRecentEventEntry> history = new();
|
||||
private const int MaxEntries = 500;
|
||||
private static long nextId = 1;
|
||||
|
||||
public static long Add(string type, object? data = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(type);
|
||||
|
||||
lock (historyLock)
|
||||
{
|
||||
long id = nextId++;
|
||||
history.Add(new MccMcpRecentEventEntry
|
||||
{
|
||||
Id = id,
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Type = type,
|
||||
Data = data
|
||||
});
|
||||
|
||||
if (history.Count > MaxEntries)
|
||||
history.RemoveRange(0, history.Count - MaxEntries);
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetLatestId()
|
||||
{
|
||||
lock (historyLock)
|
||||
{
|
||||
return history.Count > 0 ? history[^1].Id : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static MccMcpRecentEventEntry[] GetAfter(long afterId, int maxCount, string? typeFilter = null)
|
||||
{
|
||||
int count = Math.Clamp(maxCount, 1, MaxEntries);
|
||||
string? normalizedFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim();
|
||||
|
||||
lock (historyLock)
|
||||
{
|
||||
return history
|
||||
.Where(entry => entry.Id > afterId)
|
||||
.Where(entry => normalizedFilter is null
|
||||
|| entry.Type.Contains(normalizedFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(count)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
lock (historyLock)
|
||||
{
|
||||
history.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
70
MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs
Normal file
70
MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpRuntimeStateSnapshot
|
||||
{
|
||||
public long? WorldAge { get; init; }
|
||||
public long? TimeOfDay { get; init; }
|
||||
public float? RainLevel { get; init; }
|
||||
public float? ThunderLevel { get; init; }
|
||||
}
|
||||
|
||||
public static class MccMcpRuntimeStateStore
|
||||
{
|
||||
private static readonly object stateLock = new();
|
||||
private static long? worldAge;
|
||||
private static long? timeOfDay;
|
||||
private static float? rainLevel;
|
||||
private static float? thunderLevel;
|
||||
|
||||
public static void SetTime(long newWorldAge, long newTimeOfDay)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
worldAge = newWorldAge;
|
||||
timeOfDay = newTimeOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetRainLevel(float level)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
rainLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetThunderLevel(float level)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
thunderLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
public static MccMcpRuntimeStateSnapshot GetSnapshot()
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
return new MccMcpRuntimeStateSnapshot
|
||||
{
|
||||
WorldAge = worldAge,
|
||||
TimeOfDay = timeOfDay,
|
||||
RainLevel = rainLevel,
|
||||
ThunderLevel = thunderLevel
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
worldAge = null;
|
||||
timeOfDay = null;
|
||||
rainLevel = null;
|
||||
thunderLevel = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,12 +33,66 @@ public sealed class MccMcpToolSet
|
|||
return capabilities.GetPlayerState();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_world_state"), Description("Get current world state, chunk loading progress, and last observed runtime time/weather values.")]
|
||||
public object WorldState()
|
||||
{
|
||||
return capabilities.GetWorldState();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_chunk_status"), Description("Get chunk loading status for the player location or an explicit world coordinate.")]
|
||||
public object ChunkStatus(double? x = null, double? y = null, double? z = null)
|
||||
{
|
||||
return capabilities.GetChunkStatus(x, y, z);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_raycast_block"), Description("Raycast from the player's current view and return the first non-air block hit.")]
|
||||
public object RaycastBlock(double maxDistance = 8.0, bool includeNeighbors = false)
|
||||
{
|
||||
return capabilities.RaycastBlock(maxDistance, includeNeighbors);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_path_preview"), Description("Compute a path preview to a target world coordinate without moving there.")]
|
||||
public object PathPreview(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0, int maxWaypoints = 128)
|
||||
{
|
||||
return capabilities.PreviewPath(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs, maxWaypoints);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_players_list"), Description("List currently known online players.")]
|
||||
public object PlayersList()
|
||||
{
|
||||
return capabilities.GetPlayersList();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_players_detailed"), Description("List online players with UUID, latency, gamemode, and tracked coordinates when available.")]
|
||||
public object PlayersDetailed(bool includeSelf = false, bool includeCoordinates = true)
|
||||
{
|
||||
return capabilities.GetPlayersDetailed(includeSelf, includeCoordinates);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_player_stats"), Description("Get current controlled player stats, orientation, and location.")]
|
||||
public object PlayerStats()
|
||||
{
|
||||
return capabilities.GetPlayerStats();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_status_effects"), Description("Get active player status effects only.")]
|
||||
public object StatusEffects()
|
||||
{
|
||||
return capabilities.GetStatusEffects();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_recent_events"), Description("Get recent high-signal MCP runtime events after a given event ID.")]
|
||||
public object RecentEvents(long afterId = 0, int maxCount = 50, string? typeFilter = null)
|
||||
{
|
||||
return capabilities.GetRecentEvents(afterId, maxCount, typeFilter);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_loaded_bots"), Description("List currently loaded MCC bots and scripts.")]
|
||||
public object LoadedBots()
|
||||
{
|
||||
return capabilities.GetLoadedBots();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_chat_history"), Description("Get recent chat/system lines seen by MCC.")]
|
||||
public object ChatHistory(int maxCount = 50, bool includeJson = false)
|
||||
{
|
||||
|
|
@ -87,18 +141,54 @@ public sealed class MccMcpToolSet
|
|||
return capabilities.QuitClient();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_disconnect"), Description("Disconnect MCC from the current server without quitting the process.")]
|
||||
public object Disconnect()
|
||||
{
|
||||
return capabilities.DisconnectClient();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_respawn"), Description("Send the respawn packet when the controlled player is dead.")]
|
||||
public object Respawn()
|
||||
{
|
||||
return capabilities.Respawn();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_run_internal_command"), Description("Run an internal MCC command.")]
|
||||
public object RunInternalCommand([Description("MCC command line without leading slash.")] string command)
|
||||
{
|
||||
return capabilities.RunInternalCommand(command);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_animation"), Description("Play a hand-swing animation with the selected hand.")]
|
||||
public object Animation(string hand = "MainHand")
|
||||
{
|
||||
return capabilities.PlayAnimation(hand);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_toggle_sneak"), Description("Explicitly enable or disable sneaking.")]
|
||||
public object ToggleSneak(bool enabled)
|
||||
{
|
||||
return capabilities.ToggleSneak(enabled);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_toggle_sprint"), Description("Explicitly send start or stop sprinting entity actions.")]
|
||||
public object ToggleSprint(bool enabled)
|
||||
{
|
||||
return capabilities.ToggleSprint(enabled);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_change_hotbar_slot"), Description("Change active hotbar slot (1-9).")]
|
||||
public object ChangeHotbarSlot(int slot)
|
||||
{
|
||||
return capabilities.ChangeHotbarSlot(slot);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_select_item"), Description("Select a hotbar item by item type without rearranging inventory contents.")]
|
||||
public object SelectItem(string itemType, bool preferLowestSlot = true)
|
||||
{
|
||||
return capabilities.SelectHotbarItem(itemType, preferLowestSlot);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_use_item_on_hand"), Description("Use the currently held item.")]
|
||||
public object UseItemOnHand()
|
||||
{
|
||||
|
|
@ -129,6 +219,12 @@ public sealed class MccMcpToolSet
|
|||
return capabilities.InteractEntity(entityId, interaction, hand);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_attack"), Description("Attack a tracked entity explicitly.")]
|
||||
public object EntityAttack(int entityId)
|
||||
{
|
||||
return capabilities.AttackEntity(entityId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_block_scan"), Description("Scan nearby blocks around player location.")]
|
||||
public object BlockScan(int radius = 3, int maxCount = 200, string? materialFilter = null)
|
||||
{
|
||||
|
|
@ -153,6 +249,12 @@ public sealed class MccMcpToolSet
|
|||
return capabilities.LocatePlayer(playerName, includeSelf);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_nearest"), Description("Return the nearest tracked entity matching the requested filters.")]
|
||||
public object EntityNearest(string? typeFilter = null, string? nameFilter = null, double radius = 64.0, bool includePlayers = true)
|
||||
{
|
||||
return capabilities.FindNearestEntity(typeFilter, nameFilter, radius, includePlayers);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_can_reach_position"), Description("Check whether MCC can currently path to a world coordinate without moving there.")]
|
||||
public object CanReachPosition(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0)
|
||||
{
|
||||
|
|
@ -177,12 +279,30 @@ public sealed class MccMcpToolSet
|
|||
return capabilities.LookAt(x, y, z);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_look_direction"), Description("Rotate player view to a cardinal direction or straight up/down.")]
|
||||
public object LookDirection(string direction)
|
||||
{
|
||||
return capabilities.LookDirection(direction);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_look_angles"), Description("Rotate player view to explicit yaw and pitch angles.")]
|
||||
public object LookAngles(float yaw, float pitch)
|
||||
{
|
||||
return capabilities.LookAngles(yaw, pitch);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventory_snapshot"), Description("Get a snapshot of one inventory.")]
|
||||
public object InventorySnapshot([Description("Inventory ID. 0 is the player inventory.")] int inventoryId = 0)
|
||||
{
|
||||
return capabilities.GetInventorySnapshot(inventoryId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventory_search"), Description("Search the player inventory and optionally open containers for items matching a query.")]
|
||||
public object InventorySearch(string query, int maxCount = 100, bool exactMatch = false, bool includeContainers = true)
|
||||
{
|
||||
return capabilities.SearchInventories(query, maxCount, exactMatch, includeContainers);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventories_list"), Description("List currently open inventories and containers known to MCC.")]
|
||||
public object InventoriesList()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -807,13 +807,14 @@ namespace MinecraftClient
|
|||
/// <param name="keepAccountAndServerSettings">Optional, keep account and server settings</param>
|
||||
public static void Restart(int delaySeconds = 0, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
ConsoleIO.Backend.StopReadThread();
|
||||
ConsoleIO.Backend?.StopReadThread();
|
||||
new Thread(new ThreadStart(delegate
|
||||
{
|
||||
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
|
||||
if (offlinePrompt is not null)
|
||||
{
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
offlinePrompt.Item2.Cancel(); offlinePrompt.Item1.Join(); offlinePrompt = null; ConsoleIO.Reset();
|
||||
}
|
||||
if (delaySeconds > 0)
|
||||
|
|
@ -835,7 +836,8 @@ namespace MinecraftClient
|
|||
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
|
||||
if (offlinePrompt is not null)
|
||||
{
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
offlinePrompt.Item2.Cancel();
|
||||
if (Thread.CurrentThread != offlinePrompt.Item1)
|
||||
offlinePrompt.Item1.Join(1000);
|
||||
|
|
@ -907,8 +909,9 @@ namespace MinecraftClient
|
|||
|
||||
if (offlinePrompt is null)
|
||||
{
|
||||
ConsoleIO.Backend.StopReadThread();
|
||||
ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler;
|
||||
ConsoleIO.Backend?.StopReadThread();
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler;
|
||||
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
offlinePrompt = new(new Thread(new ThreadStart(delegate
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue