Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index dbfc7e9..ef0b0a4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -37,8 +37,10 @@ FetchContent_Declare(
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)
+set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(libmw)
find_package(Threads REQUIRED)
+find_package(OpenSSL REQUIRED COMPONENTS Crypto)
if(NETHACK_BUILD_ENGINE)
find_path(NETHACK_LUA_INCLUDE_DIR
@@ -133,7 +135,10 @@ endif()
set(NETHACK_MCP_SOURCES
src/engine_process.cpp
+ src/game_manager.cpp
+ src/game_record_store.cpp
src/game_session.cpp
+ src/identity.cpp
src/main.cpp
src/mcp_server.cpp
src/observation_store.cpp
@@ -141,13 +146,27 @@ set(NETHACK_MCP_SOURCES
src/game_http_server.cpp)
set(STATIC_FILES
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/home.html"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/record.html"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/home.css"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/local_time.js"
"${CMAKE_CURRENT_SOURCE_DIR}/web/index.html"
"${CMAKE_CURRENT_SOURCE_DIR}/web/viewer.css"
"${CMAKE_CURRENT_SOURCE_DIR}/web/viewer.js"
"${CMAKE_CURRENT_SOURCE_DIR}/web/kreative_square.ttf")
-include(cmake/embed_assets.cmake)
-list(APPEND NETHACK_MCP_SOURCES
+set(EMBEDDED_ASSETS_SOURCE
"${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp")
+add_custom_command(
+ OUTPUT "${EMBEDDED_ASSETS_SOURCE}"
+ COMMAND "${CMAKE_COMMAND}"
+ "-DOUTPUT=${EMBEDDED_ASSETS_SOURCE}"
+ "-DWEB_ROOT=${CMAKE_CURRENT_SOURCE_DIR}/web"
+ "-DSTATIC_FILES=${STATIC_FILES}"
+ -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_assets.cmake"
+ DEPENDS ${STATIC_FILES} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_assets.cmake"
+ VERBATIM)
+list(APPEND NETHACK_MCP_SOURCES
+ "${EMBEDDED_ASSETS_SOURCE}")
if(NETHACK_BUILD_ENGINE)
list(APPEND NETHACK_MCP_SOURCES
@@ -166,6 +185,8 @@ target_compile_definitions(nethack_mcp PRIVATE
target_link_libraries(nethack_mcp PRIVATE
mw::mw
mw::http-server
+ mw::sqlite
+ OpenSSL::Crypto
nlohmann_json::nlohmann_json
Threads::Threads)
if(NETHACK_BUILD_ENGINE)
diff --git a/README.md b/README.md
index 7865fd4..6e4c93c 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,94 @@
# nethack-mcp
-This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs it
-in a worker process, and exposes MCP over Streamable HTTP alongside a
-read-only loopback viewer.
+This project builds the pinned NetHack 5.0 engine as `libnethack.a`, runs
+each game in an isolated worker process, and serves multiple games through
+one MCP endpoint. Spectators can watch active games and browse completed
+records without receiving gameplay controls.
-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
-Build and test it with:
+The default build uses system Lua 5.4 headers and library. CMake checks the
+Lua header version before configuring NetHack.
```sh
cmake -S . -B build
cmake --build build -j24
-ctest --test-dir build --output-on-failure
```
-The produced library is:
+The produced engine library is `build/nethack-work/src/libnethack.a`.
-```text
-build/nethack-work/src/libnethack.a
-```
+HTML, JavaScript, CSS, and font assets are maintained as ordinary files in
+`web/`. A build step embeds them in a generated C++ translation unit, which is
+compiled into the executable. Asset changes regenerate that translation unit.
-Start one server manually with:
+## Local server
```sh
./build/nethack_mcp --data-root /tmp/nethack-mcp --port 8765
```
-Point each MCP client at `http://127.0.0.1:8765/mcp` using its Streamable
-HTTP transport. The server stays alive independently of client connections
-and owns one active game shared by those clients. Stop it with Ctrl-C.
+The MCP endpoint is `http://127.0.0.1:8765/mcp`; the read-only landing page is
+at `http://127.0.0.1:8765/`. Game records are stored by default at
+`~/.local/share/nethack-mcp/games.sqlite3`, outside the temporary runtime
+directory. Stop the server with Ctrl-C. A server restart ends all active
+games and records them as interrupted.
+
+Create games with the MCP `new_game` tool and a caller-supplied `model_slug`.
+Keep the returned `game_id` and `control_token`; every later game tool call
+must include both. The token is returned once and acts as a temporary game
+password. Share only the `/g/<game-id>` viewer URL with spectators.
+The endpoint supports the sessionless MCP `2026-07-28` protocol and the
+2025 handshake revisions.
+
+The home page lists the ten most recently completed games. Active games use
+`/g/<game-id>` and poll `/api/games/<game-id>/state`; completed game pages
+show their stored outcome and floor record.
+
+## Public deployment
+
+Run the application on loopback behind a TLS reverse proxy. Configure the
+public hostname exactly in the Host and Origin allowlists, and trust only the
+proxy address that connects to the application. The database directory must
+be persistent, local (not a network filesystem), and writable only by the
+service account. The game data root can be temporary storage.
+
+Example application command (replace the paths and hostname and choose limits
+that fit the host):
+
+```sh
+./build/nethack_mcp \
+ --port 8765 \
+ --data-root /run/nethack-mcp/games \
+ --database /var/lib/nethack-mcp/games.sqlite3 \
+ --public-base-url https://nethack.example/ \
+ --allowed-host nethack.example \
+ --allowed-origin https://nethack.example \
+ --trusted-proxy 127.0.0.1 \
+ --max-active-games 8 \
+ --new-games-per-client 8 \
+ --new-game-rate-window-seconds 3600 \
+ --max-rate-limit-clients 4096 \
+ --max-concurrent-requests 64 \
+ --max-open-connections 128 \
+ --max-viewer-long-polls 48 \
+ --max-worker-output-bytes 1048576
+```
+
+Public mode requires explicit host-dependent capacity settings. The default
+idle timeout is 600 seconds and the absolute game lifetime is 86,400 seconds.
+Use `--help` for the full list of startup options. The sample reverse proxy
+configuration in `deploy/nginx.conf.example` includes TLS termination,
+connection limits, request limits, and the forwarded headers expected by the
+application.
+
+Set the application limits together with the host's task, memory, and file
+descriptor limits. Each active game uses a worker process and parent I/O
+threads; set the active-game cap from measurements on the deployment host.
+`/metrics` reports active games and workers, task and memory estimates,
+runtime bytes, database write latency, HTTP latency, viewer waits, and expiry
+cleanup failures. Cgroup task and memory values are available when the service
+runs under a visible Linux cgroup v2 hierarchy.
-The browser viewer is available at `http://127.0.0.1:8765/` and serves only
-read-only state from the active session. Its map uses the bundled Kreative
-Square font, whose character cells are designed for square text graphics.
-The viewer's HTML, CSS, JavaScript, and font files live under `web/` and are
-embedded into the executable during the CMake configure step.
+Back up the SQLite database with SQLite's backup support while the service is
+running, for example with the `sqlite3` shell's `.backup` command. Do not copy
+only the main database file while WAL mode is active.
diff --git a/cmake/build_nethack.cmake b/cmake/build_nethack.cmake
index 3adc2ac..c1f0872 100644
--- a/cmake/build_nethack.cmake
+++ b/cmake/build_nethack.cmake
@@ -48,6 +48,24 @@ if(NOT setup_result EQUAL 0)
"${setup_output}\n${setup_error}")
endif()
+# Publish NetHack's native end condition before the engine tears itself down.
+set(NETHACK_END_SOURCE "${NETHACK_SOURCE_DIR}/src/end.c")
+file(READ "${NETHACK_END_SOURCE}" NETHACK_END_CONTENT)
+string(REPLACE
+ "#include \"hack.h\""
+ "#include \"hack.h\"\nextern void nethack_mcp_end_result(int) __attribute__((weak));"
+ NETHACK_END_CONTENT "${NETHACK_END_CONTENT}")
+string(FIND "${NETHACK_END_CONTENT}"
+ " program_state.gameover = 1;" NETHACK_END_HOOK_POSITION)
+if(NETHACK_END_HOOK_POSITION EQUAL -1)
+ message(FATAL_ERROR "Could not locate NetHack's terminal hook point")
+endif()
+string(REPLACE
+ " program_state.gameover = 1;"
+ " if (nethack_mcp_end_result)\n nethack_mcp_end_result(how);\n program_state.gameover = 1;"
+ NETHACK_END_CONTENT "${NETHACK_END_CONTENT}")
+file(WRITE "${NETHACK_END_SOURCE}" "${NETHACK_END_CONTENT}")
+
# 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")
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
index b930a6e..2e560ba 100644
--- a/cmake/embed_assets.cmake
+++ b/cmake/embed_assets.cmake
@@ -1,11 +1,14 @@
# Generate byte arrays without depending on external embedding tools.
+if(NOT DEFINED OUTPUT OR NOT DEFINED WEB_ROOT OR NOT DEFINED STATIC_FILES)
+ message(FATAL_ERROR "Asset embedding needs OUTPUT, WEB_ROOT, and STATIC_FILES")
+endif()
+
set(EMBEDDED_SOURCE
"#include \"embedded_assets.hpp\"\n\n"
"namespace nethack_mcp\n{\n\nnamespace\n{\n")
set(ASSET_ENTRIES "")
set(ASSET_INDEX 0)
foreach(ASSET IN LISTS STATIC_FILES)
- set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ASSET}")
file(READ "${ASSET}" ASSET_HEX HEX)
string(LENGTH "${ASSET_HEX}" ASSET_SIZE)
math(EXPR ASSET_SIZE "${ASSET_SIZE} / 2")
@@ -14,8 +17,7 @@ foreach(ASSET IN LISTS STATIC_FILES)
string(APPEND EMBEDDED_SOURCE
"const unsigned char ASSET_${ASSET_INDEX}[] = "
"{${ASSET_BYTES}0};\n")
- file(RELATIVE_PATH ASSET_NAME "${CMAKE_CURRENT_SOURCE_DIR}/web"
- "${ASSET}")
+ file(RELATIVE_PATH ASSET_NAME "${WEB_ROOT}" "${ASSET}")
get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
if(ASSET_EXTENSION STREQUAL ".html")
set(ASSET_TYPE "text/html; charset=utf-8")
@@ -45,9 +47,6 @@ string(APPEND EMBEDDED_SOURCE
"const EmbeddedAsset ASSETS[] = {\n${ASSET_ENTRIES}};\n}\n\n"
"std::span<const EmbeddedAsset> embeddedAssets()\n{\n"
" return ASSETS;\n}\n\n}\n")
-file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
-file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
- "${EMBEDDED_SOURCE}")
-configure_file(
- "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
- "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp" COPYONLY)
+get_filename_component(OUTPUT_DIRECTORY "${OUTPUT}" DIRECTORY)
+file(MAKE_DIRECTORY "${OUTPUT_DIRECTORY}")
+file(WRITE "${OUTPUT}" "${EMBEDDED_SOURCE}")
diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example
new file mode 100644
index 0000000..c568ce6
--- /dev/null
+++ b/deploy/nginx.conf.example
@@ -0,0 +1,48 @@
+events
+{
+ worker_connections 1024;
+}
+
+http
+{
+ limit_conn_zone $binary_remote_addr zone=nethack_connections:10m;
+ limit_req_zone $binary_remote_addr zone=nethack_requests:10m rate=30r/s;
+
+ upstream nethack_mcp
+ {
+ server 127.0.0.1:8765;
+ keepalive 32;
+ }
+
+ server
+ {
+ listen 80;
+ server_name nethack.example;
+ return 308 https://nethack.example$request_uri;
+ }
+
+ server
+ {
+ listen 443 ssl;
+ server_name nethack.example;
+
+ ssl_certificate /etc/letsencrypt/live/nethack.example/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/nethack.example/privkey.pem;
+ server_tokens off;
+ client_max_body_size 1m;
+
+ location /
+ {
+ limit_conn nethack_connections 32;
+ limit_req zone=nethack_requests burst=60 nodelay;
+
+ proxy_pass http://nethack_mcp;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_read_timeout 30s;
+ }
+ }
+}
diff --git a/designs/design-1-multigame.md b/designs/design-1-multigame.md
index 6433381..a1d11f6 100644
--- a/designs/design-1-multigame.md
+++ b/designs/design-1-multigame.md
@@ -364,9 +364,8 @@ spectator pages contain no gameplay controls.
At high spectator counts, blocking long polls can consume the HTTP
server's worker pool. Measure concurrent viewers as well as concurrent
-games. If the pool becomes the bottleneck, move state notifications to an
-event-driven mechanism or Server-Sent Events; WebSocket is unnecessary for
-the read-only viewer. Do not hold registry locks while a poll waits.
+games. Section 11 examines the worker cost and possible replacements.
+Do not hold registry locks while a poll waits.
## 9. Public deployment and resource bounds
@@ -464,3 +463,68 @@ games indefinitely within rate limits, or whether account-based admission
will be required. This design supports anonymous creation with a temporary
control token and strict capacity controls. Every game records the required
caller-supplied model slug; it is not verified by the server.
+
+## 11. Spectator transport and slow client connections
+
+The current spectator API uses a 15-second conditional long poll. When its
+ETag matches the latest game revision, the handler waits for a new snapshot
+or a timeout. The browser starts another poll as soon as the response
+arrives. The pinned [cpp-httplib server](https://github.com/yhirose/cpp-httplib/blob/278c2979e8c68468960c3073e28e1c51b098d6a4/httplib.h)
+assigns each accepted connection to a thread-pool task. A worker remains
+occupied while the handler waits and may remain attached to the socket
+during HTTP keep-alive. A continuously watching spectator can therefore
+occupy a worker almost continuously.
+
+The current example limits are 64 HTTP workers and 48 simultaneous viewer
+long polls. At that limit, only 16 workers remain for MCP calls and other
+HTTP requests, before considering idle keep-alive sockets. The bounded
+task queue limits additional accepted connections, but it does not make
+waiting polls cheap. These numbers require measurement on the deployment
+host rather than an assumption that each spectator uses negligible work.
+
+The alternatives discussed so far have different costs:
+
+| Transport | Worker use | Other cost |
+| --- | --- | --- |
+| Current long poll | One per waiting viewer | Sustained worker use |
+| SSE in cpp-httplib | One per open stream | Fewer requests |
+| Periodic GET | Released after response and socket close | More requests; update lag |
+| Asynchronous HTTP | No worker per idle viewer | More implementation work |
+
+SSE is a one-way HTTP stream from server to browser. Its value here depends
+on the HTTP implementation: using SSE with a blocking cpp-httplib handler
+does not solve worker exhaustion. Apache would also have to proxy each open
+stream. For periodic GETs, the browser should start a new request only after
+the previous one finishes; otherwise a network spike could accumulate
+overlapping polls. With `N` continuously watching browsers and a poll
+interval of `T` seconds, the steady request rate is roughly `N / T` per
+second. An asynchronous server could retain the same ETag and long-poll
+API: it would register a pending request, return the thread to an event
+loop, and complete the request when the game's revision changes or a timer
+expires. NetHack worker and SQLite operations must stay off that event
+loop.
+
+The planned reverse proxy is Apache HTTP Server. The intended host frequently
+has network spikes that can make a browser-visible request take more than
+ten seconds. A short application handler does not guarantee a short
+browser-visible request, and a slow browser may cause proxy backpressure.
+Apache's [event MPM](https://httpd.apache.org/docs/2.4/mod/event.html)
+can handle idle client keep-alive connections without dedicating a worker,
+but its documentation says proxied response bodies may still require a
+worker while a slow client receives them. Apache's
+[proxy module](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html) uses
+bounded transfer buffers and may reuse backend connections. Its
+[`disablereuse` option](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#proxypass)
+can close a backend connection after a request; Apache's
+[`mod_buffer`](https://httpd.apache.org/docs/2.4/mod/mod_buffer.html) may let
+a backend finish sooner by buffering output, subject to buffer size and
+memory use. Neither should be assumed to isolate the C++ server from all
+slow clients without checking the actual Apache configuration and traffic.
+
+This discussion has not selected a replacement for the current long poll.
+Before choosing one, inspect the deployed Apache MPM and proxy settings,
+measure typical and
+maximum state-response sizes, and load-test concurrent viewers during
+network spikes. Record busy Apache and C++ workers, backend response time,
+viewer update delay, rejected connections, and MCP latency. The choice
+must preserve enough capacity for gameplay calls during those spikes.
diff --git a/include/engine_process.hpp b/include/engine_process.hpp
index a077819..7cc81bc 100644
--- a/include/engine_process.hpp
+++ b/include/engine_process.hpp
@@ -1,6 +1,7 @@
#pragma once
#include <atomic>
+#include <cstddef>
#include <functional>
#include <memory>
#include <mutex>
@@ -31,13 +32,14 @@ public:
/// Spawn a worker and send its validated start message.
bool start(const Json& start_message, EventCallback callback,
- std::string& error);
+ std::string& error,
+ std::size_t max_diagnostic_bytes = 1024U * 1024U);
/// 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();
+ void terminate(bool force = false);
/// Report whether the child has not yet been reaped.
bool running() const;
@@ -52,6 +54,7 @@ private:
EventCallback callback_;
std::thread reader_thread_;
std::thread diagnostic_thread_;
+ std::size_t max_diagnostic_bytes_ = 1024U * 1024U;
mutable std::mutex state_mutex_;
std::atomic<bool> running_ = false;
};
diff --git a/include/game_http_server.hpp b/include/game_http_server.hpp
index 4db25d8..58872ae 100644
--- a/include/game_http_server.hpp
+++ b/include/game_http_server.hpp
@@ -1,24 +1,29 @@
#pragma once
#include <atomic>
+#include <cstdint>
#include <string>
#include <string_view>
#include <thread>
+#include <vector>
#include <mw/http_server.hpp>
+#include "server_config.hpp"
+
namespace nethack_mcp
{
-class GameSession;
+class GameManager;
class McpServer;
+struct GameRecord;
-/// Serve MCP and browser observations on one loopback listener.
+/// Serve shared MCP, landing, spectator, and completed-record routes.
class GameHttpServer : public mw::HTTPServer
{
public:
- /// Construct a server bound to the loopback address and requested port.
- GameHttpServer(GameSession& session, McpServer& mcp, int port);
+ /// Construct the loopback listener from the manager's public policy.
+ GameHttpServer(GameManager& manager, McpServer& mcp);
/// Stop the listener before destroying the server.
~GameHttpServer();
@@ -36,28 +41,37 @@ public:
bool running() const;
protected:
- /// Register the MCP and viewer routes.
+ /// Register shared service, per-game, and static asset routes.
void setup() override;
private:
bool validHost(const Request& request) const;
+ bool validOrigin(const Request& request) const;
void serveStatic(std::string_view path, const Request& request,
Response& response);
void servePage(const Request& request, Response& response);
+ void serveGamePage(const Request& request, Response& response);
void serveScript(const Request& request, Response& response);
void serveStyle(const Request& request, Response& response);
void serveFont(const Request& request, Response& response);
void serveState(const Request& request, Response& response);
void serveHealth(const Request& request, Response& response);
+ void serveMetrics(const Request& request, Response& response);
void serveMcp(const Request& request, Response& response);
void rejectMcpStream(const Request& request, Response& response);
- bool validOrigin(const Request& request) const;
+ void rejectRequest(Response& response) const;
+ std::string recentPage();
+ std::string recordPage(const GameRecord& record);
- GameSession& session_;
+ GameManager& manager_;
McpServer& mcp_;
- int port_;
+ ServerConfig config_;
std::thread server_thread_;
std::atomic<bool> started_ = false;
+ mutable std::atomic<std::size_t> active_requests_ = 0;
+ mutable std::atomic<std::size_t> viewer_long_polls_ = 0;
+ mutable std::atomic<std::uint64_t> request_count_ = 0;
+ mutable std::atomic<std::uint64_t> request_latency_total_us_ = 0;
};
} // namespace nethack_mcp
diff --git a/include/game_manager.hpp b/include/game_manager.hpp
new file mode 100644
index 0000000..d95b2fa
--- /dev/null
+++ b/include/game_manager.hpp
@@ -0,0 +1,112 @@
+#pragma once
+
+#include <atomic>
+#include <chrono>
+#include <condition_variable>
+#include <deque>
+#include <filesystem>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <thread>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "game_record_store.hpp"
+#include "game_session.hpp"
+#include "server_config.hpp"
+
+namespace nethack_mcp
+{
+
+/// Own the active-game registry and route agent calls to isolated sessions.
+class GameManager
+{
+public:
+ /// Open the record store, recover old rows, and start the expiry timer.
+ static mw::E<std::unique_ptr<GameManager>> create(
+ ServerConfig config, std::filesystem::path runtime_source);
+
+ ~GameManager();
+
+ GameManager(const GameManager&) = delete;
+ GameManager& operator=(const GameManager&) = delete;
+
+ /// Create and admit a new game, returning its secret once to the caller.
+ ToolResult createGame(const Json& arguments, const std::string& client_id);
+
+ /// Authorize and dispatch one game-specific MCP tool call.
+ ToolResult dispatch(const std::string& tool_name, const Json& arguments,
+ const std::string& client_id);
+
+ /// Return a shared active-game handle for read-only spectator access.
+ std::shared_ptr<GameSession> findActive(const std::string& game_id) const;
+
+ /// Return a durable record for a valid game ID, if one exists.
+ mw::E<std::optional<GameRecord>> getRecord(const std::string& game_id);
+
+ /// Return the ten most recently completed records.
+ mw::E<std::vector<GameRecord>> recentGames();
+
+ /// Stop expiry maintenance and terminate workers during server shutdown.
+ void shutdown();
+
+ /// Return the canonical public base URL used in generated viewer links.
+ const std::string& publicBaseUrl() const;
+
+ /// Return the configured game-independent HTTP service settings.
+ const ServerConfig& config() const;
+
+ /// Return the number of sessions currently present in the registry.
+ std::size_t activeGameCount() const;
+
+ /// Count worker child processes that are still running.
+ std::size_t activeWorkerCount() const;
+
+ /// Count files under the private runtime root for capacity monitoring.
+ std::uint64_t runtimeBytes() const;
+
+ /// Return cleanup failures seen by the expiry maintenance thread.
+ std::uint64_t expiryCleanupFailures() const;
+
+ /// Return latency of the most recent SQLite write in microseconds.
+ std::uint64_t databaseWriteLatencyMicroseconds() const;
+
+private:
+ GameManager(ServerConfig config,
+ std::filesystem::path runtime_source,
+ std::shared_ptr<GameRecordStore> records);
+
+ void sweepLoop();
+ void removeOrphanedDirectories();
+ void releaseReservation(const std::string& game_id);
+ bool allowCreation(const std::string& client_id,
+ std::chrono::steady_clock::time_point now);
+ bool allowControlAttempt(const std::string& client_id);
+ void recordControlFailure(const std::string& client_id);
+ void clearControlFailures(const std::string& client_id);
+ void pruneClientHistory(std::chrono::steady_clock::time_point now);
+
+ struct ClientHistory
+ {
+ std::deque<std::chrono::steady_clock::time_point> creation_times;
+ std::deque<std::chrono::steady_clock::time_point> auth_failures;
+ };
+
+ ServerConfig config_;
+ std::filesystem::path runtime_source_;
+ std::shared_ptr<GameRecordStore> records_;
+ mutable std::mutex registry_mutex_;
+ std::unordered_map<std::string, std::shared_ptr<GameSession>> sessions_;
+ std::unordered_set<std::string> reserved_ids_;
+ std::size_t pending_creations_ = 0;
+ std::unordered_map<std::string, ClientHistory> client_history_;
+ std::mutex sweep_mutex_;
+ std::condition_variable sweep_condition_;
+ std::atomic<bool> stopping_ = false;
+ std::atomic<std::uint64_t> expiry_cleanup_failures_ = 0;
+ std::thread sweep_thread_;
+};
+
+} // namespace nethack_mcp
diff --git a/include/game_record_store.hpp b/include/game_record_store.hpp
new file mode 100644
index 0000000..2f17b42
--- /dev/null
+++ b/include/game_record_store.hpp
@@ -0,0 +1,93 @@
+#pragma once
+
+#include <filesystem>
+#include <cstdint>
+#include <atomic>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/database.hpp>
+
+#include "protocol.hpp"
+
+namespace nethack_mcp
+{
+
+/// A durable summary of one game, including nullable live and terminal fields.
+struct GameRecord
+{
+ /// Canonical lowercase UUIDv7 that identifies this game.
+ std::string game_id;
+ /// Validated character name supplied when the game was created.
+ std::string character_name;
+ /// Caller-supplied model identifier associated with the game.
+ std::string model_slug;
+ /// UTC Unix seconds when game creation was accepted.
+ std::int64_t started_at_s = 0;
+ /// UTC Unix seconds of the most recent valid agent call.
+ std::int64_t last_activity_at_s = 0;
+ /// UTC Unix seconds when the record became terminal, if completed.
+ std::optional<std::int64_t> ended_at_s;
+ /// Whether the ending time was observed or recovered after restart.
+ std::optional<std::string> end_time_kind;
+ /// Terminal game outcome or administrative ending reason.
+ std::optional<std::string> end_reason;
+ /// Last numeric dungeon depth observed before termination.
+ std::optional<int> last_depth;
+ /// Greatest numeric dungeon depth observed during the game.
+ std::optional<int> deepest_depth;
+
+ /// Return public record fields for HTML or JSON responses.
+ Json toJson() const;
+};
+
+/// Store compact records outside the per-game runtime directories.
+class GameRecordStore
+{
+public:
+ /// Open the persistent database and apply numbered schema migrations.
+ static mw::E<std::unique_ptr<GameRecordStore>> open(
+ const std::filesystem::path& database_path);
+
+ /// Insert a new active game, returning false for a UUID collision.
+ mw::E<bool> insertGame(const GameRecord& record);
+
+ /// Update current and deepest location after a depth change.
+ mw::E<void> updateLocation(const std::string& game_id, int depth);
+
+ /// Update the wall-clock audit time for accepted agent activity.
+ mw::E<void> updateActivity(const std::string& game_id,
+ std::int64_t activity_at_s);
+
+ /// Finalize an active row once and report whether this call won.
+ mw::E<bool> finishGame(const std::string& game_id,
+ std::int64_t ended_at_s,
+ const std::string& end_time_kind,
+ const std::string& end_reason);
+
+ /// Return exactly the ten most recently completed game records.
+ mw::E<std::vector<GameRecord>> recentGames();
+
+ /// Look up a completed or active row by its canonical game ID.
+ mw::E<std::optional<GameRecord>> getGame(const std::string& game_id);
+
+ /// Mark unfinished records interrupted after an application restart.
+ mw::E<void> recoverInterruptedGames(std::int64_t recovery_time_s);
+
+ /// Return latency of the most recent record-store write in microseconds.
+ std::uint64_t lastWriteLatencyMicroseconds() const;
+
+private:
+ explicit GameRecordStore(std::unique_ptr<mw::SQLite> database);
+
+ mw::E<void> migrate();
+
+ std::unique_ptr<mw::SQLite> database_;
+ std::mutex mutex_;
+ std::atomic<std::uint64_t> last_write_latency_us_ = 0;
+};
+
+} // namespace nethack_mcp
diff --git a/include/game_session.hpp b/include/game_session.hpp
index 062b163..e11248c 100644
--- a/include/game_session.hpp
+++ b/include/game_session.hpp
@@ -1,14 +1,18 @@
#pragma once
#include <atomic>
+#include <array>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <mutex>
+#include <optional>
#include <string>
#include "engine_process.hpp"
+#include "game_record_store.hpp"
#include "observation_store.hpp"
+#include "server_config.hpp"
namespace nethack_mcp
{
@@ -26,20 +30,30 @@ struct ToolResult
std::string message;
};
-/// Own the single active game and serialize all gameplay input.
+/// Own one isolated game worker and serialize its gameplay input.
class GameSession
{
public:
- /// Create a session with a private data root and viewer URL.
+ /// Create a session with a fixed public ID and one-time control secret.
GameSession(std::filesystem::path data_root,
std::filesystem::path runtime_dir,
- std::string viewer_url);
+ std::string viewer_url,
+ std::string game_id,
+ std::string control_token,
+ std::string character_name,
+ std::string model_slug,
+ std::chrono::steady_clock::time_point created_at,
+ std::shared_ptr<GameRecordStore> records,
+ const ServerConfig& config);
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);
+ /// Start this game's worker and wait for its first boundary.
+ ToolResult startGame(const Json& arguments);
+
+ /// Authorize and refresh one game-specific agent call.
+ ToolResult authorize(const Json& arguments);
/// Return the latest state, optionally waiting for a revision.
ToolResult observe(const Json& arguments);
@@ -59,6 +73,24 @@ public:
/// Stop any active worker during server shutdown.
void shutdown();
+ /// Mark a game terminal when its idle or absolute deadline has passed.
+ bool expireIfNeeded();
+
+ /// Reap a completed worker and remove its private runtime directory.
+ bool cleanup();
+
+ /// Report whether this game has reached a terminal transition.
+ bool terminal() const;
+
+ /// Report whether the worker child is still running.
+ bool workerRunning() const;
+
+ /// Return the earlier idle or absolute lifetime deadline.
+ std::chrono::steady_clock::time_point nextDeadline() const;
+
+ /// Return the immutable UUID associated with this session.
+ const std::string& gameId() const;
+
/// Return the configured spectator URL.
const std::string& viewerUrl() const;
@@ -73,6 +105,8 @@ private:
ToolResult sendInput(const Json& arguments, Json response,
const std::string& expected_kind);
ToolResult errorResult(std::string code, std::string message) const;
+ ToolResult refreshAgentActivity(std::int64_t& activity_at_s);
+ bool persistAgentActivity(std::int64_t activity_at_s);
void handleWorkerMessage(const Json& message);
bool copyRuntimeFiles(const std::filesystem::path& run_directory,
std::string& error) const;
@@ -80,16 +114,40 @@ private:
const Json& state,
std::string& error);
static bool parseKey(const Json& key, int& value, std::string& error);
- static std::string makeGameId();
+ bool markTerminal(const std::string& reason,
+ const std::string& end_time_kind,
+ const std::string& lifecycle);
+ bool finishRecord(const std::string& reason,
+ const std::string& end_time_kind,
+ std::int64_t ended_at_s);
+ static std::int64_t wallClockSeconds();
std::filesystem::path data_root_;
std::filesystem::path runtime_dir_;
std::string viewer_url_;
+ std::string game_id_;
+ std::string character_name_;
+ std::string model_slug_;
+ std::shared_ptr<GameRecordStore> records_;
+ std::chrono::steady_clock::time_point created_at_;
+ std::chrono::steady_clock::time_point last_agent_activity_;
+ std::chrono::seconds idle_timeout_;
+ std::chrono::seconds max_game_duration_;
+ std::array<unsigned char, 32> control_salt_{};
+ std::array<unsigned char, 32> control_digest_{};
+ std::size_t max_worker_output_bytes_;
+ std::size_t max_character_name_bytes_;
ObservationStore observations_;
std::unique_ptr<EngineProcess> process_;
- mutable std::mutex session_mutex_;
mutable std::mutex action_mutex_;
- std::atomic<bool> stop_requested_ = false;
+ mutable std::mutex lifecycle_mutex_;
+ bool closing_ = false;
+ std::optional<std::string> end_reason_;
+ std::optional<std::string> end_time_kind_;
+ std::int64_t terminal_at_s_ = 0;
+ std::optional<std::string> terminal_result_;
+ std::optional<int> last_depth_;
+ std::atomic<bool> shutdown_requested_ = false;
std::uint64_t operation_counter_ = 0;
};
diff --git a/include/identity.hpp b/include/identity.hpp
new file mode 100644
index 0000000..dfa54eb
--- /dev/null
+++ b/include/identity.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <array>
+#include <string>
+#include <string_view>
+
+namespace nethack_mcp
+{
+
+/// Create a canonical UUIDv7 using the operating system random source.
+std::string makeGameId();
+
+/// Check the canonical lowercase UUIDv7 spelling used by public paths.
+bool validGameId(std::string_view game_id);
+
+/// Mint a one-time 256-bit bearer token encoded as base64url.
+std::string makeControlToken();
+
+/// Create a salted SHA-256 digest for an in-memory control token check.
+std::array<unsigned char, 32> hashControlToken(
+ const std::array<unsigned char, 32>& salt, std::string_view token);
+
+/// Compare token digests without data-dependent early exit.
+bool secureDigestEqual(const std::array<unsigned char, 32>& left,
+ const std::array<unsigned char, 32>& right);
+
+/// Fill a fixed-size buffer from the operating system random source.
+bool secureRandom(void* destination, std::size_t size);
+
+} // namespace nethack_mcp
diff --git a/include/mcp_server.hpp b/include/mcp_server.hpp
index 9dee1b7..7f401cf 100644
--- a/include/mcp_server.hpp
+++ b/include/mcp_server.hpp
@@ -1,28 +1,35 @@
#pragma once
-#include "game_session.hpp"
+#include "game_manager.hpp"
namespace nethack_mcp
{
-/// Dispatch MCP JSON-RPC messages for one game session.
+/// Dispatch MCP JSON-RPC messages across all active game sessions.
class McpServer
{
public:
- /// Bind the protocol dispatcher to a session.
- explicit McpServer(GameSession& session);
+ /// Bind the protocol dispatcher to the shared game manager.
+ explicit McpServer(GameManager& manager);
/// Dispatch one HTTP transport message.
/// Set should_respond when the client expects a JSON-RPC reply.
- Json handleMessage(const Json& request, bool& should_respond);
+ Json handleMessage(const Json& request, bool& should_respond,
+ const std::string& client_id = {},
+ bool modern_protocol = false);
private:
+ Json handleMessageInternal(const Json& request, bool& should_respond,
+ const std::string& client_id,
+ bool modern_protocol);
+ Json addModernMetadata(Json response, const Json& request,
+ bool modern_protocol) const;
Json tools() const;
Json toolResult(const ToolResult& result) const;
Json jsonRpcError(const Json& id, int code,
const std::string& message) const;
- GameSession& session_;
+ GameManager& manager_;
};
} // namespace nethack_mcp
diff --git a/include/server_config.hpp b/include/server_config.hpp
new file mode 100644
index 0000000..7191519
--- /dev/null
+++ b/include/server_config.hpp
@@ -0,0 +1,65 @@
+#pragma once
+
+#include <chrono>
+#include <cstddef>
+#include <filesystem>
+#include <string>
+#include <vector>
+
+namespace nethack_mcp
+{
+
+/// Startup-only limits and public URL policy for the service process.
+struct ServerConfig
+{
+ /// Loopback listener port.
+ int port = 8765;
+ /// Private per-game worker files, normally on temporary storage.
+ std::filesystem::path data_root;
+ /// Persistent SQLite database path, outside temporary storage.
+ std::filesystem::path database_path;
+ /// Canonical public URL used in MCP results and viewer links.
+ std::string public_base_url;
+ /// Exact Host header values accepted by the HTTP server.
+ std::vector<std::string> allowed_hosts;
+ /// Exact Origin header values accepted by the HTTP server.
+ std::vector<std::string> allowed_origins;
+ /// Proxy addresses permitted to supply the forwarded client address.
+ std::vector<std::string> trusted_proxy_addresses;
+ /// Maximum number of active and concurrently starting games.
+ std::size_t max_active_games = 8;
+ /// Game creation quota for each client address.
+ std::size_t new_games_per_client = 8;
+ /// Time window used for the per-client creation quota.
+ std::chrono::seconds new_game_rate_window{3600};
+ /// Bad control-token attempts allowed for each client address.
+ std::size_t control_failures_per_client = 10;
+ /// Time window used for the bad-token limit.
+ std::chrono::seconds control_failure_window{60};
+ /// Upper bound on client-address entries kept by rate limiters.
+ std::size_t max_rate_limit_clients = 4096;
+ /// Maximum number of simultaneous HTTP handler workers.
+ std::size_t max_concurrent_requests = 64;
+ /// Maximum active plus queued HTTP connection tasks.
+ std::size_t max_open_connections = 128;
+ /// Maximum concurrent spectator long polls.
+ std::size_t max_viewer_long_polls = 48;
+ /// End a game after this long without an accepted agent call.
+ std::chrono::seconds idle_timeout{600};
+ /// Maximum time from accepted creation to game termination.
+ std::chrono::seconds max_game_duration{86400};
+ /// Fallback maintenance cadence for expiry and cleanup work.
+ std::chrono::seconds lifecycle_sweep_interval{15};
+ /// Maximum duration of one browser state long poll.
+ std::chrono::seconds state_long_poll_timeout{15};
+ /// Maximum accepted MCP request body size in bytes.
+ std::size_t max_mcp_body_bytes = 1024U * 1024U;
+ /// Maximum diagnostic output bytes logged for one worker.
+ std::size_t max_worker_output_bytes = 1024U * 1024U;
+ /// Maximum accepted character name length in bytes.
+ std::size_t max_character_name_bytes = 30;
+ /// Maximum accepted model slug length in bytes.
+ std::size_t max_model_slug_bytes = 128;
+};
+
+} // namespace nethack_mcp
diff --git a/include/window_adapter.hpp b/include/window_adapter.hpp
index cbbf44b..0f04745 100644
--- a/include/window_adapter.hpp
+++ b/include/window_adapter.hpp
@@ -24,6 +24,9 @@ public:
/// Install this adapter as the callback target for NetHack.
void install();
+ /// Send the native NetHack terminal reason over the worker IPC channel.
+ static void reportTerminalResult(int how);
+
private:
struct MenuEntry
{
diff --git a/src/engine_process.cpp b/src/engine_process.cpp
index d977f76..9578239 100644
--- a/src/engine_process.cpp
+++ b/src/engine_process.cpp
@@ -11,6 +11,7 @@
#include <unistd.h>
#include <array>
+#include <algorithm>
#include <stdexcept>
#include <vector>
@@ -25,7 +26,8 @@ EngineProcess::~EngineProcess()
}
bool EngineProcess::start(const Json& start_message, EventCallback callback,
- std::string& error)
+ std::string& error,
+ std::size_t max_diagnostic_bytes)
{
if(running_)
{
@@ -97,6 +99,7 @@ bool EngineProcess::start(const Json& start_message, EventCallback callback,
}
process_id_ = child;
+ max_diagnostic_bytes_ = max_diagnostic_bytes;
callback_ = std::move(callback);
channel_ = std::make_unique<FramedChannel>(channel_fds[0]);
running_ = true;
@@ -123,12 +126,12 @@ bool EngineProcess::send(const Json& message, std::string& error)
return channel_->send(message, error);
}
-void EngineProcess::terminate()
+void EngineProcess::terminate(bool force)
{
const pid_t process_id = process_id_;
if(process_id > 0 && running_)
{
- ::kill(process_id, SIGTERM);
+ ::kill(process_id, force ? SIGKILL : SIGTERM);
}
if(channel_)
{
@@ -188,6 +191,7 @@ void EngineProcess::readLoop()
void EngineProcess::diagnosticLoop(int descriptor)
{
std::array<char, 4096> buffer{};
+ std::size_t logged_bytes = 0;
while(true)
{
const ssize_t count = ::read(descriptor, buffer.data(), buffer.size());
@@ -203,8 +207,16 @@ void EngineProcess::diagnosticLoop(int descriptor)
}
break;
}
- std::fwrite(buffer.data(), 1, static_cast<std::size_t>(count), stderr);
- std::fflush(stderr);
+ const std::size_t remaining = logged_bytes < max_diagnostic_bytes_
+ ? max_diagnostic_bytes_ - logged_bytes : 0;
+ const std::size_t bytes_to_log = std::min(
+ remaining, static_cast<std::size_t>(count));
+ if(bytes_to_log > 0)
+ {
+ std::fwrite(buffer.data(), 1, bytes_to_log, stderr);
+ std::fflush(stderr);
+ logged_bytes += bytes_to_log;
+ }
}
::close(descriptor);
}
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index c542296..5618315 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -1,10 +1,18 @@
#include "game_http_server.hpp"
#include "embedded_assets.hpp"
+#include "game_manager.hpp"
+#include "game_record_store.hpp"
#include "game_session.hpp"
+#include "identity.hpp"
#include "mcp_server.hpp"
+#include <algorithm>
#include <chrono>
+#include <cstdint>
+#include <filesystem>
+#include <fstream>
+#include <sstream>
#include <string>
#include <string_view>
@@ -14,7 +22,106 @@ namespace nethack_mcp
namespace
{
-constexpr std::size_t MAX_MCP_MESSAGE_SIZE = 1024U * 1024U;
+class CounterSlot
+{
+public:
+ CounterSlot(std::atomic<std::size_t>& counter, std::size_t limit,
+ std::atomic<std::uint64_t>* request_count = nullptr,
+ std::atomic<std::uint64_t>* latency_total_us = nullptr)
+ : counter_(counter), request_count_(request_count),
+ latency_total_us_(latency_total_us),
+ started_at_(std::chrono::steady_clock::now())
+ {
+ const std::size_t previous = counter_.fetch_add(1);
+ admitted_ = previous < limit;
+ if(!admitted_)
+ {
+ counter_.fetch_sub(1);
+ }
+ }
+
+ ~CounterSlot()
+ {
+ if(admitted_)
+ {
+ counter_.fetch_sub(1);
+ if(request_count_ != nullptr && latency_total_us_ != nullptr)
+ {
+ const auto elapsed = std::chrono::duration_cast<
+ std::chrono::microseconds>(
+ std::chrono::steady_clock::now() - started_at_).count();
+ ++(*request_count_);
+ *latency_total_us_ += static_cast<std::uint64_t>(elapsed);
+ }
+ }
+ }
+
+ bool admitted() const
+ {
+ return admitted_;
+ }
+
+private:
+ std::atomic<std::size_t>& counter_;
+ std::atomic<std::uint64_t>* request_count_;
+ std::atomic<std::uint64_t>* latency_total_us_;
+ std::chrono::steady_clock::time_point started_at_;
+ bool admitted_ = false;
+};
+
+std::uint64_t processTaskCount()
+{
+ std::uint64_t count = 0;
+ std::error_code error;
+ for(std::filesystem::directory_iterator iterator("/proc/self/task", error),
+ end;
+ !error && iterator != end; iterator.increment(error))
+ {
+ ++count;
+ }
+ return count;
+}
+
+std::uint64_t processResidentBytes()
+{
+ std::ifstream status("/proc/self/status");
+ std::string line;
+ while(std::getline(status, line))
+ {
+ if(line.rfind("VmRSS:", 0) == 0)
+ {
+ std::istringstream fields(line.substr(6));
+ std::uint64_t kilobytes = 0;
+ fields >> kilobytes;
+ return kilobytes * 1024;
+ }
+ }
+ return 0;
+}
+
+std::filesystem::path processCgroupDirectory()
+{
+ std::ifstream cgroup("/proc/self/cgroup");
+ std::string line;
+ while(std::getline(cgroup, line))
+ {
+ if(line.rfind("0::", 0) == 0)
+ {
+ const std::filesystem::path relative =
+ std::filesystem::path(line.substr(3)).relative_path();
+ return std::filesystem::path("/sys/fs/cgroup") / relative;
+ }
+ }
+ return {};
+}
+
+std::uint64_t readUnsignedFile(const std::filesystem::path& path)
+{
+ std::ifstream input(path);
+ std::uint64_t value = 0;
+ input >> value;
+ return input ? value : 0;
+}
const EmbeddedAsset* findAsset(std::string_view path)
{
@@ -28,12 +135,70 @@ const EmbeddedAsset* findAsset(std::string_view path)
return nullptr;
}
+std::string htmlEscape(std::string_view text)
+{
+ std::string escaped;
+ escaped.reserve(text.size());
+ for(char value : text)
+ {
+ switch(value)
+ {
+ case '&': escaped += "&"; break;
+ case '<': escaped += "<"; break;
+ case '>': escaped += ">"; break;
+ case '"': escaped += """; break;
+ case '\'': escaped += "'"; break;
+ default: escaped.push_back(value); break;
+ }
+ }
+ return escaped;
+}
+
+std::string timeElement(std::optional<std::int64_t> seconds)
+{
+ if(!seconds)
+ {
+ return "<span>Unknown</span>";
+ }
+ return "<time class=\"local-time\" data-unix=\""
+ + std::to_string(*seconds) + "\">" + std::to_string(*seconds)
+ + " UTC</time>";
+}
+
+void replaceTemplateValue(std::string& html, const std::string& marker,
+ const std::string& value)
+{
+ std::size_t position = 0;
+ while((position = html.find(marker, position)) != std::string::npos)
+ {
+ html.replace(position, marker.size(), value);
+ position += value.size();
+ }
+}
+
+std::string endLabel(const GameRecord& record)
+{
+ if(record.end_time_kind == "recovery")
+ {
+ return "Recovered after interruption";
+ }
+ if(record.end_reason == "ascended")
+ {
+ return "Won (ascended)";
+ }
+ return record.end_reason.value_or("Unknown");
+}
+
+std::string depthText(std::optional<int> depth)
+{
+ return depth ? std::to_string(*depth) : "Unknown";
+}
+
} // namespace
-GameHttpServer::GameHttpServer(GameSession& session, McpServer& mcp,
- int port)
- : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", port}),
- session_(session), mcp_(mcp), port_(port)
+GameHttpServer::GameHttpServer(GameManager& manager, McpServer& mcp)
+ : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", manager.config().port}),
+ manager_(manager), mcp_(mcp), config_(manager.config())
{}
GameHttpServer::~GameHttpServer()
@@ -44,10 +209,10 @@ GameHttpServer::~GameHttpServer()
bool GameHttpServer::startServer(std::string& error)
{
setup();
- if(!server.bind_to_port("127.0.0.1", port_))
+ if(!server.bind_to_port("127.0.0.1", config_.port))
{
- error = "could not bind HTTP server to 127.0.0.1:" +
- std::to_string(port_);
+ error = "could not bind HTTP server to 127.0.0.1:"
+ + std::to_string(config_.port);
return false;
}
started_ = true;
@@ -75,7 +240,14 @@ bool GameHttpServer::running() const
void GameHttpServer::setup()
{
- server.set_payload_max_length(MAX_MCP_MESSAGE_SIZE);
+ server.set_payload_max_length(config_.max_mcp_body_bytes);
+ server.new_task_queue = [
+ max_threads = config_.max_concurrent_requests,
+ max_queued = config_.max_open_connections
+ - config_.max_concurrent_requests] {
+ return new httplib::ThreadPool(max_threads, max_threads, max_queued);
+ };
+ server.set_trusted_proxies(config_.trusted_proxy_addresses);
server.Post("/mcp", [this](const Request& request,
Response& response) {
serveMcp(request, response);
@@ -91,6 +263,10 @@ void GameHttpServer::setup()
server.Get("/", [this](const Request& request, Response& response) {
servePage(request, response);
});
+ server.Get(R"(/g/[0-9a-f-]{36})",
+ [this](const Request& request, Response& response) {
+ serveGamePage(request, response);
+ });
server.Get("/viewer.js", [this](const Request& request,
Response& response) {
serveScript(request, response);
@@ -103,22 +279,34 @@ void GameHttpServer::setup()
Response& response) {
serveFont(request, response);
});
- server.Get("/api/state", [this](const Request& request,
- Response& response) {
- serveState(request, response);
+ server.Get("/home.css", [this](const Request& request,
+ Response& response) {
+ serveStatic("/home.css", request, response);
});
+ server.Get("/local-time.js", [this](const Request& request,
+ Response& response) {
+ serveStatic("/local_time.js", request, response);
+ });
+ server.Get(R"(/api/games/[0-9a-f-]{36}/state)",
+ [this](const Request& request, Response& response) {
+ serveState(request, response);
+ });
server.Get("/health", [this](const Request& request,
Response& response) {
serveHealth(request, response);
});
+ server.Get("/metrics", [this](const Request& request,
+ Response& response) {
+ serveMetrics(request, response);
+ });
}
bool GameHttpServer::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_);
+ return std::find(config_.allowed_hosts.begin(),
+ config_.allowed_hosts.end(), host)
+ != config_.allowed_hosts.end();
}
bool GameHttpServer::validOrigin(const Request& request) const
@@ -128,12 +316,28 @@ bool GameHttpServer::validOrigin(const Request& request) const
return true;
}
const std::string origin = request.get_header_value("Origin");
- return origin == "http://127.0.0.1:" + std::to_string(port_)
- || origin == "http://localhost:" + std::to_string(port_);
+ return std::find(config_.allowed_origins.begin(),
+ config_.allowed_origins.end(), origin)
+ != config_.allowed_origins.end();
+}
+
+void GameHttpServer::rejectRequest(Response& response) const
+{
+ response.status = 503;
+ response.set_header("Retry-After", "1");
+ response.set_header("Cache-Control", "no-store");
}
void GameHttpServer::serveMcp(const Request& request, Response& response)
{
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ response.set_header("Cache-Control", "no-store");
if(!validHost(request) || !validOrigin(request))
{
response.status = 403;
@@ -146,7 +350,7 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
response.status = 415;
return;
}
- if(request.body.size() > MAX_MCP_MESSAGE_SIZE)
+ if(request.body.size() > config_.max_mcp_body_bytes)
{
response.status = 413;
return;
@@ -154,7 +358,8 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
const std::string version = request.get_header_value(
"MCP-Protocol-Version");
if(!version.empty() && version != "2025-11-25"
- && version != "2025-06-18" && version != "2025-03-26")
+ && version != "2025-06-18" && version != "2025-03-26"
+ && version != "2026-07-28")
{
response.status = 400;
return;
@@ -165,14 +370,14 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
{
message = Json::parse(request.body);
}
- catch(const Json::parse_error& exception)
+ catch(const Json::parse_error&)
{
response.status = 400;
response.set_content(Json({
{"jsonrpc", "2.0"},
{"id", nullptr},
{"error", {
- {"code", -32700}, {"message", exception.what()},
+ {"code", -32700}, {"message", "invalid JSON"},
}},
}).dump(), "application/json; charset=utf-8");
return;
@@ -188,8 +393,74 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
return;
}
+ std::string body_version;
+ if(message.is_object() && message.contains("params")
+ && message.at("params").is_object()
+ && message.at("params").contains("_meta")
+ && message.at("params").at("_meta").is_object())
+ {
+ const Json& envelope = message.at("params").at("_meta");
+ const auto version_field = envelope.find(
+ "io.modelcontextprotocol/protocolVersion");
+ if(version_field != envelope.end() && version_field->is_string())
+ {
+ body_version = version_field->get<std::string>();
+ }
+ }
+ const bool modern_protocol = version == "2026-07-28"
+ || body_version == "2026-07-28";
+ if(modern_protocol)
+ {
+ const Json id = message.is_object() && message.contains("id")
+ ? message.at("id") : Json(nullptr);
+ const auto reject_header_mismatch = [&] {
+ response.status = 400;
+ response.set_content(Json({
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"error", {
+ {"code", -32020},
+ {"message", "MCP routing headers do not match request"},
+ }},
+ }).dump(), "application/json; charset=utf-8");
+ };
+ if(version != "2026-07-28" || body_version != "2026-07-28"
+ || !message.is_object() || !message.contains("method")
+ || !message.at("method").is_string())
+ {
+ reject_header_mismatch();
+ return;
+ }
+ const std::string method = message.at("method").get<std::string>();
+ if(!request.has_header("Mcp-Method")
+ || request.get_header_value("Mcp-Method") != method)
+ {
+ reject_header_mismatch();
+ return;
+ }
+ if(method == "tools/call")
+ {
+ const Json params = message.value("params", Json::object());
+ if(!params.is_object() || !params.contains("name")
+ || !params.at("name").is_string()
+ || !request.has_header("Mcp-Name")
+ || request.get_header_value("Mcp-Name")
+ != params.at("name").get<std::string>())
+ {
+ reject_header_mismatch();
+ return;
+ }
+ }
+ else if(request.has_header("Mcp-Name"))
+ {
+ reject_header_mismatch();
+ return;
+ }
+ }
+
bool should_respond = true;
- const Json reply = mcp_.handleMessage(message, should_respond);
+ const Json reply = mcp_.handleMessage(
+ message, should_respond, request.remote_addr, modern_protocol);
if(!should_respond)
{
response.status = 202;
@@ -201,12 +472,20 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
void GameHttpServer::rejectMcpStream(const Request& request,
Response& response)
{
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
if(!validHost(request) || !validOrigin(request))
{
response.status = 403;
return;
}
response.set_header("Allow", "POST");
+ response.set_header("Cache-Control", "no-store");
response.status = 405;
}
@@ -214,7 +493,14 @@ void GameHttpServer::serveStatic(std::string_view path,
const Request& request,
Response& response)
{
- if(!validHost(request))
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ if(!validHost(request) || !validOrigin(request))
{
response.status = 403;
return;
@@ -225,13 +511,76 @@ void GameHttpServer::serveStatic(std::string_view path,
response.status = 404;
return;
}
+ response.set_header("Cache-Control", "public, max-age=3600");
response.set_content(asset->content.data(), asset->content.size(),
std::string(asset->content_type));
}
void GameHttpServer::servePage(const Request& request, Response& response)
{
- serveStatic("/", request, response);
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ if(!validHost(request) || !validOrigin(request))
+ {
+ response.status = 403;
+ return;
+ }
+ response.set_header("Cache-Control", "public, max-age=60");
+ response.set_content(recentPage(), "text/html; charset=utf-8");
+}
+
+void GameHttpServer::serveGamePage(const Request& request,
+ Response& response)
+{
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ if(!validHost(request) || !validOrigin(request))
+ {
+ response.status = 403;
+ return;
+ }
+ const std::string game_id = request.path.substr(3);
+ if(!validGameId(game_id))
+ {
+ response.status = 404;
+ return;
+ }
+ if(manager_.findActive(game_id))
+ {
+ response.set_header("Cache-Control", "no-store");
+ const EmbeddedAsset* asset = findAsset("/");
+ if(asset == nullptr)
+ {
+ response.status = 500;
+ return;
+ }
+ response.set_content(asset->content.data(), asset->content.size(),
+ "text/html; charset=utf-8");
+ return;
+ }
+ response.set_header("Cache-Control", "public, max-age=60");
+ auto record = manager_.getRecord(game_id);
+ if(!record)
+ {
+ response.status = 500;
+ return;
+ }
+ if(!*record || !(*record)->ended_at_s)
+ {
+ response.status = 404;
+ return;
+ }
+ response.set_content(recordPage(**record), "text/html; charset=utf-8");
}
void GameHttpServer::serveScript(const Request& request,
@@ -252,22 +601,64 @@ void GameHttpServer::serveFont(const Request& request, Response& response)
void GameHttpServer::serveState(const Request& request, Response& response)
{
- if(!validHost(request))
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ response.set_header("Cache-Control", "no-store");
+ if(!validHost(request) || !validOrigin(request))
{
response.status = 403;
return;
}
- Json state = session_.snapshot();
+ constexpr std::string_view PREFIX = "/api/games/";
+ constexpr std::string_view SUFFIX = "/state";
+ if(request.path.size() <= PREFIX.size() + SUFFIX.size())
+ {
+ response.status = 404;
+ return;
+ }
+ const std::string game_id = request.path.substr(
+ PREFIX.size(), request.path.size() - PREFIX.size() - SUFFIX.size());
+ if(!validGameId(game_id))
+ {
+ response.status = 404;
+ return;
+ }
+ auto session = manager_.findActive(game_id);
+ if(!session)
+ {
+ auto record = manager_.getRecord(game_id);
+ if(!record)
+ {
+ response.status = 500;
+ return;
+ }
+ response.status = *record && (*record)->ended_at_s ? 410 : 404;
+ return;
+ }
+
+ Json state = session->snapshot();
const std::string requested_etag = request.get_header_value(
"If-None-Match");
- std::string etag = "\"" +
- std::to_string(state.value("revision", 0ULL)) + "\"";
+ std::string etag = "\"" + game_id + ":"
+ + std::to_string(state.value("revision", 0ULL)) + "\"";
if(requested_etag == etag)
{
- state = session_.waitForSnapshot(
- state.value("revision", 0ULL), std::chrono::seconds(15));
- etag = "\"" +
- std::to_string(state.value("revision", 0ULL)) + "\"";
+ CounterSlot poll_slot(
+ viewer_long_polls_, config_.max_viewer_long_polls);
+ if(!poll_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ state = session->waitForSnapshot(
+ state.value("revision", 0ULL), config_.state_long_poll_timeout);
+ etag = "\"" + game_id + ":"
+ + std::to_string(state.value("revision", 0ULL)) + "\"";
}
response.set_header("ETag", etag);
if(requested_etag == etag)
@@ -280,17 +671,176 @@ void GameHttpServer::serveState(const Request& request, Response& response)
void GameHttpServer::serveHealth(const Request& request, Response& response)
{
- if(!validHost(request))
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ if(!validHost(request) || !validOrigin(request))
{
response.status = 403;
return;
}
- const Json state = session_.snapshot();
+ response.set_header("Cache-Control", "no-store");
response.set_content(Json({
{"ready", true},
- {"lifecycle", state.value("lifecycle", "idle")},
- {"viewer_url", session_.viewerUrl()},
+ {"public_base_url", manager_.publicBaseUrl()},
}).dump(), "application/json; charset=utf-8");
}
+void GameHttpServer::serveMetrics(const Request& request, Response& response)
+{
+ CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
+ &request_count_, &request_latency_total_us_);
+ if(!request_slot.admitted())
+ {
+ rejectRequest(response);
+ return;
+ }
+ if(!validHost(request) || !validOrigin(request))
+ {
+ response.status = 403;
+ return;
+ }
+ const std::uint64_t worker_count = manager_.activeWorkerCount();
+ const std::uint64_t parent_task_count = processTaskCount();
+ const std::uint64_t request_count = request_count_.load();
+ const std::filesystem::path cgroup = processCgroupDirectory();
+ const double average_request_seconds = request_count == 0
+ ? 0.0
+ : static_cast<double>(request_latency_total_us_.load())
+ / static_cast<double>(request_count) / 1000000.0;
+ const std::string metrics =
+ "# HELP nethack_mcp_active_games Active game sessions.\n"
+ "# TYPE nethack_mcp_active_games gauge\n"
+ "nethack_mcp_active_games "
+ + std::to_string(manager_.activeGameCount()) + "\n"
+ "# HELP nethack_mcp_live_workers Running NetHack child processes.\n"
+ "# TYPE nethack_mcp_live_workers gauge\n"
+ "nethack_mcp_live_workers " + std::to_string(worker_count) + "\n"
+ "# HELP nethack_mcp_linux_tasks Estimated parent and worker tasks.\n"
+ "# TYPE nethack_mcp_linux_tasks gauge\n"
+ "nethack_mcp_linux_tasks "
+ + std::to_string(parent_task_count + worker_count) + "\n"
+ "# HELP nethack_mcp_parent_tasks Current server process tasks.\n"
+ "# TYPE nethack_mcp_parent_tasks gauge\n"
+ "nethack_mcp_parent_tasks " + std::to_string(parent_task_count) + "\n"
+ "# HELP nethack_mcp_cgroup_tasks Current cgroup task count.\n"
+ "# TYPE nethack_mcp_cgroup_tasks gauge\n"
+ "nethack_mcp_cgroup_tasks "
+ + std::to_string(cgroup.empty()
+ ? 0 : readUnsignedFile(cgroup / "pids.current")) + "\n"
+ "# HELP nethack_mcp_cgroup_memory_bytes Current cgroup memory use.\n"
+ "# TYPE nethack_mcp_cgroup_memory_bytes gauge\n"
+ "nethack_mcp_cgroup_memory_bytes "
+ + std::to_string(cgroup.empty()
+ ? 0 : readUnsignedFile(cgroup / "memory.current")) + "\n"
+ "# HELP nethack_mcp_process_resident_bytes Server process RSS.\n"
+ "# TYPE nethack_mcp_process_resident_bytes gauge\n"
+ "nethack_mcp_process_resident_bytes "
+ + std::to_string(processResidentBytes()) + "\n"
+ "# HELP nethack_mcp_runtime_bytes Current runtime directory size.\n"
+ "# TYPE nethack_mcp_runtime_bytes gauge\n"
+ "nethack_mcp_runtime_bytes "
+ + std::to_string(manager_.runtimeBytes()) + "\n"
+ "# HELP nethack_mcp_last_database_write_seconds Last SQLite write time.\n"
+ "# TYPE nethack_mcp_last_database_write_seconds gauge\n"
+ "nethack_mcp_last_database_write_seconds "
+ + std::to_string(static_cast<double>(
+ manager_.databaseWriteLatencyMicroseconds()) / 1000000.0) + "\n"
+ "# HELP nethack_mcp_expiry_cleanup_failures Failed cleanup attempts.\n"
+ "# TYPE nethack_mcp_expiry_cleanup_failures counter\n"
+ "nethack_mcp_expiry_cleanup_failures "
+ + std::to_string(manager_.expiryCleanupFailures()) + "\n"
+ "# HELP nethack_mcp_http_requests_total Completed HTTP handlers.\n"
+ "# TYPE nethack_mcp_http_requests_total counter\n"
+ "nethack_mcp_http_requests_total " + std::to_string(request_count) + "\n"
+ "# HELP nethack_mcp_http_request_latency_seconds Average handler time.\n"
+ "# TYPE nethack_mcp_http_request_latency_seconds gauge\n"
+ "nethack_mcp_http_request_latency_seconds "
+ + std::to_string(average_request_seconds) + "\n"
+ "# HELP nethack_mcp_viewer_long_polls Current viewer waits.\n"
+ "# TYPE nethack_mcp_viewer_long_polls gauge\n"
+ "nethack_mcp_viewer_long_polls "
+ + std::to_string(viewer_long_polls_.load()) + "\n"
+ "# HELP nethack_mcp_max_active_games Configured game capacity.\n"
+ "# TYPE nethack_mcp_max_active_games gauge\n"
+ "nethack_mcp_max_active_games "
+ + std::to_string(config_.max_active_games) + "\n"
+ "# HELP nethack_mcp_max_http_workers Configured handler worker limit.\n"
+ "# TYPE nethack_mcp_max_http_workers gauge\n"
+ "nethack_mcp_max_http_workers "
+ + std::to_string(config_.max_concurrent_requests) + "\n"
+ "# HELP nethack_mcp_max_viewer_long_polls Configured viewer wait limit.\n"
+ "# TYPE nethack_mcp_max_viewer_long_polls gauge\n"
+ "nethack_mcp_max_viewer_long_polls "
+ + std::to_string(config_.max_viewer_long_polls) + "\n";
+ response.set_header("Cache-Control", "no-store");
+ response.set_content(metrics, "text/plain; version=0.0.4; charset=utf-8");
+}
+
+std::string GameHttpServer::recentPage()
+{
+ auto records = manager_.recentGames();
+ if(!records)
+ {
+ return "<!doctype html><title>NetHack games</title><h1>"
+ "Records are temporarily unavailable.</h1>";
+ }
+ const EmbeddedAsset* asset = findAsset("/home.html");
+ if(asset == nullptr)
+ {
+ return "<!doctype html><title>NetHack games</title><h1>"
+ "Page template is unavailable.</h1>";
+ }
+ std::string rows;
+ if(records->empty())
+ {
+ rows = "<tr><td colspan=\"7\">No completed games yet.</td></tr>";
+ }
+ for(const GameRecord& record : *records)
+ {
+ rows += "<tr><td><a href=\"/g/" + htmlEscape(record.game_id) + "\">"
+ + htmlEscape(record.character_name) + "</a></td><td>"
+ + htmlEscape(record.model_slug) + "</td><td>"
+ + timeElement(record.started_at_s) + "</td><td>"
+ + timeElement(record.ended_at_s) + "</td><td>"
+ + htmlEscape(endLabel(record)) + "</td><td>"
+ + depthText(record.deepest_depth) + "</td><td>"
+ + depthText(record.last_depth) + "</td></tr>";
+ }
+ std::string html(asset->content);
+ replaceTemplateValue(html, "{{MCP_URL}}",
+ htmlEscape(manager_.publicBaseUrl() + "mcp"));
+ replaceTemplateValue(html, "{{RECENT_GAMES}}", rows);
+ return html;
+}
+
+std::string GameHttpServer::recordPage(const GameRecord& record)
+{
+ const EmbeddedAsset* asset = findAsset("/record.html");
+ if(asset == nullptr)
+ {
+ return "<!doctype html><title>NetHack game</title><h1>"
+ "Page template is unavailable.</h1>";
+ }
+ std::string html(asset->content);
+ replaceTemplateValue(html, "{{CHARACTER_NAME}}",
+ htmlEscape(record.character_name));
+ replaceTemplateValue(html, "{{GAME_ID}}", htmlEscape(record.game_id));
+ replaceTemplateValue(html, "{{MODEL_SLUG}}", htmlEscape(record.model_slug));
+ replaceTemplateValue(html, "{{STARTED_AT}}", timeElement(record.started_at_s));
+ replaceTemplateValue(html, "{{ENDED_AT}}", timeElement(record.ended_at_s));
+ replaceTemplateValue(html, "{{ENDED_TIME_LABEL}}",
+ record.end_time_kind == "recovery"
+ ? "Recovered at (actual end unknown)" : "Ended");
+ replaceTemplateValue(html, "{{END_LABEL}}", htmlEscape(endLabel(record)));
+ replaceTemplateValue(html, "{{DEEPEST_DEPTH}}",
+ depthText(record.deepest_depth));
+ replaceTemplateValue(html, "{{LAST_DEPTH}}", depthText(record.last_depth));
+ return html;
+}
+
} // namespace nethack_mcp
diff --git a/src/game_manager.cpp b/src/game_manager.cpp
new file mode 100644
index 0000000..c8b422f
--- /dev/null
+++ b/src/game_manager.cpp
@@ -0,0 +1,687 @@
+#include "game_manager.hpp"
+
+#include "identity.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cstdio>
+#include <filesystem>
+#include <string>
+#include <stdexcept>
+#include <unistd.h>
+#include <utility>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+std::int64_t wallClockSeconds()
+{
+ return std::chrono::duration_cast<std::chrono::seconds>(
+ std::chrono::system_clock::now().time_since_epoch()).count();
+}
+
+bool validPrintableAscii(const std::string& value)
+{
+ return std::all_of(value.begin(), value.end(), [](unsigned char byte) {
+ return byte >= 0x20 && byte <= 0x7e;
+ });
+}
+
+ToolResult managerError(std::string code, std::string message,
+ Json value = Json::object())
+{
+ return {false, std::move(value), std::move(code), std::move(message)};
+}
+
+void preparePrivateDirectory(const std::filesystem::path& path)
+{
+ std::error_code error;
+ const bool already_exists = std::filesystem::exists(path, error);
+ if(error)
+ {
+ throw std::filesystem::filesystem_error(
+ "could not inspect private directory", path, error);
+ }
+ if(!already_exists)
+ {
+ std::filesystem::create_directories(path);
+ std::filesystem::permissions(
+ path, std::filesystem::perms::owner_all,
+ std::filesystem::perm_options::replace);
+ }
+
+ const auto status = std::filesystem::symlink_status(path);
+ if(std::filesystem::is_symlink(status)
+ || !std::filesystem::is_directory(status))
+ {
+ throw std::runtime_error("private data root must be a real directory");
+ }
+ constexpr auto SHARED_ACCESS = std::filesystem::perms::group_all
+ | std::filesystem::perms::others_all;
+ if((status.permissions() & SHARED_ACCESS) != std::filesystem::perms::none
+ || ::access(path.c_str(), W_OK | X_OK) != 0)
+ {
+ throw std::runtime_error(
+ "private data root must be writable and accessible only to its owner");
+ }
+}
+
+} // namespace
+
+mw::E<std::unique_ptr<GameManager>> GameManager::create(
+ ServerConfig config, std::filesystem::path runtime_source)
+{
+ auto opened = GameRecordStore::open(config.database_path);
+ if(!opened)
+ {
+ return std::unexpected(opened.error());
+ }
+ auto records = std::shared_ptr<GameRecordStore>(std::move(*opened));
+ auto recovered = records->recoverInterruptedGames(wallClockSeconds());
+ if(!recovered)
+ {
+ return std::unexpected(recovered.error());
+ }
+ try
+ {
+ auto manager = std::unique_ptr<GameManager>(new GameManager(
+ std::move(config), std::move(runtime_source), std::move(records)));
+ manager->removeOrphanedDirectories();
+ manager->sweep_thread_ = std::thread(&GameManager::sweepLoop,
+ manager.get());
+ return manager;
+ }
+ catch(const std::exception& exception)
+ {
+ return std::unexpected(mw::runtimeError(exception.what()));
+ }
+}
+
+GameManager::GameManager(ServerConfig config,
+ std::filesystem::path runtime_source,
+ std::shared_ptr<GameRecordStore> records)
+ : config_(std::move(config)), runtime_source_(std::move(runtime_source)),
+ records_(std::move(records))
+{
+ preparePrivateDirectory(config_.data_root);
+}
+
+GameManager::~GameManager()
+{
+ shutdown();
+}
+
+ToolResult GameManager::createGame(const Json& arguments,
+ const std::string& client_id)
+{
+ if(!arguments.contains("model_slug")
+ || !arguments.at("model_slug").is_string())
+ {
+ return managerError(
+ "INVALID_RESPONSE", "model_slug is required and must be a string");
+ }
+ const std::string model_slug =
+ arguments.at("model_slug").get<std::string>();
+ if(model_slug.empty() || model_slug.size() > config_.max_model_slug_bytes
+ || !validPrintableAscii(model_slug))
+ {
+ return managerError(
+ "INVALID_RESPONSE",
+ "model_slug must be 1 to 128 printable ASCII bytes");
+ }
+
+ if(arguments.contains("name") && !arguments.at("name").is_string())
+ {
+ return managerError("INVALID_RESPONSE", "name must be a string");
+ }
+ const std::string character_name = arguments.value(
+ "name", std::string("Agent"));
+ if(character_name.empty()
+ || character_name.size() > config_.max_character_name_bytes
+ || !validPrintableAscii(character_name))
+ {
+ return managerError(
+ "INVALID_RESPONSE",
+ "name must be printable ASCII and within the configured limit");
+ }
+
+ const auto now = std::chrono::steady_clock::now();
+ std::chrono::steady_clock::time_point accepted_at;
+ std::int64_t accepted_at_s = 0;
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ if(sessions_.size() + pending_creations_ >= config_.max_active_games)
+ {
+ return managerError(
+ "CAPACITY_REACHED", "active game capacity has been reached",
+ {{"retry_after_seconds",
+ config_.lifecycle_sweep_interval.count()}});
+ }
+ if(!allowCreation(client_id, now))
+ {
+ return managerError(
+ "RATE_LIMITED", "new game creation rate limit reached",
+ {{"retry_after_seconds", config_.new_game_rate_window.count()}});
+ }
+ accepted_at = std::chrono::steady_clock::now();
+ accepted_at_s = wallClockSeconds();
+ ++pending_creations_;
+ }
+
+ std::string game_id;
+ std::string control_token;
+ bool record_inserted = false;
+ std::string error;
+ for(int attempt = 0; attempt < 8; ++attempt)
+ {
+ game_id = makeGameId();
+ control_token = makeControlToken();
+ if(game_id.empty() || control_token.empty())
+ {
+ error = "operating system random source failed";
+ break;
+ }
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ if(sessions_.contains(game_id) || reserved_ids_.contains(game_id))
+ {
+ game_id.clear();
+ continue;
+ }
+ reserved_ids_.insert(game_id);
+ }
+
+ GameRecord record;
+ record.game_id = game_id;
+ record.character_name = character_name;
+ record.model_slug = model_slug;
+ record.started_at_s = accepted_at_s;
+ record.last_activity_at_s = accepted_at_s;
+ auto inserted = records_->insertGame(record);
+ if(!inserted)
+ {
+ error = mw::errorMsg(inserted.error());
+ break;
+ }
+ if(*inserted)
+ {
+ record_inserted = true;
+ break;
+ }
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ reserved_ids_.erase(game_id);
+ }
+ }
+
+ if(!record_inserted)
+ {
+ releaseReservation(game_id);
+ if(error.empty())
+ {
+ error = "could not allocate a unique game identifier";
+ }
+ return managerError("RECORD_FAILURE", std::move(error));
+ }
+
+ const std::string viewer_url = config_.public_base_url + "g/" + game_id;
+ std::shared_ptr<GameSession> session;
+ try
+ {
+ session = std::make_shared<GameSession>(
+ config_.data_root, runtime_source_, viewer_url, game_id,
+ control_token, character_name, model_slug, accepted_at, records_,
+ config_);
+ }
+ catch(const std::exception& exception)
+ {
+ auto finished = records_->finishGame(
+ game_id, wallClockSeconds(), "observed", "failed");
+ [[maybe_unused]] const bool ignored = finished.has_value();
+ releaseReservation(game_id);
+ return managerError("ENGINE_FAILURE", exception.what());
+ }
+
+ ToolResult started;
+ try
+ {
+ started = session->startGame(arguments);
+ }
+ catch(const std::exception& exception)
+ {
+ auto finished = records_->finishGame(
+ game_id, wallClockSeconds(), "observed", "failed");
+ if(!finished)
+ {
+ std::fprintf(stderr, "could not record failed game %s: %s\n",
+ game_id.c_str(), mw::errorMsg(finished.error()).c_str());
+ }
+ session->cleanup();
+ releaseReservation(game_id);
+ return managerError("ENGINE_FAILURE", exception.what());
+ }
+ if(!started.success)
+ {
+ auto finished = records_->finishGame(
+ game_id, wallClockSeconds(), "observed", "failed");
+ if(!finished)
+ {
+ std::fprintf(stderr, "could not record failed game %s: %s\n",
+ game_id.c_str(), mw::errorMsg(finished.error()).c_str());
+ }
+ session->cleanup();
+ releaseReservation(game_id);
+ return started;
+ }
+
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ --pending_creations_;
+ reserved_ids_.erase(game_id);
+ sessions_.emplace(game_id, session);
+ }
+ sweep_condition_.notify_one();
+ return {
+ true,
+ {
+ {"game_id", game_id},
+ {"control_token", control_token},
+ {"viewer_url", viewer_url},
+ {"state", std::move(started.value)},
+ },
+ {},
+ {},
+ };
+}
+
+ToolResult GameManager::dispatch(const std::string& tool_name,
+ const Json& arguments,
+ const std::string& client_id)
+{
+ if(!arguments.contains("game_id") || !arguments.at("game_id").is_string())
+ {
+ return managerError("GAME_NOT_FOUND", "game was not found");
+ }
+ const std::string game_id = arguments.at("game_id").get<std::string>();
+ if(!validGameId(game_id))
+ {
+ return managerError("GAME_NOT_FOUND", "game was not found");
+ }
+ auto session = findActive(game_id);
+ if(!session)
+ {
+ return managerError("GAME_NOT_FOUND", "game was not found");
+ }
+
+ if(!allowControlAttempt(client_id))
+ {
+ return managerError(
+ "RATE_LIMITED", "too many invalid game control credentials",
+ {{"retry_after_seconds", config_.control_failure_window.count()}});
+ }
+ ToolResult authorized = session->authorize(arguments);
+ if(!authorized.success)
+ {
+ if(authorized.code == "FORBIDDEN")
+ {
+ recordControlFailure(client_id);
+ }
+ return authorized;
+ }
+ clearControlFailures(client_id);
+
+ if(tool_name == "observe") return session->observe(arguments);
+ if(tool_name == "press") return session->press(arguments);
+ if(tool_name == "select_menu") return session->selectMenu(arguments);
+ if(tool_name == "respond") return session->respond(arguments);
+ if(tool_name == "quit_game") return session->quitGame(arguments);
+ return managerError("UNKNOWN_TOOL", "unknown game tool");
+}
+
+std::shared_ptr<GameSession> GameManager::findActive(
+ const std::string& game_id) const
+{
+ std::lock_guard registry_lock(registry_mutex_);
+ const auto found = sessions_.find(game_id);
+ return found == sessions_.end() ? nullptr : found->second;
+}
+
+mw::E<std::optional<GameRecord>> GameManager::getRecord(
+ const std::string& game_id)
+{
+ if(!validGameId(game_id))
+ {
+ return std::optional<GameRecord>();
+ }
+ return records_->getGame(game_id);
+}
+
+mw::E<std::vector<GameRecord>> GameManager::recentGames()
+{
+ return records_->recentGames();
+}
+
+void GameManager::shutdown()
+{
+ if(stopping_.exchange(true))
+ {
+ return;
+ }
+ sweep_condition_.notify_all();
+ if(sweep_thread_.joinable())
+ {
+ sweep_thread_.join();
+ }
+ std::vector<std::shared_ptr<GameSession>> sessions;
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ sessions.reserve(sessions_.size());
+ for(const auto& [game_id, session] : sessions_)
+ {
+ [[maybe_unused]] const std::string& ignored = game_id;
+ sessions.push_back(session);
+ }
+ }
+ for(const auto& session : sessions)
+ {
+ session->shutdown();
+ }
+}
+
+const std::string& GameManager::publicBaseUrl() const
+{
+ return config_.public_base_url;
+}
+
+const ServerConfig& GameManager::config() const
+{
+ return config_;
+}
+
+std::size_t GameManager::activeGameCount() const
+{
+ std::lock_guard registry_lock(registry_mutex_);
+ return sessions_.size();
+}
+
+std::size_t GameManager::activeWorkerCount() const
+{
+ std::vector<std::shared_ptr<GameSession>> sessions;
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ sessions.reserve(sessions_.size());
+ for(const auto& [game_id, session] : sessions_)
+ {
+ [[maybe_unused]] const std::string& ignored = game_id;
+ sessions.push_back(session);
+ }
+ }
+ return static_cast<std::size_t>(std::count_if(
+ sessions.begin(), sessions.end(), [](const auto& session) {
+ return session->workerRunning();
+ }));
+}
+
+std::uint64_t GameManager::runtimeBytes() const
+{
+ std::uint64_t size = 0;
+ std::error_code error;
+ for(std::filesystem::recursive_directory_iterator iterator(
+ config_.data_root, error), end;
+ !error && iterator != end; iterator.increment(error))
+ {
+ if(iterator->is_regular_file(error))
+ {
+ size += iterator->file_size(error);
+ }
+ if(error)
+ {
+ error.clear();
+ }
+ }
+ return size;
+}
+
+std::uint64_t GameManager::expiryCleanupFailures() const
+{
+ return expiry_cleanup_failures_.load();
+}
+
+std::uint64_t GameManager::databaseWriteLatencyMicroseconds() const
+{
+ return records_->lastWriteLatencyMicroseconds();
+}
+
+void GameManager::sweepLoop()
+{
+ std::unique_lock wait_lock(sweep_mutex_);
+ while(!stopping_)
+ {
+ std::chrono::steady_clock::duration wait_duration =
+ config_.lifecycle_sweep_interval;
+ std::vector<std::shared_ptr<GameSession>> scheduled_sessions;
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ scheduled_sessions.reserve(sessions_.size());
+ for(const auto& [game_id, session] : sessions_)
+ {
+ [[maybe_unused]] const std::string& ignored = game_id;
+ scheduled_sessions.push_back(session);
+ }
+ }
+ const auto now = std::chrono::steady_clock::now();
+ for(const auto& session : scheduled_sessions)
+ {
+ const auto deadline = session->nextDeadline();
+ const auto until_deadline = deadline <= now
+ ? std::chrono::steady_clock::duration::zero()
+ : deadline - now;
+ wait_duration = std::min(wait_duration, until_deadline);
+ }
+ sweep_condition_.wait_for(wait_lock, wait_duration);
+ if(stopping_)
+ {
+ break;
+ }
+ wait_lock.unlock();
+
+ std::vector<std::pair<std::string, std::shared_ptr<GameSession>>> copy;
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ copy.reserve(sessions_.size());
+ for(const auto& item : sessions_)
+ {
+ copy.push_back(item);
+ }
+ }
+ for(const auto& [game_id, session] : copy)
+ {
+ session->expireIfNeeded();
+ if(session->terminal())
+ {
+ if(!session->cleanup())
+ {
+ ++expiry_cleanup_failures_;
+ continue;
+ }
+ std::lock_guard registry_lock(registry_mutex_);
+ const auto found = sessions_.find(game_id);
+ if(found != sessions_.end() && found->second == session)
+ {
+ sessions_.erase(found);
+ }
+ }
+ }
+ removeOrphanedDirectories();
+ wait_lock.lock();
+ }
+}
+
+void GameManager::removeOrphanedDirectories()
+{
+ std::unordered_set<std::string> active_ids;
+ {
+ std::lock_guard registry_lock(registry_mutex_);
+ active_ids.reserve(sessions_.size() + reserved_ids_.size());
+ for(const auto& [game_id, session] : sessions_)
+ {
+ [[maybe_unused]] const std::shared_ptr<GameSession>& ignored =
+ session;
+ active_ids.insert(game_id);
+ }
+ active_ids.insert(reserved_ids_.begin(), reserved_ids_.end());
+ }
+ std::error_code error;
+ if(!std::filesystem::exists(config_.data_root, error))
+ {
+ return;
+ }
+ for(std::filesystem::directory_iterator iterator(config_.data_root, error),
+ end;
+ !error && iterator != end; iterator.increment(error))
+ {
+ if(!iterator->is_directory(error))
+ {
+ continue;
+ }
+ const std::string game_id = iterator->path().filename().string();
+ if(!validGameId(game_id) || active_ids.contains(game_id))
+ {
+ continue;
+ }
+ std::error_code remove_error;
+ std::filesystem::remove_all(iterator->path(), remove_error);
+ if(remove_error)
+ {
+ ++expiry_cleanup_failures_;
+ std::fprintf(stderr, "could not remove orphaned game directory %s: %s\n",
+ iterator->path().c_str(), remove_error.message().c_str());
+ }
+ }
+ if(error)
+ {
+ std::fprintf(stderr, "could not scan game data root: %s\n",
+ error.message().c_str());
+ }
+}
+
+void GameManager::releaseReservation(const std::string& game_id)
+{
+ std::lock_guard registry_lock(registry_mutex_);
+ if(pending_creations_ > 0)
+ {
+ --pending_creations_;
+ }
+ reserved_ids_.erase(game_id);
+}
+
+bool GameManager::allowCreation(
+ const std::string& client_id,
+ std::chrono::steady_clock::time_point now)
+{
+ const std::string key = client_id.empty() ? "unknown" : client_id;
+ pruneClientHistory(now);
+ auto found = client_history_.find(key);
+ if(found == client_history_.end())
+ {
+ if(client_history_.size() >= config_.max_rate_limit_clients)
+ {
+ return false;
+ }
+ found = client_history_.try_emplace(key).first;
+ }
+ auto& timestamps = found->second.creation_times;
+ if(timestamps.size() >= config_.new_games_per_client)
+ {
+ return false;
+ }
+ timestamps.push_back(now);
+ return true;
+}
+
+bool GameManager::allowControlAttempt(const std::string& client_id)
+{
+ std::lock_guard registry_lock(registry_mutex_);
+ const auto now = std::chrono::steady_clock::now();
+ pruneClientHistory(now);
+ const std::string key = client_id.empty() ? "unknown" : client_id;
+ auto found = client_history_.find(key);
+ if(found == client_history_.end())
+ {
+ if(client_history_.size() >= config_.max_rate_limit_clients)
+ {
+ return false;
+ }
+ found = client_history_.try_emplace(key).first;
+ }
+ return found->second.auth_failures.size()
+ < config_.control_failures_per_client;
+}
+
+void GameManager::recordControlFailure(const std::string& client_id)
+{
+ std::lock_guard registry_lock(registry_mutex_);
+ const auto now = std::chrono::steady_clock::now();
+ pruneClientHistory(now);
+ const std::string key = client_id.empty() ? "unknown" : client_id;
+ auto found = client_history_.find(key);
+ if(found == client_history_.end())
+ {
+ if(client_history_.size() >= config_.max_rate_limit_clients)
+ {
+ return;
+ }
+ found = client_history_.try_emplace(key).first;
+ }
+ found->second.auth_failures.push_back(now);
+}
+
+void GameManager::clearControlFailures(const std::string& client_id)
+{
+ std::lock_guard registry_lock(registry_mutex_);
+ const std::string key = client_id.empty() ? "unknown" : client_id;
+ auto found = client_history_.find(key);
+ if(found != client_history_.end())
+ {
+ found->second.auth_failures.clear();
+ if(found->second.creation_times.empty())
+ {
+ client_history_.erase(found);
+ }
+ }
+}
+
+void GameManager::pruneClientHistory(
+ std::chrono::steady_clock::time_point now)
+{
+ const auto creation_cutoff = now - config_.new_game_rate_window;
+ const auto auth_cutoff = now - config_.control_failure_window;
+ for(auto iterator = client_history_.begin();
+ iterator != client_history_.end();)
+ {
+ auto& history = iterator->second;
+ while(!history.creation_times.empty()
+ && history.creation_times.front() <= creation_cutoff)
+ {
+ history.creation_times.pop_front();
+ }
+ while(!history.auth_failures.empty()
+ && history.auth_failures.front() <= auth_cutoff)
+ {
+ history.auth_failures.pop_front();
+ }
+ if(history.creation_times.empty() && history.auth_failures.empty())
+ {
+ iterator = client_history_.erase(iterator);
+ }
+ else
+ {
+ ++iterator;
+ }
+ }
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_record_store.cpp b/src/game_record_store.cpp
new file mode 100644
index 0000000..9d99046
--- /dev/null
+++ b/src/game_record_store.cpp
@@ -0,0 +1,450 @@
+#include "game_record_store.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include <unistd.h>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+using RecordRow = std::tuple<
+ std::string, std::string, std::string, std::int64_t, std::int64_t,
+ std::optional<std::int64_t>, std::optional<std::string>,
+ std::optional<std::string>, std::optional<int>, std::optional<int>>;
+
+class WriteTimer
+{
+public:
+ explicit WriteTimer(std::atomic<std::uint64_t>& latency)
+ : latency_(latency), started_at_(std::chrono::steady_clock::now())
+ {}
+
+ ~WriteTimer()
+ {
+ latency_ = static_cast<std::uint64_t>(
+ std::chrono::duration_cast<std::chrono::microseconds>(
+ std::chrono::steady_clock::now() - started_at_).count());
+ }
+
+private:
+ std::atomic<std::uint64_t>& latency_;
+ std::chrono::steady_clock::time_point started_at_;
+};
+
+GameRecord makeRecord(const RecordRow& row)
+{
+ return {
+ std::get<0>(row), std::get<1>(row), std::get<2>(row),
+ std::get<3>(row), std::get<4>(row), std::get<5>(row),
+ std::get<6>(row), std::get<7>(row), std::get<8>(row),
+ std::get<9>(row),
+ };
+}
+
+std::string recordSelect()
+{
+ return "SELECT game_id, character_name, model_slug, started_at_s, "
+ "last_activity_at_s, ended_at_s, end_time_kind, end_reason, "
+ "last_depth, deepest_depth FROM game_records ";
+}
+
+std::filesystem::path prepareDatabaseDirectory(
+ const std::filesystem::path& database_path)
+{
+ const std::filesystem::path absolute_path =
+ std::filesystem::absolute(database_path);
+ const std::filesystem::path parent = absolute_path.parent_path();
+ std::error_code error;
+ const bool already_exists = std::filesystem::exists(parent, error);
+ if(error)
+ {
+ throw std::filesystem::filesystem_error(
+ "could not inspect database directory", parent, error);
+ }
+ if(!already_exists)
+ {
+ std::filesystem::create_directories(parent);
+ std::filesystem::permissions(
+ parent, std::filesystem::perms::owner_all,
+ std::filesystem::perm_options::replace);
+ }
+
+ const auto status = std::filesystem::symlink_status(parent);
+ if(std::filesystem::is_symlink(status)
+ || !std::filesystem::is_directory(status))
+ {
+ throw std::runtime_error(
+ "database parent must be a real directory");
+ }
+ constexpr auto SHARED_ACCESS = std::filesystem::perms::group_all
+ | std::filesystem::perms::others_all;
+ if((status.permissions() & SHARED_ACCESS) != std::filesystem::perms::none
+ || ::access(parent.c_str(), W_OK | X_OK) != 0)
+ {
+ throw std::runtime_error(
+ "database parent must be writable and accessible only to its owner");
+ }
+
+ const bool database_exists = std::filesystem::exists(absolute_path, error);
+ if(error)
+ {
+ throw std::filesystem::filesystem_error(
+ "could not inspect database file", absolute_path, error);
+ }
+ if(database_exists)
+ {
+ const auto database_status =
+ std::filesystem::symlink_status(absolute_path);
+ if(std::filesystem::is_symlink(database_status)
+ || !std::filesystem::is_regular_file(database_status))
+ {
+ throw std::runtime_error(
+ "database path must be a regular file, not a symlink");
+ }
+ }
+ return absolute_path;
+}
+
+} // namespace
+
+Json GameRecord::toJson() const
+{
+ return {
+ {"game_id", game_id},
+ {"character_name", character_name},
+ {"model_slug", model_slug},
+ {"started_at_s", started_at_s},
+ {"last_activity_at_s", last_activity_at_s},
+ {"ended_at_s", ended_at_s ? Json(*ended_at_s) : Json(nullptr)},
+ {"end_time_kind", end_time_kind ? Json(*end_time_kind) : Json(nullptr)},
+ {"end_reason", end_reason ? Json(*end_reason) : Json(nullptr)},
+ {"last_depth", last_depth ? Json(*last_depth) : Json(nullptr)},
+ {"deepest_depth", deepest_depth ? Json(*deepest_depth) : Json(nullptr)},
+ };
+}
+
+GameRecordStore::GameRecordStore(std::unique_ptr<mw::SQLite> database)
+ : database_(std::move(database))
+{}
+
+mw::E<std::unique_ptr<GameRecordStore>> GameRecordStore::open(
+ const std::filesystem::path& database_path)
+{
+ std::filesystem::path absolute_database_path;
+ try
+ {
+ absolute_database_path = prepareDatabaseDirectory(database_path);
+ }
+ catch(const std::exception& exception)
+ {
+ return std::unexpected(mw::runtimeError(exception.what()));
+ }
+
+ auto database = mw::SQLite::connectFile(
+ absolute_database_path.string(), 5000);
+ if(!database)
+ {
+ return std::unexpected(database.error());
+ }
+ auto store = std::unique_ptr<GameRecordStore>(
+ new GameRecordStore(std::move(*database)));
+ auto migrated = store->migrate();
+ if(!migrated)
+ {
+ return std::unexpected(migrated.error());
+ }
+
+ try
+ {
+ std::filesystem::permissions(
+ absolute_database_path,
+ std::filesystem::perms::owner_read
+ | std::filesystem::perms::owner_write,
+ std::filesystem::perm_options::replace);
+ }
+ catch(const std::exception& exception)
+ {
+ return std::unexpected(mw::runtimeError(exception.what()));
+ }
+ return store;
+}
+
+mw::E<void> GameRecordStore::migrate()
+{
+ std::lock_guard lock(mutex_);
+ auto version = database_->evalToValue<int>("PRAGMA user_version;");
+ if(!version)
+ {
+ return std::unexpected(version.error());
+ }
+ if(*version > 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "game database schema is newer than this server"));
+ }
+ if(*version == 1)
+ {
+ return {};
+ }
+ auto begun = database_->execute("BEGIN IMMEDIATE;");
+ if(!begun)
+ {
+ return std::unexpected(begun.error());
+ }
+ auto table = database_->execute(
+ "CREATE TABLE game_records ("
+ "game_id TEXT PRIMARY KEY,"
+ "character_name TEXT NOT NULL,"
+ "model_slug TEXT NOT NULL CHECK (length(model_slug) BETWEEN 1 AND 128),"
+ "started_at_s INTEGER NOT NULL,"
+ "last_activity_at_s INTEGER NOT NULL,"
+ "ended_at_s INTEGER,"
+ "end_time_kind TEXT,"
+ "end_reason TEXT,"
+ "last_depth INTEGER,"
+ "deepest_depth INTEGER,"
+ "CHECK (end_reason IS NULL OR end_reason IN "
+ "('ascended', 'escaped', 'died', 'quit', 'failed', "
+ "'idle_timeout', 'time_limit', 'interrupted')),"
+ "CHECK ((ended_at_s IS NULL) = (end_reason IS NULL))"
+ ");");
+ if(!table)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(table.error());
+ }
+ auto index = database_->execute(
+ "CREATE INDEX game_records_recent_idx "
+ "ON game_records (ended_at_s DESC, game_id DESC) "
+ "WHERE ended_at_s IS NOT NULL;");
+ if(!index)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(index.error());
+ }
+ auto migration = database_->execute("PRAGMA user_version = 1;");
+ if(!migration)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(migration.error());
+ }
+ return database_->execute("COMMIT;");
+}
+
+mw::E<bool> GameRecordStore::insertGame(const GameRecord& record)
+{
+ WriteTimer timer(last_write_latency_us_);
+ std::lock_guard lock(mutex_);
+ auto statement = database_->statementFromStr(
+ "INSERT OR IGNORE INTO game_records "
+ "(game_id, character_name, model_slug, started_at_s, "
+ "last_activity_at_s) VALUES (?, ?, ?, ?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(statement.error());
+ }
+ auto bound = statement->bind(
+ record.game_id, record.character_name, record.model_slug,
+ record.started_at_s, record.last_activity_at_s);
+ if(!bound)
+ {
+ return std::unexpected(bound.error());
+ }
+ auto inserted = database_->execute(std::move(*statement));
+ if(!inserted)
+ {
+ return std::unexpected(inserted.error());
+ }
+ return database_->changedRowsCount() == 1;
+}
+
+mw::E<void> GameRecordStore::updateLocation(
+ const std::string& game_id, int depth)
+{
+ WriteTimer timer(last_write_latency_us_);
+ std::lock_guard lock(mutex_);
+ auto statement = database_->statementFromStr(
+ "UPDATE game_records SET last_depth = ?, "
+ "deepest_depth = CASE WHEN deepest_depth IS NULL OR deepest_depth < ? "
+ "THEN ? ELSE deepest_depth END "
+ "WHERE game_id = ? AND ended_at_s IS NULL;");
+ if(!statement)
+ {
+ return std::unexpected(statement.error());
+ }
+ auto bound = statement->bind(depth, depth, depth, game_id);
+ if(!bound)
+ {
+ return std::unexpected(bound.error());
+ }
+ return database_->execute(std::move(*statement));
+}
+
+mw::E<void> GameRecordStore::updateActivity(
+ const std::string& game_id, std::int64_t activity_at_s)
+{
+ WriteTimer timer(last_write_latency_us_);
+ std::lock_guard lock(mutex_);
+ auto statement = database_->statementFromStr(
+ "UPDATE game_records SET last_activity_at_s = ? "
+ "WHERE game_id = ? AND ended_at_s IS NULL;");
+ if(!statement)
+ {
+ return std::unexpected(statement.error());
+ }
+ auto bound = statement->bind(activity_at_s, game_id);
+ if(!bound)
+ {
+ return std::unexpected(bound.error());
+ }
+ return database_->execute(std::move(*statement));
+}
+
+mw::E<bool> GameRecordStore::finishGame(
+ const std::string& game_id, std::int64_t ended_at_s,
+ const std::string& end_time_kind, const std::string& end_reason)
+{
+ WriteTimer timer(last_write_latency_us_);
+ std::lock_guard lock(mutex_);
+ auto begun = database_->execute("BEGIN IMMEDIATE;");
+ if(!begun)
+ {
+ return std::unexpected(begun.error());
+ }
+ auto statement = database_->statementFromStr(
+ "UPDATE game_records SET ended_at_s = ?, end_time_kind = ?, "
+ "end_reason = ? WHERE game_id = ? AND ended_at_s IS NULL;");
+ if(!statement)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(statement.error());
+ }
+ auto bound = statement->bind(
+ ended_at_s, end_time_kind, end_reason, game_id);
+ if(!bound)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(bound.error());
+ }
+ auto updated = database_->execute(std::move(*statement));
+ if(!updated)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(updated.error());
+ }
+ const bool won = database_->changedRowsCount() == 1;
+ auto committed = database_->execute("COMMIT;");
+ if(!committed)
+ {
+ return std::unexpected(committed.error());
+ }
+ return won;
+}
+
+mw::E<std::vector<GameRecord>> GameRecordStore::recentGames()
+{
+ std::lock_guard lock(mutex_);
+ auto statement = database_->statementFromStr(
+ recordSelect() + "WHERE ended_at_s IS NOT NULL "
+ "ORDER BY ended_at_s DESC, game_id DESC LIMIT 10;");
+ if(!statement)
+ {
+ return std::unexpected(statement.error());
+ }
+ auto rows = database_->eval<
+ std::string, std::string, std::string, std::int64_t, std::int64_t,
+ std::optional<std::int64_t>, std::optional<std::string>,
+ std::optional<std::string>, std::optional<int>, std::optional<int>>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(rows.error());
+ }
+ std::vector<GameRecord> records;
+ records.reserve(rows->size());
+ for(const RecordRow& row : *rows)
+ {
+ records.push_back(makeRecord(row));
+ }
+ return records;
+}
+
+mw::E<std::optional<GameRecord>> GameRecordStore::getGame(
+ const std::string& game_id)
+{
+ std::lock_guard lock(mutex_);
+ auto statement = database_->statementFromStr(
+ recordSelect() + "WHERE game_id = ? LIMIT 1;");
+ if(!statement)
+ {
+ return std::unexpected(statement.error());
+ }
+ auto bound = statement->bind(game_id);
+ if(!bound)
+ {
+ return std::unexpected(bound.error());
+ }
+ auto rows = database_->eval<
+ std::string, std::string, std::string, std::int64_t, std::int64_t,
+ std::optional<std::int64_t>, std::optional<std::string>,
+ std::optional<std::string>, std::optional<int>, std::optional<int>>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(rows.error());
+ }
+ if(rows->empty())
+ {
+ return std::optional<GameRecord>();
+ }
+ return std::optional<GameRecord>(makeRecord(rows->front()));
+}
+
+mw::E<void> GameRecordStore::recoverInterruptedGames(
+ std::int64_t recovery_time_s)
+{
+ WriteTimer timer(last_write_latency_us_);
+ std::lock_guard lock(mutex_);
+ auto begun = database_->execute("BEGIN IMMEDIATE;");
+ if(!begun)
+ {
+ return std::unexpected(begun.error());
+ }
+ auto statement = database_->statementFromStr(
+ "UPDATE game_records SET ended_at_s = ?, end_time_kind = 'recovery', "
+ "end_reason = 'interrupted' WHERE ended_at_s IS NULL;");
+ if(!statement)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(statement.error());
+ }
+ auto bound = statement->bind(recovery_time_s);
+ if(!bound)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(bound.error());
+ }
+ auto updated = database_->execute(std::move(*statement));
+ if(!updated)
+ {
+ [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
+ return std::unexpected(updated.error());
+ }
+ return database_->execute("COMMIT;");
+}
+
+std::uint64_t GameRecordStore::lastWriteLatencyMicroseconds() const
+{
+ return last_write_latency_us_.load();
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_session.cpp b/src/game_session.cpp
index 0e95375..ceb258e 100644
--- a/src/game_session.cpp
+++ b/src/game_session.cpp
@@ -1,11 +1,13 @@
#include "game_session.hpp"
+#include "identity.hpp"
+
#include <algorithm>
#include <chrono>
#include <cctype>
+#include <cstdio>
#include <cstring>
#include <fstream>
-#include <random>
#include <sstream>
#include <utility>
@@ -113,33 +115,40 @@ bool resolveCharacter(const Json& arguments, Json& character,
GameSession::GameSession(std::filesystem::path data_root,
std::filesystem::path runtime_dir,
- std::string viewer_url)
+ std::string viewer_url,
+ std::string game_id,
+ std::string control_token,
+ std::string character_name,
+ std::string model_slug,
+ Clock::time_point created_at,
+ std::shared_ptr<GameRecordStore> records,
+ const ServerConfig& config)
: data_root_(std::move(data_root)), runtime_dir_(std::move(runtime_dir)),
- viewer_url_(std::move(viewer_url)), observations_(viewer_url_)
+ viewer_url_(std::move(viewer_url)), game_id_(std::move(game_id)),
+ character_name_(std::move(character_name)),
+ model_slug_(std::move(model_slug)), records_(std::move(records)),
+ created_at_(created_at), last_agent_activity_(created_at_),
+ idle_timeout_(config.idle_timeout),
+ max_game_duration_(config.max_game_duration),
+ max_worker_output_bytes_(config.max_worker_output_bytes),
+ max_character_name_bytes_(config.max_character_name_bytes),
+ observations_(viewer_url_)
{
- std::filesystem::create_directories(data_root_);
+ if(!secureRandom(control_salt_.data(), control_salt_.size()))
+ {
+ throw std::runtime_error("operating system random source failed");
+ }
+ control_digest_ = hashControlToken(control_salt_, control_token);
}
-ToolResult GameSession::newGame(const Json& arguments)
+ToolResult GameSession::startGame(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)
+ if(name.empty() || name.size() > max_character_name_bytes_)
{
return errorResult("INVALID_RESPONSE",
- "name must contain between 1 and 30 bytes");
+ "name has an invalid byte length");
}
for(unsigned char character : name)
{
@@ -168,8 +177,7 @@ ToolResult GameSession::newGame(const Json& arguments)
}
#endif
- const std::string game_id = makeGameId();
- const std::filesystem::path run_directory = data_root_ / game_id;
+ const std::filesystem::path run_directory = data_root_ / game_id_;
std::string error;
if(!copyRuntimeFiles(run_directory, error))
{
@@ -177,7 +185,7 @@ ToolResult GameSession::newGame(const Json& arguments)
}
Json starting = observations_.snapshot();
- starting["game_id"] = game_id;
+ starting["game_id"] = game_id_;
starting["lifecycle"] = "starting";
starting["pending"] = nullptr;
starting["operation"] = nullptr;
@@ -185,16 +193,12 @@ ToolResult GameSession::newGame(const Json& arguments)
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>();
- }
+ process_ = std::make_unique<EngineProcess>();
Json start_message = {
{"type", "start"},
{"ipc_version", IPC_VERSION},
- {"game_id", game_id},
+ {"game_id", game_id_},
{"run_dir", run_directory.string()},
{"name", name},
{"character", character},
@@ -205,7 +209,7 @@ ToolResult GameSession::newGame(const Json& arguments)
[this](const Json& message) {
handleWorkerMessage(message);
},
- spawn_error))
+ spawn_error, max_worker_output_bytes_))
{
Json failed = observations_.snapshot();
failed["lifecycle"] = "failed";
@@ -216,14 +220,81 @@ ToolResult GameSession::newGame(const Json& arguments)
Json state = observations_.waitForRevision(
starting_revision, std::chrono::seconds(10));
- if(state.value("game_id", "") != game_id)
+ if(state.value("game_id", "") != game_id_)
{
return errorResult("ENGINE_FAILURE",
"worker returned a different game identifier");
}
+ if(state.value("lifecycle", "") == "failed")
+ {
+ return errorResult("ENGINE_FAILURE",
+ "NetHack worker failed during startup");
+ }
return {true, std::move(state), {}, {}};
}
+ToolResult GameSession::authorize(const Json& arguments)
+{
+ if(!arguments.contains("game_id") || !arguments.at("game_id").is_string()
+ || arguments.at("game_id").get<std::string>() != game_id_)
+ {
+ return errorResult("GAME_NOT_FOUND", "game was not found");
+ }
+ if(!arguments.contains("control_token")
+ || !arguments.at("control_token").is_string())
+ {
+ return errorResult("FORBIDDEN", "game control credentials are invalid");
+ }
+
+ const std::string token = arguments.at("control_token").get<std::string>();
+ if(token.size() != 43)
+ {
+ return errorResult("FORBIDDEN", "game control credentials are invalid");
+ }
+ for(char character : token)
+ {
+ const bool valid = (character >= 'A' && character <= 'Z')
+ || (character >= 'a' && character <= 'z')
+ || (character >= '0' && character <= '9')
+ || character == '-' || character == '_';
+ if(!valid)
+ {
+ return errorResult(
+ "FORBIDDEN", "game control credentials are invalid");
+ }
+ }
+ const auto digest = hashControlToken(control_salt_, token);
+ if(!secureDigestEqual(digest, control_digest_))
+ {
+ return errorResult("FORBIDDEN", "game control credentials are invalid");
+ }
+
+ std::string expired_reason;
+ const auto now = Clock::now();
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(closing_)
+ {
+ return errorResult("GAME_CLOSING", "game cleanup has begun");
+ }
+ if(now >= created_at_ + max_game_duration_)
+ {
+ expired_reason = "time_limit";
+ }
+ else if(now >= last_agent_activity_ + idle_timeout_)
+ {
+ expired_reason = "idle_timeout";
+ }
+ }
+ if(!expired_reason.empty())
+ {
+ expireIfNeeded();
+ return errorResult("GAME_CLOSING", "game lifetime has ended");
+ }
+
+ return {true, Json::object(), {}, {}};
+}
+
ToolResult GameSession::observe(const Json& arguments)
{
const Json state = observations_.snapshot();
@@ -234,6 +305,14 @@ ToolResult GameSession::observe(const Json& arguments)
return errorResult("STALE_GAME", error);
}
+ if(arguments.contains("detail")
+ && (!arguments.at("detail").is_string()
+ || (arguments.at("detail") != "compact"
+ && arguments.at("detail") != "full")))
+ {
+ return errorResult("INVALID_RESPONSE", "detail is invalid");
+ }
+
int wait_ms = 0;
if(arguments.contains("wait_ms"))
{
@@ -249,26 +328,45 @@ ToolResult GameSession::observe(const Json& arguments)
}
}
+ std::optional<std::uint64_t> after_message_id;
+ if(arguments.contains("after_message_id"))
+ {
+ if(!arguments.at("after_message_id").is_number_integer()
+ || arguments.at("after_message_id").get<long long>() < 0)
+ {
+ return errorResult("INVALID_RESPONSE",
+ "after_message_id must be a nonnegative integer");
+ }
+ after_message_id = arguments.at("after_message_id").get<
+ std::uint64_t>();
+ }
+
+ std::int64_t activity_at_s = 0;
+ ToolResult refreshed = refreshAgentActivity(activity_at_s);
+ if(!refreshed.success)
+ {
+ expireIfNeeded();
+ return refreshed;
+ }
+ if(!persistAgentActivity(activity_at_s))
+ {
+ return errorResult("RECORD_FAILURE",
+ "could not update game activity record");
+ }
+
Json result = wait_ms == 0
? state
: observations_.waitForRevision(
state.value("revision", 0ULL),
std::chrono::milliseconds(wait_ms));
- if(arguments.contains("after_message_id"))
+ if(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)
+ if(message.value("id", 0ULL) > *after_message_id)
{
filtered.push_back(message);
}
@@ -498,32 +596,131 @@ ToolResult GameSession::quitGame(const Json& arguments)
{
return errorResult("STALE_GAME", error);
}
- std::unique_lock action_lock(action_mutex_);
- stop_requested_ = true;
- if(process_)
+ std::int64_t activity_at_s = 0;
+ ToolResult refreshed = refreshAgentActivity(activity_at_s);
+ if(!refreshed.success)
{
- process_->terminate();
+ expireIfNeeded();
+ return refreshed;
}
- Json result = observations_.snapshot();
- if(result.value("lifecycle", "") == "waiting"
- || result.value("lifecycle", "") == "starting")
+ if(!persistAgentActivity(activity_at_s))
{
- result["lifecycle"] = "aborted";
- result["pending"] = nullptr;
- result["operation"] = nullptr;
- observations_.publish(result);
+ return errorResult("RECORD_FAILURE",
+ "could not update game activity record");
}
- return {true, std::move(result), {}, {}};
+ markTerminal("quit", "observed", "ended");
+ {
+ std::unique_lock action_lock(action_mutex_);
+ if(process_)
+ {
+ process_->terminate(true);
+ process_.reset();
+ }
+ }
+ cleanup();
+ return {true, observations_.snapshot(), {}, {}};
}
void GameSession::shutdown()
{
std::unique_lock action_lock(action_mutex_);
- stop_requested_ = true;
+ shutdown_requested_ = true;
if(process_)
{
- process_->terminate();
+ process_->terminate(true);
+ process_.reset();
+ }
+}
+
+bool GameSession::expireIfNeeded()
+{
+ std::string reason;
+ const auto now = Clock::now();
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(closing_)
+ {
+ return false;
+ }
+ if(now >= created_at_ + max_game_duration_)
+ {
+ reason = "time_limit";
+ }
+ else if(now >= last_agent_activity_ + idle_timeout_)
+ {
+ reason = "idle_timeout";
+ }
+ }
+ if(reason.empty())
+ {
+ return false;
+ }
+ return markTerminal(reason, "observed", "ended");
+}
+
+bool GameSession::cleanup()
+{
+ {
+ std::unique_lock action_lock(action_mutex_);
+ if(process_)
+ {
+ process_->terminate(true);
+ process_.reset();
+ }
+ }
+ std::optional<std::string> end_reason;
+ std::optional<std::string> end_time_kind;
+ std::int64_t terminal_at_s = 0;
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ end_reason = end_reason_;
+ end_time_kind = end_time_kind_;
+ terminal_at_s = terminal_at_s_;
+ }
+ if(end_reason && end_time_kind
+ && !finishRecord(*end_reason, *end_time_kind, terminal_at_s))
+ {
+ return false;
+ }
+ std::error_code error;
+ std::filesystem::remove_all(data_root_ / game_id_, error);
+ if(error)
+ {
+ std::fprintf(stderr, "could not remove game directory %s: %s\n",
+ (data_root_ / game_id_).c_str(), error.message().c_str());
+ return false;
+ }
+ return true;
+}
+
+bool GameSession::terminal() const
+{
+ std::lock_guard lock(lifecycle_mutex_);
+ return closing_;
+}
+
+bool GameSession::workerRunning() const
+{
+ std::lock_guard action_lock(action_mutex_);
+ return process_ && process_->running();
+}
+
+Clock::time_point GameSession::nextDeadline() const
+{
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(closing_)
+ {
+ return Clock::now();
}
+ const auto absolute_deadline = created_at_ + max_game_duration_;
+ const auto idle_deadline = last_agent_activity_ + idle_timeout_;
+ return absolute_deadline < idle_deadline
+ ? absolute_deadline : idle_deadline;
+}
+
+const std::string& GameSession::gameId() const
+{
+ return game_id_;
}
const std::string& GameSession::viewerUrl() const
@@ -542,10 +739,93 @@ Json GameSession::waitForSnapshot(
return observations_.waitForRevision(revision, timeout);
}
+bool GameSession::markTerminal(const std::string& reason,
+ const std::string& end_time_kind,
+ const std::string& lifecycle)
+{
+ std::string terminal_reason = reason;
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(closing_)
+ {
+ return false;
+ }
+ const auto now = Clock::now();
+ if(now >= created_at_ + max_game_duration_)
+ {
+ terminal_reason = "time_limit";
+ }
+ else if(now >= last_agent_activity_ + idle_timeout_)
+ {
+ terminal_reason = "idle_timeout";
+ }
+ closing_ = true;
+ end_reason_ = terminal_reason;
+ end_time_kind_ = end_time_kind;
+ terminal_at_s_ = wallClockSeconds();
+ }
+
+ Json state = observations_.snapshot();
+ state["lifecycle"] = lifecycle;
+ state["end_reason"] = terminal_reason;
+ state["pending"] = nullptr;
+ state["operation"] = nullptr;
+ observations_.publish(std::move(state));
+ return true;
+}
+
+bool GameSession::finishRecord(const std::string& reason,
+ const std::string& end_time_kind,
+ std::int64_t ended_at_s)
+{
+ auto result = records_->finishGame(game_id_, ended_at_s,
+ end_time_kind, reason);
+ if(!result)
+ {
+ std::fprintf(stderr, "could not finalize game %s: %s\n",
+ game_id_.c_str(), mw::errorMsg(result.error()).c_str());
+ return false;
+ }
+ return true;
+}
+
+std::int64_t GameSession::wallClockSeconds()
+{
+ return std::chrono::duration_cast<std::chrono::seconds>(
+ std::chrono::system_clock::now().time_since_epoch()).count();
+}
+
ToolResult GameSession::sendInput(const Json& arguments, Json response,
const std::string& expected_kind)
{
+ if(expireIfNeeded())
+ {
+ return errorResult("GAME_CLOSING", "game lifetime has ended");
+ }
std::unique_lock action_lock(action_mutex_);
+ if(terminal())
+ {
+ return errorResult("GAME_CLOSING", "game cleanup has begun");
+ }
+ std::string expired_reason;
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ const auto now = Clock::now();
+ if(now >= created_at_ + max_game_duration_)
+ {
+ expired_reason = "time_limit";
+ }
+ else if(now >= last_agent_activity_ + idle_timeout_)
+ {
+ expired_reason = "idle_timeout";
+ }
+ }
+ if(!expired_reason.empty())
+ {
+ action_lock.unlock();
+ markTerminal(expired_reason, "observed", "ended");
+ return errorResult("GAME_CLOSING", "game lifetime has ended");
+ }
const Json state = observations_.snapshot();
std::string error;
if(!validGameId(arguments, state, error))
@@ -584,6 +864,15 @@ ToolResult GameSession::sendInput(const Json& arguments, Json response,
"input_id does not identify the pending boundary");
}
+ std::int64_t activity_at_s = 0;
+ ToolResult refreshed = refreshAgentActivity(activity_at_s);
+ if(!refreshed.success)
+ {
+ action_lock.unlock();
+ expireIfNeeded();
+ return refreshed;
+ }
+
Json operation = {
{"operation_id", "op_" + std::to_string(++operation_counter_)},
{"state", "running"},
@@ -600,15 +889,48 @@ ToolResult GameSession::sendInput(const Json& arguments, Json response,
{"input_id", pending.value("input_id", 0ULL)},
{"response", std::move(response)},
};
- if(!process_->send(input, error))
+ bool sent = false;
+ std::string terminal_race_reason;
{
- Json failed = observations_.snapshot();
- failed["lifecycle"] = "failed";
- failed["operation"] = nullptr;
- observations_.publish(std::move(failed));
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ const auto now = Clock::now();
+ if(closing_)
+ {
+ terminal_race_reason = end_reason_.value_or("time_limit");
+ }
+ else if(now >= created_at_ + max_game_duration_)
+ {
+ terminal_race_reason = "time_limit";
+ }
+ else
+ {
+ sent = process_->send(input, error);
+ }
+ }
+ if(!terminal_race_reason.empty())
+ {
+ action_lock.unlock();
+ markTerminal(terminal_race_reason, "observed", "ended");
+ return errorResult("GAME_CLOSING", "game lifetime has ended");
+ }
+ if(!sent)
+ {
+ action_lock.unlock();
+ if(!persistAgentActivity(activity_at_s))
+ {
+ std::fprintf(stderr, "could not update activity for game %s\n",
+ game_id_.c_str());
+ }
+ markTerminal("failed", "observed", "failed");
return errorResult("ENGINE_FAILURE", error);
}
+ action_lock.unlock();
+ if(!persistAgentActivity(activity_at_s))
+ {
+ std::fprintf(stderr, "could not update activity for game %s\n",
+ game_id_.c_str());
+ }
Json result = observations_.waitForRevision(
operation_revision, std::chrono::seconds(10));
return {true, std::move(result), {}, {}};
@@ -621,6 +943,49 @@ ToolResult GameSession::errorResult(std::string code,
std::move(message)};
}
+ToolResult GameSession::refreshAgentActivity(std::int64_t& activity_at_s)
+{
+ std::string expired_reason;
+ const auto now = Clock::now();
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(closing_)
+ {
+ return errorResult("GAME_CLOSING", "game cleanup has begun");
+ }
+ if(now >= created_at_ + max_game_duration_)
+ {
+ expired_reason = "time_limit";
+ }
+ else if(now >= last_agent_activity_ + idle_timeout_)
+ {
+ expired_reason = "idle_timeout";
+ }
+ else
+ {
+ last_agent_activity_ = now;
+ activity_at_s = wallClockSeconds();
+ }
+ }
+ if(!expired_reason.empty())
+ {
+ return errorResult("GAME_CLOSING", "game lifetime has ended");
+ }
+ return {true, Json::object(), {}, {}};
+}
+
+bool GameSession::persistAgentActivity(std::int64_t activity_at_s)
+{
+ auto updated = records_->updateActivity(game_id_, activity_at_s);
+ if(!updated)
+ {
+ std::fprintf(stderr, "could not update activity for game %s: %s\n",
+ game_id_.c_str(), mw::errorMsg(updated.error()).c_str());
+ return false;
+ }
+ return true;
+}
+
void GameSession::handleWorkerMessage(const Json& message)
{
const std::string type = message.value("type", "");
@@ -641,30 +1006,84 @@ void GameSession::handleWorkerMessage(const Json& message)
if(type == "snapshot" && message.contains("snapshot"))
{
Json snapshot = message.at("snapshot");
+ std::optional<int> depth;
+ if(snapshot.contains("private_location")
+ && snapshot.at("private_location").is_object()
+ && snapshot.at("private_location").contains("depth")
+ && snapshot.at("private_location").at("depth").is_number_integer())
+ {
+ depth = snapshot.at("private_location").at("depth").get<int>();
+ }
+ snapshot.erase("private_location");
snapshot["viewer_url"] = viewer_url_;
snapshot["operation"] = nullptr;
observations_.publish(std::move(snapshot));
+ if(depth)
+ {
+ bool changed = false;
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ changed = !last_depth_ || *last_depth_ != *depth;
+ }
+ if(changed)
+ {
+ auto updated = records_->updateLocation(game_id_, *depth);
+ if(!updated)
+ {
+ std::fprintf(stderr,
+ "could not update location for game %s: %s\n",
+ game_id_.c_str(),
+ mw::errorMsg(updated.error()).c_str());
+ }
+ else
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ last_depth_ = *depth;
+ }
+ }
+ }
+ return;
+ }
+ if(type == "terminal_result")
+ {
+ const std::string reason = message.value("end_reason", "");
+ if(reason == "ascended" || reason == "escaped" || reason == "died"
+ || reason == "quit" || reason == "failed")
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(!closing_)
+ {
+ terminal_result_ = reason;
+ }
+ }
return;
}
if(type == "exiting")
{
- Json snapshot = observations_.snapshot();
- if(stop_requested_)
+ if(shutdown_requested_)
{
- snapshot["lifecycle"] = "aborted";
+ return;
+ }
+ std::string reason = "failed";
+ {
+ std::lock_guard lifecycle_lock(lifecycle_mutex_);
+ if(closing_)
+ {
+ return;
+ }
+ if(terminal_result_)
+ {
+ reason = *terminal_result_;
+ }
}
- else if(message.value("exit_code", -1) == 0
- && message.value("signal", 0) == 0)
+ if(reason == "failed")
{
- snapshot["lifecycle"] = "ended";
+ markTerminal("failed", "observed", "failed");
}
else
{
- snapshot["lifecycle"] = "failed";
+ markTerminal(reason, "observed", "ended");
}
- snapshot["pending"] = nullptr;
- snapshot["operation"] = nullptr;
- observations_.publish(std::move(snapshot));
}
}
@@ -769,13 +1188,4 @@ bool GameSession::parseKey(const Json& key, int& value, std::string& error)
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/identity.cpp b/src/identity.cpp
new file mode 100644
index 0000000..1c4b445
--- /dev/null
+++ b/src/identity.cpp
@@ -0,0 +1,172 @@
+#include "identity.hpp"
+
+#include <array>
+#include <cerrno>
+#include <chrono>
+#include <cstdint>
+#include <sys/types.h>
+#include <string>
+#include <string_view>
+#include <sys/random.h>
+
+#include <openssl/crypto.h>
+#include <openssl/evp.h>
+
+namespace nethack_mcp
+{
+
+namespace
+{
+
+constexpr char BASE64URL_ALPHABET[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+std::string encodeBase64Url(const unsigned char* bytes, std::size_t size)
+{
+ std::string encoded;
+ encoded.reserve((size * 4 + 2) / 3);
+ for(std::size_t index = 0; index < size; index += 3)
+ {
+ const std::uint32_t first = bytes[index];
+ const std::uint32_t second = index + 1 < size ? bytes[index + 1] : 0;
+ const std::uint32_t third = index + 2 < size ? bytes[index + 2] : 0;
+ const std::uint32_t block = (first << 16) | (second << 8) | third;
+ encoded.push_back(BASE64URL_ALPHABET[(block >> 18) & 0x3f]);
+ encoded.push_back(BASE64URL_ALPHABET[(block >> 12) & 0x3f]);
+ if(index + 1 < size)
+ {
+ encoded.push_back(BASE64URL_ALPHABET[(block >> 6) & 0x3f]);
+ }
+ if(index + 2 < size)
+ {
+ encoded.push_back(BASE64URL_ALPHABET[block & 0x3f]);
+ }
+ }
+ return encoded;
+}
+
+bool lowerHex(char value)
+{
+ return (value >= '0' && value <= '9')
+ || (value >= 'a' && value <= 'f');
+}
+
+} // namespace
+
+std::string makeGameId()
+{
+ std::array<unsigned char, 16> bytes{};
+ if(!secureRandom(bytes.data(), bytes.size()))
+ {
+ return {};
+ }
+
+ const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
+ std::chrono::system_clock::now().time_since_epoch()).count();
+ const std::uint64_t timestamp = static_cast<std::uint64_t>(now);
+ for(int index = 5; index >= 0; --index)
+ {
+ bytes[static_cast<std::size_t>(index)] =
+ static_cast<unsigned char>(timestamp >> (8 * (5 - index)));
+ }
+ bytes[6] = static_cast<unsigned char>((bytes[6] & 0x0f) | 0x70);
+ bytes[8] = static_cast<unsigned char>((bytes[8] & 0x3f) | 0x80);
+
+ constexpr char HEX[] = "0123456789abcdef";
+ std::string game_id;
+ game_id.reserve(36);
+ for(std::size_t index = 0; index < bytes.size(); ++index)
+ {
+ if(index == 4 || index == 6 || index == 8 || index == 10)
+ {
+ game_id.push_back('-');
+ }
+ game_id.push_back(HEX[bytes[index] >> 4]);
+ game_id.push_back(HEX[bytes[index] & 0x0f]);
+ }
+ return game_id;
+}
+
+bool validGameId(std::string_view game_id)
+{
+ if(game_id.size() != 36 || game_id[8] != '-' || game_id[13] != '-'
+ || game_id[18] != '-' || game_id[23] != '-')
+ {
+ return false;
+ }
+ for(std::size_t index = 0; index < game_id.size(); ++index)
+ {
+ if(index == 8 || index == 13 || index == 18 || index == 23)
+ {
+ continue;
+ }
+ if(!lowerHex(game_id[index]))
+ {
+ return false;
+ }
+ }
+ return game_id[14] == '7'
+ && (game_id[19] == '8' || game_id[19] == '9'
+ || game_id[19] == 'a' || game_id[19] == 'b');
+}
+
+std::string makeControlToken()
+{
+ std::array<unsigned char, 32> bytes{};
+ if(!secureRandom(bytes.data(), bytes.size()))
+ {
+ return {};
+ }
+ return encodeBase64Url(bytes.data(), bytes.size());
+}
+
+std::array<unsigned char, 32> hashControlToken(
+ const std::array<unsigned char, 32>& salt, std::string_view token)
+{
+ std::array<unsigned char, 32> digest{};
+ EVP_MD_CTX* context = EVP_MD_CTX_new();
+ if(context == nullptr)
+ {
+ return digest;
+ }
+ unsigned int digest_size = 0;
+ const bool success = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1
+ && EVP_DigestUpdate(context, salt.data(), salt.size()) == 1
+ && EVP_DigestUpdate(context, token.data(), token.size()) == 1
+ && EVP_DigestFinal_ex(context, digest.data(), &digest_size) == 1
+ && digest_size == digest.size();
+ EVP_MD_CTX_free(context);
+ if(!success)
+ {
+ digest.fill(0);
+ }
+ return digest;
+}
+
+bool secureDigestEqual(const std::array<unsigned char, 32>& left,
+ const std::array<unsigned char, 32>& right)
+{
+ return CRYPTO_memcmp(left.data(), right.data(), left.size()) == 0;
+}
+
+bool secureRandom(void* destination, std::size_t size)
+{
+ auto* output = static_cast<unsigned char*>(destination);
+ std::size_t offset = 0;
+ while(offset < size)
+ {
+ const ssize_t count = ::getrandom(output + offset, size - offset, 0);
+ if(count < 0)
+ {
+ if(errno == EINTR)
+ {
+ continue;
+ }
+ return false;
+ }
+ offset += static_cast<std::size_t>(count);
+ }
+ return true;
+}
+
+} // namespace nethack_mcp
diff --git a/src/main.cpp b/src/main.cpp
index 8352951..7ca2535 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,16 +1,21 @@
#include "engine_worker.hpp"
#include "game_http_server.hpp"
-#include "game_session.hpp"
+#include "game_manager.hpp"
#include "mcp_server.hpp"
+#include "server_config.hpp"
+#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <chrono>
#include <csignal>
#include <exception>
#include <filesystem>
+#include <limits>
#include <string>
+#include <sys/resource.h>
#include <thread>
+#include <utility>
namespace
{
@@ -22,51 +27,390 @@ void requestStop(int)
stop_requested = 1;
}
-struct Options
+enum class ParseResult
{
- std::filesystem::path data_root;
- int port = 8765;
+ SUCCESS,
+ HELP,
+ ERROR,
};
-bool parseOptions(int argc, char* argv[], Options& options)
+bool parsePositive(const std::string& text, std::size_t& value)
{
- options.data_root = std::filesystem::temp_directory_path()
+ if(text.empty() || text.front() == '-')
+ {
+ return false;
+ }
+ try
+ {
+ std::size_t consumed = 0;
+ const unsigned long long parsed = std::stoull(text, &consumed);
+ if(consumed != text.size() || parsed == 0
+ || parsed > static_cast<unsigned long long>(
+ std::numeric_limits<std::size_t>::max())
+ || parsed > static_cast<unsigned long long>(
+ std::numeric_limits<std::int64_t>::max()))
+ {
+ return false;
+ }
+ value = static_cast<std::size_t>(parsed);
+ return true;
+ }
+ catch(const std::exception&)
+ {
+ return false;
+ }
+}
+
+bool validResourceBounds(const nethack_mcp::ServerConfig& config)
+{
+ constexpr std::size_t MAX_ACTIVE_GAMES = 1024;
+ constexpr std::size_t MAX_HTTP_WORKERS = 1024;
+ constexpr std::size_t MAX_HTTP_CONNECTIONS = 1'000'000;
+ constexpr std::size_t MAX_RATE_LIMIT_CLIENTS = 1'000'000;
+ constexpr std::size_t MAX_MCP_BODY_BYTES = 64U * 1024U * 1024U;
+ constexpr std::size_t MAX_WORKER_OUTPUT_BYTES = 1024U * 1024U * 1024U;
+ constexpr std::int64_t MAX_DURATION_SECONDS = 315'360'000;
+ const auto valid_duration = [](std::chrono::seconds duration) {
+ return duration.count() > 0
+ && duration.count() <= MAX_DURATION_SECONDS;
+ };
+
+ return config.max_active_games <= MAX_ACTIVE_GAMES
+ && config.max_concurrent_requests <= MAX_HTTP_WORKERS
+ && config.max_open_connections <= MAX_HTTP_CONNECTIONS
+ && config.new_games_per_client <= 1'000'000
+ && config.control_failures_per_client <= 10'000
+ && config.max_rate_limit_clients <= MAX_RATE_LIMIT_CLIENTS
+ && config.max_viewer_long_polls <= MAX_HTTP_WORKERS
+ && config.max_mcp_body_bytes <= MAX_MCP_BODY_BYTES
+ && config.max_worker_output_bytes <= MAX_WORKER_OUTPUT_BYTES
+ && valid_duration(config.new_game_rate_window)
+ && valid_duration(config.control_failure_window)
+ && valid_duration(config.idle_timeout)
+ && valid_duration(config.max_game_duration)
+ && valid_duration(config.lifecycle_sweep_interval)
+ && valid_duration(config.state_long_poll_timeout);
+}
+
+bool parsePort(const std::string& text, int& port)
+{
+ std::size_t parsed = 0;
+ if(!parsePositive(text, parsed) || parsed > 65535)
+ {
+ return false;
+ }
+ port = static_cast<int>(parsed);
+ return true;
+}
+
+void printUsage()
+{
+ std::fputs(
+ "Usage: nethack_mcp [options]\n"
+ " --port PORT Loopback HTTP port (8765)\n"
+ " --data-root PATH Temporary game files\n"
+ " --database PATH Persistent SQLite records\n"
+ " --public-base-url URL Canonical public URL ending in /\n"
+ " --allowed-host HOST Allowed Host header (repeatable)\n"
+ " --allowed-origin ORIGIN Allowed Origin header (repeatable)\n"
+ " --trusted-proxy ADDRESS Trusted reverse proxy (repeatable)\n"
+ " --max-active-games N Active game capacity\n"
+ " --new-games-per-client N Creation quota per rate window\n"
+ " --new-game-rate-window-seconds N Creation quota window\n"
+ " --control-failures-per-client N Bad-token limit (10)\n"
+ " --control-failure-window-seconds N Bad-token window (60)\n"
+ " --max-rate-limit-clients N Tracked client cap (4096)\n"
+ " --max-concurrent-requests N HTTP request worker limit\n"
+ " --max-open-connections N Total HTTP connection limit\n"
+ " --max-viewer-long-polls N Simultaneous spectator waits\n"
+ " --idle-timeout-seconds N Idle game lifetime (600)\n"
+ " --max-game-duration-seconds N Absolute game lifetime (86400)\n"
+ " --lifecycle-sweep-seconds N Expiry sweep interval (15)\n"
+ " --state-long-poll-seconds N Viewer state wait (15)\n"
+ " --max-mcp-body-bytes N MCP request size (1048576)\n"
+ " --max-worker-output-bytes N Worker log byte limit\n"
+ " --help Show this help\n",
+ stdout);
+}
+
+ParseResult parseOptions(int argc, char* argv[],
+ nethack_mcp::ServerConfig& config)
+{
+ config.data_root = std::filesystem::temp_directory_path()
/ "nethack-mcp";
+ const char* home = std::getenv("HOME");
+ const std::filesystem::path home_path = home != nullptr && home[0] != '\0'
+ ? std::filesystem::path(home) : std::filesystem::current_path();
+ config.database_path = home_path / ".local" / "share" / "nethack-mcp"
+ / "games.sqlite3";
+
+ bool base_url_explicit = false;
+ bool hosts_explicit = false;
+ bool origins_explicit = false;
+ bool proxies_explicit = false;
+ bool active_games_explicit = false;
+ bool new_games_explicit = false;
+ bool rate_window_explicit = false;
+ bool rate_limit_clients_explicit = false;
+ bool concurrent_requests_explicit = false;
+ bool open_connections_explicit = false;
+ bool long_polls_explicit = false;
+ bool worker_output_explicit = false;
for(int index = 1; index < argc; ++index)
{
const std::string argument = argv[index];
- if(argument == "--data-root" && index + 1 < argc)
+ if(argument == "--help")
{
- options.data_root = argv[++index];
+ printUsage();
+ return ParseResult::HELP;
}
- else if(argument == "--port" && index + 1 < argc)
+ if(index + 1 >= argc)
{
- try
+ return ParseResult::ERROR;
+ }
+ const std::string value = argv[++index];
+ std::size_t number = 0;
+ if(argument == "--port")
+ {
+ if(!parsePort(value, config.port)) return ParseResult::ERROR;
+ }
+ else if(argument == "--data-root")
+ {
+ config.data_root = value;
+ }
+ else if(argument == "--database")
+ {
+ config.database_path = value;
+ }
+ else if(argument == "--public-base-url")
+ {
+ config.public_base_url = value;
+ base_url_explicit = true;
+ }
+ else if(argument == "--allowed-host")
+ {
+ if(!hosts_explicit)
{
- options.port = std::stoi(argv[++index]);
+ config.allowed_hosts.clear();
+ hosts_explicit = true;
}
- catch(const std::exception&)
+ config.allowed_hosts.push_back(value);
+ }
+ else if(argument == "--allowed-origin")
+ {
+ if(!origins_explicit)
{
- return false;
+ config.allowed_origins.clear();
+ origins_explicit = true;
}
- if(options.port < 1 || options.port > 65535)
+ config.allowed_origins.push_back(value);
+ }
+ else if(argument == "--trusted-proxy")
+ {
+ if(!proxies_explicit)
{
- return false;
+ config.trusted_proxy_addresses.clear();
+ proxies_explicit = true;
}
+ config.trusted_proxy_addresses.push_back(value);
}
- else if(argument == "--help")
+ else if(argument == "--max-active-games")
{
- std::fputs(
- "Usage: nethack_mcp [--data-root PATH] [--port PORT]\n",
- stdout);
- return false;
+ if(!parsePositive(value, config.max_active_games))
+ return ParseResult::ERROR;
+ active_games_explicit = true;
+ }
+ else if(argument == "--new-games-per-client")
+ {
+ if(!parsePositive(value, config.new_games_per_client))
+ return ParseResult::ERROR;
+ new_games_explicit = true;
+ }
+ else if(argument == "--new-game-rate-window-seconds")
+ {
+ if(!parsePositive(value, number)) return ParseResult::ERROR;
+ config.new_game_rate_window = std::chrono::seconds(number);
+ rate_window_explicit = true;
+ }
+ else if(argument == "--control-failures-per-client")
+ {
+ if(!parsePositive(value, config.control_failures_per_client))
+ return ParseResult::ERROR;
+ }
+ else if(argument == "--control-failure-window-seconds")
+ {
+ if(!parsePositive(value, number)) return ParseResult::ERROR;
+ config.control_failure_window = std::chrono::seconds(number);
+ }
+ else if(argument == "--max-rate-limit-clients")
+ {
+ if(!parsePositive(value, config.max_rate_limit_clients))
+ return ParseResult::ERROR;
+ rate_limit_clients_explicit = true;
+ }
+ else if(argument == "--max-concurrent-requests")
+ {
+ if(!parsePositive(value, config.max_concurrent_requests))
+ return ParseResult::ERROR;
+ concurrent_requests_explicit = true;
+ }
+ else if(argument == "--max-open-connections")
+ {
+ if(!parsePositive(value, config.max_open_connections))
+ return ParseResult::ERROR;
+ open_connections_explicit = true;
+ }
+ else if(argument == "--max-viewer-long-polls")
+ {
+ if(!parsePositive(value, config.max_viewer_long_polls))
+ return ParseResult::ERROR;
+ long_polls_explicit = true;
+ }
+ else if(argument == "--idle-timeout-seconds")
+ {
+ if(!parsePositive(value, number)) return ParseResult::ERROR;
+ config.idle_timeout = std::chrono::seconds(number);
+ }
+ else if(argument == "--max-game-duration-seconds")
+ {
+ if(!parsePositive(value, number)) return ParseResult::ERROR;
+ config.max_game_duration = std::chrono::seconds(number);
+ }
+ else if(argument == "--lifecycle-sweep-seconds")
+ {
+ if(!parsePositive(value, number)) return ParseResult::ERROR;
+ config.lifecycle_sweep_interval = std::chrono::seconds(number);
+ }
+ else if(argument == "--state-long-poll-seconds")
+ {
+ if(!parsePositive(value, number)) return ParseResult::ERROR;
+ config.state_long_poll_timeout = std::chrono::seconds(number);
+ }
+ else if(argument == "--max-mcp-body-bytes")
+ {
+ if(!parsePositive(value, config.max_mcp_body_bytes))
+ return ParseResult::ERROR;
+ }
+ else if(argument == "--max-worker-output-bytes")
+ {
+ if(!parsePositive(value, config.max_worker_output_bytes))
+ return ParseResult::ERROR;
+ worker_output_explicit = true;
}
else
{
- return false;
+ return ParseResult::ERROR;
}
}
- return true;
+
+ if(!base_url_explicit)
+ {
+ config.public_base_url = "http://127.0.0.1:"
+ + std::to_string(config.port) + "/";
+ }
+ if(!hosts_explicit)
+ {
+ config.allowed_hosts = {
+ "127.0.0.1:" + std::to_string(config.port),
+ "localhost:" + std::to_string(config.port),
+ };
+ }
+ if(!origins_explicit)
+ {
+ config.allowed_origins = {
+ "http://127.0.0.1:" + std::to_string(config.port),
+ "http://localhost:" + std::to_string(config.port),
+ };
+ }
+ if(config.public_base_url.empty()
+ || (config.public_base_url.rfind("https://", 0) != 0
+ && config.public_base_url.rfind("http://", 0) != 0)
+ || config.public_base_url.back() != '/')
+ {
+ return ParseResult::ERROR;
+ }
+ const std::size_t scheme_end = config.public_base_url.find("://");
+ const std::size_t path_start = config.public_base_url.find('/', scheme_end + 3);
+ if(path_start != config.public_base_url.size() - 1)
+ {
+ return ParseResult::ERROR;
+ }
+ const std::string authority = config.public_base_url.substr(
+ scheme_end + 3, path_start - (scheme_end + 3));
+ if(authority.empty()
+ || std::find(config.allowed_hosts.begin(), config.allowed_hosts.end(),
+ authority) == config.allowed_hosts.end())
+ {
+ return ParseResult::ERROR;
+ }
+ const std::string origin = config.public_base_url.substr(0, path_start);
+ if(std::find(config.allowed_origins.begin(), config.allowed_origins.end(),
+ origin) == config.allowed_origins.end())
+ {
+ return ParseResult::ERROR;
+ }
+ const std::string host_name = authority.substr(0, authority.find(':'));
+ const bool loopback = host_name == "127.0.0.1"
+ || host_name == "localhost";
+ if(!loopback && config.public_base_url.rfind("https://", 0) != 0)
+ {
+ return ParseResult::ERROR;
+ }
+ if(!loopback && !proxies_explicit)
+ {
+ return ParseResult::ERROR;
+ }
+ if(!loopback
+ && (!active_games_explicit || !new_games_explicit
+ || !rate_window_explicit || !rate_limit_clients_explicit
+ || !concurrent_requests_explicit
+ || !open_connections_explicit || !long_polls_explicit
+ || !worker_output_explicit))
+ {
+ return ParseResult::ERROR;
+ }
+ if(config.max_open_connections <= config.max_concurrent_requests
+ || config.max_viewer_long_polls > config.max_concurrent_requests
+ || !validResourceBounds(config))
+ {
+ return ParseResult::ERROR;
+ }
+ return ParseResult::SUCCESS;
+}
+
+bool configureDescriptorLimit(const nethack_mcp::ServerConfig& config)
+{
+ struct rlimit limits{};
+ if(::getrlimit(RLIMIT_NOFILE, &limits) != 0)
+ {
+ return false;
+ }
+ constexpr rlim_t RESERVED_DESCRIPTORS = 64;
+ const rlim_t maximum = std::numeric_limits<rlim_t>::max();
+ const rlim_t active_games = static_cast<rlim_t>(config.max_active_games);
+ const rlim_t open_connections =
+ static_cast<rlim_t>(config.max_open_connections);
+ if(static_cast<std::size_t>(active_games) != config.max_active_games
+ || static_cast<std::size_t>(open_connections)
+ != config.max_open_connections
+ || active_games > (maximum - RESERVED_DESCRIPTORS) / 2)
+ {
+ return false;
+ }
+ const rlim_t worker_descriptors = 2 * active_games;
+ if(open_connections > maximum - worker_descriptors
+ - RESERVED_DESCRIPTORS)
+ {
+ return false;
+ }
+ const rlim_t desired = open_connections + worker_descriptors
+ + RESERVED_DESCRIPTORS;
+ if(desired > limits.rlim_max)
+ {
+ return false;
+ }
+ limits.rlim_cur = desired;
+ return ::setrlimit(RLIMIT_NOFILE, &limits) == 0;
}
} // namespace
@@ -78,45 +422,56 @@ int main(int argc, char* argv[])
return nethack_mcp::runEngineWorker(argc, argv);
}
- Options options;
- if(!parseOptions(argc, argv, options))
+ nethack_mcp::ServerConfig config;
+ const ParseResult parsed = parseOptions(argc, argv, config);
+ if(parsed == ParseResult::HELP)
+ {
+ return 0;
+ }
+ if(parsed == ParseResult::ERROR)
+ {
+ std::fputs("invalid or incomplete command line options; use --help\n",
+ stderr);
+ return 2;
+ }
+ if(!configureDescriptorLimit(config))
{
- return argc > 1 && std::string(argv[1]) == "--help" ? 0 : 2;
+ std::fputs("could not apply the configured open-connection limit\n",
+ stderr);
+ return 1;
}
- try
+ auto manager_result = nethack_mcp::GameManager::create(
+ std::move(config), NETHACK_RUNTIME_DIR);
+ if(!manager_result)
{
- nethack_mcp::GameSession session(
- options.data_root,
- NETHACK_RUNTIME_DIR,
- "http://127.0.0.1:" + std::to_string(options.port) + "/");
- nethack_mcp::McpServer mcp(session);
- nethack_mcp::GameHttpServer http_server(session, mcp,
- options.port);
- std::string error;
- if(!http_server.startServer(error))
- {
- std::fprintf(stderr, "%s\n", error.c_str());
- return 1;
- }
- std::fprintf(stderr, "MCP: http://127.0.0.1:%d/mcp\n",
- options.port);
- std::fprintf(stderr, "Spectator: %s\n",
- session.viewerUrl().c_str());
- std::signal(SIGINT, requestStop);
- std::signal(SIGTERM, requestStop);
- while(!stop_requested && http_server.running())
- {
- std::this_thread::sleep_for(std::chrono::milliseconds(200));
- }
- const bool interrupted = stop_requested != 0;
- session.shutdown();
- http_server.stopServer();
- return interrupted ? 0 : 1;
- }
- catch(const std::exception& exception)
- {
- std::fprintf(stderr, "nethack_mcp: %s\n", exception.what());
+ std::fprintf(stderr, "could not initialize game manager: %s\n",
+ mw::errorMsg(manager_result.error()).c_str());
return 1;
}
+ std::unique_ptr<nethack_mcp::GameManager> manager =
+ std::move(*manager_result);
+ nethack_mcp::McpServer mcp(*manager);
+ nethack_mcp::GameHttpServer http_server(*manager, mcp);
+ std::string error;
+ if(!http_server.startServer(error))
+ {
+ std::fprintf(stderr, "%s\n", error.c_str());
+ manager->shutdown();
+ return 1;
+ }
+ std::fprintf(stderr, "MCP: %smcp\n",
+ manager->publicBaseUrl().c_str());
+ std::fprintf(stderr, "Spectators: %s\n",
+ manager->publicBaseUrl().c_str());
+ std::signal(SIGINT, requestStop);
+ std::signal(SIGTERM, requestStop);
+ while(!stop_requested && http_server.running())
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ }
+ const bool interrupted = stop_requested != 0;
+ http_server.stopServer();
+ manager->shutdown();
+ return interrupted ? 0 : 1;
}
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
index a02a9a7..ffc53c7 100644
--- a/src/mcp_server.cpp
+++ b/src/mcp_server.cpp
@@ -1,6 +1,7 @@
#include "mcp_server.hpp"
#include <string>
+#include <string_view>
#include <utility>
namespace nethack_mcp
@@ -9,13 +10,17 @@ namespace nethack_mcp
namespace
{
-bool supportedProtocolVersion(const std::string& version)
+bool supportedLegacyProtocolVersion(const std::string& version)
{
return version == "2025-11-25"
|| version == "2025-06-18"
|| version == "2025-03-26";
}
+constexpr std::string_view MODERN_PROTOCOL_VERSION = "2026-07-28";
+constexpr std::string_view SERVER_INFO_META_KEY =
+ "io.modelcontextprotocol/serverInfo";
+
Json objectSchema(Json properties, Json required = Json::array())
{
return {
@@ -35,18 +40,43 @@ Json gameIdProperty()
{
return {
{"type", "string"},
- {"minLength", 1},
- {"maxLength", 128},
+ {"minLength", 36},
+ {"maxLength", 36},
+ {"pattern",
+ "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-"
+ "[0-9a-f]{12}$"},
+ };
+}
+
+Json controlTokenProperty()
+{
+ return {
+ {"type", "string"},
+ {"minLength", 43},
+ {"maxLength", 43},
+ {"pattern", "^[A-Za-z0-9_-]{43}$"},
};
}
} // namespace
-McpServer::McpServer(GameSession& session)
- : session_(session)
+McpServer::McpServer(GameManager& manager)
+ : manager_(manager)
{}
-Json McpServer::handleMessage(const Json& request, bool& should_respond)
+Json McpServer::handleMessage(const Json& request, bool& should_respond,
+ const std::string& client_id,
+ bool modern_protocol)
+{
+ Json reply = handleMessageInternal(
+ request, should_respond, client_id, modern_protocol);
+ return addModernMetadata(std::move(reply), request, modern_protocol);
+}
+
+Json McpServer::handleMessageInternal(const Json& request,
+ bool& should_respond,
+ const std::string& client_id,
+ bool modern_protocol)
{
should_respond = true;
if(!request.is_object()
@@ -76,6 +106,11 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
}
if(method == "initialize")
{
+ if(modern_protocol)
+ {
+ return jsonRpcError(id, -32601,
+ "initialize is not available in this protocol");
+ }
const Json params = request.value("params", Json::object());
if(!params.is_object())
{
@@ -89,10 +124,10 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
"protocolVersion must be a string");
}
const std::string version = params.value("protocolVersion", "");
- if(!version.empty() && !supportedProtocolVersion(version))
+ if(!version.empty() && !supportedLegacyProtocolVersion(version))
{
return jsonRpcError(id, -32602,
- "unsupported MCP protocol version: " + version);
+ "unsupported MCP protocol version");
}
const std::string negotiated_version = version.empty()
? "2025-11-25" : version;
@@ -111,8 +146,28 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
}
if(method == "ping")
{
+ if(modern_protocol)
+ {
+ return jsonRpcError(id, -32601,
+ "ping is not available in this protocol");
+ }
return {{"jsonrpc", "2.0"}, {"id", id}, {"result", {}}};
}
+ if(method == "server/discover")
+ {
+ if(!modern_protocol)
+ {
+ return jsonRpcError(id, -32601, "method not found: server/discover");
+ }
+ return {
+ {"jsonrpc", "2.0"},
+ {"id", id},
+ {"result", {
+ {"supportedVersions", {std::string(MODERN_PROTOCOL_VERSION)}},
+ {"capabilities", {{"tools", Json::object()}}},
+ }},
+ };
+ }
if(method == "resources/list")
{
return {
@@ -153,15 +208,14 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
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);
+ if(name == "new_game") result = manager_.createGame(arguments, client_id);
+ else if(name == "observe" || name == "press"
+ || name == "select_menu" || name == "respond"
+ || name == "quit_game")
+ result = manager_.dispatch(name, arguments, client_id);
else
{
- return jsonRpcError(id, -32602, "unknown tool: " + name);
+ return jsonRpcError(id, -32602, "unknown tool");
}
return {
{"jsonrpc", "2.0"},
@@ -169,7 +223,38 @@ Json McpServer::handleMessage(const Json& request, bool& should_respond)
{"result", toolResult(result)},
};
}
- return jsonRpcError(id, -32601, "method not found: " + method);
+ return jsonRpcError(id, -32601, "method not found");
+}
+
+Json McpServer::addModernMetadata(Json response, const Json& request,
+ bool modern_protocol) const
+{
+ if(!modern_protocol || !response.is_object())
+ {
+ return response;
+ }
+ const std::string method = request.is_object()
+ && request.contains("method") && request.at("method").is_string()
+ ? request.at("method").get<std::string>() : std::string();
+
+ const Json server_info = {
+ {"name", "nethack-mcp"},
+ {"version", "0.1.0"},
+ };
+ if(response.contains("result") && response.at("result").is_object())
+ {
+ Json& result = response["result"];
+ result["_meta"][std::string(SERVER_INFO_META_KEY)] = server_info;
+ result["resultType"] = "complete";
+ if(method == "server/discover" || method == "tools/list"
+ || method == "resources/list"
+ || method == "resources/templates/list")
+ {
+ result["ttlMs"] = 300000;
+ result["cacheScope"] = "public";
+ }
+ }
+ return response;
}
Json McpServer::tools() const
@@ -184,46 +269,56 @@ Json McpServer::tools() const
return Json::array({
{
{"name", "new_game"},
- {"description", "Start one fresh NetHack game."},
+ {"description", "Start one fresh NetHack game and return its control token."},
{"inputSchema", objectSchema({
- {"name", {{"type", "string"}, {"maxLength", 30}}},
+ {"name", {
+ {"type", "string"}, {"minLength", 1},
+ {"maxLength", 30}, {"pattern", "^[ -~]+$"},
+ }},
+ {"model_slug", {
+ {"type", "string"}, {"minLength", 1},
+ {"maxLength", 128}, {"pattern", "^[ -~]+$"},
+ }},
{"role", stringProperty()},
{"race", stringProperty()},
{"gender", stringProperty()},
{"alignment", stringProperty()},
- })},
+ }, {"model_slug"})},
},
{
{"name", "observe"},
{"description", "Read the latest complete game observation."},
{"inputSchema", objectSchema({
{"game_id", game_id},
+ {"control_token", controlTokenProperty()},
{"detail", {{"type", "string"},
{"enum", {"compact", "full"}}}},
{"after_message_id", {{"type", "integer"}, {"minimum", 0}}},
{"wait_ms", {{"type", "integer"},
{"minimum", 0}, {"maximum", 10000}}},
- })},
+ }, {"game_id", "control_token"})},
},
{
{"name", "press"},
{"description", "Send one key to a pending key boundary."},
{"inputSchema", objectSchema({
{"game_id", game_id},
+ {"control_token", controlTokenProperty()},
{"input_id", {{"type", "integer"}, {"minimum", 1}}},
{"key", stringProperty()},
- }, {"game_id", "input_id", "key"})},
+ }, {"game_id", "control_token", "input_id", "key"})},
},
{
{"name", "select_menu"},
{"description", "Submit complete selections for a menu."},
{"inputSchema", objectSchema({
{"game_id", game_id},
+ {"control_token", controlTokenProperty()},
{"input_id", {{"type", "integer"}, {"minimum", 1}}},
{"selections", {{"type", "array"},
{"items", selection}}},
{"cancel", {{"type", "boolean"}}},
- }, {"game_id", "input_id", "selections"})},
+ }, {"game_id", "control_token", "input_id", "selections"})},
},
{
{"name", "respond"},
@@ -232,6 +327,7 @@ Json McpServer::tools() const
{"type", "object"},
{"properties", {
{"game_id", game_id},
+ {"control_token", controlTokenProperty()},
{"input_id", {{"type", "integer"}, {"minimum", 1}}},
{"text", stringProperty()},
{"choice", stringProperty()},
@@ -239,7 +335,7 @@ Json McpServer::tools() const
{"acknowledge", {{"type", "boolean"}}},
{"cancel", {{"type", "boolean"}}},
}},
- {"required", {"game_id", "input_id"}},
+ {"required", {"game_id", "control_token", "input_id"}},
{"additionalProperties", false},
{"oneOf", {
{{"required", {"text"}}},
@@ -255,7 +351,8 @@ Json McpServer::tools() const
{"description", "Stop the active worker administratively."},
{"inputSchema", objectSchema({
{"game_id", game_id},
- }, {"game_id"})},
+ {"control_token", controlTokenProperty()},
+ }, {"game_id", "control_token"})},
},
});
}
diff --git a/src/window_adapter.cpp b/src/window_adapter.cpp
index b1c93ce..5e915c2 100644
--- a/src/window_adapter.cpp
+++ b/src/window_adapter.cpp
@@ -12,13 +12,8 @@
extern "C"
{
-#include "config.h"
-#include "integer.h"
-#include "tradstdc.h"
-#include "global.h"
-#include "wintype.h"
+#include "hack.h"
#include "func_tab.h"
-#include "botl.h"
}
namespace nethack_mcp
@@ -631,10 +626,47 @@ Json WindowAdapter::makeSnapshot() const
{"messages_truncated", false},
{"inventory", inventory_},
{"pending", nullptr},
+ {"private_location", {{"depth", static_cast<int>(depth(&u.uz))}}},
};
return snapshot;
}
+void WindowAdapter::reportTerminalResult(int how)
+{
+ if(active_adapter_ == nullptr)
+ {
+ return;
+ }
+ std::string reason = "failed";
+ if(how == ASCENDED)
+ {
+ reason = "ascended";
+ }
+ else if(how == ESCAPED)
+ {
+ reason = "escaped";
+ }
+ else if(how == QUIT)
+ {
+ reason = "quit";
+ }
+ else if(how >= DIED && how < PANICKED)
+ {
+ reason = "died";
+ }
+ std::string error;
+ if(!active_adapter_->channel_.send({
+ {"type", "terminal_result"},
+ {"game_id", active_adapter_->game_id_},
+ {"native_how", how},
+ {"end_reason", reason},
+ }, error))
+ {
+ std::fprintf(stderr, "could not send terminal result: %s\n",
+ error.c_str());
+ }
+}
+
Json WindowAdapter::makePending(std::string kind, std::string source) const
{
return {
@@ -766,3 +798,8 @@ int WindowAdapter::resolveCommand(const std::string& command) const
}
} // namespace nethack_mcp
+
+extern "C" void nethack_mcp_end_result(int how)
+{
+ nethack_mcp::WindowAdapter::reportTerminalResult(how);
+}
diff --git a/web/home.css b/web/home.css
new file mode 100644
index 0000000..c4a3735
--- /dev/null
+++ b/web/home.css
@@ -0,0 +1,36 @@
+body {
+ color: #222;
+ font: 16px system-ui, sans-serif;
+ margin: 3rem auto;
+ max-width: 1100px;
+ padding: 0 1rem;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+th,
+td {
+ border-bottom: 1px solid #ccc;
+ padding: .55rem;
+ text-align: left;
+}
+
+a {
+ color: #154b37;
+}
+
+code {
+ overflow-wrap: anywhere;
+}
+
+dt {
+ font-weight: bold;
+ margin-top: 1rem;
+}
+
+dd {
+ margin: .25rem 0;
+}
diff --git a/web/home.html b/web/home.html
new file mode 100644
index 0000000..2fc24e5
--- /dev/null
+++ b/web/home.html
@@ -0,0 +1,35 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Public NetHack games</title>
+ <link rel="stylesheet" href="/home.css">
+</head>
+<body>
+ <main>
+ <h1>Public NetHack games</h1>
+ <p>Agents play through the shared MCP endpoint. Spectators can watch
+ live games and browse completed records.</p>
+ <p>MCP endpoint: <code>{{MCP_URL}}</code></p>
+ <h2>Ten most recent completed games</h2>
+ <table>
+ <thead>
+ <tr>
+ <th>Character</th>
+ <th>Model</th>
+ <th>Started</th>
+ <th>Ended</th>
+ <th>Outcome</th>
+ <th>Lowest floor</th>
+ <th>End floor</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{RECENT_GAMES}}
+ </tbody>
+ </table>
+ </main>
+ <script src="/local-time.js"></script>
+</body>
+</html>
diff --git a/web/local_time.js b/web/local_time.js
new file mode 100644
index 0000000..7a6faa3
--- /dev/null
+++ b/web/local_time.js
@@ -0,0 +1,9 @@
+for(const node of document.querySelectorAll(".local-time"))
+{
+ const seconds = Number(node.dataset.unix);
+ node.textContent = new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZoneName: "short",
+ }).format(new Date(seconds * 1000));
+}
diff --git a/web/record.html b/web/record.html
new file mode 100644
index 0000000..8c73619
--- /dev/null
+++ b/web/record.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Completed NetHack game</title>
+ <link rel="stylesheet" href="/home.css">
+</head>
+<body>
+ <main>
+ <p><a href="/">Recent games</a></p>
+ <h1>{{CHARACTER_NAME}}</h1>
+ <dl>
+ <dt>Game ID</dt><dd><code>{{GAME_ID}}</code></dd>
+ <dt>Model</dt><dd>{{MODEL_SLUG}}</dd>
+ <dt>Started</dt><dd>{{STARTED_AT}}</dd>
+ <dt>{{ENDED_TIME_LABEL}}</dt><dd>{{ENDED_AT}}</dd>
+ <dt>Outcome</dt><dd>{{END_LABEL}}</dd>
+ <dt>Lowest floor visited</dt><dd>{{DEEPEST_DEPTH}}</dd>
+ <dt>Ending floor</dt><dd>{{LAST_DEPTH}}</dd>
+ </dl>
+ </main>
+ <script src="/local-time.js"></script>
+</body>
+</html>
diff --git a/web/viewer.js b/web/viewer.js
index 190ac0d..30b669a 100644
--- a/web/viewer.js
+++ b/web/viewer.js
@@ -1,5 +1,10 @@
(() => {
let etag = "";
+ const path_match = window.location.pathname.match(
+ /^\/g\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/);
+ const game_id = path_match ? path_match[1] : "";
+ const state_url = game_id
+ ? `/api/games/${game_id}/state` : "";
const lifecycle = document.querySelector("#lifecycle");
const map = document.querySelector("#map");
const status = document.querySelector("#status");
@@ -115,12 +120,27 @@
async function poll() {
let retry_delay = 0;
+ if(!game_id)
+ {
+ lifecycle.textContent = "Invalid game URL";
+ return;
+ }
try
{
const headers = etag ? {"If-None-Match": etag} : {};
- const response = await fetch("/api/state", {
+ const response = await fetch(state_url, {
headers, cache: "no-store",
});
+ if(response.status === 410)
+ {
+ window.location.replace(`/g/${game_id}`);
+ return;
+ }
+ if(response.status === 404)
+ {
+ lifecycle.textContent = "Game not found";
+ return;
+ }
if(response.status !== 304)
{
if(!response.ok) throw new Error(`HTTP ${response.status}`);