BareGit

Embed viewer assets and complete MCP discovery handling

- Move viewer HTML, CSS, JavaScript, and font into source files.
- Generate C++ byte arrays during CMake configuration and serve them in memory.
- Support MCP resource discovery requests with empty resource lists.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 22 12:28:15 2026 -0700
Commit: 148983b2df5fd511d83ecd029abe5d199bd4c857

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index b3cda00..651d8de 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -139,6 +139,16 @@ set(NETHACK_MCP_SOURCES
     src/observation_store.cpp
     src/protocol.cpp
     src/spectator_server.cpp)
+
+set(STATIC_FILES
+    "${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
+    "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp")
+
 if(NETHACK_BUILD_ENGINE)
     list(APPEND NETHACK_MCP_SOURCES
         src/engine_worker.cpp
diff --git a/README.md b/README.md
index fac9447..28ddb91 100644
--- a/README.md
+++ b/README.md
@@ -30,4 +30,7 @@ Run the server with:
 
 The MCP server reads newline-delimited JSON-RPC from standard input. The
 browser viewer is available at `http://127.0.0.1:8765/` and serves only
-read-only state from the active session.
+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.
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
new file mode 100644
index 0000000..b930a6e
--- /dev/null
+++ b/cmake/embed_assets.cmake
@@ -0,0 +1,53 @@
+# Generate byte arrays without depending on external embedding tools.
+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")
+    string(REGEX REPLACE "([0-9A-Fa-f][0-9A-Fa-f])" "0x\\1,"
+        ASSET_BYTES "${ASSET_HEX}")
+    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}")
+    get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
+    if(ASSET_EXTENSION STREQUAL ".html")
+        set(ASSET_TYPE "text/html; charset=utf-8")
+    elseif(ASSET_EXTENSION STREQUAL ".css")
+        set(ASSET_TYPE "text/css; charset=utf-8")
+    elseif(ASSET_EXTENSION STREQUAL ".js")
+        set(ASSET_TYPE "application/javascript; charset=utf-8")
+    elseif(ASSET_EXTENSION STREQUAL ".ttf")
+        set(ASSET_TYPE "font/ttf")
+    else()
+        set(ASSET_TYPE "application/octet-stream")
+    endif()
+    if(ASSET_NAME STREQUAL "index.html")
+        set(ASSET_PATH "/")
+    elseif(ASSET_NAME STREQUAL "kreative_square.ttf")
+        set(ASSET_PATH "/viewer-font.ttf")
+    else()
+        set(ASSET_PATH "/${ASSET_NAME}")
+    endif()
+    string(APPEND ASSET_ENTRIES
+        "    {\"${ASSET_PATH}\", \"${ASSET_TYPE}\", "
+        "{reinterpret_cast<const char*>(ASSET_${ASSET_INDEX}), "
+        "${ASSET_SIZE}}},\n")
+    math(EXPR ASSET_INDEX "${ASSET_INDEX} + 1")
+endforeach()
+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)
diff --git a/include/embedded_assets.hpp b/include/embedded_assets.hpp
new file mode 100644
index 0000000..106a3d6
--- /dev/null
+++ b/include/embedded_assets.hpp
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <span>
+#include <string_view>
+
+namespace nethack_mcp
+{
+
+/// One HTTP resource stored in the executable.
+struct EmbeddedAsset
+{
+    /// Exact request path for this resource.
+    std::string_view path;
+    /// HTTP content type, including charset for text resources.
+    std::string_view content_type;
+    /// Resource bytes, which may contain null bytes.
+    std::string_view content;
+};
+
+/// Access static resources with storage lasting for the process lifetime.
+std::span<const EmbeddedAsset> embeddedAssets();
+
+} // namespace nethack_mcp
diff --git a/include/spectator_server.hpp b/include/spectator_server.hpp
index e3b5d64..f0a933f 100644
--- a/include/spectator_server.hpp
+++ b/include/spectator_server.hpp
@@ -2,6 +2,7 @@
 
 #include <atomic>
 #include <string>
+#include <string_view>
 #include <thread>
 
 #include <mw/http_server.hpp>
@@ -36,9 +37,12 @@ protected:
 
 private:
     bool validHost(const Request& request) const;
+    void serveStatic(std::string_view path, const Request& request,
+                     Response& response);
     void servePage(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);
 
diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp
index 42445c5..08bcf46 100644
--- a/src/mcp_server.cpp
+++ b/src/mcp_server.cpp
@@ -154,6 +154,22 @@ Json McpServer::dispatch(const Json& request, bool& should_respond)
     {
         return {{"jsonrpc", "2.0"}, {"id", id}, {"result", {}}};
     }
+    if(method == "resources/list")
+    {
+        return {
+            {"jsonrpc", "2.0"},
+            {"id", id},
+            {"result", {{"resources", Json::array()}}},
+        };
+    }
+    if(method == "resources/templates/list")
+    {
+        return {
+            {"jsonrpc", "2.0"},
+            {"id", id},
+            {"result", {{"resourceTemplates", Json::array()}}},
+        };
+    }
     if(method == "tools/list")
     {
         return {
diff --git a/src/spectator_server.cpp b/src/spectator_server.cpp
index 59942f3..df3cd91 100644
--- a/src/spectator_server.cpp
+++ b/src/spectator_server.cpp
@@ -1,8 +1,10 @@
 #include "spectator_server.hpp"
 
+#include "embedded_assets.hpp"
 #include "game_session.hpp"
 
 #include <string>
+#include <string_view>
 
 namespace nethack_mcp
 {
@@ -10,104 +12,18 @@ namespace nethack_mcp
 namespace
 {
 
-constexpr char VIEWER_HTML[] = R"HTML(<!doctype html>
-<html lang="en">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title>NetHack spectator</title>
-  <link rel="stylesheet" href="/viewer.css">
-</head>
-<body>
-  <main>
-    <header>
-      <h1>NetHack spectator</h1>
-      <p id="lifecycle">Connecting…</p>
-    </header>
-    <pre id="map" aria-label="NetHack map"></pre>
-    <section>
-      <h2>Status</h2>
-      <pre id="status"></pre>
-    </section>
-    <section>
-      <h2>Pending input</h2>
-      <pre id="pending">None</pre>
-    </section>
-    <section>
-      <h2>Messages</h2>
-      <ol id="messages"></ol>
-    </section>
-  </main>
-  <script src="/viewer.js"></script>
-</body>
-</html>
-)HTML";
-
-constexpr char VIEWER_SCRIPT[] = R"JS((() => {
-  let etag = "";
-  const lifecycle = document.querySelector("#lifecycle");
-  const map = document.querySelector("#map");
-  const status = document.querySelector("#status");
-  const pending = document.querySelector("#pending");
-  const messages = document.querySelector("#messages");
-
-  function show(value) {
-    lifecycle.textContent = value.lifecycle || "unknown";
-    map.textContent = (value.map && value.map.rows || []).join("\n");
-    status.textContent = JSON.stringify(value.status || {}, null, 2);
-    pending.textContent = value.pending
-      ? JSON.stringify(value.pending, null, 2) : "None";
-    messages.replaceChildren();
-    for (const message of value.messages || []) {
-      const item = document.createElement("li");
-      item.textContent = message.text || "";
-      messages.append(item);
-    }
-  }
-
-  async function poll() {
-    try {
-      const headers = etag ? {"If-None-Match": etag} : {};
-      const response = await fetch("/api/state", {headers, cache: "no-store"});
-      if (response.status === 304) return;
-      if (!response.ok) throw new Error(`HTTP ${response.status}`);
-      etag = response.headers.get("ETag") || "";
-      show(await response.json());
-    } catch (error) {
-      lifecycle.textContent = `Disconnected: ${error.message}`;
+const EmbeddedAsset* findAsset(std::string_view path)
+{
+    for(const auto& asset : embeddedAssets())
+    {
+        if(asset.path == path)
+        {
+            return &asset;
+        }
     }
-  }
-
-  poll();
-  setInterval(poll, 250);
-})();
-)JS";
-
-constexpr char VIEWER_STYLE[] = R"CSS(:root {
-  color-scheme: dark;
-  font-family: system-ui, sans-serif;
-  background: #171717;
-  color: #eeeeee;
+    return nullptr;
 }
 
-body { margin: 0; }
-main { max-width: 1000px; margin: 0 auto; padding: 1rem; }
-h1, h2 { font-weight: 600; }
-h1 { margin-bottom: 0.25rem; }
-h2 { font-size: 1rem; margin-bottom: 0.35rem; }
-pre { overflow: auto; }
-#map {
-  border: 1px solid #555;
-  padding: 0.75rem;
-  line-height: 1.1;
-  font: 16px/1.1 monospace;
-  white-space: pre;
-  min-height: 21em;
-}
-section { margin-top: 1rem; }
-#messages { max-height: 16rem; overflow: auto; }
-)CSS";
-
 } // namespace
 
 SpectatorServer::SpectatorServer(GameSession& session, int port)
@@ -160,6 +76,10 @@ void SpectatorServer::setup()
                                        Response& response) {
         serveStyle(request, response);
     });
+    server.Get("/viewer-font.ttf", [this](const Request& request,
+                                             Response& response) {
+        serveFont(request, response);
+    });
     server.Get("/api/state", [this](const Request& request,
                                       Response& response) {
         serveState(request, response);
@@ -178,36 +98,44 @@ bool SpectatorServer::validHost(const Request& request) const
         host == "localhost:" + std::to_string(port_);
 }
 
-void SpectatorServer::servePage(const Request& request, Response& response)
+void SpectatorServer::serveStatic(std::string_view path,
+                                  const Request& request,
+                                  Response& response)
 {
     if(!validHost(request))
     {
         response.status = 403;
         return;
     }
-    response.set_content(VIEWER_HTML, "text/html; charset=utf-8");
+    const EmbeddedAsset* asset = findAsset(path);
+    if(asset == nullptr)
+    {
+        response.status = 404;
+        return;
+    }
+    response.set_content(asset->content.data(), asset->content.size(),
+                         std::string(asset->content_type));
+}
+
+void SpectatorServer::servePage(const Request& request, Response& response)
+{
+    serveStatic("/", request, response);
 }
 
 void SpectatorServer::serveScript(const Request& request,
                                   Response& response)
 {
-    if(!validHost(request))
-    {
-        response.status = 403;
-        return;
-    }
-    response.set_content(VIEWER_SCRIPT,
-                         "application/javascript; charset=utf-8");
+    serveStatic("/viewer.js", request, response);
 }
 
 void SpectatorServer::serveStyle(const Request& request, Response& response)
 {
-    if(!validHost(request))
-    {
-        response.status = 403;
-        return;
-    }
-    response.set_content(VIEWER_STYLE, "text/css; charset=utf-8");
+    serveStatic("/viewer.css", request, response);
+}
+
+void SpectatorServer::serveFont(const Request& request, Response& response)
+{
+    serveStatic("/viewer-font.ttf", request, response);
 }
 
 void SpectatorServer::serveState(const Request& request, Response& response)
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..4cb00d2
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>NetHack spectator</title>
+  <link rel="stylesheet" href="/viewer.css">
+</head>
+<body>
+  <main>
+    <header>
+      <h1>NetHack spectator</h1>
+      <p id="lifecycle">Connecting…</p>
+    </header>
+    <pre id="map" aria-label="NetHack map"></pre>
+    <section>
+      <h2>Status</h2>
+      <pre id="status"></pre>
+    </section>
+    <section>
+      <h2>Pending input</h2>
+      <pre id="pending">None</pre>
+    </section>
+    <section>
+      <h2>Messages</h2>
+      <ol id="messages"></ol>
+    </section>
+  </main>
+  <script src="/viewer.js"></script>
+</body>
+</html>
diff --git a/web/kreative_square.ttf b/web/kreative_square.ttf
new file mode 100644
index 0000000..41b5286
Binary files /dev/null and b/web/kreative_square.ttf differ
diff --git a/web/kreative_square_ofl.txt b/web/kreative_square_ofl.txt
new file mode 100644
index 0000000..a694297
--- /dev/null
+++ b/web/kreative_square_ofl.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2017-2020 Kreative Software.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web/viewer.css b/web/viewer.css
new file mode 100644
index 0000000..9fc6e91
--- /dev/null
+++ b/web/viewer.css
@@ -0,0 +1,32 @@
+@font-face {
+  font-family: "Kreative Square";
+  src: url("/viewer-font.ttf") format("truetype");
+  font-display: block;
+}
+
+:root {
+  color-scheme: dark;
+  font-family: system-ui, sans-serif;
+  background: #171717;
+  color: #eeeeee;
+}
+
+body { margin: 0; }
+main { width: 100vh; margin: 0 auto; padding: 1rem; }
+h1, h2 { font-weight: 600; }
+h1 { margin-bottom: 0.25rem; }
+h2 { font-size: 1rem; margin-bottom: 0.35rem; }
+pre { overflow: auto; }
+#map {
+  border: 1px solid #555;
+  padding: 0.75rem;
+  font-family: "Kreative Square", monospace;
+  font-size: 16px;
+  line-height: 1;
+  font-variant-ligatures: none;
+  white-space: pre;
+  height: 21em;
+  width: 79em;
+}
+section { margin-top: 1rem; }
+#messages { max-height: 16rem; overflow: auto; }
diff --git a/web/viewer.js b/web/viewer.js
new file mode 100644
index 0000000..ccd8453
--- /dev/null
+++ b/web/viewer.js
@@ -0,0 +1,38 @@
+(() => {
+  let etag = "";
+  const lifecycle = document.querySelector("#lifecycle");
+  const map = document.querySelector("#map");
+  const status = document.querySelector("#status");
+  const pending = document.querySelector("#pending");
+  const messages = document.querySelector("#messages");
+
+  function show(value) {
+    lifecycle.textContent = value.lifecycle || "unknown";
+    map.textContent = (value.map && value.map.rows || []).join("\n");
+    status.textContent = JSON.stringify(value.status || {}, null, 2);
+    pending.textContent = value.pending
+      ? JSON.stringify(value.pending, null, 2) : "None";
+    messages.replaceChildren();
+    for (const message of value.messages || []) {
+      const item = document.createElement("li");
+      item.textContent = message.text || "";
+      messages.append(item);
+    }
+  }
+
+  async function poll() {
+    try {
+      const headers = etag ? {"If-None-Match": etag} : {};
+      const response = await fetch("/api/state", {headers, cache: "no-store"});
+      if (response.status === 304) return;
+      if (!response.ok) throw new Error(`HTTP ${response.status}`);
+      etag = response.headers.get("ETag") || "";
+      show(await response.json());
+    } catch (error) {
+      lifecycle.textContent = `Disconnected: ${error.message}`;
+    }
+  }
+
+  poll();
+  setInterval(poll, 250);
+})();