BareGit

Add Arch package and TOML server configuration

Load listener, path, and resource settings from TOML with CLI overrides.
Package the NetHack runtime assets with a sysusers account and systemd unit.
Author: MetroWind <chris.corsair@gmail.com>
Date: Wed Sep 23 14:15:49 2026 -0700
Commit: 48aec3e2d3134f6ef8da4f997645cf2fc8317815

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 58dd6d4..0c84c71 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,12 +33,18 @@ FetchContent_Declare(
     GIT_REPOSITORY https://github.com/MetroWind/libmw.git
     GIT_TAG 00f857d93fda0f0eb84bb8ab540b58063f1c91b2
     GIT_SHALLOW FALSE)
+FetchContent_Declare(
+    tomlplusplus
+    GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
+    GIT_TAG v3.4.0
+    GIT_SHALLOW FALSE)
 
 set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
 set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
 FetchContent_MakeAvailable(libmw)
+FetchContent_MakeAvailable(tomlplusplus)
 find_package(Threads REQUIRED)
 find_package(OpenSSL REQUIRED COMPONENTS Crypto)
 
@@ -134,6 +140,7 @@ else()
 endif()
 
 set(NETHACK_MCP_SOURCES
+    src/config_file.cpp
     src/engine_process.cpp
     src/game_manager.cpp
     src/game_record_store.cpp
@@ -187,6 +194,7 @@ target_link_libraries(nethack_mcp PRIVATE
     mw::sqlite
     OpenSSL::Crypto
     nlohmann_json::nlohmann_json
+    tomlplusplus::tomlplusplus
     Threads::Threads)
 if(NETHACK_BUILD_ENGINE)
     target_include_directories(nethack_mcp PRIVATE
diff --git a/README.md b/README.md
index 5d1c331..3767dbb 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,13 @@ at `http://127.0.0.1:8765/`. Game records are stored by default at
 directory. Stop the server with Ctrl-C. A server restart ends all active
 games and records them as interrupted.
 
+Startup settings can be loaded from a TOML file with `--config PATH`. The
+configuration is read first; command-line options override it. The Arch
+package ships a sample configuration at
+`packages/arch/nethack-mcp.toml`. Set `[server].listen_address` to choose the
+network address; it defaults to `127.0.0.1`. The `--listen-address` option
+overrides the config file.
+
 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
@@ -46,11 +53,13 @@ viewer returns to the home page. Completed games have no individual page.
 
 ## 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.
+Run the application on loopback behind a TLS reverse proxy. Set the public
+base URL in the application configuration and configure the proxy to accept
+the intended hostname. The server uses the peer address of each connection;
+behind a reverse proxy, per-client limits are therefore shared by clients
+connecting through that proxy. 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):
@@ -61,9 +70,6 @@ that fit the host):
   --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 \
@@ -77,8 +83,7 @@ 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.
+connection limits, and request limits.
 
 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
diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example
index c568ce6..076a016 100644
--- a/deploy/nginx.conf.example
+++ b/deploy/nginx.conf.example
@@ -38,9 +38,6 @@ http
 
             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-0-mcp.md b/designs/design-0-mcp.md
index 16f6dda..33aecb8 100644
--- a/designs/design-0-mcp.md
+++ b/designs/design-0-mcp.md
@@ -454,15 +454,16 @@ tab. Back off when hidden or disconnected. Use revision-based ETags and
 revalidation. Never implement gameplay POST routes or an HTTP MCP endpoint
 in this version.
 
-Bind to `127.0.0.1`, with a configurable port; default 8765. If occupied,
+Bind to `127.0.0.1` by default, with a configurable address and port; default
+port 8765. If occupied,
 fail promptly with a useful error or allow an explicitly requested port
 0 to choose a free port. Report the bound URL on stderr and in tool
 results. Do not automatically launch a browser.
 
 Render game text using DOM text nodes or `textContent`, never `innerHTML`.
-Do not enable cross-origin access; validate the Host header against the
-bound loopback address. These measures keep game strings as text and the
-local endpoint scoped to the intended viewer.
+Do not enable cross-origin access. For public deployment, the reverse proxy
+routes the intended public host to the configured listener address. The
+application does not inspect Host or Origin headers.
 
 The inspected libmw `HTTPServer::start()` spins until its listener is
 running, which may hang on bind failure. During implementation, use an
diff --git a/designs/design-1-multigame.md b/designs/design-1-multigame.md
index 8cab565..7a716c4 100644
--- a/designs/design-1-multigame.md
+++ b/designs/design-1-multigame.md
@@ -39,8 +39,8 @@ secrets.
 `main.cpp` currently creates one `GameSession` and one `McpServer`. A
 `GameSession` owns one `EngineProcess`, one `ObservationStore`, and the locks
 that serialize its inputs. `GameHttpServer` serves a single `/api/state`
-endpoint and accepts only localhost `Host` values. `new_game` already returns
-a `game_id`, but `makeGameId()` builds it from a clock and counter. The
+endpoint on a loopback listener. `new_game` already returns a `game_id`, but
+`makeGameId()` builds it from a clock and counter. The
 `observe` tool currently accepts calls without a game ID. These behaviors
 must change before public multi-game use.
 
@@ -376,12 +376,13 @@ concurrent games. Section 11 examines the worker cost.
 
 ## 9. Public deployment and resource bounds
 
-Run the C++ listener on loopback behind a TLS reverse proxy. Configure the
-public base URL and an explicit allowed Host and Origin list. Preserve the
-current validation against hostile origins, adapted for the public host;
-do not accept any Host value by default. The reverse proxy should forward
-the original host and scheme in a controlled way. Never trust an arbitrary
-client-supplied forwarded header when constructing viewer URLs.
+The listener defaults to loopback and its address is configurable through
+`[server].listen_address`. For public deployment, run it on loopback behind a
+TLS reverse proxy. Configure the public base URL in the application. Let the
+reverse proxy enforce its public Host and Origin policy; the application does
+not inspect those headers. The application uses the socket peer address and
+ignores forwarded address headers. When all requests pass through one proxy,
+per-client rate limits therefore apply to the proxy connection address.
 
 Expose all operating limits as startup configuration. Keep the following
 defaults where behavior already exists or a value has been chosen here;
@@ -457,8 +458,8 @@ spectators do not reset idle time; an authorized agent observation does;
 both the idle and 24-hour limits wake waiting calls; a call near the
 absolute deadline cannot extend it; concurrent timeout and worker exit
 finalize once; a restart
-marks orphaned rows interrupted; runtime directories are removed; public
-Host and Origin rules reject unexpected values. Load-test increasing game
+marks orphaned rows interrupted; runtime directories are removed; the
+reverse proxy rejects unexpected public hosts. Load-test increasing game
 and viewer counts on the actual host, recording task count, PSS or cgroup
 memory, tmpfs use, request latency, and failure rates before setting a
 production active-game limit.
diff --git a/include/config_file.hpp b/include/config_file.hpp
new file mode 100644
index 0000000..a0aa20f
--- /dev/null
+++ b/include/config_file.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include "server_config.hpp"
+
+#include <filesystem>
+#include <string>
+
+namespace nethack_mcp
+{
+
+/// Records settings whose explicit values are required for public mode.
+struct ConfigExplicitSettings
+{
+    /// Whether the canonical public URL was specified.
+    bool public_base_url = false;
+    /// Whether the active game limit was specified.
+    bool max_active_games = false;
+    /// Whether the game creation quota was specified.
+    bool new_games_per_client = false;
+    /// Whether the game creation quota window was specified.
+    bool new_game_rate_window = false;
+    /// Whether the client tracking capacity was specified.
+    bool max_rate_limit_clients = false;
+    /// Whether the HTTP worker capacity was specified.
+    bool max_concurrent_requests = false;
+    /// Whether the HTTP connection capacity was specified.
+    bool max_open_connections = false;
+    /// Whether the worker output limit was specified.
+    bool max_worker_output_bytes = false;
+};
+
+/// Loads TOML settings into the existing server configuration.
+bool readConfigFile(const std::filesystem::path& path,
+                    ServerConfig& config,
+                    ConfigExplicitSettings& explicit_settings,
+                    std::string& error);
+
+} // namespace nethack_mcp
diff --git a/include/game_http_server.hpp b/include/game_http_server.hpp
index b31490a..a4ece85 100644
--- a/include/game_http_server.hpp
+++ b/include/game_http_server.hpp
@@ -22,7 +22,7 @@ struct GameRecord;
 class GameHttpServer : public mw::HTTPServer
 {
 public:
-    /// Construct the loopback listener from the manager's public policy.
+    /// Construct the HTTP listener from the manager's server configuration.
     GameHttpServer(GameManager& manager, McpServer& mcp);
 
     /// Stop the listener before destroying the server.
@@ -45,8 +45,6 @@ protected:
     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);
diff --git a/include/server_config.hpp b/include/server_config.hpp
index 8edb16c..bf98e0f 100644
--- a/include/server_config.hpp
+++ b/include/server_config.hpp
@@ -4,28 +4,25 @@
 #include <cstddef>
 #include <filesystem>
 #include <string>
-#include <vector>
 
 namespace nethack_mcp
 {
 
-/// Startup-only limits and public URL policy for the service process.
+/// Startup-only listener settings, limits, and public URL policy.
 struct ServerConfig
 {
-    /// Loopback listener port.
+    /// Network address used by the HTTP listener.
+    std::string listen_address = "127.0.0.1";
+    /// HTTP 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;
+    /// Directory containing the NetHack runtime data files.
+    std::filesystem::path runtime_directory;
     /// 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.
diff --git a/packages/arch/PKGBUILD b/packages/arch/PKGBUILD
new file mode 100644
index 0000000..d795a4b
--- /dev/null
+++ b/packages/arch/PKGBUILD
@@ -0,0 +1,62 @@
+pkgname=nethack-mcp
+pkgver=0.1.0.r1.g0000000
+pkgrel=1
+pkgdesc='Multi-game NetHack server with an MCP interface'
+arch=('x86_64')
+url='https://git.xeno.darksair.org/nethack-mcp.git'
+license=('custom:NetHack' 'MIT')
+depends=('curl' 'lua' 'openssl' 'sqlite' 'systemd')
+makedepends=('cmake' 'git' 'lua')
+backup=('etc/nethack-mcp.toml')
+source=(
+    'nethack-mcp::git+https://git.xeno.darksair.org/nethack-mcp.git'
+    'nethack-mcp.service'
+    'nethack-mcp.sysusers'
+    'nethack-mcp.toml'
+)
+sha256sums=(
+    'SKIP'
+    'a9ef1632e3afffa4785cbe0de2cf8cb9478ca67629eb41bca52b2fd15b129ab8'
+    '1c1622cbd9842aa7a466abed401de52b273b0f20eb8ac590b67d6fea7c15c2fd'
+    '813c726c9b3026fe32e0f8f9edd952c90d1fcf2d507b52c15636454e9f40b4e8'
+)
+
+pkgver() {
+    cd "$srcdir/nethack-mcp"
+    printf '0.1.0.r%s.g%s' \
+        "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
+}
+
+build() {
+    cd "$srcdir"
+    cmake -S nethack-mcp -B build \
+        -DCMAKE_BUILD_TYPE=Release \
+        -DCMAKE_INSTALL_PREFIX=/usr \
+        -DNETHACK_BUILD_ENGINE=ON \
+        -Wno-dev
+    cmake --build build -j24
+}
+
+package() {
+    install -Dm755 "$srcdir/build/nethack_mcp" \
+        "$pkgdir/usr/bin/nethack_mcp"
+
+    local runtime_dir="$srcdir/build/nethack-work/playground"
+    install -d "$pkgdir/usr/share/nethack-mcp/runtime"
+    for data_file in nhdat symbols license sysconf; do
+        install -Dm644 "$runtime_dir/$data_file" \
+            "$pkgdir/usr/share/nethack-mcp/runtime/$data_file"
+    done
+
+    install -Dm644 "$srcdir/nethack-mcp.toml" \
+        "$pkgdir/etc/nethack-mcp.toml"
+    install -Dm644 "$srcdir/nethack-mcp.sysusers" \
+        "$pkgdir/usr/lib/sysusers.d/nethack-mcp.conf"
+    install -Dm644 "$srcdir/nethack-mcp.service" \
+        "$pkgdir/usr/lib/systemd/system/nethack-mcp.service"
+
+    install -Dm644 "$runtime_dir/license" \
+        "$pkgdir/usr/share/licenses/nethack-mcp/NetHack"
+    install -Dm644 "$srcdir/build/_deps/tomlplusplus-src/LICENSE" \
+        "$pkgdir/usr/share/licenses/nethack-mcp/MIT"
+}
diff --git a/packages/arch/README.md b/packages/arch/README.md
new file mode 100644
index 0000000..303f9e5
--- /dev/null
+++ b/packages/arch/README.md
@@ -0,0 +1,25 @@
+# Arch Linux package
+
+Build and install the package from this directory with:
+
+```sh
+makepkg -si
+```
+
+The package builds the pinned NetHack engine and installs its runtime files
+with the server. It does not depend on a separately installed NetHack game.
+It installs `/etc/nethack-mcp.toml`, creates the `nethack-mcp` service user
+through `sysusers.d`, and provides a systemd unit.
+
+Review the configuration and start the service with:
+
+```sh
+sudoedit /etc/nethack-mcp.toml
+sudo systemctl enable --now nethack-mcp.service
+```
+
+The defaults listen on loopback. To publish the service through an HTTPS
+reverse proxy, update the public URL and capacity settings in the TOML file.
+Configure Apache to accept the intended hostname. Requests from proxied
+clients share the proxy's peer address for per-client limits. Keep the
+configured data directory and database path writable by the service account.
diff --git a/packages/arch/nethack-mcp.service b/packages/arch/nethack-mcp.service
new file mode 100644
index 0000000..24f8f55
--- /dev/null
+++ b/packages/arch/nethack-mcp.service
@@ -0,0 +1,31 @@
+[Unit]
+Description=NetHack MCP server
+After=network.target
+
+[Service]
+Type=simple
+User=nethack-mcp
+Group=nethack-mcp
+StateDirectory=nethack-mcp
+StateDirectoryMode=0750
+RuntimeDirectory=nethack-mcp
+RuntimeDirectoryMode=0750
+WorkingDirectory=/var/lib/nethack-mcp
+ExecStart=/usr/bin/nethack_mcp --config /etc/nethack-mcp.toml
+Restart=on-failure
+RestartSec=3
+UMask=0077
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+LockPersonality=true
+RestrictSUIDSGID=true
+RestrictRealtime=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/packages/arch/nethack-mcp.sysusers b/packages/arch/nethack-mcp.sysusers
new file mode 100644
index 0000000..37e2835
--- /dev/null
+++ b/packages/arch/nethack-mcp.sysusers
@@ -0,0 +1,2 @@
+g nethack-mcp -
+u nethack-mcp - "NetHack MCP service" /var/lib/nethack-mcp /usr/bin/nologin
diff --git a/packages/arch/nethack-mcp.toml b/packages/arch/nethack-mcp.toml
new file mode 100644
index 0000000..e190144
--- /dev/null
+++ b/packages/arch/nethack-mcp.toml
@@ -0,0 +1,24 @@
+[server]
+listen_address = "127.0.0.1"
+port = 8765
+public_base_url = "http://127.0.0.1:8765/"
+
+[paths]
+data_root = "/run/nethack-mcp/games"
+database = "/var/lib/nethack-mcp/games.sqlite3"
+runtime_dir = "/usr/share/nethack-mcp/runtime"
+
+[limits]
+max_active_games = 8
+new_games_per_client = 8
+new_game_rate_window_seconds = 3600
+control_failures_per_client = 10
+control_failure_window_seconds = 60
+max_rate_limit_clients = 4096
+max_concurrent_requests = 64
+max_open_connections = 128
+idle_timeout_seconds = 600
+max_game_duration_seconds = 86400
+lifecycle_sweep_seconds = 15
+max_mcp_body_bytes = 1048576
+max_worker_output_bytes = 1048576
diff --git a/src/config_file.cpp b/src/config_file.cpp
new file mode 100644
index 0000000..94a3aa3
--- /dev/null
+++ b/src/config_file.cpp
@@ -0,0 +1,295 @@
+#include "config_file.hpp"
+
+#include <toml++/toml.hpp>
+
+#include <chrono>
+#include <cstdint>
+#include <initializer_list>
+#include <limits>
+#include <string_view>
+
+namespace nethack_mcp
+{
+namespace
+{
+
+bool fail(const std::filesystem::path& path, const std::string& message,
+          std::string& error)
+{
+    error = path.string() + ": " + message;
+    return false;
+}
+
+bool validateKeys(const toml::table& table,
+                  std::initializer_list<std::string_view> allowed,
+                  const std::string& section,
+                  const std::filesystem::path& path,
+                  std::string& error)
+{
+    std::size_t matched = 0;
+    for(const std::string_view key : allowed)
+    {
+        if(table.get(key) != nullptr)
+        {
+            ++matched;
+        }
+    }
+    if(matched != table.size())
+    {
+        return fail(path, "unknown setting in [" + section + "]", error);
+    }
+    return true;
+}
+
+bool readSection(const toml::table& root, std::string_view name,
+                 const toml::table*& section,
+                 const std::filesystem::path& path,
+                 std::string& error)
+{
+    const toml::node* node = root.get(name);
+    if(node == nullptr)
+    {
+        section = nullptr;
+        return true;
+    }
+    section = node->as_table();
+    if(section == nullptr)
+    {
+        return fail(path, "[" + std::string(name) + "] must be a table",
+                    error);
+    }
+    return true;
+}
+
+bool readString(const toml::table& table, std::string_view key,
+                const std::string& section, std::string& value,
+                const std::filesystem::path& path, std::string& error)
+{
+    const toml::node* node = table.get(key);
+    if(node == nullptr)
+    {
+        return true;
+    }
+    const auto parsed = node->value<std::string>();
+    if(!parsed || parsed->empty())
+    {
+        return fail(path, "[" + section + "]." + std::string(key)
+            + " must be a non-empty string", error);
+    }
+    value = *parsed;
+    return true;
+}
+
+bool readPath(const toml::table& table, std::string_view key,
+              const std::string& section, std::filesystem::path& value,
+              const std::filesystem::path& path, std::string& error)
+{
+    std::string parsed;
+    if(!readString(table, key, section, parsed, path, error))
+    {
+        return false;
+    }
+    if(table.get(key) != nullptr)
+    {
+        value = parsed;
+    }
+    return true;
+}
+
+bool readPositiveInteger(const toml::table& table, std::string_view key,
+                         const std::string& section, std::size_t& value,
+                         const std::filesystem::path& path,
+                         std::string& error)
+{
+    const toml::node* node = table.get(key);
+    if(node == nullptr)
+    {
+        return true;
+    }
+    const auto parsed = node->value<std::int64_t>();
+    if(!parsed || *parsed <= 0
+       || static_cast<std::uint64_t>(*parsed)
+           > static_cast<std::uint64_t>(
+               std::numeric_limits<std::size_t>::max()))
+    {
+        return fail(path, "[" + section + "]." + std::string(key)
+            + " must be a positive integer", error);
+    }
+    value = static_cast<std::size_t>(*parsed);
+    return true;
+}
+
+bool readPort(const toml::table& table, std::string_view key,
+              int& value, const std::filesystem::path& path,
+              std::string& error)
+{
+    const toml::node* node = table.get(key);
+    if(node == nullptr)
+    {
+        return true;
+    }
+    const auto parsed = node->value<std::int64_t>();
+    if(!parsed || *parsed <= 0 || *parsed > 65535)
+    {
+        return fail(path, "[server].port must be an integer from 1 to 65535",
+                    error);
+    }
+    value = static_cast<int>(*parsed);
+    return true;
+}
+
+bool readSeconds(const toml::table& table, std::string_view key,
+                 const std::string& section, std::chrono::seconds& value,
+                 const std::filesystem::path& path, std::string& error)
+{
+    std::size_t parsed = 0;
+    if(!readPositiveInteger(table, key, section, parsed, path, error))
+    {
+        return false;
+    }
+    if(table.get(key) != nullptr)
+    {
+        value = std::chrono::seconds(parsed);
+    }
+    return true;
+}
+
+bool readServerSection(const toml::table& table, ServerConfig& config,
+                       ConfigExplicitSettings& explicit_settings,
+                       const std::filesystem::path& path, std::string& error)
+{
+    if(!validateKeys(table, {
+           "listen_address", "port", "public_base_url",
+       }, "server", path, error)
+       || !readString(table, "listen_address", "server",
+                      config.listen_address, path, error)
+       || !readPort(table, "port", config.port, path, error)
+       || !readString(table, "public_base_url", "server",
+                      config.public_base_url, path, error))
+    {
+        return false;
+    }
+    explicit_settings.public_base_url =
+        table.get("public_base_url") != nullptr;
+    return true;
+}
+
+bool readPathsSection(const toml::table& table, ServerConfig& config,
+                      const std::filesystem::path& path, std::string& error)
+{
+    return validateKeys(table, {
+               "data_root", "database", "runtime_dir",
+           }, "paths", path, error)
+        && readPath(table, "data_root", "paths", config.data_root,
+                    path, error)
+        && readPath(table, "database", "paths", config.database_path,
+                    path, error)
+        && readPath(table, "runtime_dir", "paths", config.runtime_directory,
+                    path, error);
+}
+
+bool readLimitsSection(const toml::table& table, ServerConfig& config,
+                       ConfigExplicitSettings& explicit_settings,
+                       const std::filesystem::path& path, std::string& error)
+{
+    if(!validateKeys(table, {
+           "max_active_games", "new_games_per_client",
+           "new_game_rate_window_seconds", "control_failures_per_client",
+           "control_failure_window_seconds", "max_rate_limit_clients",
+           "max_concurrent_requests", "max_open_connections",
+           "idle_timeout_seconds", "max_game_duration_seconds",
+           "lifecycle_sweep_seconds", "max_mcp_body_bytes",
+           "max_worker_output_bytes",
+       }, "limits", path, error)
+       || !readPositiveInteger(table, "max_active_games", "limits",
+                               config.max_active_games, path, error)
+       || !readPositiveInteger(table, "new_games_per_client", "limits",
+                               config.new_games_per_client, path, error)
+       || !readSeconds(table, "new_game_rate_window_seconds", "limits",
+                       config.new_game_rate_window, path, error)
+       || !readPositiveInteger(table, "control_failures_per_client", "limits",
+                               config.control_failures_per_client, path, error)
+       || !readSeconds(table, "control_failure_window_seconds", "limits",
+                       config.control_failure_window, path, error)
+       || !readPositiveInteger(table, "max_rate_limit_clients", "limits",
+                               config.max_rate_limit_clients, path, error)
+       || !readPositiveInteger(table, "max_concurrent_requests", "limits",
+                               config.max_concurrent_requests, path, error)
+       || !readPositiveInteger(table, "max_open_connections", "limits",
+                               config.max_open_connections, path, error)
+       || !readSeconds(table, "idle_timeout_seconds", "limits",
+                       config.idle_timeout, path, error)
+       || !readSeconds(table, "max_game_duration_seconds", "limits",
+                       config.max_game_duration, path, error)
+       || !readSeconds(table, "lifecycle_sweep_seconds", "limits",
+                       config.lifecycle_sweep_interval, path, error)
+       || !readPositiveInteger(table, "max_mcp_body_bytes", "limits",
+                               config.max_mcp_body_bytes, path, error)
+       || !readPositiveInteger(table, "max_worker_output_bytes", "limits",
+                               config.max_worker_output_bytes, path, error))
+    {
+        return false;
+    }
+    explicit_settings.max_active_games =
+        table.get("max_active_games") != nullptr;
+    explicit_settings.new_games_per_client =
+        table.get("new_games_per_client") != nullptr;
+    explicit_settings.new_game_rate_window =
+        table.get("new_game_rate_window_seconds") != nullptr;
+    explicit_settings.max_rate_limit_clients =
+        table.get("max_rate_limit_clients") != nullptr;
+    explicit_settings.max_concurrent_requests =
+        table.get("max_concurrent_requests") != nullptr;
+    explicit_settings.max_open_connections =
+        table.get("max_open_connections") != nullptr;
+    explicit_settings.max_worker_output_bytes =
+        table.get("max_worker_output_bytes") != nullptr;
+    return true;
+}
+
+} // namespace
+
+bool readConfigFile(const std::filesystem::path& path,
+                    ServerConfig& config,
+                    ConfigExplicitSettings& explicit_settings,
+                    std::string& error)
+{
+    toml::table root;
+    try
+    {
+        root = toml::parse_file(path.string());
+    }
+    catch(const std::exception& exception)
+    {
+        return fail(path, exception.what(), error);
+    }
+
+    if(!validateKeys(root, {"server", "paths", "limits"}, "root",
+                     path, error))
+    {
+        return false;
+    }
+
+    const toml::table* server = nullptr;
+    const toml::table* paths = nullptr;
+    const toml::table* limits = nullptr;
+    if(!readSection(root, "server", server, path, error)
+       || !readSection(root, "paths", paths, path, error)
+       || !readSection(root, "limits", limits, path, error))
+    {
+        return false;
+    }
+    if(server != nullptr
+       && !readServerSection(*server, config, explicit_settings, path, error))
+    {
+        return false;
+    }
+    if(paths != nullptr && !readPathsSection(*paths, config, path, error))
+    {
+        return false;
+    }
+    return limits == nullptr
+        || readLimitsSection(*limits, config, explicit_settings, path, error);
+}
+
+} // namespace nethack_mcp
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index 4325f0d..033ec11 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -7,7 +7,6 @@
 #include "identity.hpp"
 #include "mcp_server.hpp"
 
-#include <algorithm>
 #include <chrono>
 #include <cstdint>
 #include <filesystem>
@@ -197,7 +196,8 @@ std::string depthText(std::optional<int> depth)
 } // namespace
 
 GameHttpServer::GameHttpServer(GameManager& manager, McpServer& mcp)
-        : mw::HTTPServer(mw::IPSocketInfo{"127.0.0.1", manager.config().port}),
+        : mw::HTTPServer(mw::IPSocketInfo{
+              manager.config().listen_address, manager.config().port}),
           manager_(manager), mcp_(mcp), config_(manager.config())
 {}
 
@@ -209,9 +209,10 @@ GameHttpServer::~GameHttpServer()
 bool GameHttpServer::startServer(std::string& error)
 {
     setup();
-    if(!server.bind_to_port("127.0.0.1", config_.port))
+    if(!server.bind_to_port(config_.listen_address, config_.port))
     {
-        error = "could not bind HTTP server to 127.0.0.1:"
+        error = "could not bind HTTP server to " + config_.listen_address
+            + ":"
             + std::to_string(config_.port);
         return false;
     }
@@ -249,7 +250,6 @@ void GameHttpServer::setup()
             - 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);
@@ -303,26 +303,6 @@ void GameHttpServer::setup()
     });
 }
 
-bool GameHttpServer::validHost(const Request& request) const
-{
-    const std::string host = request.get_header_value("Host");
-    return std::find(config_.allowed_hosts.begin(),
-                     config_.allowed_hosts.end(), host)
-        != config_.allowed_hosts.end();
-}
-
-bool GameHttpServer::validOrigin(const Request& request) const
-{
-    if(!request.has_header("Origin"))
-    {
-        return true;
-    }
-    const std::string origin = request.get_header_value("Origin");
-    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;
@@ -340,11 +320,6 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
         return;
     }
     response.set_header("Cache-Control", "no-store");
-    if(!validHost(request) || !validOrigin(request))
-    {
-        response.status = 403;
-        return;
-    }
     const std::string content_type = request.get_header_value("Content-Type");
     if(content_type != "application/json"
        && content_type.rfind("application/json;", 0) != 0)
@@ -471,7 +446,8 @@ void GameHttpServer::serveMcp(const Request& request, Response& response)
     response.set_content(reply.dump(), "application/json; charset=utf-8");
 }
 
-void GameHttpServer::rejectMcpStream(const Request& request,
+void GameHttpServer::rejectMcpStream(
+                                      [[maybe_unused]] const Request& request,
                                       Response& response)
 {
     CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
@@ -481,18 +457,13 @@ void GameHttpServer::rejectMcpStream(const Request& request,
         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;
 }
 
 void GameHttpServer::serveStatic(std::string_view path,
-                                  const Request& request,
+                                  [[maybe_unused]] const Request& request,
                                   Response& response)
 {
     CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
@@ -502,11 +473,6 @@ void GameHttpServer::serveStatic(std::string_view path,
         rejectRequest(response);
         return;
     }
-    if(!validHost(request) || !validOrigin(request))
-    {
-        response.status = 403;
-        return;
-    }
     const EmbeddedAsset* asset = findAsset(path);
     if(asset == nullptr)
     {
@@ -518,7 +484,8 @@ void GameHttpServer::serveStatic(std::string_view path,
                          std::string(asset->content_type));
 }
 
-void GameHttpServer::servePage(const Request& request, Response& response)
+void GameHttpServer::servePage([[maybe_unused]] const Request& request,
+                               Response& response)
 {
     CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
                              &request_count_, &request_latency_total_us_);
@@ -527,11 +494,6 @@ void GameHttpServer::servePage(const Request& request, Response& response)
         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");
 }
@@ -546,11 +508,6 @@ void GameHttpServer::serveGamePage(const Request& request,
         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))
     {
@@ -612,11 +569,6 @@ void GameHttpServer::serveState(const Request& request, Response& response)
         return;
     }
     response.set_header("Cache-Control", "no-store");
-    if(!validHost(request) || !validOrigin(request))
-    {
-        response.status = 403;
-        return;
-    }
     constexpr std::string_view PREFIX = "/api/games/";
     constexpr std::string_view SUFFIX = "/state";
     if(request.path.size() <= PREFIX.size() + SUFFIX.size())
@@ -667,7 +619,8 @@ void GameHttpServer::serveState(const Request& request, Response& response)
     response.set_content(state.dump(), "application/json; charset=utf-8");
 }
 
-void GameHttpServer::serveHealth(const Request& request, Response& response)
+void GameHttpServer::serveHealth([[maybe_unused]] const Request& request,
+                                 Response& response)
 {
     CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
                              &request_count_, &request_latency_total_us_);
@@ -676,11 +629,6 @@ void GameHttpServer::serveHealth(const Request& request, Response& response)
         rejectRequest(response);
         return;
     }
-    if(!validHost(request) || !validOrigin(request))
-    {
-        response.status = 403;
-        return;
-    }
     response.set_header("Cache-Control", "no-store");
     response.set_content(Json({
         {"ready", true},
@@ -688,7 +636,8 @@ void GameHttpServer::serveHealth(const Request& request, Response& response)
     }).dump(), "application/json; charset=utf-8");
 }
 
-void GameHttpServer::serveMetrics(const Request& request, Response& response)
+void GameHttpServer::serveMetrics([[maybe_unused]] const Request& request,
+                                  Response& response)
 {
     CounterSlot request_slot(active_requests_, config_.max_concurrent_requests,
                              &request_count_, &request_latency_total_us_);
@@ -697,11 +646,6 @@ void GameHttpServer::serveMetrics(const Request& request, Response& response)
         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();
diff --git a/src/main.cpp b/src/main.cpp
index a15b049..4ab99ba 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,10 +1,10 @@
+#include "config_file.hpp"
 #include "engine_worker.hpp"
 #include "game_http_server.hpp"
 #include "game_manager.hpp"
 #include "mcp_server.hpp"
 #include "server_config.hpp"
 
-#include <algorithm>
 #include <cstdio>
 #include <cstdlib>
 #include <chrono>
@@ -12,6 +12,7 @@
 #include <exception>
 #include <filesystem>
 #include <limits>
+#include <optional>
 #include <string>
 #include <sys/resource.h>
 #include <thread>
@@ -105,13 +106,13 @@ void printUsage()
 {
     std::fputs(
         "Usage: nethack_mcp [options]\n"
-        "  --port PORT                         Loopback HTTP port (8765)\n"
+        "  --config PATH                       Read TOML configuration\n"
+        "  --listen-address ADDRESS            HTTP bind address (127.0.0.1)\n"
+        "  --port PORT                         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"
+        "  --runtime-dir PATH                  NetHack runtime data files\n"
+        "  --public-base-url URL               Canonical URL ending in /\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"
@@ -130,7 +131,8 @@ void printUsage()
 }
 
 ParseResult parseOptions(int argc, char* argv[],
-                         nethack_mcp::ServerConfig& config)
+                         nethack_mcp::ServerConfig& config,
+                         std::string& error)
 {
     config.data_root = std::filesystem::temp_directory_path()
         / "nethack-mcp";
@@ -139,33 +141,59 @@ ParseResult parseOptions(int argc, char* argv[],
         ? std::filesystem::path(home) : std::filesystem::current_path();
     config.database_path = home_path / ".local" / "share" / "nethack-mcp"
         / "games.sqlite3";
+    config.runtime_directory = NETHACK_RUNTIME_DIR;
 
-    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 worker_output_explicit = false;
     for(int index = 1; index < argc; ++index)
     {
-        const std::string argument = argv[index];
-        if(argument == "--help")
+        if(std::string(argv[index]) == "--help")
         {
             printUsage();
             return ParseResult::HELP;
         }
+    }
+
+    std::optional<std::filesystem::path> config_path;
+    for(int index = 1; index < argc; ++index)
+    {
+        if(std::string(argv[index]) == "--config")
+        {
+            if(config_path.has_value() || index + 1 >= argc)
+            {
+                error = "--config requires exactly one file path";
+                return ParseResult::ERROR;
+            }
+            config_path = argv[++index];
+        }
+    }
+
+    nethack_mcp::ConfigExplicitSettings explicit_settings;
+    if(config_path.has_value()
+       && !nethack_mcp::readConfigFile(*config_path, config,
+                                      explicit_settings, error))
+    {
+        return ParseResult::ERROR;
+    }
+
+    for(int index = 1; index < argc; ++index)
+    {
+        const std::string argument = argv[index];
+        if(argument == "--config")
+        {
+            ++index;
+            continue;
+        }
         if(index + 1 >= argc)
         {
             return ParseResult::ERROR;
         }
         const std::string value = argv[++index];
         std::size_t number = 0;
-        if(argument == "--port")
+        if(argument == "--listen-address")
+        {
+            if(value.empty()) return ParseResult::ERROR;
+            config.listen_address = value;
+        }
+        else if(argument == "--port")
         {
             if(!parsePort(value, config.port)) return ParseResult::ERROR;
         }
@@ -177,55 +205,32 @@ ParseResult parseOptions(int argc, char* argv[],
         {
             config.database_path = value;
         }
-        else if(argument == "--public-base-url")
-        {
-            config.public_base_url = value;
-            base_url_explicit = true;
-        }
-        else if(argument == "--allowed-host")
+        else if(argument == "--runtime-dir")
         {
-            if(!hosts_explicit)
-            {
-                config.allowed_hosts.clear();
-                hosts_explicit = true;
-            }
-            config.allowed_hosts.push_back(value);
+            config.runtime_directory = value;
         }
-        else if(argument == "--allowed-origin")
-        {
-            if(!origins_explicit)
-            {
-                config.allowed_origins.clear();
-                origins_explicit = true;
-            }
-            config.allowed_origins.push_back(value);
-        }
-        else if(argument == "--trusted-proxy")
+        else if(argument == "--public-base-url")
         {
-            if(!proxies_explicit)
-            {
-                config.trusted_proxy_addresses.clear();
-                proxies_explicit = true;
-            }
-            config.trusted_proxy_addresses.push_back(value);
+            config.public_base_url = value;
+            explicit_settings.public_base_url = true;
         }
         else if(argument == "--max-active-games")
         {
             if(!parsePositive(value, config.max_active_games))
                 return ParseResult::ERROR;
-            active_games_explicit = true;
+            explicit_settings.max_active_games = true;
         }
         else if(argument == "--new-games-per-client")
         {
             if(!parsePositive(value, config.new_games_per_client))
                 return ParseResult::ERROR;
-            new_games_explicit = true;
+            explicit_settings.new_games_per_client = 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;
+            explicit_settings.new_game_rate_window = true;
         }
         else if(argument == "--control-failures-per-client")
         {
@@ -241,19 +246,19 @@ ParseResult parseOptions(int argc, char* argv[],
         {
             if(!parsePositive(value, config.max_rate_limit_clients))
                 return ParseResult::ERROR;
-            rate_limit_clients_explicit = true;
+            explicit_settings.max_rate_limit_clients = true;
         }
         else if(argument == "--max-concurrent-requests")
         {
             if(!parsePositive(value, config.max_concurrent_requests))
                 return ParseResult::ERROR;
-            concurrent_requests_explicit = true;
+            explicit_settings.max_concurrent_requests = true;
         }
         else if(argument == "--max-open-connections")
         {
             if(!parsePositive(value, config.max_open_connections))
                 return ParseResult::ERROR;
-            open_connections_explicit = true;
+            explicit_settings.max_open_connections = true;
         }
         else if(argument == "--idle-timeout-seconds")
         {
@@ -279,7 +284,7 @@ ParseResult parseOptions(int argc, char* argv[],
         {
             if(!parsePositive(value, config.max_worker_output_bytes))
                 return ParseResult::ERROR;
-            worker_output_explicit = true;
+            explicit_settings.max_worker_output_bytes = true;
         }
         else
         {
@@ -287,25 +292,11 @@ ParseResult parseOptions(int argc, char* argv[],
         }
     }
 
-    if(!base_url_explicit)
+    if(!explicit_settings.public_base_url)
     {
         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)
@@ -314,22 +305,15 @@ ParseResult parseOptions(int argc, char* argv[],
         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);
+    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())
+    if(authority.empty())
     {
         return ParseResult::ERROR;
     }
@@ -340,16 +324,14 @@ ParseResult parseOptions(int argc, char* argv[],
     {
         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
-           || !worker_output_explicit))
+       && (!explicit_settings.max_active_games
+           || !explicit_settings.new_games_per_client
+           || !explicit_settings.new_game_rate_window
+           || !explicit_settings.max_rate_limit_clients
+           || !explicit_settings.max_concurrent_requests
+           || !explicit_settings.max_open_connections
+           || !explicit_settings.max_worker_output_bytes))
     {
         return ParseResult::ERROR;
     }
@@ -406,15 +388,19 @@ int main(int argc, char* argv[])
     }
 
     nethack_mcp::ServerConfig config;
-    const ParseResult parsed = parseOptions(argc, argv, config);
+    std::string parse_error;
+    const ParseResult parsed = parseOptions(argc, argv, config, parse_error);
     if(parsed == ParseResult::HELP)
     {
         return 0;
     }
     if(parsed == ParseResult::ERROR)
     {
-        std::fputs("invalid or incomplete command line options; use --help\n",
-                   stderr);
+        if(parse_error.empty())
+        {
+            parse_error = "invalid or incomplete command line options";
+        }
+        std::fprintf(stderr, "%s; use --help\n", parse_error.c_str());
         return 2;
     }
     if(!configureDescriptorLimit(config))
@@ -424,8 +410,9 @@ int main(int argc, char* argv[])
         return 1;
     }
 
+    const std::filesystem::path runtime_source = config.runtime_directory;
     auto manager_result = nethack_mcp::GameManager::create(
-        std::move(config), NETHACK_RUNTIME_DIR);
+        std::move(config), runtime_source);
     if(!manager_result)
     {
         std::fprintf(stderr, "could not initialize game manager: %s\n",