diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt
index ba837f7..6941e6e 100644
--- a/editor/CMakeLists.txt
+++ b/editor/CMakeLists.txt
@@ -1370,4 +1370,25 @@ target_link_libraries(step245_test PRIVATE
tree_sitter_go
tree_sitter_org)
+# Step 246: Standalone MCP server (no SDL/ImGui)
+add_executable(whetstone_mcp src/mcp_main.cpp)
+target_include_directories(whetstone_mcp PRIVATE src)
+target_link_libraries(whetstone_mcp PRIVATE
+ nlohmann_json::nlohmann_json
+ unofficial::tree-sitter::tree-sitter
+ tree_sitter_python tree_sitter_cpp tree_sitter_elisp
+ tree_sitter_javascript tree_sitter_typescript
+ tree_sitter_java tree_sitter_rust tree_sitter_go
+ tree_sitter_org)
+
+add_executable(step246_test tests/step246_test.cpp)
+target_include_directories(step246_test PRIVATE src)
+target_link_libraries(step246_test PRIVATE
+ nlohmann_json::nlohmann_json
+ unofficial::tree-sitter::tree-sitter
+ tree_sitter_python tree_sitter_cpp tree_sitter_elisp
+ tree_sitter_javascript tree_sitter_typescript
+ tree_sitter_java tree_sitter_rust tree_sitter_go
+ tree_sitter_org)
+
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)
diff --git a/editor/src/mcp_main.cpp b/editor/src/mcp_main.cpp
new file mode 100644
index 0000000..465eb7a
--- /dev/null
+++ b/editor/src/mcp_main.cpp
@@ -0,0 +1,152 @@
+// Step 246: Standalone MCP server entry point.
+//
+// Wires HeadlessEditorState (Step 245) + MCPBridge (Step 212) into a
+// standalone `whetstone_mcp` binary that any MCP client can launch
+// over stdio. No SDL, no ImGui, no OpenGL.
+//
+// Usage: whetstone_mcp [--workspace
] [--language ] [--verbose]
+
+#include "HeadlessEditorState.h"
+#include "MCPBridge.h"
+#include "ast/Serialization.h"
+
+#include
+#include
+#include
+#include
+
+// -----------------------------------------------------------------------
+// CLI helpers (same pattern as pipeline_main.cpp)
+// -----------------------------------------------------------------------
+
+static std::string getArg(int argc, char** argv, const std::string& key) {
+ for (int i = 1; i < argc - 1; ++i) {
+ if (argv[i] == key) return argv[i + 1];
+ }
+ return "";
+}
+
+static bool hasFlag(int argc, char** argv, const std::string& key) {
+ for (int i = 1; i < argc; ++i) {
+ if (argv[i] == key) return true;
+ }
+ return false;
+}
+
+// -----------------------------------------------------------------------
+// Signal handling (same pattern as orchestrator_main.cpp)
+// -----------------------------------------------------------------------
+
+static volatile sig_atomic_t g_running = 1;
+
+static void signalHandler(int /*sig*/) {
+ g_running = 0;
+ exit(0);
+}
+
+// -----------------------------------------------------------------------
+// Resource reader — routes whetstone:// URIs to headless state
+// -----------------------------------------------------------------------
+
+static json readResource(HeadlessEditorState& state,
+ const std::string& uri) {
+ if (uri == "whetstone://ast") {
+ Module* ast = state.activeAST();
+ if (ast) return toJson(ast);
+ return {{"error", "No active AST"}};
+ }
+ if (uri == "whetstone://diagnostics") {
+ return state.buildDiagnosticsJson();
+ }
+ if (uri == "whetstone://libraries") {
+ // Return available functions from primitives registry
+ state.library.primitives.setRoot(state.activeAST());
+ state.library.primitives.setLanguage(state.defaultLanguage);
+ auto funcs = state.library.primitives.getAvailableFunctions();
+ json libs = json::array();
+ for (const auto& p : funcs) {
+ libs.push_back(json{{"name", p.name}, {"kind", p.kind},
+ {"source", p.source}});
+ }
+ return libs;
+ }
+ if (uri == "whetstone://annotations") {
+ Module* ast = state.activeAST();
+ if (!ast) return json::array();
+ json annotations = json::array();
+ for (auto* child : ast->allChildren()) {
+ if (isAnnotationNode(child)) {
+ annotations.push_back(toJson(child));
+ }
+ }
+ return annotations;
+ }
+ if (uri == "whetstone://settings") {
+ return {
+ {"workspace", state.workspaceRoot},
+ {"language", state.defaultLanguage},
+ {"verbose", state.verbose},
+ {"bufferCount", (int)state.bufferStates.size()},
+ {"mode", "headless-mcp"}
+ };
+ }
+ return {{"error", "Unknown resource: " + uri}};
+}
+
+// -----------------------------------------------------------------------
+// main
+// -----------------------------------------------------------------------
+
+int main(int argc, char** argv) {
+ // Parse CLI args
+ std::string workspace = getArg(argc, argv, "--workspace");
+ std::string language = getArg(argc, argv, "--language");
+ bool verbose = hasFlag(argc, argv, "--verbose");
+
+ if (hasFlag(argc, argv, "--help") || hasFlag(argc, argv, "-h")) {
+ std::cerr << "Usage: whetstone_mcp [--workspace ] "
+ "[--language ] [--verbose]\n";
+ return 0;
+ }
+
+ // Signal handling
+ signal(SIGINT, signalHandler);
+ signal(SIGTERM, signalHandler);
+
+ // Create headless state
+ HeadlessEditorState state;
+ state.verbose = verbose;
+ if (!workspace.empty()) state.workspaceRoot = workspace;
+ if (!language.empty()) state.defaultLanguage = language;
+
+ // Create an empty default buffer so getAST etc. work immediately
+ state.openBuffer("(scratch)", "", state.defaultLanguage);
+
+ // Set default agent role to Refactor for full MCP access
+ state.setAgentRole("mcp-session", AgentRole::Refactor);
+
+ // Create bridge and wire handlers
+ MCPBridge bridge;
+
+ bridge.setRequestHandler([&state](const json& request) -> json {
+ return state.processAgentRequest(request, "mcp-session");
+ });
+
+ bridge.setResourceReader([&state](const std::string& uri) -> json {
+ return readResource(state, uri);
+ });
+
+ // Log startup info to stderr (stdout is the MCP transport)
+ std::cerr << "[whetstone-mcp] Starting MCP server v0.1.0\n";
+ if (!workspace.empty())
+ std::cerr << "[whetstone-mcp] Workspace: " << workspace << "\n";
+ std::cerr << "[whetstone-mcp] Language: " << state.defaultLanguage << "\n";
+ if (verbose)
+ std::cerr << "[whetstone-mcp] Verbose mode enabled\n";
+ std::cerr << "[whetstone-mcp] Listening on stdio...\n";
+
+ // Run the MCP stdio transport loop
+ bridge.runStdio();
+
+ return 0;
+}
diff --git a/editor/tests/step246_test.cpp b/editor/tests/step246_test.cpp
new file mode 100644
index 0000000..455c28d
--- /dev/null
+++ b/editor/tests/step246_test.cpp
@@ -0,0 +1,337 @@
+// Step 246 TDD Test: mcp_main.cpp — Standalone MCP Server Entry Point
+//
+// Tests the MCP server wiring end-to-end (in-process, not subprocess):
+// HeadlessEditorState + MCPBridge connected via the same lambdas as mcp_main.
+#include "HeadlessEditorState.h"
+#include "MCPBridge.h"
+#include "ast/Serialization.h"
+
+#include
+#include
+#include
+
+static void expect(bool cond, const std::string& name,
+ int& passed, int& failed) {
+ if (cond) {
+ std::cout << "Test " << (passed + failed + 1)
+ << " PASS: " << name << "\n";
+ ++passed;
+ } else {
+ std::cout << "Test " << (passed + failed + 1)
+ << " FAIL: " << name << "\n";
+ ++failed;
+ }
+}
+
+// -----------------------------------------------------------------------
+// Resource reader (same as mcp_main.cpp)
+// -----------------------------------------------------------------------
+static json readResource(HeadlessEditorState& state,
+ const std::string& uri) {
+ if (uri == "whetstone://ast") {
+ Module* ast = state.activeAST();
+ if (ast) return toJson(ast);
+ return {{"error", "No active AST"}};
+ }
+ if (uri == "whetstone://diagnostics") {
+ return state.buildDiagnosticsJson();
+ }
+ if (uri == "whetstone://libraries") {
+ state.library.primitives.setRoot(state.activeAST());
+ state.library.primitives.setLanguage(state.defaultLanguage);
+ auto funcs = state.library.primitives.getAvailableFunctions();
+ json libs = json::array();
+ for (const auto& p : funcs) {
+ libs.push_back(json{{"name", p.name}, {"kind", p.kind},
+ {"source", p.source}});
+ }
+ return libs;
+ }
+ if (uri == "whetstone://annotations") {
+ Module* ast = state.activeAST();
+ if (!ast) return json::array();
+ json annotations = json::array();
+ for (auto* child : ast->allChildren()) {
+ if (isAnnotationNode(child)) {
+ annotations.push_back(toJson(child));
+ }
+ }
+ return annotations;
+ }
+ if (uri == "whetstone://settings") {
+ return {
+ {"workspace", state.workspaceRoot},
+ {"language", state.defaultLanguage},
+ {"verbose", state.verbose},
+ {"bufferCount", (int)state.bufferStates.size()},
+ {"mode", "headless-mcp"}
+ };
+ }
+ return {{"error", "Unknown resource: " + uri}};
+}
+
+// -----------------------------------------------------------------------
+// CLI arg helpers (same signatures as mcp_main.cpp, for test #12)
+// -----------------------------------------------------------------------
+static std::string getArg(int argc, char** argv, const std::string& key) {
+ for (int i = 1; i < argc - 1; ++i) {
+ if (argv[i] == key) return argv[i + 1];
+ }
+ return "";
+}
+
+static bool hasFlag(int argc, char** argv, const std::string& key) {
+ for (int i = 1; i < argc; ++i) {
+ if (argv[i] == key) return true;
+ }
+ return false;
+}
+
+int main() {
+ int passed = 0;
+ int failed = 0;
+
+ // --- Set up HeadlessEditorState + MCPBridge (same wiring as mcp_main) ---
+ HeadlessEditorState state;
+ state.workspaceRoot = "/tmp/mcp-test";
+ state.defaultLanguage = "python";
+ state.verbose = false;
+
+ // Open a buffer with Python source for AST tests
+ std::string src = "def greet(name):\n return 'hello ' + name\n";
+ state.openBuffer("test.py", src, "python");
+ state.setAgentRole("mcp-session", AgentRole::Refactor);
+
+ MCPBridge bridge;
+ bridge.setRequestHandler([&state](const json& request) -> json {
+ return state.processAgentRequest(request, "mcp-session");
+ });
+ bridge.setResourceReader([&state](const std::string& uri) -> json {
+ return readResource(state, uri);
+ });
+
+ // ---------------------------------------------------------------
+ // Test 1: initialize request
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 1},
+ {"method", "initialize"},
+ {"params", {{"protocolVersion", "2024-11-05"},
+ {"clientInfo", {{"name", "test"}}}}}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ expect(r.contains("result") &&
+ r["result"].contains("protocolVersion") &&
+ r["result"].contains("serverInfo"),
+ "initialize returns protocolVersion and serverInfo",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 2: notifications/initialized (no crash, empty response)
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"},
+ {"method", "notifications/initialized"}};
+ std::string resp = bridge.processMessage(req.dump());
+ expect(resp.empty(), "notifications/initialized returns empty",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 3: tools/list returns 10+ tools
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 3},
+ {"method", "tools/list"}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ int toolCount = 0;
+ if (r.contains("result") && r["result"].contains("tools"))
+ toolCount = (int)r["result"]["tools"].size();
+ expect(toolCount >= 10,
+ "tools/list returns 10+ tools (got " +
+ std::to_string(toolCount) + ")",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 4: resources/list returns 5 resources
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 4},
+ {"method", "resources/list"}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ int resCount = 0;
+ if (r.contains("result") && r["result"].contains("resources"))
+ resCount = (int)r["result"]["resources"].size();
+ expect(resCount == 5,
+ "resources/list returns 5 resources (got " +
+ std::to_string(resCount) + ")",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 5: prompts/list returns 4 prompts
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 5},
+ {"method", "prompts/list"}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ int promptCount = 0;
+ if (r.contains("result") && r["result"].contains("prompts"))
+ promptCount = (int)r["result"]["prompts"].size();
+ expect(promptCount == 4,
+ "prompts/list returns 4 prompts (got " +
+ std::to_string(promptCount) + ")",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 6: tools/call whetstone_run_pipeline
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 6},
+ {"method", "tools/call"},
+ {"params", {{"name", "whetstone_run_pipeline"},
+ {"arguments", {
+ {"source", "def foo(): pass"},
+ {"sourceLanguage", "python"},
+ {"targetLanguage", "cpp"}
+ }}}}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ bool hasContent = r.contains("result") &&
+ r["result"].contains("content") &&
+ !r["result"]["content"].empty();
+ std::string text = "";
+ if (hasContent)
+ text = r["result"]["content"][0].value("text", "");
+ expect(hasContent && !text.empty() &&
+ r["result"].value("isError", true) == false,
+ "whetstone_run_pipeline generates code",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 7: tools/call whetstone_get_ast
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 7},
+ {"method", "tools/call"},
+ {"params", {{"name", "whetstone_get_ast"},
+ {"arguments", json::object()}}}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ bool hasContent = r.contains("result") &&
+ r["result"].contains("content") &&
+ !r["result"]["content"].empty();
+ std::string text = "";
+ if (hasContent)
+ text = r["result"]["content"][0].value("text", "");
+ expect(hasContent && !text.empty() &&
+ text.find("ast") != std::string::npos,
+ "whetstone_get_ast returns AST data",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 8: resources/read whetstone://ast
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 8},
+ {"method", "resources/read"},
+ {"params", {{"uri", "whetstone://ast"}}}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ bool hasContents = r.contains("result") &&
+ r["result"].contains("contents") &&
+ !r["result"]["contents"].empty();
+ std::string text = "";
+ if (hasContents)
+ text = r["result"]["contents"][0].value("text", "");
+ expect(hasContents && !text.empty(),
+ "resources/read whetstone://ast returns content",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 9: resources/read whetstone://diagnostics
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 9},
+ {"method", "resources/read"},
+ {"params", {{"uri", "whetstone://diagnostics"}}}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ bool hasContents = r.contains("result") &&
+ r["result"].contains("contents") &&
+ !r["result"]["contents"].empty();
+ expect(hasContents,
+ "resources/read whetstone://diagnostics returns content",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 10: ping
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 10},
+ {"method", "ping"}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ expect(r.contains("result"),
+ "ping returns result",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 11: resources/read whetstone://settings
+ // ---------------------------------------------------------------
+ {
+ json req = {{"jsonrpc", "2.0"}, {"id", 11},
+ {"method", "resources/read"},
+ {"params", {{"uri", "whetstone://settings"}}}};
+ std::string resp = bridge.processMessage(req.dump());
+ json r = json::parse(resp);
+ bool hasContents = r.contains("result") &&
+ r["result"].contains("contents") &&
+ !r["result"]["contents"].empty();
+ std::string text = "";
+ if (hasContents)
+ text = r["result"]["contents"][0].value("text", "");
+ expect(hasContents && text.find("headless-mcp") != std::string::npos,
+ "resources/read whetstone://settings has headless-mcp mode",
+ passed, failed);
+ }
+
+ // ---------------------------------------------------------------
+ // Test 12: CLI arg parsing helpers
+ // ---------------------------------------------------------------
+ {
+ const char* testArgs[] = {
+ "whetstone_mcp",
+ "--workspace", "/tmp/ws",
+ "--language", "rust",
+ "--verbose"
+ };
+ int testArgc = 6;
+ char** testArgv = const_cast(testArgs);
+
+ std::string ws = getArg(testArgc, testArgv, "--workspace");
+ std::string lang = getArg(testArgc, testArgv, "--language");
+ bool verb = hasFlag(testArgc, testArgv, "--verbose");
+ std::string missing = getArg(testArgc, testArgv, "--missing");
+
+ expect(ws == "/tmp/ws" && lang == "rust" && verb &&
+ missing.empty(),
+ "CLI arg parsing works correctly",
+ passed, failed);
+ }
+
+ std::cout << "\n=== Step 246 Results: " << passed << " passed, "
+ << failed << " failed ===\n";
+ return failed == 0 ? 0 : 1;
+}
diff --git a/progress.md b/progress.md
index 3fc07ac..5ed910e 100644
--- a/progress.md
+++ b/progress.md
@@ -23,3 +23,25 @@ but with zero GUI dependencies (no ImGui, no SDL, no WebSocket).
- JSON roundtrip cloning (matching EditorUtils.h) instead of manual deep copy
- Separate HeadlessAgentState (no AgentMarketplace/WebSocket dependency chain)
- All files under 600-line architecture limit
+
+### Step 246: mcp_main.cpp — Standalone MCP Server Entry Point
+**Status:** PASS (12/12 tests)
+
+Wires HeadlessEditorState + MCPBridge into a standalone `whetstone_mcp` binary
+that any MCP client (Claude Code, Cursor, etc.) can launch over stdio.
+
+**Files created:**
+- `editor/src/mcp_main.cpp` — Standalone MCP server entry point with CLI arg
+ parsing (--workspace, --language, --verbose), signal handling, resource reader
+ lambda routing whetstone:// URIs, and MCPBridge stdio loop
+- `editor/tests/step246_test.cpp` — 12 test cases: initialize, notifications,
+ tools/list (10+), resources/list (5), prompts/list (4), tools/call pipeline,
+ tools/call get_ast, resources/read ast, resources/read diagnostics, ping,
+ resources/read settings, CLI arg parsing
+
+**Key design decisions:**
+- No SDL, no ImGui, no OpenGL — headless only binary
+- Fixed "mcp-session" session ID (MCP stdio is single-client)
+- Default Refactor role for full MCP access
+- Empty scratch buffer on startup so getAST works immediately
+- All logging to stderr (stdout is the MCP transport)