diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1490c05..51d6bd8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,8 @@ "mcp__minecraft-bridge__clear_area", "mcp__minecraft-bridge__fill", "mcp__minecraft-bridge__teleport", - "mcp__minecraft-bridge__save_schematic" + "mcp__minecraft-bridge__save_schematic", + "mcp__minecraft-bridge__get_biome" ] } } diff --git a/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc b/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc index 9bd2de0..57660ae 100644 Binary files a/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc and b/minecraft-mcp/src/minecraft_mcp/__pycache__/server.cpython-314.pyc differ diff --git a/minecraft-mcp/src/minecraft_mcp/server.py b/minecraft-mcp/src/minecraft_mcp/server.py index ebd8833..06cd6e1 100644 --- a/minecraft-mcp/src/minecraft_mcp/server.py +++ b/minecraft-mcp/src/minecraft_mcp/server.py @@ -1,5 +1,8 @@ +import asyncio import json +import math import os +import time from typing import Any import httpx @@ -10,6 +13,17 @@ BASE_URL = f"http://localhost:{MINECRAFT_PORT}" mcp = FastMCP("minecraft-bridge") +# --------------------------------------------------------------------------- +# Build-session state +# --------------------------------------------------------------------------- + +_build_session_active = False +_build_session_lock = asyncio.Lock() +_last_build_time: float = 0.0 +_BUILD_SESSION_TIMEOUT = 3.0 # seconds of inactivity before session ends +_build_session_origin: tuple[float, float, float] | None = None +_watcher_task: asyncio.Task | None = None + async def _request(method: str, path: str, **kwargs) -> Any: """Make an HTTP request to the Minecraft mod server.""" @@ -35,6 +49,98 @@ def _fmt(data: Any) -> str: return json.dumps(data, indent=2) +# --------------------------------------------------------------------------- +# Build-session helpers (internal, not exposed as tools) +# --------------------------------------------------------------------------- + + +async def _set_bot_state(state: str) -> None: + await _request("POST", "/set_state", json={"state": state}) + + +async def _teleport_bot(x: float, y: float, z: float) -> None: + await _request("POST", "/teleport", json={"x": x, "y": y, "z": z}) + + +async def _look_at_pos(x: float, y: float, z: float) -> None: + await _request("POST", "/look_at", json={"x": x, "y": y, "z": z}) + + +async def _get_bot_position() -> tuple[float, float, float] | None: + data = await _request("GET", "/status") + if isinstance(data, dict) and "position" in data: + p = data["position"] + return (p["x"], p["y"], p["z"]) + return None + + +def _compute_viewing_position( + build_x: float, build_y: float, build_z: float, + bot_x: float, bot_y: float, bot_z: float, +) -> tuple[float, float, float]: + """Compute a spot 5 blocks from the build, on the side the bot is on.""" + dx = bot_x - build_x + dz = bot_z - build_z + dist = math.hypot(dx, dz) + if dist < 0.01: + dx, dz = 1.0, 0.0 + dist = 1.0 + scale = 5.0 / dist + return (build_x + dx * scale, build_y, build_z + dz * scale) + + +async def _ensure_build_session(x: float, y: float, z: float) -> None: + """Start or continue a build session, managing state/teleport/look.""" + global _build_session_active, _last_build_time, _build_session_origin + global _watcher_task + + async with _build_session_lock: + _last_build_time = time.monotonic() + + if _build_session_active: + # Session already running — just look at the new target + _build_session_origin = (x, y, z) + await _look_at_pos(x, y, z) + return + + # --- Start a new session --- + _build_session_active = True + _build_session_origin = (x, y, z) + + await _set_bot_state("building") + + pos = await _get_bot_position() + if pos is not None: + bx, by, bz = pos + dist = math.sqrt((bx - x) ** 2 + (by - y) ** 2 + (bz - z) ** 2) + if dist > 16: + vx, vy, vz = _compute_viewing_position(x, y, z, bx, by, bz) + await _teleport_bot(vx, vy, vz) + + await _look_at_pos(x, y, z) + + # Start the watcher if not already running + if _watcher_task is None or _watcher_task.done(): + _watcher_task = asyncio.create_task(_build_session_watcher()) + + +async def _build_session_watcher() -> None: + """Background task that ends the build session after inactivity.""" + global _build_session_active, _build_session_origin + + while True: + await asyncio.sleep(1.0) + async with _build_session_lock: + if not _build_session_active: + return + elapsed = time.monotonic() - _last_build_time + if elapsed >= _BUILD_SESSION_TIMEOUT: + await _set_bot_state("idle") + _build_session_active = False + _build_session_origin = None + return + + # --------------------------------------------------------------------------- # Observation tools (GET) # --------------------------------------------------------------------------- @@ -106,7 +212,8 @@ async def get_weather() -> str: @mcp.tool() async def move_to(x: float, y: float, z: float) -> str: - """Move the bot to the given coordinates by pathfinding. Returns distance moved.""" + """Move the bot to the given coordinates by pathfinding. Returns distance moved. + Use this to walk up to players or move closer to inspect builds.""" return _fmt(await _request("POST", "/move_to", json={"x": x, "y": y, "z": z})) @@ -119,6 +226,7 @@ async def teleport(x: float, y: float, z: float) -> str: @mcp.tool() async def place_block(x: int, y: int, z: int, block_type: str) -> str: """Place a block at the given coordinates. block_type is a Minecraft block ID like 'stone' or 'oak_planks'.""" + await _ensure_build_session(x, y, z) return _fmt(await _request("POST", "/place_block", json={ "x": x, "y": y, "z": z, "type": block_type, })) @@ -127,12 +235,18 @@ async def place_block(x: int, y: int, z: int, block_type: str) -> str: @mcp.tool() async def place_blocks(blocks: list[dict]) -> str: """Place multiple blocks at once. Each entry is {x, y, z, type}. Max 10,000 blocks per call.""" + if blocks: + cx = sum(b.get("x", 0) for b in blocks) / len(blocks) + cy = sum(b.get("y", 0) for b in blocks) / len(blocks) + cz = sum(b.get("z", 0) for b in blocks) / len(blocks) + await _ensure_build_session(cx, cy, cz) return _fmt(await _request("POST", "/place_blocks", json={"blocks": blocks})) @mcp.tool() async def fill(x1: int, y1: int, z1: int, x2: int, y2: int, z2: int, block_type: str) -> str: """Fill a bounding box with a single block type. Max volume 32x32x32.""" + await _ensure_build_session((x1 + x2) / 2, (y1 + y2) / 2, (z1 + z2) / 2) return _fmt(await _request("POST", "/fill", json={ "x1": x1, "y1": y1, "z1": z1, "x2": x2, "y2": y2, "z2": z2, @@ -143,12 +257,14 @@ async def fill(x1: int, y1: int, z1: int, x2: int, y2: int, z2: int, block_type: @mcp.tool() async def break_block(x: int, y: int, z: int) -> str: """Break (destroy) the block at the given coordinates.""" + await _ensure_build_session(x, y, z) return _fmt(await _request("POST", "/break_block", json={"x": x, "y": y, "z": z})) @mcp.tool() async def clear_area(x1: int, y1: int, z1: int, x2: int, y2: int, z2: int) -> str: """Clear all blocks in a bounding box (replace with air). Max volume 64x64x64.""" + await _ensure_build_session((x1 + x2) / 2, (y1 + y2) / 2, (z1 + z2) / 2) return _fmt(await _request("POST", "/clear_area", json={ "x1": x1, "y1": y1, "z1": z1, "x2": x2, "y2": y2, "z2": z2, @@ -176,6 +292,7 @@ async def place_structure( Max 64 blocks in any dimension. """ + await _ensure_build_session(origin_x, origin_y, origin_z) return _fmt(await _request("POST", "/place_structure", json={ "origin": {"x": origin_x, "y": origin_y, "z": origin_z}, "palette": palette, @@ -199,6 +316,7 @@ async def save_schematic(name: str, x1: int, y1: int, z1: int, x2: int, y2: int, async def load_schematic(name: str, x: int, y: int, z: int) -> str: """Load a previously saved schematic and place it at the given origin coordinates. Name may only contain letters, numbers, underscore, and hyphen.""" + await _ensure_build_session(x, y, z) return _fmt(await _request("POST", "/load_schematic", json={ "name": name, "x": x, "y": y, "z": z, }, timeout=30.0)) @@ -206,7 +324,8 @@ async def load_schematic(name: str, x: int, y: int, z: int) -> str: @mcp.tool() async def set_state(state: str) -> str: - """Set the bot's visual state. Valid states: idle, thinking, building, moving.""" + """Set the bot's visual state. Valid states: idle, thinking, building, moving. + Note: building state is auto-managed when using build tools. Use this manually for thinking or moving.""" return _fmt(await _request("POST", "/set_state", json={"state": state})) @@ -218,7 +337,9 @@ async def look_at(x: float, y: float, z: float) -> str: @mcp.tool() async def chat(message: str) -> str: - """Send a chat message in-game as the bot.""" + """Send a chat message in-game as the bot. + Use this to narrate what you're doing, react to builds, or respond to players. + Keep messages short and in-character.""" return _fmt(await _request("POST", "/chat", json={"message": message}))