From 06706aeacf345ad221be25f54ff347d2c08d7deb Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 05:40:32 +0000 Subject: [PATCH] =?UTF-8?q?Step=20246:=20mcp=5Fmain.cpp=20=E2=80=94=20stan?= =?UTF-8?q?dalone=20MCP=20server=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires HeadlessEditorState + MCPBridge into a standalone whetstone_mcp binary that any MCP client (Claude Code, Cursor, etc.) can launch over stdio. No SDL, no ImGui, no OpenGL — headless only. 12/12 tests pass. Co-Authored-By: Claude Opus 4.6 --- editor/CMakeLists.txt | 21 +++ editor/src/mcp_main.cpp | 152 +++++++++++++++ editor/tests/step246_test.cpp | 337 ++++++++++++++++++++++++++++++++++ progress.md | 22 +++ 4 files changed, 532 insertions(+) create mode 100644 editor/src/mcp_main.cpp create mode 100644 editor/tests/step246_test.cpp 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)