BareGit

Implement NetHack MCP server and engine integration

- Build the pinned NetHack library as libnethack.a with system Lua.
- Add the isolated worker, framed IPC, MCP tools, and spectator viewer.
- Add startup verification, runtime data setup, and build documentation.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 22 11:59:50 2026 -0700
Commit: f09d7184f5f9e5b0437799a92ed7fdb48399d6ea

Changes

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0f69e60
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/build/
+/build-*/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..b3cda00
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,165 @@
+cmake_minimum_required(VERSION 3.24)
+
+project(nethack_mcp VERSION 0.1.0 LANGUAGES C CXX)
+
+set(CMAKE_CXX_STANDARD 23)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+option(NETHACK_BUILD_ENGINE "Build the pinned NetHack library" ON)
+
+include(FetchContent)
+
+# Declare these before loading libmw. Its current CMake files declare the
+# same projects without pins, so the application's declarations must win.
+FetchContent_Declare(
+    httplib
+    GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
+    GIT_TAG 278c2979e8c68468960c3073e28e1c51b098d6a4
+    GIT_SHALLOW FALSE)
+FetchContent_Declare(
+    spdlog
+    GIT_REPOSITORY https://github.com/gabime/spdlog.git
+    GIT_TAG 57cb5fb7a8ff30079751728234623230535a5c92
+    GIT_SHALLOW FALSE)
+FetchContent_Declare(
+    json
+    GIT_REPOSITORY https://github.com/nlohmann/json.git
+    GIT_TAG aa391dc0a56f8409e2e7aca6e7c9a9d766d44ce8
+    GIT_SHALLOW FALSE)
+FetchContent_Declare(
+    libmw
+    GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+    GIT_TAG 00f857d93fda0f0eb84bb8ab540b58063f1c91b2
+    GIT_SHALLOW FALSE)
+
+set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
+FetchContent_MakeAvailable(libmw)
+find_package(Threads REQUIRED)
+
+if(NETHACK_BUILD_ENGINE)
+    find_path(NETHACK_LUA_INCLUDE_DIR
+        NAMES lua.h
+        PATH_SUFFIXES lua5.4 lua54 lua)
+    find_library(NETHACK_LUA_LIBRARY
+        NAMES lua5.4 lua54 lua)
+    if(NOT NETHACK_LUA_INCLUDE_DIR OR NOT NETHACK_LUA_LIBRARY)
+        message(FATAL_ERROR
+            "NetHack requires Lua 5.4 headers and a linkable library")
+    endif()
+
+    file(STRINGS "${NETHACK_LUA_INCLUDE_DIR}/lua.h"
+        NETHACK_LUA_VERSION_LINE REGEX "^#define LUA_VERSION_NUM")
+    if(NOT NETHACK_LUA_VERSION_LINE MATCHES "504")
+        message(FATAL_ERROR
+            "NetHack requires Lua 5.4 headers; found "
+            "${NETHACK_LUA_INCLUDE_DIR}/lua.h")
+    endif()
+
+    FetchContent_Declare(
+        nethack
+        GIT_REPOSITORY https://github.com/NetHack/NetHack.git
+        GIT_TAG c94fd5225beef48143244bfb7bc42682aad58741
+        GIT_SHALLOW FALSE)
+    FetchContent_MakeAvailable(nethack)
+
+    set(NETHACK_WORK_DIR
+        "${CMAKE_BINARY_DIR}/nethack-work"
+        CACHE PATH "Isolated NetHack build directory")
+    set(NETHACK_LIBRARY "${NETHACK_WORK_DIR}/src/libnh.a")
+    set(NETHACK_COMPAT_LIBRARY
+        "${NETHACK_WORK_DIR}/src/libnethack.a")
+    set(NETHACK_RUNTIME_DIR
+        "${NETHACK_WORK_DIR}/playground")
+    set(NETHACK_BUILD_MARKER
+        "${NETHACK_WORK_DIR}/.nethack-library-built")
+    file(MAKE_DIRECTORY "${NETHACK_WORK_DIR}/include")
+
+    add_custom_command(
+        OUTPUT "${NETHACK_BUILD_MARKER}"
+        BYPRODUCTS
+            "${NETHACK_COMPAT_LIBRARY}"
+            "${NETHACK_RUNTIME_DIR}/nhdat"
+            "${NETHACK_RUNTIME_DIR}/symbols"
+            "${NETHACK_RUNTIME_DIR}/license"
+            "${NETHACK_RUNTIME_DIR}/sysconf"
+        COMMAND "${CMAKE_COMMAND}" -E rm -rf "${NETHACK_WORK_DIR}"
+        COMMAND "${CMAKE_COMMAND}" -E copy_directory
+            "${nethack_SOURCE_DIR}" "${NETHACK_WORK_DIR}"
+        COMMAND "${CMAKE_COMMAND}"
+            "-DNETHACK_SOURCE_DIR=${NETHACK_WORK_DIR}"
+            "-DNETHACK_LUA_INCLUDE_DIR=${NETHACK_LUA_INCLUDE_DIR}"
+            "-DNETHACK_LUA_LIBRARY=${NETHACK_LUA_LIBRARY}"
+            "-DNETHACK_COMPILER=${CMAKE_C_COMPILER}"
+            "-DNETHACK_ARCHIVER=${CMAKE_AR}"
+            -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_nethack.cmake"
+        DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_nethack.cmake"
+        VERBATIM)
+
+    add_custom_target(nethack_engine_build
+        DEPENDS "${NETHACK_BUILD_MARKER}")
+
+    add_library(nethack_lib STATIC IMPORTED GLOBAL)
+    set_target_properties(nethack_lib PROPERTIES
+        IMPORTED_LOCATION "${NETHACK_COMPAT_LIBRARY}"
+        INTERFACE_INCLUDE_DIRECTORIES
+            "${NETHACK_WORK_DIR}/include"
+        INTERFACE_LINK_LIBRARIES
+            "${NETHACK_LUA_LIBRARY};m;dl")
+    add_dependencies(nethack_lib nethack_engine_build)
+
+    add_library(NetHack::libnethack ALIAS nethack_lib)
+
+    add_executable(nethack_engine_probe src/engine_probe.cpp)
+    target_compile_options(nethack_engine_probe PRIVATE
+        -Wall -Wextra -Wpedantic)
+    target_compile_definitions(nethack_engine_probe PRIVATE
+        NETHACK_PROBE_RUNTIME_DIR="${NETHACK_RUNTIME_DIR}")
+    target_include_directories(nethack_engine_probe PRIVATE
+        "${NETHACK_WORK_DIR}/include")
+    target_link_libraries(nethack_engine_probe PRIVATE nethack_lib)
+
+    enable_testing()
+    add_test(NAME nethack_engine_probe COMMAND nethack_engine_probe)
+    set_tests_properties(nethack_engine_probe PROPERTIES
+        WORKING_DIRECTORY "${NETHACK_RUNTIME_DIR}"
+        TIMEOUT 10)
+else()
+    set(NETHACK_RUNTIME_DIR "${CMAKE_CURRENT_BINARY_DIR}")
+endif()
+
+set(NETHACK_MCP_SOURCES
+    src/engine_process.cpp
+    src/game_session.cpp
+    src/main.cpp
+    src/mcp_server.cpp
+    src/observation_store.cpp
+    src/protocol.cpp
+    src/spectator_server.cpp)
+if(NETHACK_BUILD_ENGINE)
+    list(APPEND NETHACK_MCP_SOURCES
+        src/engine_worker.cpp
+        src/window_adapter.cpp)
+else()
+    list(APPEND NETHACK_MCP_SOURCES src/engine_worker_stub.cpp)
+endif()
+
+add_executable(nethack_mcp ${NETHACK_MCP_SOURCES})
+target_compile_features(nethack_mcp PRIVATE cxx_std_23)
+target_compile_options(nethack_mcp PRIVATE -Wall -Wextra -Wpedantic)
+target_include_directories(nethack_mcp PRIVATE include)
+target_compile_definitions(nethack_mcp PRIVATE
+    NETHACK_RUNTIME_DIR="${NETHACK_RUNTIME_DIR}")
+target_link_libraries(nethack_mcp PRIVATE
+    mw::mw
+    mw::http-server
+    nlohmann_json::nlohmann_json
+    Threads::Threads)
+if(NETHACK_BUILD_ENGINE)
+    target_include_directories(nethack_mcp PRIVATE
+        "${NETHACK_WORK_DIR}/include")
+    target_link_libraries(nethack_mcp PRIVATE nethack_lib)
+endif()
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fac9447
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# nethack-mcp
+
+This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs it
+in a worker process, and exposes observations through MCP stdio plus a
+read-only loopback viewer.
+
+The default build uses system Lua 5.4 headers and library. CMake checks the
+Lua header version before configuring NetHack; Lua 5.4.8 is used by the
+current pinned NetHack build.
+
+Build and test it with:
+
+```sh
+cmake -S . -B build
+cmake --build build -j24
+ctest --test-dir build --output-on-failure
+```
+
+The produced library is:
+
+```text
+build/nethack-work/src/libnethack.a
+```
+
+Run the server with:
+
+```sh
+./build/nethack_mcp --data-root /tmp/nethack-mcp --port 8765
+```
+
+The MCP server reads newline-delimited JSON-RPC from standard input. The
+browser viewer is available at `http://127.0.0.1:8765/` and serves only
+read-only state from the active session.
diff --git a/cmake/build_nethack.cmake b/cmake/build_nethack.cmake
new file mode 100644
index 0000000..3adc2ac
--- /dev/null
+++ b/cmake/build_nethack.cmake
@@ -0,0 +1,157 @@
+cmake_minimum_required(VERSION 3.24)
+
+if(NOT DEFINED NETHACK_SOURCE_DIR
+   OR NOT DEFINED NETHACK_LUA_INCLUDE_DIR
+   OR NOT DEFINED NETHACK_LUA_LIBRARY
+   OR NOT DEFINED NETHACK_COMPILER
+   OR NOT DEFINED NETHACK_ARCHIVER)
+    message(FATAL_ERROR "NetHack build adapter arguments are incomplete")
+endif()
+
+find_program(NETHACK_MAKE make REQUIRED)
+find_program(NETHACK_SH sh REQUIRED)
+
+set(NETHACK_COMMON_ARGS
+    "CC=${NETHACK_COMPILER}"
+    "AR=${NETHACK_ARCHIVER}"
+    "WANT_LIBNH=1"
+    "WANT_SYSTEM_LUA=1"
+    "LINUX_DISTRO=debian"
+    "PKG_EXISTS=yes"
+    "LUA_VERSION=5.4.8"
+    "LUAHEADERS=${NETHACK_LUA_INCLUDE_DIR}"
+    "LUACFLAGS=-I${NETHACK_LUA_INCLUDE_DIR}"
+    "LUALIBS=${NETHACK_LUA_LIBRARY} -lm -ldl")
+
+function(run_nethack_make)
+    execute_process(
+        COMMAND "${NETHACK_MAKE}" ${NETHACK_COMMON_ARGS} ${ARGN}
+        WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}"
+        RESULT_VARIABLE result
+        OUTPUT_VARIABLE output
+        ERROR_VARIABLE error)
+    if(NOT result EQUAL 0)
+        message(FATAL_ERROR
+            "NetHack make failed with ${result}\n${output}\n${error}")
+    endif()
+endfunction()
+
+execute_process(
+    COMMAND "${NETHACK_SH}" setup.sh hints/linux.500
+    WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}/sys/unix"
+    RESULT_VARIABLE setup_result
+    OUTPUT_VARIABLE setup_output
+    ERROR_VARIABLE setup_error)
+if(NOT setup_result EQUAL 0)
+    message(FATAL_ERROR
+        "NetHack setup failed with ${setup_result}\n"
+        "${setup_output}\n${setup_error}")
+endif()
+
+# The upstream libnh rule names this archive even when system Lua is used.
+# It is a dependency marker only; the executable links the system library.
+file(MAKE_DIRECTORY "${NETHACK_SOURCE_DIR}/lib/lua")
+execute_process(
+    COMMAND "${NETHACK_ARCHIVER}" rcs
+        "${NETHACK_SOURCE_DIR}/lib/lua/liblua-5.4.8.a"
+    RESULT_VARIABLE archive_result
+    OUTPUT_VARIABLE archive_output
+    ERROR_VARIABLE archive_error)
+if(NOT archive_result EQUAL 0)
+    message(FATAL_ERROR
+        "Could not create NetHack Lua dependency marker: ${archive_output}\n"
+        "${archive_error}")
+endif()
+
+# Header generation is not safe to run concurrently with the upstream
+# generated-file rules. Once it is complete, source compilation is parallel.
+run_nethack_make(-j1 lua_support)
+run_nethack_make(-C src -j1 pregame)
+run_nethack_make(-C src -j24 libnh.a)
+run_nethack_make(-j1 dlb)
+
+# The upstream rule combines the normal Unix object list with the library
+# object list. Remove the normal terminal entry points before publishing the
+# archive, otherwise linking the library into a host program pulls in a
+# second main() and duplicate Unix support functions.
+execute_process(
+    COMMAND "${NETHACK_ARCHIVER}" d libnh.a
+        unixmain.o getline.o termcap.o topl.o wintty.o
+    WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}/src"
+    RESULT_VARIABLE remove_unix_result
+    OUTPUT_VARIABLE remove_unix_output
+    ERROR_VARIABLE remove_unix_error)
+if(NOT remove_unix_result EQUAL 0)
+    message(FATAL_ERROR
+        "Could not remove normal Unix objects from libnh.a: "
+        "${remove_unix_output}\n${remove_unix_error}")
+endif()
+
+# The Linux libnh rule omits hacklib.o even though the normal executable
+# links hacklib.a separately. Make the published library self-contained.
+execute_process(
+    COMMAND "${NETHACK_ARCHIVER}" rcs libnh.a hacklib.o
+    WORKING_DIRECTORY "${NETHACK_SOURCE_DIR}/src"
+    RESULT_VARIABLE hacklib_result
+    OUTPUT_VARIABLE hacklib_output
+    ERROR_VARIABLE hacklib_error)
+if(NOT hacklib_result EQUAL 0)
+    message(FATAL_ERROR
+        "Could not add NetHack hacklib.o to libnh.a: ${hacklib_output}\n"
+        "${hacklib_error}")
+endif()
+
+execute_process(
+    COMMAND "${CMAKE_COMMAND}" -E copy_if_different
+        "${NETHACK_SOURCE_DIR}/src/libnh.a"
+        "${NETHACK_SOURCE_DIR}/src/libnethack.a"
+    RESULT_VARIABLE copy_result
+    OUTPUT_VARIABLE copy_output
+    ERROR_VARIABLE copy_error)
+if(NOT copy_result EQUAL 0)
+    message(FATAL_ERROR
+        "Could not publish libnethack.a compatibility name: ${copy_output}\n"
+        "${copy_error}")
+endif()
+
+set(NETHACK_PLAYGROUND "${NETHACK_SOURCE_DIR}/playground")
+file(MAKE_DIRECTORY "${NETHACK_PLAYGROUND}/save")
+
+foreach(data_file nhdat symbols license)
+    execute_process(
+        COMMAND "${CMAKE_COMMAND}" -E copy_if_different
+            "${NETHACK_SOURCE_DIR}/dat/${data_file}"
+            "${NETHACK_PLAYGROUND}/${data_file}"
+        RESULT_VARIABLE data_result
+        OUTPUT_VARIABLE data_output
+        ERROR_VARIABLE data_error)
+    if(NOT data_result EQUAL 0)
+        message(FATAL_ERROR
+            "Could not install NetHack data file ${data_file}: "
+            "${data_output}\n${data_error}")
+    endif()
+endforeach()
+
+execute_process(
+    COMMAND "${CMAKE_COMMAND}" -E copy_if_different
+        "${NETHACK_SOURCE_DIR}/sys/libnh/sysconf"
+        "${NETHACK_PLAYGROUND}/sysconf"
+    RESULT_VARIABLE sysconf_result
+    OUTPUT_VARIABLE sysconf_output
+    ERROR_VARIABLE sysconf_error)
+if(NOT sysconf_result EQUAL 0)
+    message(FATAL_ERROR
+        "Could not install NetHack sysconf: ${sysconf_output}\n"
+        "${sysconf_error}")
+endif()
+
+foreach(runtime_file perm record logfile xlogfile livelog)
+    file(WRITE "${NETHACK_PLAYGROUND}/${runtime_file}" "")
+    file(CHMOD "${NETHACK_PLAYGROUND}/${runtime_file}"
+        FILE_PERMISSIONS OWNER_READ OWNER_WRITE)
+endforeach()
+
+file(CHMOD "${NETHACK_PLAYGROUND}" "${NETHACK_PLAYGROUND}/save"
+    DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE)
+
+file(TOUCH "${NETHACK_SOURCE_DIR}/.nethack-library-built")
diff --git a/include/engine_process.hpp b/include/engine_process.hpp
new file mode 100644
index 0000000..a077819
--- /dev/null
+++ b/include/engine_process.hpp
@@ -0,0 +1,59 @@
+#pragma once
+
+#include <atomic>
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <thread>
+#include <sys/types.h>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// Spawn and supervise one isolated NetHack worker process.
+class EngineProcess
+{
+public:
+    /// Receive worker events from the reader thread.
+    using EventCallback = std::function<void(const Json&)>;
+
+    /// Construct an idle process supervisor.
+    EngineProcess() = default;
+
+    /// Stop a worker and reap it before destroying the supervisor.
+    ~EngineProcess();
+
+    EngineProcess(const EngineProcess&) = delete;
+    EngineProcess& operator=(const EngineProcess&) = delete;
+
+    /// Spawn a worker and send its validated start message.
+    bool start(const Json& start_message, EventCallback callback,
+               std::string& error);
+
+    /// Send one validated input message to the worker.
+    bool send(const Json& message, std::string& error);
+
+    /// Request termination and wait for the child and reader threads.
+    void terminate();
+
+    /// Report whether the child has not yet been reaped.
+    bool running() const;
+
+private:
+    void readLoop();
+    void diagnosticLoop(int descriptor);
+    std::string executablePath() const;
+
+    pid_t process_id_ = -1;
+    std::unique_ptr<FramedChannel> channel_;
+    EventCallback callback_;
+    std::thread reader_thread_;
+    std::thread diagnostic_thread_;
+    mutable std::mutex state_mutex_;
+    std::atomic<bool> running_ = false;
+};
+
+} // namespace nethack_mcp
diff --git a/include/engine_worker.hpp b/include/engine_worker.hpp
new file mode 100644
index 0000000..3305ee8
--- /dev/null
+++ b/include/engine_worker.hpp
@@ -0,0 +1,9 @@
+#pragma once
+
+namespace nethack_mcp
+{
+
+/// Run the isolated NetHack worker selected by the internal command line.
+int runEngineWorker(int argc, char* argv[]);
+
+} // namespace nethack_mcp
diff --git a/include/game_session.hpp b/include/game_session.hpp
new file mode 100644
index 0000000..3903aa6
--- /dev/null
+++ b/include/game_session.hpp
@@ -0,0 +1,91 @@
+#pragma once
+
+#include <atomic>
+#include <cstdint>
+#include <filesystem>
+#include <mutex>
+#include <string>
+
+#include "engine_process.hpp"
+#include "observation_store.hpp"
+
+namespace nethack_mcp
+{
+
+/// Result returned by one MCP tool implementation.
+struct ToolResult
+{
+    /// Report whether the requested tool operation completed.
+    bool success = false;
+    /// Return the complete state associated with the operation.
+    Json value = Json::object();
+    /// Stable machine-readable error code when `success` is false.
+    std::string code;
+    /// Human-readable error detail when `success` is false.
+    std::string message;
+};
+
+/// Own the single active game and serialize all gameplay input.
+class GameSession
+{
+public:
+    /// Create a session with a private data root and viewer URL.
+    GameSession(std::filesystem::path data_root,
+                std::filesystem::path runtime_dir,
+                std::string viewer_url);
+
+    GameSession(const GameSession&) = delete;
+    GameSession& operator=(const GameSession&) = delete;
+
+    /// Start one new NetHack worker and wait for its first boundary.
+    ToolResult newGame(const Json& arguments);
+
+    /// Return the latest state, optionally waiting for a revision.
+    ToolResult observe(const Json& arguments);
+
+    /// Answer a pending one-key input boundary.
+    ToolResult press(const Json& arguments);
+
+    /// Answer a pending text, choice, command, or acknowledgement boundary.
+    ToolResult respond(const Json& arguments);
+
+    /// Answer a pending menu boundary with complete selections.
+    ToolResult selectMenu(const Json& arguments);
+
+    /// Administratively stop the worker and retain its final state.
+    ToolResult quitGame(const Json& arguments);
+
+    /// Stop any active worker during server shutdown.
+    void shutdown();
+
+    /// Return the configured spectator URL.
+    const std::string& viewerUrl() const;
+
+    /// Return the latest state for the spectator server.
+    Json snapshot() const;
+
+private:
+    ToolResult sendInput(const Json& arguments, Json response,
+                         const std::string& expected_kind);
+    ToolResult errorResult(std::string code, std::string message) const;
+    void handleWorkerMessage(const Json& message);
+    bool copyRuntimeFiles(const std::filesystem::path& run_directory,
+                          std::string& error) const;
+    static bool validGameId(const Json& arguments,
+                            const Json& state,
+                            std::string& error);
+    static bool parseKey(const Json& key, int& value, std::string& error);
+    static std::string makeGameId();
+
+    std::filesystem::path data_root_;
+    std::filesystem::path runtime_dir_;
+    std::string viewer_url_;
+    ObservationStore observations_;
+    std::unique_ptr<EngineProcess> process_;
+    mutable std::mutex session_mutex_;
+    mutable std::mutex action_mutex_;
+    std::atomic<bool> stop_requested_ = false;
+    std::uint64_t operation_counter_ = 0;
+};
+
+} // namespace nethack_mcp
diff --git a/include/mcp_server.hpp b/include/mcp_server.hpp
new file mode 100644
index 0000000..0066fba
--- /dev/null
+++ b/include/mcp_server.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <iosfwd>
+
+#include "game_session.hpp"
+
+namespace nethack_mcp
+{
+
+/// Implement the MCP JSON-RPC stdio transport for one game session.
+class McpServer
+{
+public:
+    /// Bind the protocol dispatcher to a session.
+    explicit McpServer(GameSession& session);
+
+    /// Read requests until stdio reaches EOF.
+    int run(std::istream& input, std::ostream& output);
+
+private:
+    Json dispatch(const Json& request, bool& should_respond);
+    Json tools() const;
+    Json toolResult(const ToolResult& result) const;
+    Json jsonRpcError(const Json& id, int code,
+                      const std::string& message) const;
+
+    GameSession& session_;
+};
+
+} // namespace nethack_mcp
diff --git a/include/observation_store.hpp b/include/observation_store.hpp
new file mode 100644
index 0000000..a8b6d5b
--- /dev/null
+++ b/include/observation_store.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <chrono>
+#include <condition_variable>
+#include <cstdint>
+#include <mutex>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// Own and publish complete snapshots without exposing engine memory.
+class ObservationStore
+{
+public:
+    /// Construct an idle store with a stable initial snapshot.
+    explicit ObservationStore(std::string viewer_url);
+
+    /// Return the latest complete snapshot.
+    Json snapshot() const;
+
+    /// Publish a replacement snapshot and return its new revision.
+    std::uint64_t publish(Json snapshot);
+
+    /// Wait for a revision newer than `revision`, or return the latest state.
+    Json waitForRevision(
+        std::uint64_t revision,
+        std::chrono::milliseconds timeout) const;
+
+private:
+    mutable std::mutex mutex_;
+    mutable std::condition_variable condition_;
+    Json snapshot_;
+    std::uint64_t revision_ = 0;
+};
+
+} // namespace nethack_mcp
diff --git a/include/protocol.hpp b/include/protocol.hpp
new file mode 100644
index 0000000..7131c91
--- /dev/null
+++ b/include/protocol.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <cstdint>
+#include <mutex>
+#include <string>
+
+#include <nlohmann/json.hpp>
+
+namespace nethack_mcp
+{
+
+using Json = nlohmann::json;
+
+/// The version of the private parent/worker protocol.
+inline constexpr std::uint32_t IPC_VERSION = 1;
+
+/// The maximum encoded payload accepted by the private protocol.
+inline constexpr std::uint32_t MAX_IPC_FRAME_SIZE = 4U * 1024U * 1024U;
+
+/// Exchange length-prefixed JSON messages over one file descriptor.
+class FramedChannel
+{
+public:
+    /// Take ownership of an already connected stream descriptor.
+    explicit FramedChannel(int descriptor);
+
+    FramedChannel(const FramedChannel&) = delete;
+    FramedChannel& operator=(const FramedChannel&) = delete;
+
+    /// Close the descriptor owned by this channel.
+    ~FramedChannel();
+
+    /// Send one JSON message, returning a diagnostic on failure.
+    bool send(const Json& message, std::string& error);
+
+    /// Read one JSON message, returning a diagnostic on failure or EOF.
+    bool receive(Json& message, std::string& error);
+
+    /// Interrupt a blocked read and release the descriptor.
+    void close();
+
+private:
+    bool readExact(void* buffer, std::size_t size, std::string& error);
+    bool writeExact(const void* buffer, std::size_t size,
+                   std::string& error);
+
+    int descriptor_;
+    std::mutex write_mutex_;
+};
+
+} // namespace nethack_mcp
diff --git a/include/spectator_server.hpp b/include/spectator_server.hpp
new file mode 100644
index 0000000..e3b5d64
--- /dev/null
+++ b/include/spectator_server.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <atomic>
+#include <string>
+#include <thread>
+
+#include <mw/http_server.hpp>
+
+namespace nethack_mcp
+{
+
+class GameSession;
+
+/// Serve immutable game observations to a loopback browser viewer.
+class SpectatorServer : public mw::HTTPServer
+{
+public:
+    /// Construct a server bound to the loopback address and requested port.
+    SpectatorServer(GameSession& session, int port);
+
+    /// Stop the listener before destroying the server.
+    ~SpectatorServer();
+
+    SpectatorServer(const SpectatorServer&) = delete;
+    SpectatorServer& operator=(const SpectatorServer&) = delete;
+
+    /// Bind synchronously so an occupied port fails promptly.
+    bool startServer(std::string& error);
+
+    /// Stop the listener and join its serving thread.
+    void stopServer();
+
+protected:
+    /// Register the read-only viewer routes.
+    void setup() override;
+
+private:
+    bool validHost(const Request& request) const;
+    void servePage(const Request& request, Response& response);
+    void serveScript(const Request& request, Response& response);
+    void serveStyle(const Request& request, Response& response);
+    void serveState(const Request& request, Response& response);
+    void serveHealth(const Request& request, Response& response);
+
+    GameSession& session_;
+    int port_;
+    std::thread server_thread_;
+    std::atomic<bool> started_ = false;
+};
+
+} // namespace nethack_mcp
diff --git a/include/window_adapter.hpp b/include/window_adapter.hpp
new file mode 100644
index 0000000..514dc29
--- /dev/null
+++ b/include/window_adapter.hpp
@@ -0,0 +1,77 @@
+#pragma once
+
+#include <cstdarg>
+#include <cstdint>
+#include <map>
+#include <string>
+#include <vector>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// Translate the pinned NetHack shim into owned observations and input.
+class WindowAdapter
+{
+public:
+    /// Bind an adapter to the worker's private IPC channel.
+    WindowAdapter(FramedChannel& channel, std::string game_id);
+
+    WindowAdapter(const WindowAdapter&) = delete;
+    WindowAdapter& operator=(const WindowAdapter&) = delete;
+
+    /// Install this adapter as the callback target for NetHack.
+    void install();
+
+private:
+    struct MenuEntry
+    {
+        int entry_id = 0;
+        std::string text;
+        bool selectable = false;
+        bool selected = false;
+        std::vector<unsigned char> identifier;
+    };
+
+    static void callback(const char* name, void* return_ptr,
+                         const char* format, ...);
+    void handleCallback(const char* name, void* return_ptr,
+                        const char* format, std::va_list arguments);
+
+    Json makeSnapshot() const;
+    Json waitForInput(Json pending);
+    Json makePending(std::string kind, std::string source) const;
+    void publishSnapshot(Json snapshot);
+    void addMessage(const char* message);
+    void setIntegerReturn(void* return_ptr, int value) const;
+    void setCharacterReturn(void* return_ptr, char value) const;
+    void copyTextReturn(void* return_ptr, const std::string& text) const;
+    int resolveCommand(const std::string& command) const;
+
+    FramedChannel& channel_;
+    std::string game_id_;
+    std::uint64_t sequence_ = 0;
+    std::uint64_t input_id_ = 0;
+    int next_window_id_ = 1;
+    std::map<int, int> window_types_;
+    int active_menu_window_ = -1;
+    int next_menu_entry_id_ = 1;
+    int cursor_x_ = 1;
+    int cursor_y_ = 0;
+    std::vector<std::string> map_rows_;
+    std::vector<Json> messages_;
+    Json status_ = Json::object();
+    Json inventory_ = {
+        {"known", false},
+        {"stale", true},
+        {"entries", Json::array()},
+    };
+    std::vector<MenuEntry> menu_entries_;
+    std::string menu_prompt_;
+    std::string text_window_;
+
+    static WindowAdapter* active_adapter_;
+};
+
+} // namespace nethack_mcp
diff --git a/src/engine_probe.cpp b/src/engine_probe.cpp
new file mode 100644
index 0000000..3bdcf8a
--- /dev/null
+++ b/src/engine_probe.cpp
@@ -0,0 +1,183 @@
+#include <cstdarg>
+#include <cstdlib>
+#include <cstdio>
+#include <cstring>
+#include <map>
+#include <vector>
+#include <unistd.h>
+
+extern "C"
+{
+#include "config.h"
+#include "integer.h"
+#include "tradstdc.h"
+#include "global.h"
+#include "wintype.h"
+}
+
+extern "C"
+{
+
+int nhmain(int argc, char* argv[]);
+
+using ShimCallback = void (*)(const char*, void*, const char*, ...);
+
+void shim_graphics_set_callback(ShimCallback callback);
+
+}
+
+namespace
+{
+
+std::map<int, std::vector<anything>> menus;
+bool map_was_drawn = false;
+
+void set_int_return(void* return_ptr, int value)
+{
+    if(return_ptr != nullptr)
+    {
+        *static_cast<int*>(return_ptr) = value;
+    }
+}
+
+void set_string_return(void* return_ptr, const char* value)
+{
+    if(return_ptr == nullptr)
+    {
+        return;
+    }
+
+    auto** destination = static_cast<char**>(return_ptr);
+    *destination = const_cast<char*>(value);
+}
+
+void probe_callback(const char* name, void* return_ptr, const char* fmt, ...)
+{
+    if(name == nullptr)
+    {
+        return;
+    }
+
+    va_list arguments;
+    va_start(arguments, fmt);
+    if(std::strcmp(name, "shim_raw_print") == 0
+       || std::strcmp(name, "shim_raw_print_bold") == 0)
+    {
+        const char* message = va_arg(arguments, const char*);
+        std::fprintf(stderr, "NetHack: %s\n",
+                     message == nullptr ? "(null)" : message);
+    }
+    else if(std::strcmp(name, "shim_start_menu") == 0)
+    {
+        int window = va_arg(arguments, int);
+        (void) va_arg(arguments, unsigned long);
+        menus[window].clear();
+    }
+    else if(std::strcmp(name, "shim_add_menu") == 0)
+    {
+        int window = va_arg(arguments, int);
+        (void) va_arg(arguments, const void*);
+        const auto* identifier = va_arg(arguments, const anything*);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, const char*);
+        (void) va_arg(arguments, unsigned int);
+        if(identifier != nullptr)
+        {
+            menus[window].push_back(*identifier);
+        }
+    }
+    else if(std::strcmp(name, "shim_select_menu") == 0)
+    {
+        int window = va_arg(arguments, int);
+        int how = va_arg(arguments, int);
+        auto** menu_list = va_arg(arguments, menu_item**);
+        const auto found = menus.find(window);
+        if(how != 0 && menu_list != nullptr && found != menus.end()
+           && !found->second.empty())
+        {
+            auto* selected = static_cast<menu_item*>(
+                std::malloc(sizeof(menu_item)));
+            if(selected != nullptr)
+            {
+                selected[0].item = found->second.front();
+                selected[0].count = -1;
+                selected[0].itemflags = 0;
+                *menu_list = selected;
+                va_end(arguments);
+                set_int_return(return_ptr, 1);
+                return;
+            }
+        }
+        va_end(arguments);
+        set_int_return(return_ptr, 0);
+        return;
+    }
+    else if(std::strcmp(name, "shim_print_glyph") == 0)
+    {
+        map_was_drawn = true;
+    }
+    else if(map_was_drawn
+            && (std::strcmp(name, "shim_nhgetch") == 0
+                || std::strcmp(name, "shim_nh_poskey") == 0))
+    {
+        std::fprintf(stderr, "NetHack library startup reached the first key.\n");
+        std::_Exit(0);
+    }
+    va_end(arguments);
+    if(name == nullptr || return_ptr == nullptr)
+    {
+        return;
+    }
+
+    if(std::strcmp(name, "shim_nhgetch") == 0
+       || std::strcmp(name, "shim_nh_poskey") == 0
+       || std::strcmp(name, "shim_message_menu") == 0)
+    {
+        *static_cast<int*>(return_ptr) = 27;
+    }
+    else if(std::strcmp(name, "shim_yn_function") == 0)
+    {
+        *static_cast<char*>(return_ptr) = 27;
+    }
+    else if(std::strcmp(name, "shim_get_ext_cmd") == 0)
+    {
+        *static_cast<int*>(return_ptr) = -1;
+    }
+    else if(std::strcmp(name, "shim_select_menu") == 0)
+    {
+        *static_cast<int*>(return_ptr) = 0;
+    }
+    else if(std::strcmp(name, "shim_get_color_string") == 0)
+    {
+        set_string_return(return_ptr, "");
+    }
+}
+
+} // namespace
+
+int main(int argc, char* argv[])
+{
+#ifdef NETHACK_PROBE_RUNTIME_DIR
+    if(chdir(NETHACK_PROBE_RUNTIME_DIR) != 0)
+    {
+        std::perror("chdir");
+        return 1;
+    }
+#endif
+
+    shim_graphics_set_callback(probe_callback);
+
+    if(argc == 1)
+    {
+        char program[] = "nethack_engine_probe";
+        char name_option[] = "-u";
+        char name[] = "Agent";
+        char* probe_argv[] = {program, name_option, name, nullptr};
+        return nhmain(3, probe_argv);
+    }
+
+    return nhmain(argc, argv);
+}
diff --git a/src/engine_process.cpp b/src/engine_process.cpp
new file mode 100644
index 0000000..d977f76
--- /dev/null
+++ b/src/engine_process.cpp
@@ -0,0 +1,225 @@
+#include "engine_process.hpp"
+
+#include <cerrno>
+#include <csignal>
+#include <cstdio>
+#include <cstring>
+#include <fcntl.h>
+#include <spawn.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include <array>
+#include <stdexcept>
+#include <vector>
+
+extern char** environ;
+
+namespace nethack_mcp
+{
+
+EngineProcess::~EngineProcess()
+{
+    terminate();
+}
+
+bool EngineProcess::start(const Json& start_message, EventCallback callback,
+                          std::string& error)
+{
+    if(running_)
+    {
+        error = "a NetHack worker is already running";
+        return false;
+    }
+
+    int channel_fds[2] = {-1, -1};
+    int diagnostic_fds[2] = {-1, -1};
+    if(::socketpair(AF_UNIX, SOCK_STREAM, 0, channel_fds) != 0)
+    {
+        error = std::string("could not create worker IPC: ")
+            + std::strerror(errno);
+        return false;
+    }
+    if(::pipe(diagnostic_fds) != 0)
+    {
+        error = std::string("could not create worker diagnostics pipe: ")
+            + std::strerror(errno);
+        ::close(channel_fds[0]);
+        ::close(channel_fds[1]);
+        return false;
+    }
+
+    posix_spawn_file_actions_t actions;
+    posix_spawn_file_actions_init(&actions);
+    posix_spawn_file_actions_adddup2(&actions, channel_fds[1], 3);
+    if(channel_fds[1] != 3)
+    {
+        posix_spawn_file_actions_addclose(&actions, channel_fds[1]);
+    }
+    posix_spawn_file_actions_addclose(&actions, channel_fds[0]);
+    posix_spawn_file_actions_adddup2(&actions, diagnostic_fds[1], STDOUT_FILENO);
+    posix_spawn_file_actions_adddup2(&actions, diagnostic_fds[1], STDERR_FILENO);
+    if(diagnostic_fds[1] != STDOUT_FILENO
+       && diagnostic_fds[1] != STDERR_FILENO)
+    {
+        posix_spawn_file_actions_addclose(&actions, diagnostic_fds[1]);
+    }
+    posix_spawn_file_actions_addclose(&actions, diagnostic_fds[0]);
+
+    std::string executable = executablePath();
+    std::vector<char*> arguments;
+    std::vector<char> executable_storage(executable.begin(), executable.end());
+    executable_storage.push_back('\0');
+    char engine_option[] = "--engine";
+    char ipc_option[] = "--ipc-fd";
+    char ipc_descriptor[] = "3";
+    arguments.push_back(executable_storage.data());
+    arguments.push_back(engine_option);
+    arguments.push_back(ipc_option);
+    arguments.push_back(ipc_descriptor);
+    arguments.push_back(nullptr);
+
+    pid_t child = -1;
+    const int spawn_result = posix_spawn(
+        &child, executable.c_str(), &actions, nullptr, arguments.data(),
+        environ);
+    posix_spawn_file_actions_destroy(&actions);
+    ::close(channel_fds[1]);
+    ::close(diagnostic_fds[1]);
+    if(spawn_result != 0)
+    {
+        ::close(channel_fds[0]);
+        ::close(diagnostic_fds[0]);
+        error = std::string("could not spawn NetHack worker: ")
+            + std::strerror(spawn_result);
+        return false;
+    }
+
+    process_id_ = child;
+    callback_ = std::move(callback);
+    channel_ = std::make_unique<FramedChannel>(channel_fds[0]);
+    running_ = true;
+    diagnostic_thread_ = std::thread(
+        &EngineProcess::diagnosticLoop, this, diagnostic_fds[0]);
+    reader_thread_ = std::thread(&EngineProcess::readLoop, this);
+
+    if(!send(start_message, error))
+    {
+        terminate();
+        return false;
+    }
+    return true;
+}
+
+bool EngineProcess::send(const Json& message, std::string& error)
+{
+    std::lock_guard lock(state_mutex_);
+    if(!channel_ || !running_)
+    {
+        error = "NetHack worker is not running";
+        return false;
+    }
+    return channel_->send(message, error);
+}
+
+void EngineProcess::terminate()
+{
+    const pid_t process_id = process_id_;
+    if(process_id > 0 && running_)
+    {
+        ::kill(process_id, SIGTERM);
+    }
+    if(channel_)
+    {
+        channel_->close();
+    }
+    if(reader_thread_.joinable())
+    {
+        reader_thread_.join();
+    }
+    if(diagnostic_thread_.joinable())
+    {
+        diagnostic_thread_.join();
+    }
+    channel_.reset();
+    process_id_ = -1;
+    running_ = false;
+}
+
+bool EngineProcess::running() const
+{
+    return running_;
+}
+
+void EngineProcess::readLoop()
+{
+    Json message;
+    std::string error;
+    while(channel_ && channel_->receive(message, error))
+    {
+        if(callback_)
+        {
+            callback_(message);
+        }
+    }
+
+    const pid_t process_id = process_id_;
+    int status = 0;
+    if(process_id > 0)
+    {
+        while(::waitpid(process_id, &status, 0) < 0 && errno == EINTR)
+        {
+        }
+    }
+    running_ = false;
+    Json exiting = {
+        {"type", "exiting"},
+        {"exit_code", WIFEXITED(status) ? WEXITSTATUS(status) : -1},
+        {"signal", WIFSIGNALED(status) ? WTERMSIG(status) : 0},
+        {"error", error},
+    };
+    if(callback_)
+    {
+        callback_(exiting);
+    }
+}
+
+void EngineProcess::diagnosticLoop(int descriptor)
+{
+    std::array<char, 4096> buffer{};
+    while(true)
+    {
+        const ssize_t count = ::read(descriptor, buffer.data(), buffer.size());
+        if(count == 0)
+        {
+            break;
+        }
+        if(count < 0)
+        {
+            if(errno == EINTR)
+            {
+                continue;
+            }
+            break;
+        }
+        std::fwrite(buffer.data(), 1, static_cast<std::size_t>(count), stderr);
+        std::fflush(stderr);
+    }
+    ::close(descriptor);
+}
+
+std::string EngineProcess::executablePath() const
+{
+    std::array<char, 4096> buffer{};
+    const ssize_t length = ::readlink("/proc/self/exe", buffer.data(),
+                                     buffer.size() - 1);
+    if(length <= 0)
+    {
+        throw std::runtime_error("could not resolve the server executable");
+    }
+    buffer[static_cast<std::size_t>(length)] = '\0';
+    return buffer.data();
+}
+
+} // namespace nethack_mcp
diff --git a/src/engine_worker.cpp b/src/engine_worker.cpp
new file mode 100644
index 0000000..f14feec
--- /dev/null
+++ b/src/engine_worker.cpp
@@ -0,0 +1,108 @@
+#include "engine_worker.hpp"
+
+#include "protocol.hpp"
+#include "window_adapter.hpp"
+
+#include <cstdio>
+#include <cstdlib>
+#include <filesystem>
+#include <stdexcept>
+#include <string>
+#include <unistd.h>
+#include <vector>
+
+extern "C"
+{
+int nhmain(int argc, char* argv[]);
+}
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+int findIntegerArgument(int argc, char* argv[], const char* name)
+{
+    for(int index = 0; index + 1 < argc; ++index)
+    {
+        if(std::string(argv[index]) == name)
+        {
+            return std::stoi(argv[index + 1]);
+        }
+    }
+    return -1;
+}
+
+} // namespace
+
+int runEngineWorker(int argc, char* argv[])
+{
+    const int descriptor = findIntegerArgument(argc, argv, "--ipc-fd");
+    if(descriptor < 0)
+    {
+        std::fputs("--engine requires --ipc-fd\n", stderr);
+        return 2;
+    }
+
+    try
+    {
+        FramedChannel channel(descriptor);
+        Json hello = {
+            {"type", "hello"},
+            {"ipc_version", IPC_VERSION},
+            {"game_id", nullptr},
+            {"nethack_commit",
+             "c94fd5225beef48143244bfb7bc42682aad58741"},
+            {"lua_version", "5.4.8"},
+        };
+        std::string error;
+        if(!channel.send(hello, error))
+        {
+            throw std::runtime_error(error);
+        }
+
+        Json start;
+        if(!channel.receive(start, error)
+           || start.value("type", "") != "start")
+        {
+            throw std::runtime_error(
+                error.empty() ? "worker did not receive start" : error);
+        }
+
+        const std::string game_id = start.at("game_id").get<std::string>();
+        const std::filesystem::path run_directory =
+            start.at("run_dir").get<std::string>();
+        const std::string player_name =
+            start.value("name", std::string("Agent"));
+
+        std::filesystem::current_path(run_directory);
+        setenv("HOME", run_directory.c_str(), 1);
+        setenv("HACKDIR", run_directory.c_str(), 1);
+        setenv("NETHACKDIR", run_directory.c_str(), 1);
+        setenv("NETHACKOPTIONS", "showexp,showscore,time", 1);
+
+        WindowAdapter adapter(channel, game_id);
+        adapter.install();
+
+        char program[] = "nethack_mcp";
+        char name_option[] = "-u";
+        std::vector<char> name(player_name.begin(), player_name.end());
+        name.push_back('\0');
+        char* nethack_argv[] = {
+            program,
+            name_option,
+            name.data(),
+            nullptr,
+        };
+        return nhmain(3, nethack_argv);
+    }
+    catch(const std::exception& exception)
+    {
+        std::fprintf(stderr, "NetHack worker failed: %s\n",
+                     exception.what());
+        return 2;
+    }
+}
+
+} // namespace nethack_mcp
diff --git a/src/engine_worker_stub.cpp b/src/engine_worker_stub.cpp
new file mode 100644
index 0000000..093123a
--- /dev/null
+++ b/src/engine_worker_stub.cpp
@@ -0,0 +1,15 @@
+#include "engine_worker.hpp"
+
+#include <cstdio>
+
+namespace nethack_mcp
+{
+
+int runEngineWorker([[maybe_unused]] int argc,
+                    [[maybe_unused]] char* argv[])
+{
+    std::fputs("this build does not include the NetHack engine\n", stderr);
+    return 2;
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_session.cpp b/src/game_session.cpp
new file mode 100644
index 0000000..46617a8
--- /dev/null
+++ b/src/game_session.cpp
@@ -0,0 +1,667 @@
+#include "game_session.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cctype>
+#include <cstring>
+#include <fstream>
+#include <random>
+#include <sstream>
+#include <utility>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+using Clock = std::chrono::steady_clock;
+
+std::string jsonString(const Json& value, const char* key,
+                       std::string fallback = {})
+{
+    if(!value.contains(key) || !value.at(key).is_string())
+    {
+        return fallback;
+    }
+    return value.at(key).get<std::string>();
+}
+
+} // namespace
+
+GameSession::GameSession(std::filesystem::path data_root,
+                         std::filesystem::path runtime_dir,
+                         std::string viewer_url)
+        : data_root_(std::move(data_root)), runtime_dir_(std::move(runtime_dir)),
+          viewer_url_(std::move(viewer_url)), observations_(viewer_url_)
+{
+    std::filesystem::create_directories(data_root_);
+}
+
+ToolResult GameSession::newGame(const Json& arguments)
+{
+    std::unique_lock action_lock(action_mutex_);
+    {
+        std::lock_guard session_lock(session_mutex_);
+        if(process_ && process_->running())
+        {
+            return errorResult("GAME_ACTIVE", "a game is already active");
+        }
+        if(process_)
+        {
+            process_.reset();
+        }
+    }
+
+    std::string name = jsonString(arguments, "name", "Agent");
+    if(name.empty() || name.size() > 30)
+    {
+        return errorResult("INVALID_RESPONSE",
+                           "name must contain between 1 and 30 bytes");
+    }
+    for(unsigned char character : name)
+    {
+        if(character < 0x20 || character > 0x7e)
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "name must contain printable ASCII only");
+        }
+    }
+
+    const std::string game_id = makeGameId();
+    const std::filesystem::path run_directory = data_root_ / game_id;
+    std::string error;
+    if(!copyRuntimeFiles(run_directory, error))
+    {
+        return errorResult("ENGINE_FAILURE", error);
+    }
+
+    Json starting = observations_.snapshot();
+    starting["game_id"] = game_id;
+    starting["lifecycle"] = "starting";
+    starting["pending"] = nullptr;
+    starting["operation"] = nullptr;
+    starting["viewer_url"] = viewer_url_;
+    const std::uint64_t starting_revision = observations_.publish(
+        std::move(starting));
+
+    stop_requested_ = false;
+    {
+        std::lock_guard session_lock(session_mutex_);
+        process_ = std::make_unique<EngineProcess>();
+    }
+
+    Json start_message = {
+        {"type", "start"},
+        {"ipc_version", IPC_VERSION},
+        {"game_id", game_id},
+        {"run_dir", run_directory.string()},
+        {"name", name},
+    };
+    std::string spawn_error;
+    if(!process_->start(
+           start_message,
+           [this](const Json& message) {
+               handleWorkerMessage(message);
+           },
+           spawn_error))
+    {
+        Json failed = observations_.snapshot();
+        failed["lifecycle"] = "failed";
+        failed["pending"] = nullptr;
+        observations_.publish(std::move(failed));
+        return errorResult("ENGINE_FAILURE", spawn_error);
+    }
+
+    Json state = observations_.waitForRevision(
+        starting_revision, std::chrono::seconds(10));
+    if(state.value("game_id", "") != game_id)
+    {
+        return errorResult("ENGINE_FAILURE",
+                           "worker returned a different game identifier");
+    }
+    return {true, std::move(state), {}, {}};
+}
+
+ToolResult GameSession::observe(const Json& arguments)
+{
+    const Json state = observations_.snapshot();
+    std::string error;
+    if(arguments.contains("game_id")
+       && !validGameId(arguments, state, error))
+    {
+        return errorResult("STALE_GAME", error);
+    }
+
+    int wait_ms = 0;
+    if(arguments.contains("wait_ms"))
+    {
+        if(!arguments.at("wait_ms").is_number_integer())
+        {
+            return errorResult("INVALID_RESPONSE", "wait_ms must be an integer");
+        }
+        wait_ms = arguments.at("wait_ms").get<int>();
+        if(wait_ms < 0 || wait_ms > 10000)
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "wait_ms must be between 0 and 10000");
+        }
+    }
+
+    Json result = wait_ms == 0
+        ? state
+        : observations_.waitForRevision(
+              state.value("revision", 0ULL),
+              std::chrono::milliseconds(wait_ms));
+
+    if(arguments.contains("after_message_id"))
+    {
+        if(!arguments.at("after_message_id").is_number_unsigned())
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "after_message_id must be an integer");
+        }
+        const std::uint64_t after = arguments.at("after_message_id").get<
+            std::uint64_t>();
+        const Json all_messages = result.value("messages", Json::array());
+        Json filtered = Json::array();
+        for(const Json& message : all_messages)
+        {
+            if(message.value("id", 0ULL) > after)
+            {
+                filtered.push_back(message);
+            }
+        }
+        result["messages"] = std::move(filtered);
+    }
+    return {true, std::move(result), {}, {}};
+}
+
+ToolResult GameSession::press(const Json& arguments)
+{
+    int key = 0;
+    std::string error;
+    if(!parseKey(arguments.value("key", Json()), key, error))
+    {
+        return errorResult("INVALID_RESPONSE", error);
+    }
+    return sendInput(arguments, {{"value", key}}, "key");
+}
+
+ToolResult GameSession::respond(const Json& arguments)
+{
+    const Json state = observations_.snapshot();
+    std::string error;
+    if(!validGameId(arguments, state, error))
+    {
+        return errorResult("STALE_GAME", error);
+    }
+    const Json pending = state.value("pending", Json());
+    if(!pending.is_object())
+    {
+        return errorResult("WRONG_INPUT_KIND", "there is no pending input");
+    }
+
+    int response_variants = 0;
+    response_variants += arguments.contains("text") ? 1 : 0;
+    response_variants += arguments.contains("choice") ? 1 : 0;
+    response_variants += arguments.contains("command") ? 1 : 0;
+    response_variants += arguments.contains("acknowledge") ? 1 : 0;
+    response_variants += arguments.contains("cancel") ? 1 : 0;
+    if(response_variants != 1)
+    {
+        return errorResult("INVALID_RESPONSE",
+                           "respond requires exactly one response variant");
+    }
+
+    Json response;
+    if(arguments.contains("text"))
+    {
+        if(!arguments.at("text").is_string()
+           || pending.value("kind", "") != "text")
+        {
+            return errorResult("WRONG_INPUT_KIND",
+                               "the pending input does not accept text");
+        }
+        const std::string text = arguments.at("text").get<std::string>();
+        if(text.find('\n') != std::string::npos
+           || text.find('\r') != std::string::npos
+           || text.size() > pending.value("max_bytes", 255U))
+        {
+            return errorResult("LIMIT_EXCEEDED", "text response is invalid");
+        }
+        response = {{"text", text}};
+    }
+    else if(arguments.contains("choice"))
+    {
+        if(!arguments.at("choice").is_string()
+           || arguments.at("choice").get<std::string>().size() != 1
+           || pending.value("kind", "") != "choice")
+        {
+            return errorResult("WRONG_INPUT_KIND",
+                               "the pending input does not accept a choice");
+        }
+        const char choice = arguments.at("choice").get<std::string>()[0];
+        const std::string choices = pending.value("choices", "");
+        if(choice != 27 && choices.find(choice) == std::string::npos)
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "choice is not in the offered choices");
+        }
+        response = {{"value", static_cast<int>(choice)}};
+    }
+    else if(arguments.contains("command"))
+    {
+        if(!arguments.at("command").is_string()
+           || pending.value("kind", "") != "command")
+        {
+            return errorResult("WRONG_INPUT_KIND",
+                               "the pending input does not accept a command");
+        }
+        const std::string command = arguments.at("command").get<std::string>();
+        const Json offered = pending.value("commands", Json::array());
+        if(std::find(offered.begin(), offered.end(), command) == offered.end())
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "command is not offered by NetHack");
+        }
+        response = {{"command", command}};
+    }
+    else if(arguments.contains("acknowledge"))
+    {
+        if(!arguments.at("acknowledge").is_boolean()
+           || !arguments.at("acknowledge").get<bool>())
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "acknowledge must be true");
+        }
+        if(pending.value("kind", "") != "acknowledge")
+        {
+            return errorResult("WRONG_INPUT_KIND",
+                               "the pending input is not an acknowledgement");
+        }
+        response = {{"acknowledge", true}};
+    }
+    else if(arguments.contains("cancel"))
+    {
+        if(!arguments.at("cancel").is_boolean()
+           || !arguments.at("cancel").get<bool>())
+        {
+            return errorResult("INVALID_RESPONSE", "cancel must be true");
+        }
+        response = {{"cancel", true}};
+    }
+    return sendInput(arguments, std::move(response),
+                     pending.value("kind", ""));
+}
+
+ToolResult GameSession::selectMenu(const Json& arguments)
+{
+    const Json state = observations_.snapshot();
+    std::string error;
+    if(!validGameId(arguments, state, error))
+    {
+        return errorResult("STALE_GAME", error);
+    }
+    const Json pending = state.value("pending", Json());
+    if(!pending.is_object() || pending.value("kind", "") != "menu")
+    {
+        return errorResult("WRONG_INPUT_KIND", "there is no pending menu");
+    }
+    if(!arguments.contains("selections")
+       || !arguments.at("selections").is_array())
+    {
+        return errorResult("INVALID_SELECTION",
+                           "selections must be an array");
+    }
+    if(arguments.contains("cancel")
+       && !arguments.at("cancel").is_boolean())
+    {
+        return errorResult("INVALID_SELECTION", "cancel must be boolean");
+    }
+    Json response = {
+        {"selections", arguments.at("selections")},
+        {"cancel", arguments.value("cancel", false)},
+    };
+    std::vector<int> seen;
+    for(const Json& selection : response["selections"])
+    {
+        if(!selection.is_object()
+           || !selection.contains("entry_id")
+           || !selection.at("entry_id").is_number_integer())
+        {
+            return errorResult("INVALID_SELECTION",
+                               "each selection needs an integer entry_id");
+        }
+        const int entry_id = selection.at("entry_id").get<int>();
+        if(std::find(seen.begin(), seen.end(), entry_id) != seen.end())
+        {
+            return errorResult("INVALID_SELECTION",
+                               "menu entries may only be selected once");
+        }
+        seen.push_back(entry_id);
+        if(selection.contains("count")
+           && (!selection.at("count").is_number_integer()
+               || selection.at("count").get<long long>() <= 0))
+        {
+            return errorResult("INVALID_SELECTION",
+                               "menu count must be positive");
+        }
+    }
+    const int mode = pending.value("mode", 0);
+    if(mode == 0 && !response["selections"].empty())
+    {
+        return errorResult("INVALID_SELECTION",
+                           "this menu does not accept selections");
+    }
+    if(mode == 1 && response["selections"].size() > 1)
+    {
+        return errorResult("INVALID_SELECTION",
+                           "this menu accepts only one selection");
+    }
+    for(const Json& selection : response["selections"])
+    {
+        const int entry_id = selection.at("entry_id").get<int>();
+        const auto entry = std::find_if(
+            pending.at("entries").begin(), pending.at("entries").end(),
+            [entry_id](const Json& item) {
+                return item.value("entry_id", 0) == entry_id;
+            });
+        if(entry == pending.at("entries").end()
+           || !entry->value("selectable", false))
+        {
+            return errorResult("INVALID_SELECTION",
+                               "menu entry is not selectable");
+        }
+    }
+    if(response.value("cancel", false) && !response["selections"].empty())
+    {
+        return errorResult("INVALID_SELECTION",
+                           "cancel cannot include selections");
+    }
+    return sendInput(arguments, std::move(response), "menu");
+}
+
+ToolResult GameSession::quitGame(const Json& arguments)
+{
+    const Json state = observations_.snapshot();
+    std::string error;
+    if(!validGameId(arguments, state, error))
+    {
+        return errorResult("STALE_GAME", error);
+    }
+    std::unique_lock action_lock(action_mutex_);
+    stop_requested_ = true;
+    if(process_)
+    {
+        process_->terminate();
+    }
+    Json result = observations_.snapshot();
+    if(result.value("lifecycle", "") == "waiting"
+       || result.value("lifecycle", "") == "starting")
+    {
+        result["lifecycle"] = "aborted";
+        result["pending"] = nullptr;
+        result["operation"] = nullptr;
+        observations_.publish(result);
+    }
+    return {true, std::move(result), {}, {}};
+}
+
+void GameSession::shutdown()
+{
+    std::unique_lock action_lock(action_mutex_);
+    stop_requested_ = true;
+    if(process_)
+    {
+        process_->terminate();
+    }
+}
+
+const std::string& GameSession::viewerUrl() const
+{
+    return viewer_url_;
+}
+
+Json GameSession::snapshot() const
+{
+    return observations_.snapshot();
+}
+
+ToolResult GameSession::sendInput(const Json& arguments, Json response,
+                                  const std::string& expected_kind)
+{
+    std::unique_lock action_lock(action_mutex_);
+    const Json state = observations_.snapshot();
+    std::string error;
+    if(!validGameId(arguments, state, error))
+    {
+        return errorResult("STALE_GAME", error);
+    }
+    const Json pending = state.value("pending", Json());
+    if(!pending.is_object()
+       || pending.value("kind", "") != expected_kind)
+    {
+        return errorResult("WRONG_INPUT_KIND",
+                           "the requested response does not match pending input");
+    }
+    if(state.value("operation", Json()) != nullptr)
+    {
+        return errorResult("BUSY", "another gameplay operation is running");
+    }
+    if(!process_ || !process_->running())
+    {
+        return errorResult("ENGINE_FAILURE", "NetHack worker is not running");
+    }
+    if(!arguments.contains("input_id")
+       || !arguments.at("input_id").is_number_integer())
+    {
+        return errorResult("INVALID_RESPONSE", "input_id must be an integer");
+    }
+    const long long provided_input_id =
+        arguments.at("input_id").get<long long>();
+    const std::uint64_t pending_input_id =
+        pending.value("input_id", 0ULL);
+    if(provided_input_id < 0
+       || static_cast<std::uint64_t>(provided_input_id)
+              != pending_input_id)
+    {
+        return errorResult("STALE_INPUT",
+                           "input_id does not identify the pending boundary");
+    }
+
+    Json operation = {
+        {"operation_id", "op_" + std::to_string(++operation_counter_)},
+        {"state", "running"},
+    };
+    Json running_state = state;
+    running_state["operation"] = operation;
+    const std::uint64_t operation_revision = observations_.publish(
+        std::move(running_state));
+
+    Json input = {
+        {"type", "input"},
+        {"ipc_version", IPC_VERSION},
+        {"game_id", state.value("game_id", "")},
+        {"input_id", pending.value("input_id", 0ULL)},
+        {"response", std::move(response)},
+    };
+    if(!process_->send(input, error))
+    {
+        Json failed = observations_.snapshot();
+        failed["lifecycle"] = "failed";
+        failed["operation"] = nullptr;
+        observations_.publish(std::move(failed));
+        return errorResult("ENGINE_FAILURE", error);
+    }
+
+    Json result = observations_.waitForRevision(
+        operation_revision, std::chrono::seconds(10));
+    return {true, std::move(result), {}, {}};
+}
+
+ToolResult GameSession::errorResult(std::string code,
+                                    std::string message) const
+{
+    return {false, observations_.snapshot(), std::move(code),
+            std::move(message)};
+}
+
+void GameSession::handleWorkerMessage(const Json& message)
+{
+    const std::string type = message.value("type", "");
+    if(type == "hello")
+    {
+        if(message.value("ipc_version", 0U) != IPC_VERSION)
+        {
+            Json failed = observations_.snapshot();
+            failed["lifecycle"] = "failed";
+            failed["messages"].push_back({
+                {"id", 0},
+                {"text", "worker IPC version mismatch"},
+            });
+            observations_.publish(std::move(failed));
+        }
+        return;
+    }
+    if(type == "snapshot" && message.contains("snapshot"))
+    {
+        Json snapshot = message.at("snapshot");
+        snapshot["viewer_url"] = viewer_url_;
+        snapshot["operation"] = nullptr;
+        observations_.publish(std::move(snapshot));
+        return;
+    }
+    if(type == "exiting")
+    {
+        Json snapshot = observations_.snapshot();
+        if(stop_requested_)
+        {
+            snapshot["lifecycle"] = "aborted";
+        }
+        else if(message.value("exit_code", -1) == 0
+                && message.value("signal", 0) == 0)
+        {
+            snapshot["lifecycle"] = "ended";
+        }
+        else
+        {
+            snapshot["lifecycle"] = "failed";
+        }
+        snapshot["pending"] = nullptr;
+        snapshot["operation"] = nullptr;
+        observations_.publish(std::move(snapshot));
+    }
+}
+
+bool GameSession::copyRuntimeFiles(
+    const std::filesystem::path& run_directory, std::string& error) const
+{
+    try
+    {
+        std::filesystem::create_directories(run_directory / "save");
+        const std::filesystem::perms owner_permissions =
+            std::filesystem::perms::owner_read
+            | std::filesystem::perms::owner_write
+            | std::filesystem::perms::owner_exec;
+        std::filesystem::permissions(run_directory, owner_permissions,
+                                     std::filesystem::perm_options::replace);
+        std::filesystem::permissions(run_directory / "save",
+                                     owner_permissions,
+                                     std::filesystem::perm_options::replace);
+        for(const char* filename : {"nhdat", "symbols", "license", "sysconf"})
+        {
+            const auto source = runtime_dir_ / filename;
+            if(!std::filesystem::is_regular_file(source))
+            {
+                error = "NetHack runtime file is missing: " + source.string();
+                return false;
+            }
+            std::filesystem::copy_file(source, run_directory / filename);
+        }
+        for(const char* filename : {"perm", "record", "logfile",
+                                    "xlogfile", "livelog"})
+        {
+            const auto destination = run_directory / filename;
+            std::ofstream file(destination);
+            file.close();
+            std::filesystem::permissions(
+                destination,
+                std::filesystem::perms::owner_read
+                    | std::filesystem::perms::owner_write,
+                std::filesystem::perm_options::replace);
+        }
+        return true;
+    }
+    catch(const std::exception& exception)
+    {
+        error = exception.what();
+        return false;
+    }
+}
+
+bool GameSession::validGameId(const Json& arguments, const Json& state,
+                              std::string& error)
+{
+    const std::string current_id = state.contains("game_id")
+        && state.at("game_id").is_string()
+        ? state.at("game_id").get<std::string>() : std::string();
+    if(!arguments.contains("game_id") || !arguments.at("game_id").is_string())
+    {
+        error = "game_id is required";
+        return false;
+    }
+    if(current_id.empty() || arguments.at("game_id").get<std::string>()
+           != current_id)
+    {
+        error = "game_id does not identify the active game";
+        return false;
+    }
+    return true;
+}
+
+bool GameSession::parseKey(const Json& key, int& value, std::string& error)
+{
+    if(!key.is_string())
+    {
+        error = "key must be a string";
+        return false;
+    }
+    const std::string text = key.get<std::string>();
+    if(text.size() == 1
+       && static_cast<unsigned char>(text[0]) >= 0x20
+       && static_cast<unsigned char>(text[0]) <= 0x7e)
+    {
+        value = static_cast<unsigned char>(text[0]);
+        return true;
+    }
+    if(text == "ENTER") value = '\n';
+    else if(text == "ESC") value = 27;
+    else if(text == "SPACE") value = ' ';
+    else if(text == "TAB") value = '\t';
+    else if(text == "BACKSPACE") value = '\b';
+    else if(text.size() == 6 && text.rfind("CTRL_", 0) == 0
+            && text[5] >= 'A' && text[5] <= 'Z')
+        value = text[5] - 'A' + 1;
+    else if(text.size() == 6 && text.rfind("META_", 0) == 0
+            && static_cast<unsigned char>(text[5]) >= 0x20
+            && static_cast<unsigned char>(text[5]) <= 0x7e)
+        value = 0x80 | static_cast<unsigned char>(text[5]);
+    else
+    {
+        error = "key must be one printable ASCII byte or a named key";
+        return false;
+    }
+    return value != 0;
+}
+
+std::string GameSession::makeGameId()
+{
+    static std::atomic<std::uint64_t> counter = 0;
+    const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
+        Clock::now().time_since_epoch()).count();
+    return "g_" + std::to_string(now) + "_"
+        + std::to_string(++counter);
+}
+
+} // namespace nethack_mcp
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..8d78de3
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,103 @@
+#include "engine_worker.hpp"
+#include "game_session.hpp"
+#include "mcp_server.hpp"
+#include "spectator_server.hpp"
+
+#include <cstdio>
+#include <cstdlib>
+#include <exception>
+#include <filesystem>
+#include <iostream>
+#include <string>
+
+namespace
+{
+
+struct Options
+{
+    std::filesystem::path data_root;
+    int port = 8765;
+};
+
+bool parseOptions(int argc, char* argv[], Options& options)
+{
+    options.data_root = std::filesystem::temp_directory_path()
+        / "nethack-mcp";
+    for(int index = 1; index < argc; ++index)
+    {
+        const std::string argument = argv[index];
+        if(argument == "--data-root" && index + 1 < argc)
+        {
+            options.data_root = argv[++index];
+        }
+        else if(argument == "--port" && index + 1 < argc)
+        {
+            try
+            {
+                options.port = std::stoi(argv[++index]);
+            }
+            catch(const std::exception&)
+            {
+                return false;
+            }
+            if(options.port < 1 || options.port > 65535)
+            {
+                return false;
+            }
+        }
+        else if(argument == "--help")
+        {
+            std::fputs(
+                "Usage: nethack_mcp [--data-root PATH] [--port PORT]\n",
+                stdout);
+            return false;
+        }
+        else
+        {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace
+
+int main(int argc, char* argv[])
+{
+    if(argc > 1 && std::string(argv[1]) == "--engine")
+    {
+        return nethack_mcp::runEngineWorker(argc, argv);
+    }
+
+    Options options;
+    if(!parseOptions(argc, argv, options))
+    {
+        return argc > 1 && std::string(argv[1]) == "--help" ? 0 : 2;
+    }
+
+    try
+    {
+        nethack_mcp::GameSession session(
+            options.data_root,
+            NETHACK_RUNTIME_DIR,
+            "http://127.0.0.1:" + std::to_string(options.port) + "/");
+        nethack_mcp::SpectatorServer spectator(session, options.port);
+        std::string error;
+        if(!spectator.startServer(error))
+        {
+            std::fprintf(stderr, "%s\n", error.c_str());
+            return 1;
+        }
+        std::fprintf(stderr, "Spectator: %s\n", session.viewerUrl().c_str());
+
+        nethack_mcp::McpServer server(session);
+        const int result = server.run(std::cin, std::cout);
+        spectator.stopServer();
+        return result;
+    }
+    catch(const std::exception& exception)
+    {
+        std::fprintf(stderr, "nethack_mcp: %s\n", exception.what());
+        return 1;
+    }
+}
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
new file mode 100644
index 0000000..42445c5
--- /dev/null
+++ b/src/mcp_server.cpp
@@ -0,0 +1,323 @@
+#include "mcp_server.hpp"
+
+#include <cstddef>
+#include <iostream>
+#include <string>
+#include <utility>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+constexpr std::size_t MAX_STDIO_MESSAGE_SIZE = 1024U * 1024U;
+
+bool supportedProtocolVersion(const std::string& version)
+{
+    return version == "2025-11-25"
+        || version == "2025-06-18"
+        || version == "2025-03-26"
+        || version == "2024-11-05";
+}
+
+Json objectSchema(Json properties, Json required = Json::array())
+{
+    return {
+        {"type", "object"},
+        {"properties", std::move(properties)},
+        {"required", std::move(required)},
+        {"additionalProperties", false},
+    };
+}
+
+Json stringProperty()
+{
+    return {{"type", "string"}};
+}
+
+Json gameIdProperty()
+{
+    return {
+        {"type", "string"},
+        {"minLength", 1},
+        {"maxLength", 128},
+    };
+}
+
+} // namespace
+
+McpServer::McpServer(GameSession& session)
+        : session_(session)
+{}
+
+int McpServer::run(std::istream& input, std::ostream& output)
+{
+    std::string line;
+    while(std::getline(input, line))
+    {
+        if(line.size() > MAX_STDIO_MESSAGE_SIZE)
+        {
+            output << jsonRpcError(nullptr, -32600,
+                                   "JSON-RPC message exceeds 1 MiB")
+                   .dump() << '\n' << std::flush;
+            continue;
+        }
+
+        Json request;
+        try
+        {
+            request = Json::parse(line);
+        }
+        catch(const Json::parse_error& exception)
+        {
+            output << jsonRpcError(nullptr, -32700, exception.what()).dump()
+                   << '\n' << std::flush;
+            continue;
+        }
+
+        bool should_respond = true;
+        Json response = dispatch(request, should_respond);
+        if(should_respond)
+        {
+            output << response.dump() << '\n' << std::flush;
+        }
+    }
+    session_.shutdown();
+    return 0;
+}
+
+Json McpServer::dispatch(const Json& request, bool& should_respond)
+{
+    should_respond = true;
+    if(!request.is_object()
+       || !request.contains("jsonrpc")
+       || !request.at("jsonrpc").is_string()
+       || request.at("jsonrpc").get<std::string>() != "2.0"
+       || !request.contains("method") || !request.at("method").is_string())
+    {
+        const Json id = request.is_object() && request.contains("id")
+            ? request.at("id") : Json(nullptr);
+        return jsonRpcError(id, -32600, "invalid JSON-RPC request");
+    }
+
+    const Json id = request.contains("id") ? request.at("id")
+                                            : Json(nullptr);
+
+    const std::string method = request.at("method").get<std::string>();
+    if(!request.contains("id"))
+    {
+        should_respond = false;
+    }
+    if(method == "notifications/initialized"
+       || method == "notifications/cancelled")
+    {
+        should_respond = false;
+        return Json();
+    }
+    if(method == "initialize")
+    {
+        const Json params = request.value("params", Json::object());
+        if(!params.is_object())
+        {
+            return jsonRpcError(id, -32602,
+                                "initialize params must be an object");
+        }
+        if(params.contains("protocolVersion")
+           && !params.at("protocolVersion").is_string())
+        {
+            return jsonRpcError(id, -32602,
+                                "protocolVersion must be a string");
+        }
+        const std::string version = params.value("protocolVersion", "");
+        if(!version.empty() && !supportedProtocolVersion(version))
+        {
+            return jsonRpcError(id, -32602,
+                                "unsupported MCP protocol version: " + version);
+        }
+        const std::string negotiated_version = version.empty()
+            ? "2025-11-25" : version;
+        return {
+            {"jsonrpc", "2.0"},
+            {"id", id},
+            {"result", {
+                {"protocolVersion", negotiated_version},
+                {"capabilities", {{"tools", Json::object()}}},
+                {"serverInfo", {
+                    {"name", "nethack-mcp"},
+                    {"version", "0.1.0"},
+                }},
+            }},
+        };
+    }
+    if(method == "ping")
+    {
+        return {{"jsonrpc", "2.0"}, {"id", id}, {"result", {}}};
+    }
+    if(method == "tools/list")
+    {
+        return {
+            {"jsonrpc", "2.0"},
+            {"id", id},
+            {"result", {{"tools", tools()}}},
+        };
+    }
+    if(method == "tools/call")
+    {
+        const Json params = request.value("params", Json::object());
+        if(!params.is_object() || !params.contains("name")
+           || !params.at("name").is_string())
+        {
+            return jsonRpcError(id, -32602, "tools/call needs a tool name");
+        }
+        const Json arguments = params.value("arguments", Json::object());
+        if(!arguments.is_object())
+        {
+            return jsonRpcError(id, -32602, "tool arguments must be an object");
+        }
+
+        const std::string name = params.at("name").get<std::string>();
+        ToolResult result;
+        if(name == "new_game") result = session_.newGame(arguments);
+        else if(name == "observe") result = session_.observe(arguments);
+        else if(name == "press") result = session_.press(arguments);
+        else if(name == "select_menu") result = session_.selectMenu(arguments);
+        else if(name == "respond") result = session_.respond(arguments);
+        else if(name == "quit_game") result = session_.quitGame(arguments);
+        else
+        {
+            return jsonRpcError(id, -32602, "unknown tool: " + name);
+        }
+        return {
+            {"jsonrpc", "2.0"},
+            {"id", id},
+            {"result", toolResult(result)},
+        };
+    }
+    return jsonRpcError(id, -32601, "method not found: " + method);
+}
+
+Json McpServer::tools() const
+{
+    const Json game_id = gameIdProperty();
+    const Json selection = objectSchema(
+        {
+            {"entry_id", {{"type", "integer"}, {"minimum", 1}}},
+            {"count", {{"type", "integer"}, {"minimum", 1}}},
+        },
+        {"entry_id"});
+    return Json::array({
+        {
+            {"name", "new_game"},
+            {"description", "Start one fresh NetHack game."},
+            {"inputSchema", objectSchema({
+                {"name", {{"type", "string"}, {"maxLength", 30}}},
+                {"role", stringProperty()},
+                {"race", stringProperty()},
+                {"gender", stringProperty()},
+                {"alignment", stringProperty()},
+            })},
+        },
+        {
+            {"name", "observe"},
+            {"description", "Read the latest complete game observation."},
+            {"inputSchema", objectSchema({
+                {"game_id", game_id},
+                {"detail", {{"type", "string"},
+                             {"enum", {"compact", "full"}}}},
+                {"after_message_id", {{"type", "integer"}, {"minimum", 0}}},
+                {"wait_ms", {{"type", "integer"},
+                              {"minimum", 0}, {"maximum", 10000}}},
+            })},
+        },
+        {
+            {"name", "press"},
+            {"description", "Send one key to a pending key boundary."},
+            {"inputSchema", objectSchema({
+                {"game_id", game_id},
+                {"input_id", {{"type", "integer"}, {"minimum", 1}}},
+                {"key", stringProperty()},
+            }, {"game_id", "input_id", "key"})},
+        },
+        {
+            {"name", "select_menu"},
+            {"description", "Submit complete selections for a menu."},
+            {"inputSchema", objectSchema({
+                {"game_id", game_id},
+                {"input_id", {{"type", "integer"}, {"minimum", 1}}},
+                {"selections", {{"type", "array"},
+                                 {"items", selection}}},
+                {"cancel", {{"type", "boolean"}}},
+            }, {"game_id", "input_id", "selections"})},
+        },
+        {
+            {"name", "respond"},
+            {"description", "Answer a non-key pending boundary."},
+            {"inputSchema", {
+                {"type", "object"},
+                {"properties", {
+                    {"game_id", game_id},
+                    {"input_id", {{"type", "integer"}, {"minimum", 1}}},
+                    {"text", stringProperty()},
+                    {"choice", stringProperty()},
+                    {"command", stringProperty()},
+                    {"acknowledge", {{"type", "boolean"}}},
+                    {"cancel", {{"type", "boolean"}}},
+                }},
+                {"required", {"game_id", "input_id"}},
+                {"additionalProperties", false},
+                {"oneOf", {
+                    {{"required", {"text"}}},
+                    {{"required", {"choice"}}},
+                    {{"required", {"command"}}},
+                    {{"required", {"acknowledge"}}},
+                    {{"required", {"cancel"}}},
+                }},
+            }},
+        },
+        {
+            {"name", "quit_game"},
+            {"description", "Stop the active worker administratively."},
+            {"inputSchema", objectSchema({
+                {"game_id", game_id},
+            }, {"game_id"})},
+        },
+    });
+}
+
+Json McpServer::toolResult(const ToolResult& result) const
+{
+    Json structured = result.value;
+    Json text_value = result.success
+        ? result.value
+        : Json({
+              {"error", {
+                  {"code", result.code},
+                  {"message", result.message},
+              }},
+              {"state", result.value},
+          });
+    Json response = {
+        {"content", Json::array({
+            {{"type", "text"}, {"text", text_value.dump()}},
+        })},
+        {"structuredContent", structured},
+    };
+    if(!result.success)
+    {
+        response["isError"] = true;
+    }
+    return response;
+}
+
+Json McpServer::jsonRpcError(const Json& id, int code,
+                             const std::string& message) const
+{
+    return {
+        {"jsonrpc", "2.0"},
+        {"id", id},
+        {"error", {{"code", code}, {"message", message}}},
+    };
+}
+
+} // namespace nethack_mcp
diff --git a/src/observation_store.cpp b/src/observation_store.cpp
new file mode 100644
index 0000000..c33aa67
--- /dev/null
+++ b/src/observation_store.cpp
@@ -0,0 +1,62 @@
+#include "observation_store.hpp"
+
+#include <utility>
+
+namespace nethack_mcp
+{
+
+ObservationStore::ObservationStore(std::string viewer_url)
+        : snapshot_({
+              {"schema_version", 1},
+              {"game_id", nullptr},
+              {"revision", 0},
+              {"lifecycle", "idle"},
+              {"operation", nullptr},
+              {"map", {
+                  {"width", 79},
+                  {"height", 21},
+                  {"origin", {{"x", 1}, {"y", 0}}},
+                  {"rows", Json::array()},
+                  {"cursor", nullptr},
+              }},
+              {"status", Json::object()},
+              {"messages", Json::array()},
+              {"messages_truncated", false},
+              {"inventory", {
+                  {"known", false},
+                  {"stale", true},
+                  {"entries", Json::array()},
+              }},
+              {"pending", nullptr},
+              {"viewer_url", std::move(viewer_url)},
+          })
+{}
+
+Json ObservationStore::snapshot() const
+{
+    std::lock_guard lock(mutex_);
+    return snapshot_;
+}
+
+std::uint64_t ObservationStore::publish(Json snapshot)
+{
+    std::lock_guard lock(mutex_);
+    ++revision_;
+    snapshot["revision"] = revision_;
+    snapshot_ = std::move(snapshot);
+    condition_.notify_all();
+    return revision_;
+}
+
+Json ObservationStore::waitForRevision(
+    std::uint64_t revision,
+    std::chrono::milliseconds timeout) const
+{
+    std::unique_lock lock(mutex_);
+    condition_.wait_for(lock, timeout, [&] {
+        return revision_ > revision;
+    });
+    return snapshot_;
+}
+
+} // namespace nethack_mcp
diff --git a/src/protocol.cpp b/src/protocol.cpp
new file mode 100644
index 0000000..49586ce
--- /dev/null
+++ b/src/protocol.cpp
@@ -0,0 +1,134 @@
+#include "protocol.hpp"
+
+#include <cerrno>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <arpa/inet.h>
+
+namespace nethack_mcp
+{
+
+FramedChannel::FramedChannel(int descriptor)
+        : descriptor_(descriptor)
+{}
+
+FramedChannel::~FramedChannel()
+{
+    close();
+}
+
+bool FramedChannel::send(const Json& message, std::string& error)
+{
+    const std::string payload = message.dump();
+    if(payload.size() > MAX_IPC_FRAME_SIZE)
+    {
+        error = "IPC payload exceeds the 4 MiB limit";
+        return false;
+    }
+
+    const std::uint32_t length = htonl(
+        static_cast<std::uint32_t>(payload.size()));
+    std::lock_guard lock(write_mutex_);
+    return writeExact(&length, sizeof(length), error)
+        && writeExact(payload.data(), payload.size(), error);
+}
+
+bool FramedChannel::receive(Json& message, std::string& error)
+{
+    std::uint32_t encoded_length = 0;
+    if(!readExact(&encoded_length, sizeof(encoded_length), error))
+    {
+        return false;
+    }
+
+    const std::uint32_t length = ntohl(encoded_length);
+    if(length > MAX_IPC_FRAME_SIZE)
+    {
+        error = "IPC payload exceeds the 4 MiB limit";
+        return false;
+    }
+
+    std::string payload(length, '\0');
+    if(!readExact(payload.data(), payload.size(), error))
+    {
+        return false;
+    }
+
+    try
+    {
+        message = Json::parse(payload);
+    }
+    catch(const Json::parse_error& exception)
+    {
+        error = std::string("invalid IPC JSON: ") + exception.what();
+        return false;
+    }
+    return true;
+}
+
+void FramedChannel::close()
+{
+    if(descriptor_ >= 0)
+    {
+        ::shutdown(descriptor_, SHUT_RDWR);
+        ::close(descriptor_);
+        descriptor_ = -1;
+    }
+}
+
+bool FramedChannel::readExact(void* buffer, std::size_t size,
+                              std::string& error)
+{
+    auto* destination = static_cast<char*>(buffer);
+    std::size_t offset = 0;
+    while(offset < size)
+    {
+        const ssize_t result = ::read(descriptor_, destination + offset,
+                                      size - offset);
+        if(result == 0)
+        {
+            error = "IPC channel closed";
+            return false;
+        }
+        if(result < 0)
+        {
+            if(errno == EINTR)
+            {
+                continue;
+            }
+            error = std::string("IPC read failed: ") + std::strerror(errno);
+            return false;
+        }
+        offset += static_cast<std::size_t>(result);
+    }
+    return true;
+}
+
+bool FramedChannel::writeExact(const void* buffer, std::size_t size,
+                               std::string& error)
+{
+    const auto* source = static_cast<const char*>(buffer);
+    std::size_t offset = 0;
+    while(offset < size)
+    {
+        const ssize_t result = ::write(descriptor_, source + offset,
+                                       size - offset);
+        if(result < 0)
+        {
+            if(errno == EINTR)
+            {
+                continue;
+            }
+            error = std::string("IPC write failed: ") + std::strerror(errno);
+            return false;
+        }
+        offset += static_cast<std::size_t>(result);
+    }
+    return true;
+}
+
+} // namespace nethack_mcp
diff --git a/src/spectator_server.cpp b/src/spectator_server.cpp
new file mode 100644
index 0000000..59942f3
--- /dev/null
+++ b/src/spectator_server.cpp
@@ -0,0 +1,247 @@
+#include "spectator_server.hpp"
+
+#include "game_session.hpp"
+
+#include <string>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+constexpr char VIEWER_HTML[] = R"HTML(<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>NetHack spectator</title>
+  <link rel="stylesheet" href="/viewer.css">
+</head>
+<body>
+  <main>
+    <header>
+      <h1>NetHack spectator</h1>
+      <p id="lifecycle">Connecting…</p>
+    </header>
+    <pre id="map" aria-label="NetHack map"></pre>
+    <section>
+      <h2>Status</h2>
+      <pre id="status"></pre>
+    </section>
+    <section>
+      <h2>Pending input</h2>
+      <pre id="pending">None</pre>
+    </section>
+    <section>
+      <h2>Messages</h2>
+      <ol id="messages"></ol>
+    </section>
+  </main>
+  <script src="/viewer.js"></script>
+</body>
+</html>
+)HTML";
+
+constexpr char VIEWER_SCRIPT[] = R"JS((() => {
+  let etag = "";
+  const lifecycle = document.querySelector("#lifecycle");
+  const map = document.querySelector("#map");
+  const status = document.querySelector("#status");
+  const pending = document.querySelector("#pending");
+  const messages = document.querySelector("#messages");
+
+  function show(value) {
+    lifecycle.textContent = value.lifecycle || "unknown";
+    map.textContent = (value.map && value.map.rows || []).join("\n");
+    status.textContent = JSON.stringify(value.status || {}, null, 2);
+    pending.textContent = value.pending
+      ? JSON.stringify(value.pending, null, 2) : "None";
+    messages.replaceChildren();
+    for (const message of value.messages || []) {
+      const item = document.createElement("li");
+      item.textContent = message.text || "";
+      messages.append(item);
+    }
+  }
+
+  async function poll() {
+    try {
+      const headers = etag ? {"If-None-Match": etag} : {};
+      const response = await fetch("/api/state", {headers, cache: "no-store"});
+      if (response.status === 304) return;
+      if (!response.ok) throw new Error(`HTTP ${response.status}`);
+      etag = response.headers.get("ETag") || "";
+      show(await response.json());
+    } catch (error) {
+      lifecycle.textContent = `Disconnected: ${error.message}`;
+    }
+  }
+
+  poll();
+  setInterval(poll, 250);
+})();
+)JS";
+
+constexpr char VIEWER_STYLE[] = R"CSS(:root {
+  color-scheme: dark;
+  font-family: system-ui, sans-serif;
+  background: #171717;
+  color: #eeeeee;
+}
+
+body { margin: 0; }
+main { max-width: 1000px; margin: 0 auto; padding: 1rem; }
+h1, h2 { font-weight: 600; }
+h1 { margin-bottom: 0.25rem; }
+h2 { font-size: 1rem; margin-bottom: 0.35rem; }
+pre { overflow: auto; }
+#map {
+  border: 1px solid #555;
+  padding: 0.75rem;
+  line-height: 1.1;
+  font: 16px/1.1 monospace;
+  white-space: pre;
+  min-height: 21em;
+}
+section { margin-top: 1rem; }
+#messages { max-height: 16rem; overflow: auto; }
+)CSS";
+
+} // namespace
+
+SpectatorServer::SpectatorServer(GameSession& session, int port)
+        : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", port}),
+          session_(session), port_(port)
+{}
+
+SpectatorServer::~SpectatorServer()
+{
+    stopServer();
+}
+
+bool SpectatorServer::startServer(std::string& error)
+{
+    setup();
+    if(!server.bind_to_port("127.0.0.1", port_))
+    {
+        error = "could not bind spectator server to 127.0.0.1:" +
+            std::to_string(port_);
+        return false;
+    }
+    started_ = true;
+    server_thread_ = std::thread([this] {
+        server.listen_after_bind();
+        started_ = false;
+    });
+    return true;
+}
+
+void SpectatorServer::stopServer()
+{
+    server.stop();
+    if(server_thread_.joinable())
+    {
+        server_thread_.join();
+    }
+    started_ = false;
+}
+
+void SpectatorServer::setup()
+{
+    server.Get("/", [this](const Request& request, Response& response) {
+        servePage(request, response);
+    });
+    server.Get("/viewer.js", [this](const Request& request,
+                                      Response& response) {
+        serveScript(request, response);
+    });
+    server.Get("/viewer.css", [this](const Request& request,
+                                       Response& response) {
+        serveStyle(request, response);
+    });
+    server.Get("/api/state", [this](const Request& request,
+                                      Response& response) {
+        serveState(request, response);
+    });
+    server.Get("/health", [this](const Request& request,
+                                   Response& response) {
+        serveHealth(request, response);
+    });
+}
+
+bool SpectatorServer::validHost(const Request& request) const
+{
+    const std::string host = request.get_header_value("Host");
+    return host == "127.0.0.1" || host == "127.0.0.1:" +
+        std::to_string(port_) || host == "localhost" ||
+        host == "localhost:" + std::to_string(port_);
+}
+
+void SpectatorServer::servePage(const Request& request, Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    response.set_content(VIEWER_HTML, "text/html; charset=utf-8");
+}
+
+void SpectatorServer::serveScript(const Request& request,
+                                  Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    response.set_content(VIEWER_SCRIPT,
+                         "application/javascript; charset=utf-8");
+}
+
+void SpectatorServer::serveStyle(const Request& request, Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    response.set_content(VIEWER_STYLE, "text/css; charset=utf-8");
+}
+
+void SpectatorServer::serveState(const Request& request, Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const Json state = session_.snapshot();
+    const std::string etag = "\"" +
+        std::to_string(state.value("revision", 0ULL)) + "\"";
+    response.set_header("ETag", etag);
+    if(request.get_header_value("If-None-Match") == etag)
+    {
+        response.status = 304;
+        return;
+    }
+    response.set_content(state.dump(), "application/json; charset=utf-8");
+}
+
+void SpectatorServer::serveHealth(const Request& request, Response& response)
+{
+    if(!validHost(request))
+    {
+        response.status = 403;
+        return;
+    }
+    const Json state = session_.snapshot();
+    response.set_content(Json({
+        {"ready", true},
+        {"lifecycle", state.value("lifecycle", "idle")},
+        {"viewer_url", session_.viewerUrl()},
+    }).dump(), "application/json; charset=utf-8");
+}
+
+} // namespace nethack_mcp
diff --git a/src/window_adapter.cpp b/src/window_adapter.cpp
new file mode 100644
index 0000000..615ebb9
--- /dev/null
+++ b/src/window_adapter.cpp
@@ -0,0 +1,614 @@
+#include "window_adapter.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+#include <cstdio>
+#include <cstring>
+#include <exception>
+#include <stdexcept>
+#include <utility>
+
+extern "C"
+{
+#include "config.h"
+#include "integer.h"
+#include "tradstdc.h"
+#include "global.h"
+#include "wintype.h"
+#include "func_tab.h"
+}
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+using ShimCallback = void (*)(const char*, void*, const char*, ...);
+
+extern "C" void shim_graphics_set_callback(ShimCallback callback);
+
+constexpr int MAP_WIDTH = 79;
+constexpr int MAP_HEIGHT = 21;
+constexpr std::size_t MAX_MESSAGES = 500;
+constexpr std::size_t MAX_TEXT_BYTES = 4096;
+
+std::string copyString(const char* value)
+{
+    return value == nullptr ? std::string() : std::string(value);
+}
+
+} // namespace
+
+WindowAdapter* WindowAdapter::active_adapter_ = nullptr;
+
+WindowAdapter::WindowAdapter(FramedChannel& channel, std::string game_id)
+        : channel_(channel), game_id_(std::move(game_id)),
+          map_rows_(MAP_HEIGHT, std::string(MAP_WIDTH, ' '))
+{}
+
+void WindowAdapter::install()
+{
+    active_adapter_ = this;
+    shim_graphics_set_callback(&WindowAdapter::callback);
+}
+
+void WindowAdapter::callback(const char* name, void* return_ptr,
+                            const char* format, ...)
+{
+    if(active_adapter_ == nullptr)
+    {
+        return;
+    }
+
+    va_list arguments;
+    va_start(arguments, format);
+    try
+    {
+        active_adapter_->handleCallback(name, return_ptr, format, arguments);
+    }
+    catch(const std::exception& exception)
+    {
+        std::fprintf(stderr, "NetHack callback failed: %s\n",
+                     exception.what());
+        std::_Exit(2);
+    }
+    catch(...)
+    {
+        std::fputs("NetHack callback failed with an unknown exception\n",
+                   stderr);
+        std::_Exit(2);
+    }
+    va_end(arguments);
+}
+
+void WindowAdapter::handleCallback(const char* name, void* return_ptr,
+                                   const char* format,
+                                   std::va_list arguments)
+{
+    if(name == nullptr)
+    {
+        throw std::runtime_error("NetHack sent a nameless callback");
+    }
+
+    if(std::strcmp(name, "shim_init_nhwindows") == 0)
+    {
+        return;
+    }
+
+    if(std::strcmp(name, "shim_create_nhwindow") == 0)
+    {
+        const int type = va_arg(arguments, int);
+        const int window = next_window_id_++;
+        window_types_[window] = type;
+        setIntegerReturn(return_ptr, window);
+        return;
+    }
+
+    if(std::strcmp(name, "shim_destroy_nhwindow") == 0)
+    {
+        return;
+    }
+
+    if(std::strcmp(name, "shim_clear_nhwindow") == 0)
+    {
+        const int window = va_arg(arguments, int);
+        if(window_types_[window] == NHW_MAP)
+        {
+            map_rows_.assign(MAP_HEIGHT, std::string(MAP_WIDTH, ' '));
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_curs") == 0)
+    {
+        const int window = va_arg(arguments, int);
+        const int x = va_arg(arguments, int);
+        const int y = va_arg(arguments, int);
+        if(window_types_[window] == NHW_MAP)
+        {
+            cursor_x_ = x;
+            cursor_y_ = y;
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_print_glyph") == 0)
+    {
+        const int window = va_arg(arguments, int);
+        const int x = va_arg(arguments, int);
+        const int y = va_arg(arguments, int);
+        const auto* glyph = va_arg(arguments, const glyph_info*);
+        (void) va_arg(arguments, const glyph_info*);
+        if(window_types_[window] == NHW_MAP && glyph != nullptr
+           && y >= 0 && y < MAP_HEIGHT
+           && x >= 1 && x <= MAP_WIDTH)
+        {
+            const unsigned char symbol = static_cast<unsigned char>(
+                glyph->ttychar);
+            map_rows_[y][x - 1] = std::isprint(symbol)
+                ? static_cast<char>(symbol) : '?';
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_putstr") == 0)
+    {
+        const int window = va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        const char* text = va_arg(arguments, const char*);
+        if(text == nullptr)
+        {
+            return;
+        }
+        if(window_types_[window] == NHW_STATUS)
+        {
+            status_["text"] = text;
+        }
+        else if(window_types_[window] == NHW_MESSAGE)
+        {
+            addMessage(text);
+        }
+        else if(window_types_[window] == NHW_TEXT)
+        {
+            text_window_.append(text);
+            if(text_window_.size() > MAX_TEXT_BYTES)
+            {
+                text_window_.resize(MAX_TEXT_BYTES);
+            }
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_raw_print") == 0
+       || std::strcmp(name, "shim_raw_print_bold") == 0)
+    {
+        addMessage(va_arg(arguments, const char*));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_putmsghistory") == 0)
+    {
+        addMessage(va_arg(arguments, const char*));
+        (void) va_arg(arguments, int);
+        return;
+    }
+
+    if(std::strcmp(name, "shim_start_menu") == 0)
+    {
+        active_menu_window_ = va_arg(arguments, int);
+        (void) va_arg(arguments, unsigned long);
+        menu_entries_.clear();
+        menu_prompt_.clear();
+        next_menu_entry_id_ = 1;
+        return;
+    }
+
+    if(std::strcmp(name, "shim_add_menu") == 0)
+    {
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, const glyph_info*);
+        const auto* identifier = va_arg(arguments, const anything*);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        const char* text = va_arg(arguments, const char*);
+        const unsigned item_flags = va_arg(arguments, unsigned int);
+        MenuEntry entry;
+        entry.entry_id = next_menu_entry_id_++;
+        entry.text = copyString(text);
+        entry.selectable = identifier != nullptr;
+        entry.selected = (item_flags & MENU_ITEMFLAGS_SELECTED) != 0;
+        if(identifier != nullptr)
+        {
+            entry.identifier.resize(sizeof(anything));
+            std::memcpy(entry.identifier.data(), identifier,
+                        sizeof(anything));
+        }
+        menu_entries_.push_back(std::move(entry));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_end_menu") == 0)
+    {
+        (void) va_arg(arguments, int);
+        menu_prompt_ = copyString(va_arg(arguments, const char*));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_select_menu") == 0)
+    {
+        const int window = va_arg(arguments, int);
+        const int how = va_arg(arguments, int);
+        auto** menu_list = va_arg(arguments, menu_item**);
+        (void) window;
+        Json pending = makePending("menu", name);
+        pending["mode"] = how;
+        pending["prompt"] = menu_prompt_;
+        pending["entries"] = Json::array();
+        for(const MenuEntry& entry : menu_entries_)
+        {
+            pending["entries"].push_back({
+                {"entry_id", entry.entry_id},
+                {"text", entry.text},
+                {"selectable", entry.selectable},
+                {"selected", entry.selected},
+            });
+        }
+        const Json response = waitForInput(std::move(pending));
+        if(menu_list == nullptr || response.value("cancel", false))
+        {
+            setIntegerReturn(return_ptr, 0);
+            return;
+        }
+
+        const Json selections = response.value("selections", Json::array());
+        if(selections.empty())
+        {
+            setIntegerReturn(return_ptr, 0);
+            return;
+        }
+        auto* selected = static_cast<menu_item*>(std::malloc(
+            sizeof(menu_item) * selections.size()));
+        if(selected == nullptr)
+        {
+            throw std::runtime_error("could not allocate menu response");
+        }
+        std::size_t selected_count = 0;
+        for(const Json& item : selections)
+        {
+            const int entry_id = item.at("entry_id").get<int>();
+            const auto found = std::find_if(
+                menu_entries_.begin(), menu_entries_.end(),
+                [entry_id](const MenuEntry& entry) {
+                    return entry.entry_id == entry_id && entry.selectable;
+                });
+            if(found == menu_entries_.end())
+            {
+                std::free(selected);
+                throw std::runtime_error("unknown menu entry response");
+            }
+            std::memset(&selected[selected_count].item, 0,
+                        sizeof(selected[selected_count].item));
+            std::memcpy(&selected[selected_count].item,
+                        found->identifier.data(), sizeof(anything));
+            selected[selected_count].count = item.value("count", -1L);
+            selected[selected_count].itemflags =
+                MENU_ITEMFLAGS_SELECTED;
+            ++selected_count;
+        }
+        *menu_list = selected;
+        setIntegerReturn(return_ptr, static_cast<int>(selected_count));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_display_file") == 0)
+    {
+        const std::string filename = copyString(
+            va_arg(arguments, const char*));
+        (void) va_arg(arguments, int);
+        Json pending = makePending("acknowledge", name);
+        pending["filename"] = filename;
+        pending["text"] = text_window_;
+        (void) waitForInput(std::move(pending));
+        text_window_.clear();
+        return;
+    }
+
+    if(std::strcmp(name, "shim_display_nhwindow") == 0)
+    {
+        const int window = va_arg(arguments, int);
+        const int blocking = va_arg(arguments, int);
+        if(blocking != 0 && window_types_[window] != NHW_MENU)
+        {
+            Json pending = makePending("acknowledge", name);
+            pending["text"] = text_window_;
+            (void) waitForInput(std::move(pending));
+            text_window_.clear();
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_wait_synch") == 0)
+    {
+        Json pending = makePending("acknowledge", name);
+        (void) waitForInput(std::move(pending));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_nhgetch") == 0
+       || std::strcmp(name, "shim_nh_poskey") == 0)
+    {
+        Json pending = makePending("key", name);
+        const Json response = waitForInput(std::move(pending));
+        const int value = response.value("value", 27);
+        if(std::strcmp(name, "shim_nh_poskey") == 0)
+        {
+            auto* x = va_arg(arguments, coordxy*);
+            auto* y = va_arg(arguments, coordxy*);
+            auto* modifier = va_arg(arguments, int*);
+            if(x != nullptr) *x = static_cast<coordxy>(cursor_x_);
+            if(y != nullptr) *y = static_cast<coordxy>(cursor_y_);
+            if(modifier != nullptr) *modifier = 0;
+        }
+        setIntegerReturn(return_ptr, value);
+        return;
+    }
+
+    if(std::strcmp(name, "shim_yn_function") == 0)
+    {
+        const std::string query = copyString(va_arg(arguments, const char*));
+        const std::string choices = copyString(
+            va_arg(arguments, const char*));
+        const int default_value = va_arg(arguments, int);
+        Json pending = makePending("choice", name);
+        pending["query"] = query;
+        pending["choices"] = choices;
+        pending["default"] = default_value;
+        const Json response = waitForInput(std::move(pending));
+        const int value = response.value("value", 27);
+        setCharacterReturn(return_ptr, static_cast<char>(value));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_getlin") == 0)
+    {
+        const std::string query = copyString(va_arg(arguments, const char*));
+        auto* buffer = va_arg(arguments, char*);
+        Json pending = makePending("text", name);
+        pending["query"] = query;
+        pending["max_bytes"] = 255;
+        const Json response = waitForInput(std::move(pending));
+        const std::string text = response.value("cancel", false)
+            ? std::string(1, 27)
+            : response.value("text", std::string());
+        if(buffer != nullptr)
+        {
+            const std::size_t length = std::min<std::size_t>(
+                text.size(), 255);
+            std::memcpy(buffer, text.data(), length);
+            buffer[length] = '\0';
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_get_ext_cmd") == 0)
+    {
+        Json pending = makePending("command", name);
+        pending["commands"] = Json::array();
+        for(const ext_func_tab* command = extcmdlist;
+            command != nullptr && command->ef_txt != nullptr; ++command)
+        {
+            pending["commands"].push_back(command->ef_txt);
+        }
+        const Json response = waitForInput(std::move(pending));
+        setIntegerReturn(return_ptr,
+                         resolveCommand(response.value("command", "")));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_get_color_string") == 0)
+    {
+        copyTextReturn(return_ptr, "");
+        return;
+    }
+
+    if(std::strcmp(name, "shim_getmsghistory") == 0)
+    {
+        (void) va_arg(arguments, int);
+        if(return_ptr != nullptr)
+        {
+            *static_cast<char**>(return_ptr) = nullptr;
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_update_inventory") == 0)
+    {
+        inventory_["stale"] = true;
+        return;
+    }
+
+    if(std::strcmp(name, "shim_exit_nhwindows") == 0)
+    {
+        addMessage(va_arg(arguments, const char*));
+        return;
+    }
+
+    if(std::strcmp(name, "shim_nhbell") == 0
+       || std::strcmp(name, "shim_mark_synch") == 0
+       || std::strcmp(name, "shim_delay_output") == 0
+       || std::strcmp(name, "shim_resume_nhwindows") == 0
+       || std::strcmp(name, "shim_suspend_nhwindows") == 0
+       || std::strcmp(name, "shim_number_pad") == 0
+       || std::strcmp(name, "shim_change_color") == 0
+       || std::strcmp(name, "shim_change_background") == 0
+       || std::strcmp(name, "shim_preference_update") == 0
+       || std::strcmp(name, "shim_status_init") == 0
+       || std::strcmp(name, "shim_status_enablefield") == 0
+       || std::strcmp(name, "shim_status_update") == 0
+       || std::strcmp(name, "shim_player_selection") == 0
+       || std::strcmp(name, "shim_ctrl_nhwindow") == 0)
+    {
+        return;
+    }
+
+    if(return_ptr != nullptr && format != nullptr && format[0] != 'v')
+    {
+        std::fprintf(stderr, "Unhandled NetHack callback: %s (%s)\n",
+                     name, format);
+        std::memset(return_ptr, 0, sizeof(int));
+    }
+}
+
+Json WindowAdapter::makeSnapshot() const
+{
+    Json snapshot = {
+        {"schema_version", 1},
+        {"game_id", game_id_},
+        {"lifecycle", "waiting"},
+        {"operation", nullptr},
+        {"map", {
+            {"width", MAP_WIDTH},
+            {"height", MAP_HEIGHT},
+            {"origin", {{"x", 1}, {"y", 0}}},
+            {"rows", map_rows_},
+            {"cursor", {{"x", cursor_x_}, {"y", cursor_y_}}},
+        }},
+        {"status", status_},
+        {"messages", messages_},
+        {"messages_truncated", false},
+        {"inventory", inventory_},
+        {"pending", nullptr},
+    };
+    return snapshot;
+}
+
+Json WindowAdapter::makePending(std::string kind, std::string source) const
+{
+    return {
+        {"input_id", input_id_ + 1},
+        {"kind", std::move(kind)},
+        {"source", std::move(source)},
+    };
+}
+
+Json WindowAdapter::waitForInput(Json pending)
+{
+    input_id_++;
+    pending["input_id"] = input_id_;
+    Json snapshot = makeSnapshot();
+    snapshot["pending"] = pending;
+    publishSnapshot(std::move(snapshot));
+
+    while(true)
+    {
+        Json message;
+        std::string error;
+        if(!channel_.receive(message, error))
+        {
+            throw std::runtime_error(error);
+        }
+        if(message.value("type", "") == "shutdown")
+        {
+            throw std::runtime_error("worker shutdown requested");
+        }
+        if(message.value("type", "") != "input"
+           || message.value("game_id", "") != game_id_
+           || message.value("input_id", 0ULL) != input_id_)
+        {
+            continue;
+        }
+        return message.value("response", Json::object());
+    }
+}
+
+void WindowAdapter::publishSnapshot(Json snapshot)
+{
+    Json message = {
+        {"type", "snapshot"},
+        {"ipc_version", IPC_VERSION},
+        {"game_id", game_id_},
+        {"sequence", ++sequence_},
+        {"snapshot", std::move(snapshot)},
+    };
+    std::string error;
+    if(!channel_.send(message, error))
+    {
+        throw std::runtime_error(error);
+    }
+}
+
+void WindowAdapter::addMessage(const char* message)
+{
+    const std::string text = copyString(message);
+    if(text.empty())
+    {
+        return;
+    }
+    static std::uint64_t message_id = 0;
+    messages_.push_back({
+        {"id", ++message_id},
+        {"text", text},
+    });
+    if(messages_.size() > MAX_MESSAGES)
+    {
+        messages_.erase(messages_.begin());
+    }
+}
+
+void WindowAdapter::setIntegerReturn(void* return_ptr, int value) const
+{
+    if(return_ptr != nullptr)
+    {
+        *static_cast<int*>(return_ptr) = value;
+    }
+}
+
+void WindowAdapter::setCharacterReturn(void* return_ptr, char value) const
+{
+    if(return_ptr != nullptr)
+    {
+        *static_cast<char*>(return_ptr) = value;
+    }
+}
+
+void WindowAdapter::copyTextReturn(void* return_ptr,
+                                   const std::string& text) const
+{
+    if(return_ptr != nullptr)
+    {
+        auto** destination = static_cast<char**>(return_ptr);
+        if(text.empty())
+        {
+            static char empty[] = "";
+            *destination = empty;
+        }
+        else
+        {
+            *destination = const_cast<char*>(text.c_str());
+        }
+    }
+}
+
+int WindowAdapter::resolveCommand(const std::string& command) const
+{
+    if(command.empty())
+    {
+        return -1;
+    }
+    int index = 0;
+    for(const ext_func_tab* entry = extcmdlist;
+        entry != nullptr && entry->ef_txt != nullptr; ++entry, ++index)
+    {
+        if(command == entry->ef_txt)
+        {
+            return index;
+        }
+    }
+    return -1;
+}
+
+} // namespace nethack_mcp