From 273a3af00893a292366976bdf8c68b5bac75ce53 Mon Sep 17 00:00:00 2001 From: SS-42 Date: Thu, 2 Jul 2026 21:28:22 +0300 Subject: [PATCH] fix(mcp): normalize project paths for ADR lookups Signed-off-by: SS-42 --- src/mcp/mcp.c | 106 +++++++++-- src/pipeline/fqn.c | 38 +++- tests/test_mcp.c | 434 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 562 insertions(+), 16 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 034e2e59..fd9dd163 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -60,6 +60,8 @@ enum { #include "pipeline/artifact.h" #ifdef _WIN32 +#include +#include #include #define getpid _getpid #else @@ -720,6 +722,59 @@ char *cbm_mcp_get_string_arg(const char *args_json, const char *key) { return result; } +static char *canonicalize_repo_path_if_exists(char *repo_path) { + if (!repo_path) { + return NULL; + } + bool root_syntax = true; + for (const char *p = repo_path; *p; p++) { + if (*p != '/' && *p != '\\' && *p != ':') { + root_syntax = false; + break; + } + } + if (root_syntax) { + return repo_path; + } + + char real[CBM_SZ_4K]; +#ifdef _WIN32 + if (_access(repo_path, 0) == 0 && _fullpath(real, repo_path, sizeof(real))) { + cbm_normalize_path_sep(real); + char *canonical = heap_strdup(real); + if (canonical) { + free(repo_path); + return canonical; + } + } +#else + if (realpath(repo_path, real)) { + cbm_normalize_path_sep(real); + char *canonical = heap_strdup(real); + if (canonical) { + free(repo_path); + return canonical; + } + } +#endif + + return repo_path; +} + +static char *normalize_project_arg(char *project) { + if (!project || (!strchr(project, '/') && !strchr(project, '\\'))) { + return project; + } + + project = canonicalize_repo_path_if_exists(project); + char *normalized = cbm_project_name_from_path(project); + if (normalized) { + free(project); + return normalized; + } + return project; +} + /* Resolve the project argument, accepting the canonical "project" key plus the * aliases a caller naturally reaches for (#640): list_projects surfaces the * field as "name" and the not-found hint says "pass the project name", so @@ -737,7 +792,7 @@ static char *get_project_arg(const char *args_json) { if (!p) { p = cbm_mcp_get_string_arg(args_json, "projectName"); } - return p; + return normalize_project_arg(p); } int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val) { @@ -1163,6 +1218,26 @@ static char *build_no_store_error(const char *project) { } \ } while (0) +static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) { + if (store && project) { + cbm_adr_t adr; + memset(&adr, 0, sizeof(adr)); + if (cbm_store_adr_get(store, project, &adr) == CBM_STORE_OK) { + cbm_store_adr_free(&adr); + return true; + } + } + + if (!root_path) { + return false; + } + + char adr_path[CBM_SZ_4K]; + snprintf(adr_path, sizeof(adr_path), "%s/.codebase-memory/adr.md", root_path); + struct stat adr_st; + return stat(adr_path, &adr_st) == 0; +} + /* ── Tool handler implementations ─────────────────────────────── */ /* Return true if filename is a valid project .db file (not temp/internal). @@ -1411,10 +1486,7 @@ static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) { /* Check ADR presence */ cbm_project_t proj_info = {0}; if (cbm_store_get_project(store, project, &proj_info) == 0 && proj_info.root_path) { - char adr_path[CBM_SZ_4K]; - snprintf(adr_path, sizeof(adr_path), "%s/.codebase-memory/adr.md", proj_info.root_path); - struct stat adr_st; - bool adr_exists = (stat(adr_path, &adr_st) == 0); + bool adr_exists = project_has_adr(store, project, proj_info.root_path); yyjson_mut_obj_add_bool(doc, root, "adr_present", adr_exists); if (!adr_exists) { yyjson_mut_obj_add_str( @@ -3204,17 +3276,14 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc * } } - char adr_path[CBM_SZ_4K]; - snprintf(adr_path, sizeof(adr_path), "%s/.codebase-memory/adr.md", repo_path); - struct stat adr_st; - bool adr_exists = (stat(adr_path, &adr_st) == 0); + bool adr_exists = project_has_adr(store, project_name, repo_path); yyjson_mut_obj_add_bool(doc, root, "adr_present", adr_exists); if (!adr_exists && !degraded) { yyjson_mut_obj_add_str( doc, root, "adr_hint", "Project indexed. Consider creating an Architecture Decision Record: " "explore the codebase with get_architecture(aspects=['all']), then use " - "manage_adr(mode='store') to persist architectural insights across sessions."); + "manage_adr(mode='update') to persist architectural insights across sessions."); } bool has_artifact = cbm_artifact_exists(repo_path); @@ -3240,6 +3309,8 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result("repo_path is required", true); } + repo_path = canonicalize_repo_path_if_exists(repo_path); + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { free(mode_str); free(name_override); @@ -4678,10 +4749,13 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *root_path = get_project_root(srv, project); if (!root_path) { + char *err = build_no_store_error(project); + char *res = cbm_mcp_text_result(err, true); + free(err); free(project); free(base_branch); free(scope); - return cbm_mcp_text_result("project not found", true); + return res; } if (!validate_search_path_arg(root_path)) { @@ -4896,10 +4970,13 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { * the UI are visible to each other (#256). */ cbm_store_t *resolved = resolve_store(srv, project); if (!resolved) { + char *err = build_no_store_error(project); + char *res = cbm_mcp_text_result(err, true); + free(err); free(project); free(mode_str); free(content); - return cbm_mcp_text_result("project not found", true); + return res; } /* resolve_store opens file-backed projects READ-ONLY (query stores must @@ -4915,10 +4992,13 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { if (resolved_db_path) { owned_rw = cbm_store_open_path(resolved_db_path); if (!owned_rw) { + char *err = build_no_store_error(project); + char *res = cbm_mcp_text_result(err, true); + free(err); free(project); free(mode_str); free(content); - return cbm_mcp_text_result("project not found", true); + return res; } store = owned_rw; } diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index 7578176d..d0594ff5 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -14,6 +14,10 @@ #include #include #include // strdup +#ifdef _WIN32 +#include +#include +#endif /* Maximum path segments in a FQN (CBM_SZ_256 slots total, -2 for project + name) */ #define FQN_MAX_PATH_SEGS 254 @@ -384,13 +388,45 @@ static char *fqn_bound_name_len(char *name) { return name; } +static bool path_is_root_syntax(const char *path) { + if (!path || !path[0]) { + return false; + } + for (const char *p = path; *p; p++) { + if (*p != '/' && *p != '\\' && *p != ':') { + return false; + } + } + return true; +} + char *cbm_project_name_from_path(const char *abs_path) { if (!abs_path || !abs_path[0]) { return strdup("root"); } + if (path_is_root_syntax(abs_path)) { + return strdup("root"); + } + + char real[CBM_SZ_4K]; + const char *name_path = abs_path; +#ifdef _WIN32 + if (_access(abs_path, 0) == 0 && _fullpath(real, abs_path, sizeof(real))) { + cbm_normalize_path_sep(real); + name_path = real; + } +#else + if (realpath(abs_path, real)) { + cbm_normalize_path_sep(real); + name_path = real; + } +#endif /* Work on mutable copy */ - char *path = strdup(abs_path); + char *path = strdup(name_path); + if (!path) { + return NULL; + } size_t len = strlen(path); /* Normalize path separators */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index d3bfbe65..297cd91c 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5,9 +5,11 @@ */ #include "../src/foundation/compat.h" #include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */ +#include "../src/foundation/constants.h" #include "../src/foundation/log.h" #include "test_framework.h" #include +#include #include #include #include @@ -15,6 +17,15 @@ #include #include #include /* chmod / stat for read-only query reproductions */ +#ifdef _WIN32 +#include +#define cbm_chdir _chdir +#define cbm_getcwd _getcwd +#else +#include +#define cbm_chdir chdir +#define cbm_getcwd getcwd +#endif static char mcp_log_buf[4096]; @@ -22,6 +33,48 @@ static void mcp_capture_log(const char *line) { snprintf(mcp_log_buf, sizeof(mcp_log_buf), "%s", line ? line : ""); } +static bool response_contains_json_fragment(const char *response, const char *fragment) { + if (!response || !fragment) { + return false; + } + if (strstr(response, fragment)) { + return true; + } + + char escaped[512]; + size_t out = 0; + for (size_t i = 0; fragment[i] && out + 2 < sizeof(escaped); i++) { + if (fragment[i] == '"') { + escaped[out++] = '\\'; + } + escaped[out++] = fragment[i]; + } + escaped[out] = '\0'; + return strstr(response, escaped) != NULL; +} + +static void restore_cache_dir(const char *saved_copy) { + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } +} + +static void cleanup_project_db(const char *cache, const char *project) { + if (!cache || !project) { + return; + } + + char path[CBM_SZ_4K]; + snprintf(path, sizeof(path), "%s/%s.db", cache, project); + cbm_unlink(path); + snprintf(path, sizeof(path), "%s/%s.db-wal", cache, project); + cbm_unlink(path); + snprintf(path, sizeof(path), "%s/%s.db-shm", cache, project); + cbm_unlink(path); +} + /* ══════════════════════════════════════════════════════════════════ * JSON-RPC PARSING * ══════════════════════════════════════════════════════════════════ */ @@ -1464,7 +1517,7 @@ TEST(tool_detect_changes_no_project) { "\"params\":{\"name\":\"detect_changes\"," "\"arguments\":{}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "not found")); + ASSERT_NOT_NULL(strstr(resp, "missing required argument: project")); free(resp); cbm_mcp_server_free(srv); @@ -1479,7 +1532,7 @@ TEST(tool_manage_adr_no_project) { "\"params\":{\"name\":\"manage_adr\"," "\"arguments\":{}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "not found")); + ASSERT_NOT_NULL(strstr(resp, "missing required argument: project")); free(resp); cbm_mcp_server_free(srv); @@ -1585,6 +1638,377 @@ TEST(tool_manage_adr_unified_backend_issue256) { PASS(); } +TEST(tool_index_repository_reports_store_backed_adr) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-adr-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Store-backed ADR metadata.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); + ASSERT_NULL(strstr(resp, "adr_hint")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Store-backed ADR metadata.")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-dot-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-dot-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return helper()\n\ndef helper():\n return 1\n", fp); + fclose(fp); + + char old_cwd[CBM_SZ_4K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + ASSERT_EQ(cbm_chdir(tmp_dir), 0); + char *resp = + cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + ASSERT_NOT_NULL(resp); + if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); + } + ASSERT_NOT_NULL(strstr(resp, project)); + ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Dot-path ADR marker.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + ASSERT_EQ(cbm_chdir(tmp_dir), 0); + resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT_NOT_NULL(strstr(resp, project)); + ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); + ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\"")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Dot-path ADR marker.")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +TEST(tool_manage_adr_not_found_rich_error) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-missing-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = + cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"cbm-no-such-project-zzz\",\"mode\":\"get\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "or not indexed")); + ASSERT_NOT_NULL(strstr(resp, "hint")); + free(resp); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +} + +TEST(tool_manage_adr_get_accepts_abs_path) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-abspath-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-abspath-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Abs-path normalization test.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", tmp_dir); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Abs-path normalization test.")); + ASSERT_NULL(strstr(resp, "or not indexed")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +TEST(tool_manage_adr_get_accepts_symlink_path) { +#ifdef _WIN32 + PASS(); +#else + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-realpath-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-realpath-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + char link_path[320]; + snprintf(link_path, sizeof(link_path), "%s-link", tmp_dir); + (void)unlink(link_path); + if (symlink(tmp_dir, link_path) != 0) { + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", link_path); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT_NOT_NULL(strstr(resp, project)); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Symlink-path normalization test.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", link_path); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Symlink-path normalization test.")); + ASSERT_NULL(strstr(resp, "or not indexed")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + unlink(link_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +#endif +} + +TEST(tool_detect_changes_not_found_rich_error) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-detect-missing-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = + cbm_mcp_handle_tool(srv, "detect_changes", "{\"project\":\"cbm-no-such-project-zzz\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "or not indexed")); + ASSERT_NOT_NULL(strstr(resp, "hint")); + free(resp); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +} + TEST(tool_ingest_traces_basic) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -3331,6 +3755,12 @@ SUITE(mcp) { RUN_TEST(tool_manage_adr_no_project); RUN_TEST(tool_manage_adr_get_with_existing_adr); RUN_TEST(tool_manage_adr_unified_backend_issue256); + RUN_TEST(tool_index_repository_reports_store_backed_adr); + RUN_TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr); + RUN_TEST(tool_manage_adr_not_found_rich_error); + RUN_TEST(tool_manage_adr_get_accepts_abs_path); + RUN_TEST(tool_manage_adr_get_accepts_symlink_path); + RUN_TEST(tool_detect_changes_not_found_rich_error); RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty);