From 010507af44db7b8c2ade99317249ab1fbd390374 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 17 Feb 2026 20:51:33 -0700 Subject: [PATCH] Add step 619 per-project MCP config --- editor/CMakeLists.txt | 9 ++ editor/src/MCPProjectConfig.h | 102 ++++++++++++++++++++ editor/src/mcp_main.cpp | 37 ++++++-- editor/tests/step619_test.cpp | 169 ++++++++++++++++++++++++++++++++++ progress.md | 36 ++++++++ 5 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 editor/src/MCPProjectConfig.h create mode 100644 editor/tests/step619_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 9e6330d..6a08831 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -4450,4 +4450,13 @@ target_link_libraries(step618_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step619_test tests/step619_test.cpp) +target_include_directories(step619_test PRIVATE src) +target_link_libraries(step619_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/MCPProjectConfig.h b/editor/src/MCPProjectConfig.h new file mode 100644 index 0000000..ca13b92 --- /dev/null +++ b/editor/src/MCPProjectConfig.h @@ -0,0 +1,102 @@ +#pragma once +// Step 619: Per-project MCP config (.whetstone.json) + +#include +#include +#include + +#include + +using json = nlohmann::json; + +struct MCPProjectConfigData { + std::string workspace; + std::string defaultLanguage; + std::string agentRole; + std::string mcpWorkspaceAlias; +}; + +struct MCPProjectConfigLoadResult { + bool found = false; + MCPProjectConfigData config; + std::string error; +}; + +class MCPProjectConfig { +public: + static bool parseText(const std::string& text, + MCPProjectConfigData* out, + std::string* error) { + if (!out || !error) return false; + error->clear(); + if (text.empty()) { + *error = "config_empty"; + return false; + } + + json parsed; + try { + parsed = json::parse(text); + } catch (...) { + *error = "config_json_parse_failed"; + return false; + } + return parseJson(parsed, out, error); + } + + static bool parseJson(const json& parsed, + MCPProjectConfigData* out, + std::string* error) { + if (!out || !error) return false; + error->clear(); + if (!parsed.is_object()) { + *error = "config_not_object"; + return false; + } + + MCPProjectConfigData data; + if (!readOptionalString(parsed, "workspace", &data.workspace, error)) return false; + if (!readOptionalString(parsed, "defaultLanguage", &data.defaultLanguage, error)) return false; + if (!readOptionalString(parsed, "agentRole", &data.agentRole, error)) return false; + if (!readOptionalString(parsed, "mcpWorkspaceAlias", &data.mcpWorkspaceAlias, error)) return false; + *out = data; + return true; + } + + static MCPProjectConfigLoadResult loadFromWorkspace(const std::string& workspaceRoot) { + MCPProjectConfigLoadResult out; + if (workspaceRoot.empty()) { + out.error = "workspace_missing"; + return out; + } + + std::filesystem::path path = std::filesystem::path(workspaceRoot) / ".whetstone.json"; + if (!std::filesystem::exists(path)) return out; + out.found = true; + + std::ifstream in(path); + if (!in.is_open()) { + out.error = "config_open_failed"; + return out; + } + + std::string text((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (!parseText(text, &out.config, &out.error)) return out; + return out; + } + +private: + static bool readOptionalString(const json& parsed, + const char* key, + std::string* out, + std::string* error) { + if (!parsed.contains(key)) return true; + if (!parsed[key].is_string()) { + *error = std::string("config_field_invalid_type:") + key; + return false; + } + *out = parsed[key].get(); + return true; + } +}; diff --git a/editor/src/mcp_main.cpp b/editor/src/mcp_main.cpp index 465eb7a..d5d93ee 100644 --- a/editor/src/mcp_main.cpp +++ b/editor/src/mcp_main.cpp @@ -8,6 +8,8 @@ #include "HeadlessEditorState.h" #include "MCPBridge.h" +#include "MCPProjectConfig.h" +#include "AgentPermissionPolicy.h" #include "ast/Serialization.h" #include @@ -116,14 +118,34 @@ int main(int argc, char** argv) { // Create headless state HeadlessEditorState state; state.verbose = verbose; - if (!workspace.empty()) state.workspaceRoot = workspace; - if (!language.empty()) state.defaultLanguage = language; + MCPProjectConfigLoadResult configLoad; + if (!workspace.empty()) { + configLoad = MCPProjectConfig::loadFromWorkspace(workspace); + if (!configLoad.error.empty()) { + std::cerr << "[whetstone-mcp] Config load warning: " << configLoad.error << "\n"; + } + } + + if (configLoad.found && !configLoad.config.workspace.empty()) { + state.workspaceRoot = configLoad.config.workspace; + } else if (!workspace.empty()) { + state.workspaceRoot = workspace; + } + + if (!language.empty()) { + state.defaultLanguage = language; + } else if (configLoad.found && !configLoad.config.defaultLanguage.empty()) { + state.defaultLanguage = configLoad.config.defaultLanguage; + } // 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); + AgentRole role = AgentRole::Refactor; + if (configLoad.found && !configLoad.config.agentRole.empty()) { + role = AgentPermissionPolicy::roleFromString(configLoad.config.agentRole); + } + state.setAgentRole("mcp-session", role); // Create bridge and wire handlers MCPBridge bridge; @@ -138,8 +160,11 @@ int main(int argc, char** argv) { // 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"; + if (!state.workspaceRoot.empty()) + std::cerr << "[whetstone-mcp] Workspace: " << state.workspaceRoot << "\n"; + if (configLoad.found && !configLoad.config.mcpWorkspaceAlias.empty()) + std::cerr << "[whetstone-mcp] Workspace alias: " + << configLoad.config.mcpWorkspaceAlias << "\n"; std::cerr << "[whetstone-mcp] Language: " << state.defaultLanguage << "\n"; if (verbose) std::cerr << "[whetstone-mcp] Verbose mode enabled\n"; diff --git a/editor/tests/step619_test.cpp b/editor/tests/step619_test.cpp new file mode 100644 index 0000000..49b3021 --- /dev/null +++ b/editor/tests/step619_test.cpp @@ -0,0 +1,169 @@ +// Step 619: .whetstone.json per-project config schema (12 tests) + +#include "MCPProjectConfig.h" + +#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::filesystem::path tempRoot() { + std::filesystem::path root = std::filesystem::temp_directory_path() / "whetstone_step619"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +static void writeFile(const std::filesystem::path& path, const std::string& content) { + std::ofstream out(path); + out << content; +} + +void test_parse_valid_all_fields() { + TEST(parse_valid_all_fields); + MCPProjectConfigData config; + std::string error; + std::string text = + R"({"workspace":"/tmp/ws","defaultLanguage":"rust","agentRole":"generator","mcpWorkspaceAlias":"alpha"})"; + CHECK(MCPProjectConfig::parseText(text, &config, &error), "parse should succeed"); + CHECK(config.workspace == "/tmp/ws", "workspace mismatch"); + CHECK(config.defaultLanguage == "rust", "language mismatch"); + CHECK(config.agentRole == "generator", "role mismatch"); + CHECK(config.mcpWorkspaceAlias == "alpha", "alias mismatch"); + PASS(); +} + +void test_parse_valid_partial_fields() { + TEST(parse_valid_partial_fields); + MCPProjectConfigData config; + std::string error; + CHECK(MCPProjectConfig::parseText(R"({"defaultLanguage":"python"})", &config, &error), + "parse should succeed"); + CHECK(config.defaultLanguage == "python", "language mismatch"); + CHECK(config.workspace.empty(), "workspace should be empty"); + PASS(); +} + +void test_parse_empty_text_fails() { + TEST(parse_empty_text_fails); + MCPProjectConfigData config; + std::string error; + CHECK(!MCPProjectConfig::parseText("", &config, &error), "parse should fail"); + CHECK(error == "config_empty", "wrong error"); + PASS(); +} + +void test_parse_invalid_json_fails() { + TEST(parse_invalid_json_fails); + MCPProjectConfigData config; + std::string error; + CHECK(!MCPProjectConfig::parseText("{bad", &config, &error), "parse should fail"); + CHECK(error == "config_json_parse_failed", "wrong error"); + PASS(); +} + +void test_parse_non_object_fails() { + TEST(parse_non_object_fails); + MCPProjectConfigData config; + std::string error; + CHECK(!MCPProjectConfig::parseText(R"(["bad"])", &config, &error), "parse should fail"); + CHECK(error == "config_not_object", "wrong error"); + PASS(); +} + +void test_parse_invalid_workspace_type_fails() { + TEST(parse_invalid_workspace_type_fails); + MCPProjectConfigData config; + std::string error; + CHECK(!MCPProjectConfig::parseText(R"({"workspace":42})", &config, &error), "parse should fail"); + CHECK(error == "config_field_invalid_type:workspace", "wrong error"); + PASS(); +} + +void test_parse_invalid_default_language_type_fails() { + TEST(parse_invalid_default_language_type_fails); + MCPProjectConfigData config; + std::string error; + CHECK(!MCPProjectConfig::parseText(R"({"defaultLanguage":42})", &config, &error), "parse should fail"); + CHECK(error == "config_field_invalid_type:defaultLanguage", "wrong error"); + PASS(); +} + +void test_load_from_workspace_missing_workspace_fails() { + TEST(load_from_workspace_missing_workspace_fails); + auto result = MCPProjectConfig::loadFromWorkspace(""); + CHECK(!result.found, "found should be false"); + CHECK(result.error == "workspace_missing", "wrong error"); + PASS(); +} + +void test_load_from_workspace_without_file_returns_not_found() { + TEST(load_from_workspace_without_file_returns_not_found); + std::filesystem::path root = tempRoot() / "ws-empty"; + std::filesystem::create_directories(root); + auto result = MCPProjectConfig::loadFromWorkspace(root.string()); + CHECK(!result.found, "found should be false"); + CHECK(result.error.empty(), "error should be empty"); + PASS(); +} + +void test_load_from_workspace_reads_valid_config() { + TEST(load_from_workspace_reads_valid_config); + std::filesystem::path root = tempRoot() / "ws-valid"; + std::filesystem::create_directories(root); + writeFile(root / ".whetstone.json", + R"({"workspace":"/tmp/alt","defaultLanguage":"go","agentRole":"refactor","mcpWorkspaceAlias":"beta"})"); + auto result = MCPProjectConfig::loadFromWorkspace(root.string()); + CHECK(result.found, "found should be true"); + CHECK(result.error.empty(), "error should be empty"); + CHECK(result.config.defaultLanguage == "go", "language mismatch"); + CHECK(result.config.mcpWorkspaceAlias == "beta", "alias mismatch"); + PASS(); +} + +void test_load_from_workspace_reports_parse_error() { + TEST(load_from_workspace_reports_parse_error); + std::filesystem::path root = tempRoot() / "ws-bad"; + std::filesystem::create_directories(root); + writeFile(root / ".whetstone.json", "{bad"); + auto result = MCPProjectConfig::loadFromWorkspace(root.string()); + CHECK(result.found, "found should be true when file exists"); + CHECK(result.error == "config_json_parse_failed", "wrong error"); + PASS(); +} + +void test_parse_includes_agent_role_and_alias_fields() { + TEST(parse_includes_agent_role_and_alias_fields); + MCPProjectConfigData config; + std::string error; + CHECK(MCPProjectConfig::parseText(R"({"agentRole":"linter","mcpWorkspaceAlias":"gamma"})", &config, &error), + "parse should succeed"); + CHECK(config.agentRole == "linter", "agentRole mismatch"); + CHECK(config.mcpWorkspaceAlias == "gamma", "alias mismatch"); + PASS(); +} + +int main() { + std::cout << "Step 619: .whetstone.json per-project config schema\n"; + + test_parse_valid_all_fields(); // 1 + test_parse_valid_partial_fields(); // 2 + test_parse_empty_text_fails(); // 3 + test_parse_invalid_json_fails(); // 4 + test_parse_non_object_fails(); // 5 + test_parse_invalid_workspace_type_fails(); // 6 + test_parse_invalid_default_language_type_fails(); // 7 + test_load_from_workspace_missing_workspace_fails(); // 8 + test_load_from_workspace_without_file_returns_not_found(); // 9 + test_load_from_workspace_reads_valid_config(); // 10 + test_load_from_workspace_reports_parse_error(); // 11 + test_parse_includes_agent_role_and_alias_fields(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 0434dfb..59a2b32 100644 --- a/progress.md +++ b/progress.md @@ -12216,3 +12216,39 @@ readiness tool. - `editor/src/Sprint36aIntegration.h` within header-size limit (`71` <= `600`) - `editor/tests/step618_test.cpp` within test-file size guidance (`121` lines) - Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md` + +### Step 619: `.whetstone.json` per-project config schema +**Status:** PASS (12/12 tests) + +Introduces per-project MCP config parsing/loading for: +`workspace`, `defaultLanguage`, `agentRole`, and `mcpWorkspaceAlias`. +`whetstone_mcp` now reads `.whetstone.json` from the `--workspace` directory +at startup when present. + +**Files added:** +- `editor/src/MCPProjectConfig.h` - project config schema and loader: + - JSON text/object parsing with field type validation + - workspace-root file loading from `.whetstone.json` +- `editor/tests/step619_test.cpp` - 12 tests covering: + - schema parse success/failure paths + - invalid field type handling + - workspace file discovery/load/error behavior + +**Files modified:** +- `editor/src/mcp_main.cpp` - startup config integration: + - loads `.whetstone.json` for provided `--workspace` + - applies configured workspace/language/agent role defaults + - reports workspace alias in startup logs when provided +- `editor/CMakeLists.txt` - `step619_test` target + +**Verification run:** +- `cmake -S editor -B editor/build-native` - PASS +- `cmake --build editor/build-native --target step619_test step618_test` - PASS +- `./editor/build-native/step619_test` - PASS (12/12) +- `./editor/build-native/step618_test` - PASS (8/8) regression coverage + +**Architecture gate check:** +- `editor/src/MCPProjectConfig.h` within header-size limit (`102` <= `600`) +- `editor/src/mcp_main.cpp` within main-size limit (`177` <= `1500`) +- `editor/tests/step619_test.cpp` within test-file size guidance (`169` lines) +- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`