Files
whetstone_DSL/editor/src/MCPHttpServer.h
Bill af6fa169b7 Sprint 291: Add HTTP/SSE daemon mode to whetstone_mcp (Steps 1983-1987)
Eliminates the dual-process bug where multiple Claude Code sessions each
spawn their own whetstone_mcp stdio process with split in-memory state.
All sessions now share a single persistent daemon via MCP HTTP+SSE transport.

Changes:
- MCPHttpServer.h: HTTP+SSE transport (cpp-httplib via FetchContent).
  GET /sse streams events per session, POST /message routes JSON-RPC,
  GET /health for status. Thread-safe session map with mutex/cv/queue.
- MCPBridge.h: adds runHttp(port, workspace) method
- mcp_main.cpp: --daemon/--port flags, daemon.pid lockfile, duplicate guard
- CMakeLists.txt: FetchContent for cpp-httplib, step1983-1987 targets
- tools/start-whetstone-daemon.sh: idempotent startup, health-check poll
- 5/5 tests passing (step1987_test)

Note: Documents/.mcp.json switched to SSE transport in working directory
(not tracked in this repo). Daemon must be started before Claude Code sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 11:18:11 -06:00

281 lines
9.9 KiB
C++

#pragma once
// Step 1983: MCPHttpServer — HTTP+SSE transport for MCP daemon mode.
//
// Implements MCP 2024-11-05 HTTP+SSE transport so whetstone_mcp can run as
// a persistent shared daemon instead of a per-session stdio process:
//
// GET /sse — open SSE stream; sends endpoint event
// POST /message?sessionId=<id> — receive JSON-RPC; push response to SSE
// GET /health — status JSON
//
// Multiple Claude Code sessions all connect to the same daemon and share
// a single in-memory HeadlessEditorState (recording, workspace, AST, etc.).
//
// Usage:
// MCPHttpServer srv;
// srv.setRequestHandler([&state](const json& req) {
// return state.processAgentRequest(req, "mcp-session");
// });
// srv.setWorkspace("/path/to/workspace");
// srv.listen(7600); // blocking
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <memory>
#include <mutex>
#include <queue>
#include <random>
#include <sstream>
#include <string>
#include <unordered_map>
#include <nlohmann/json.hpp>
#include <httplib.h>
using json = nlohmann::json;
class MCPHttpServer {
public:
using RpcHandler = std::function<json(const json&)>;
void setRequestHandler(RpcHandler h) { handler_ = std::move(h); }
void setWorkspace(const std::string& root) { workspaceRoot_ = root; }
// Start HTTP server on given port (blocking). Returns when stopped.
void listen(int port) {
port_ = port;
_writePidFile();
httplib::Server svr;
svr_ = &svr;
svr.Get("/sse", [this](const httplib::Request& req, httplib::Response& res) {
_sseHandler(req, res);
});
svr.Post("/message", [this](const httplib::Request& req, httplib::Response& res) {
_messageHandler(req, res);
});
svr.Get("/health", [this](const httplib::Request& req, httplib::Response& res) {
_healthHandler(req, res);
});
std::cerr << "[whetstone-mcp] HTTP+SSE daemon on port " << port << "\n";
svr.listen("0.0.0.0", port);
_removePidFile();
svr_ = nullptr;
}
void stop() {
if (svr_) svr_->stop();
std::lock_guard<std::mutex> lk(sessionsMtx_);
for (auto& [id, sess] : sessions_) {
std::lock_guard<std::mutex> slk(sess->mtx);
sess->closed = true;
sess->cv.notify_all();
}
sessions_.clear();
}
// Generate a unique session ID (hex, 128-bit). Also used by tests.
std::string newSessionId() {
std::uniform_int_distribution<uint64_t> dist;
std::ostringstream oss;
oss << std::hex << dist(rng_) << dist(rng_);
return oss.str();
}
private:
// -----------------------------------------------------------------------
// Per-connection session state
// -----------------------------------------------------------------------
struct Session {
std::string id;
std::mutex mtx;
std::queue<std::string> pending; // outbound JSON-RPC response strings
std::condition_variable cv;
bool closed = false;
bool endpointSent = false; // did we send the "endpoint" event yet?
};
// -----------------------------------------------------------------------
// GET /sse
// -----------------------------------------------------------------------
void _sseHandler(const httplib::Request& /*req*/, httplib::Response& res) {
auto sess = std::make_shared<Session>();
sess->id = newSessionId();
{
std::lock_guard<std::mutex> lk(sessionsMtx_);
sessions_[sess->id] = sess;
}
// The endpoint URL the client will POST messages to
std::string endpointEvent =
"event: endpoint\ndata: /message?sessionId=" + sess->id + "\n\n";
res.set_header("Cache-Control", "no-cache");
res.set_header("X-Accel-Buffering","no");
// Chunked SSE stream — provider is called in a loop until it returns false
res.set_chunked_content_provider(
"text/event-stream",
// Content provider: called repeatedly by httplib's send loop
[sess, endpointEvent](size_t /*offset*/, httplib::DataSink& sink) -> bool {
// First call: send the endpoint event
{
std::unique_lock<std::mutex> lk(sess->mtx);
if (!sess->endpointSent) {
sess->endpointSent = true;
lk.unlock();
return sink.write(endpointEvent.data(), endpointEvent.size());
}
}
// Subsequent calls: wait for outbound messages or keepalive timeout
std::unique_lock<std::mutex> lk(sess->mtx);
bool got = sess->cv.wait_for(lk, std::chrono::seconds(25),
[&sess]{ return !sess->pending.empty() || sess->closed; });
if (sess->closed) return false;
if (!got) {
// Timeout — send SSE comment as keepalive to prevent proxy timeouts
static const std::string kKeepalive = ": keepalive\n\n";
lk.unlock();
return sink.write(kKeepalive.data(), kKeepalive.size());
}
// Drain the pending queue
while (!sess->pending.empty()) {
std::string frame = "event: message\ndata: " +
sess->pending.front() + "\n\n";
sess->pending.pop();
lk.unlock();
if (!sink.write(frame.data(), frame.size())) return false;
lk.lock();
}
return true;
},
// Releaser: remove session when the SSE connection closes
[this, sess](bool /*success*/) {
std::lock_guard<std::mutex> lk(sessionsMtx_);
sessions_.erase(sess->id);
}
);
}
// -----------------------------------------------------------------------
// POST /message?sessionId=<id>
// -----------------------------------------------------------------------
void _messageHandler(const httplib::Request& req, httplib::Response& res) {
std::string sessionId = req.get_param_value("sessionId");
if (sessionId.empty()) {
res.status = 400;
res.set_content("{\"error\":\"missing sessionId\"}", "application/json");
return;
}
std::shared_ptr<Session> sess;
{
std::lock_guard<std::mutex> lk(sessionsMtx_);
auto it = sessions_.find(sessionId);
if (it == sessions_.end()) {
res.status = 404;
res.set_content("{\"error\":\"session not found\"}", "application/json");
return;
}
sess = it->second;
}
// Parse and dispatch JSON-RPC request
json response;
try {
json request = json::parse(req.body);
if (handler_) {
response = handler_(request);
} else {
response = {
{"jsonrpc","2.0"},
{"id", request.value("id", json(nullptr))},
{"error", {{"code",-32603},{"message","No handler registered"}}}
};
}
} catch (const json::exception& /*e*/) {
response = {
{"jsonrpc","2.0"}, {"id", json(nullptr)},
{"error", {{"code",-32700},{"message","Parse error"}}}
};
}
// Push response onto the session's SSE queue
if (!response.is_null()) {
std::lock_guard<std::mutex> lk(sess->mtx);
sess->pending.push(response.dump());
sess->cv.notify_one();
}
// 202 Accepted — actual response delivered via SSE
res.status = 202;
res.set_content("", "application/json");
}
// -----------------------------------------------------------------------
// GET /health
// -----------------------------------------------------------------------
void _healthHandler(const httplib::Request& /*req*/, httplib::Response& res) {
size_t sessionCount;
{
std::lock_guard<std::mutex> lk(sessionsMtx_);
sessionCount = sessions_.size();
}
json body = {
{"status", "ok"},
{"sessions", (int)sessionCount},
{"port", port_}
};
res.set_content(body.dump(), "application/json");
}
// -----------------------------------------------------------------------
// PID file — written on listen(), removed on exit
// -----------------------------------------------------------------------
void _writePidFile() {
if (workspaceRoot_.empty()) return;
std::filesystem::path dir =
std::filesystem::path(workspaceRoot_) / ".whetstone";
std::error_code ec;
std::filesystem::create_directories(dir, ec);
std::ofstream f(dir / "daemon.pid");
if (f) f << ::getpid();
}
void _removePidFile() {
if (workspaceRoot_.empty()) return;
std::filesystem::path p =
std::filesystem::path(workspaceRoot_) / ".whetstone" / "daemon.pid";
std::error_code ec;
std::filesystem::remove(p, ec);
}
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
RpcHandler handler_;
std::string workspaceRoot_;
int port_ = 7600;
httplib::Server* svr_ = nullptr;
std::mt19937_64 rng_{std::random_device{}()};
std::mutex sessionsMtx_;
std::unordered_map<std::string, std::shared_ptr<Session>> sessions_;
};