From 1be9b6102ae0430f7d2cb49bba65757698507306 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 16 Feb 2026 16:05:50 -0700 Subject: [PATCH] Step 417: add MCP config and discovery utilities --- editor/CMakeLists.txt | 9 ++ editor/src/MCPServerConfig.h | 246 ++++++++++++++++++++++++++++++++++ editor/tests/step417_test.cpp | 159 ++++++++++++++++++++++ progress.md | 51 +++++++ 4 files changed, 465 insertions(+) create mode 100644 editor/src/MCPServerConfig.h create mode 100644 editor/tests/step417_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index c921748..d4e188f 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2632,4 +2632,13 @@ target_link_libraries(step416_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step417_test tests/step417_test.cpp) +target_include_directories(step417_test PRIVATE src) +target_link_libraries(step417_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) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/MCPServerConfig.h b/editor/src/MCPServerConfig.h new file mode 100644 index 0000000..97d4442 --- /dev/null +++ b/editor/src/MCPServerConfig.h @@ -0,0 +1,246 @@ +#pragma once + +#include "MCPServer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +class MCPServerConfig { +public: + struct Discovery { + std::string binaryPath; + std::string workspaceRoot; + std::string primaryLanguage; + int toolCount = 0; + std::map categoryCounts; + }; + + static std::string resolveBinaryPath( + const std::vector& candidates = {}, + const std::string& pathEnv = "") { + for (const auto& c : candidates) { + if (isExecutable(c) || fileExists(c)) return std::filesystem::absolute(c).string(); + } + + const std::vector defaults = { + "editor/build-native/whetstone_mcp", + "editor/build-native/whetstone_mcp.exe", + "build-native/whetstone_mcp", + "build-native/whetstone_mcp.exe", + "whetstone_mcp" + }; + for (const auto& c : defaults) { + if (isExecutable(c) || fileExists(c)) return std::filesystem::absolute(c).string(); + } + + std::string pathValue = pathEnv.empty() ? getenvString("PATH") : pathEnv; + auto pathEntries = splitPath(pathValue); + for (const auto& entry : pathEntries) { + std::filesystem::path full = std::filesystem::path(entry) / "whetstone_mcp"; + if (isExecutable(full.string()) || fileExists(full.string())) return full.string(); + std::filesystem::path fullExe = std::filesystem::path(entry) / "whetstone_mcp.exe"; + if (isExecutable(fullExe.string()) || fileExists(fullExe.string())) return fullExe.string(); + } + return "whetstone_mcp"; + } + + static std::string detectWorkspaceRoot(const std::string& startDir) { + if (startDir.empty()) return ""; + std::filesystem::path cur = std::filesystem::absolute(startDir); + while (true) { + if (std::filesystem::exists(cur / ".git") || + std::filesystem::exists(cur / "CMakeLists.txt") || + std::filesystem::exists(cur / "progress.md")) { + return cur.string(); + } + if (cur == cur.root_path()) break; + cur = cur.parent_path(); + } + return std::filesystem::absolute(startDir).string(); + } + + static std::string detectPrimaryLanguage( + const std::string& workspaceRoot, + size_t maxFilesToScan = 4000) { + if (workspaceRoot.empty() || !std::filesystem::exists(workspaceRoot)) return "cpp"; + + std::unordered_map extCount; + size_t scanned = 0; + for (const auto& e : std::filesystem::recursive_directory_iterator(workspaceRoot)) { + if (scanned >= maxFilesToScan) break; + if (!e.is_regular_file()) continue; + auto ext = e.path().extension().string(); + if (ext.empty()) continue; + extCount[toLower(ext)]++; + scanned++; + } + + if (extCount.empty()) return "cpp"; + + const std::vector> mapping = { + {".py", "python"}, {".cpp", "cpp"}, {".cc", "cpp"}, {".cxx", "cpp"}, + {".cs", "csharp"}, {".fs", "fsharp"}, {".vb", "vbnet"}, + {".sql", "postgresql"}, {".js", "javascript"}, {".ts", "typescript"}, + {".java", "java"}, {".rs", "rust"}, {".go", "go"}, {".c", "c"} + }; + + std::string bestLang = "cpp"; + int bestCount = -1; + for (const auto& [ext, lang] : mapping) { + auto it = extCount.find(ext); + if (it != extCount.end() && it->second > bestCount) { + bestCount = it->second; + bestLang = lang; + } + } + return bestLang; + } + + static json buildMcpConfig( + const std::string& binaryPath, + const std::string& workspaceRoot, + const std::string& language) { + json args = json::array(); + if (!workspaceRoot.empty()) { + args.push_back("--workspace"); + args.push_back(workspaceRoot); + } + if (!language.empty()) { + args.push_back("--language"); + args.push_back(language); + } + + return { + {"mcpServers", { + {"whetstone", { + {"command", binaryPath}, + {"args", args} + }} + }} + }; + } + + static bool writeMcpConfigFile(const std::string& outputPath, const json& config) { + std::ofstream out(outputPath); + if (!out.is_open()) return false; + out << config.dump(2) << "\n"; + return true; + } + + static json buildServerManifest(const MCPServer& server) { + std::map> categories; + for (const auto& t : server.getTools()) { + std::string cat = categorizeTool(t.name); + categories[cat].push_back({ + {"name", t.name}, + {"description", t.description} + }); + } + + json byCategory = json::object(); + for (const auto& [k, v] : categories) byCategory[k] = v; + + return { + {"toolCount", (int)server.getTools().size()}, + {"categories", byCategory} + }; + } + + static Discovery discover(const std::string& startDir) { + Discovery d; + d.binaryPath = resolveBinaryPath(); + d.workspaceRoot = detectWorkspaceRoot(startDir); + d.primaryLanguage = detectPrimaryLanguage(d.workspaceRoot); + + MCPServer server; + d.toolCount = (int)server.getTools().size(); + auto manifest = buildServerManifest(server); + if (manifest.contains("categories")) { + for (auto it = manifest["categories"].begin(); it != manifest["categories"].end(); ++it) { + d.categoryCounts[it.key()] = (int)it.value().size(); + } + } + return d; + } + + static std::string categorizeTool(const std::string& toolName) { + const std::string n = toLower(toolName); + if (n.find("whetstone_get_ast") == 0 || n.find("batch_mutate") != std::string::npos || + n.find("call_hierarchy") != std::string::npos || n.find("_mutate") != std::string::npos) { + return "AST"; + } + if (n.find("diagnostic") != std::string::npos || n.find("quick_fix") != std::string::npos) { + return "Diagnostics"; + } + if (n.find("workflow") != std::string::npos || n.find("workitem") != std::string::npos || + n.find("review") != std::string::npos) { + return "Workflow"; + } + if (n.find("route") != std::string::npos || n.find("orchestrate") != std::string::npos) { + return "Routing"; + } + if (n.find("file_") != std::string::npos || n.find("save_") != std::string::npos) { + return "FileOps"; + } + if (n.find("workspace") != std::string::npos || n.find("open_file") != std::string::npos || + n.find("close_file") != std::string::npos || n.find("buffer") != std::string::npos || + n.find("project") != std::string::npos) { + return "Project"; + } + return "Misc"; + } + +private: + static std::string getenvString(const std::string& key) { + const char* v = std::getenv(key.c_str()); + return v ? std::string(v) : ""; + } + + static bool isExecutable(const std::string& path) { + if (path.empty()) return false; + std::error_code ec; + auto p = std::filesystem::path(path); + if (!std::filesystem::exists(p, ec) || std::filesystem::is_directory(p, ec)) return false; + auto perms = std::filesystem::status(p, ec).permissions(); + if (ec) return false; + using perms_t = std::filesystem::perms; + return (perms & perms_t::owner_exec) != perms_t::none || + (perms & perms_t::group_exec) != perms_t::none || + (perms & perms_t::others_exec) != perms_t::none; + } + + static bool fileExists(const std::string& path) { + if (path.empty()) return false; + std::error_code ec; + auto p = std::filesystem::path(path); + return std::filesystem::exists(p, ec) && !std::filesystem::is_directory(p, ec); + } + + static std::vector splitPath(const std::string& path) { + std::vector out; + std::string cur; + for (char c : path) { + if (c == ':') { + if (!cur.empty()) out.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + if (!cur.empty()) out.push_back(cur); + return out; + } + + static std::string toLower(const std::string& s) { + std::string out = s; + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c){ return static_cast(std::tolower(c)); }); + return out; + } +}; diff --git a/editor/tests/step417_test.cpp b/editor/tests/step417_test.cpp new file mode 100644 index 0000000..36b4e6f --- /dev/null +++ b/editor/tests/step417_test.cpp @@ -0,0 +1,159 @@ +// Step 417: MCP Server Configuration + Discovery Tests (12 tests) + +#include "MCPServerConfig.h" + +#include +#include +#include +#include + +static int passed = 0, failed = 0; +#define TEST(name) { std::cout << " " << #name << "... "; } +#define PASS() { std::cout << "PASS\n"; ++passed; } +#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; } +#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {} + +static std::string makeTempDir(const std::string& name) { + auto dir = std::filesystem::temp_directory_path() / ("whetstone_" + name); + std::filesystem::create_directories(dir); + return dir.string(); +} + +void test_resolve_binary_path_from_candidate() { + TEST(resolve_binary_path_from_candidate); + std::string path = MCPServerConfig::resolveBinaryPath({"editor/build-native/whetstone_mcp"}); + CHECK(!path.empty(), "expected binary path from explicit candidate"); + PASS(); +} + +void test_resolve_binary_path_from_defaults() { + TEST(resolve_binary_path_from_defaults); + std::string path = MCPServerConfig::resolveBinaryPath(); + CHECK(!path.empty(), "expected binary path from default discovery"); + PASS(); +} + +void test_detect_workspace_root_from_repo() { + TEST(detect_workspace_root_from_repo); + std::string root = MCPServerConfig::detectWorkspaceRoot("."); + CHECK(!root.empty(), "workspace root empty"); + CHECK(std::filesystem::exists(std::filesystem::path(root) / "progress.md"), + "expected progress.md at workspace root"); + PASS(); +} + +void test_detect_primary_language_cpp_majority() { + TEST(detect_primary_language_cpp_majority); + auto dir = std::filesystem::path(makeTempDir("lang_cpp")); + std::ofstream(dir / "a.cpp") << "int main() {}\n"; + std::ofstream(dir / "b.cpp") << "int add(int a,int b){return a+b;}\n"; + std::ofstream(dir / "c.py") << "print('x')\n"; + std::string lang = MCPServerConfig::detectPrimaryLanguage(dir.string()); + CHECK(lang == "cpp", "expected cpp primary language"); + PASS(); +} + +void test_detect_primary_language_python_majority() { + TEST(detect_primary_language_python_majority); + auto dir = std::filesystem::path(makeTempDir("lang_py")); + std::ofstream(dir / "a.py") << "print('a')\n"; + std::ofstream(dir / "b.py") << "print('b')\n"; + std::ofstream(dir / "c.cpp") << "int main(){}\n"; + std::string lang = MCPServerConfig::detectPrimaryLanguage(dir.string()); + CHECK(lang == "python", "expected python primary language"); + PASS(); +} + +void test_build_mcp_config_structure() { + TEST(build_mcp_config_structure); + json cfg = MCPServerConfig::buildMcpConfig("/bin/whetstone_mcp", "/repo", "cpp"); + CHECK(cfg.contains("mcpServers"), "missing mcpServers"); + CHECK(cfg["mcpServers"].contains("whetstone"), "missing whetstone server key"); + CHECK(cfg["mcpServers"]["whetstone"]["command"] == "/bin/whetstone_mcp", + "wrong command field"); + PASS(); +} + +void test_build_mcp_config_args_contains_workspace_language() { + TEST(build_mcp_config_args_contains_workspace_language); + json cfg = MCPServerConfig::buildMcpConfig("/bin/whetstone_mcp", "/repo", "python"); + auto args = cfg["mcpServers"]["whetstone"]["args"]; + std::string dumped = args.dump(); + CHECK(dumped.find("--workspace") != std::string::npos, "missing workspace arg"); + CHECK(dumped.find("--language") != std::string::npos, "missing language arg"); + CHECK(dumped.find("python") != std::string::npos, "missing language value"); + PASS(); +} + +void test_write_mcp_config_file() { + TEST(write_mcp_config_file); + auto dir = std::filesystem::path(makeTempDir("mcp_cfg")); + auto path = (dir / ".mcp.json").string(); + json cfg = MCPServerConfig::buildMcpConfig("/bin/whetstone_mcp", "/repo", "cpp"); + CHECK(MCPServerConfig::writeMcpConfigFile(path, cfg), "write config failed"); + CHECK(std::filesystem::exists(path), "config file not created"); + PASS(); +} + +void test_manifest_tool_count_at_least_68() { + TEST(manifest_tool_count_at_least_68); + MCPServer server; + json manifest = MCPServerConfig::buildServerManifest(server); + CHECK(manifest["toolCount"].get() >= 68, "expected at least 68 tools"); + PASS(); +} + +void test_manifest_required_categories_present() { + TEST(manifest_required_categories_present); + MCPServer server; + json manifest = MCPServerConfig::buildServerManifest(server); + auto cats = manifest["categories"]; + CHECK(cats.contains("AST"), "missing AST category"); + CHECK(cats.contains("Diagnostics"), "missing Diagnostics category"); + CHECK(cats.contains("Project"), "missing Project category"); + CHECK(cats.contains("FileOps"), "missing FileOps category"); + PASS(); +} + +void test_categorize_tool_names() { + TEST(categorize_tool_names); + CHECK(MCPServerConfig::categorizeTool("whetstone_get_ast") == "AST", "ast category"); + CHECK(MCPServerConfig::categorizeTool("whetstone_get_diagnostics") == "Diagnostics", + "diagnostics category"); + CHECK(MCPServerConfig::categorizeTool("whetstone_workspace_list") == "Project", + "project category"); + CHECK(MCPServerConfig::categorizeTool("whetstone_file_write") == "FileOps", + "fileops category"); + PASS(); +} + +void test_discover_end_to_end() { + TEST(discover_end_to_end); + auto d = MCPServerConfig::discover("."); + CHECK(!d.workspaceRoot.empty(), "workspace missing"); + CHECK(!d.binaryPath.empty(), "binary path missing"); + CHECK(d.toolCount >= 68, "expected at least 68 tools in discovery"); + CHECK(!d.primaryLanguage.empty(), "primary language missing"); + PASS(); +} + +int main() { + std::cout << "Step 417: MCP Server Configuration + Discovery Tests\n"; + + test_resolve_binary_path_from_candidate(); // 1 + test_resolve_binary_path_from_defaults(); // 2 + test_detect_workspace_root_from_repo(); // 3 + test_detect_primary_language_cpp_majority(); // 4 + test_detect_primary_language_python_majority(); // 5 + test_build_mcp_config_structure(); // 6 + test_build_mcp_config_args_contains_workspace_language(); // 7 + test_write_mcp_config_file(); // 8 + test_manifest_tool_count_at_least_68(); // 9 + test_manifest_required_categories_present(); // 10 + test_categorize_tool_names(); // 11 + test_discover_end_to_end(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) + << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index cb668ab..e256757 100644 --- a/progress.md +++ b/progress.md @@ -4227,6 +4227,57 @@ annotation mapper, and cross-stack class-to-SQL flow checkpoints. - `editor/src/MCPServer.h` (`1679` > `600`) - `editor/src/HeadlessAgentRPCHandler.h` (`2629` > `600`) +# Sprint 18 Progress — Claude Code Plugin + MCP Workflow Tools + +## Phase 18a: Claude Code Integration + +### Step 417: MCP Server Configuration + Discovery +**Status:** PASS (12/12 tests) + +Added MCP server discovery/configuration utilities for plugin-style client setup. +This provides a concrete path toward `.mcp.json` generation and manifest +inspection without adding a custom plugin binary. + +**Files created:** +- `editor/src/MCPServerConfig.h` — MCP config/discovery support: + - binary path resolution (`whetstone_mcp`) from explicit candidates, common + build locations, and PATH fallback + - workspace-root detection (walk-up for `.git`, `CMakeLists.txt`, `progress.md`) + - primary-language detection from workspace extension frequency + - `.mcp.json` config JSON construction and file writing + - server manifest generation from `MCPServer::getTools()` with category grouping + - unified discovery snapshot with binary path, workspace root, language, + tool count, and per-category counts +- `editor/tests/step417_test.cpp` — 12 tests covering: + 1. explicit candidate binary path resolution + 2. default binary path resolution + 3. workspace root detection + 4. primary-language detection (C++ majority) + 5. primary-language detection (Python majority) + 6. `.mcp.json` structure generation + 7. workspace/language arg generation + 8. `.mcp.json` write-out + 9. manifest tool-count threshold (68+) + 10. required category presence + 11. tool-name categorization mapping + 12. end-to-end discovery snapshot + +**Files modified:** +- `editor/CMakeLists.txt` — `step417_test` target + +**Verification run:** +- `step417_test` — PASS (12/12) new step coverage +- `step416_test` — PASS (8/8) regression coverage +- `step415_test` — PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/MCPServerConfig.h` within header-size limit (`246` <= `600`) +- `editor/tests/step417_test.cpp` within test-file size guidance (`159` lines) +- Legacy oversized headers persist: + - `editor/src/ast/Serialization.h` (`1427` > `600`) + - `editor/src/MCPServer.h` (`1679` > `600`) + - `editor/src/HeadlessAgentRPCHandler.h` (`2629` > `600`) + # Roadmap Planning — Sprints 12-25+ ## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)