From a8abd97644c63dd8e1fe242943ad1f7dd5954b49 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 20:49:09 +0200 Subject: [PATCH] fix(mcp): discover projects with underscore-prefixed names Use the shared exact-name database predicate for all five MCP cache scans, including reindex root ownership. Preserve exclusions for the two internal stores and cover listing, lookup, hints, and root reuse. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 44 +++--------- tests/test_mcp.c | 183 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 35 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c877d5110..c8112abcc 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -19,9 +19,8 @@ enum { MCP_COL_7 = 7, MCP_COL_10 = 10, MCP_COL_16 = 16, - MCP_DB_EXT = 3, /* strlen(".db") */ - MCP_MIN_DB_NAME = 4, /* min length for "x.db" */ - MCP_SEPARATOR = 2, /* space for separator chars */ + MCP_DB_EXT = 3, /* strlen(".db") */ + MCP_SEPARATOR = 2, /* space for separator chars */ MCP_DEFAULT_DEPTH = 3, MCP_DEFAULT_BFS_DEPTH = 2, MCP_DEFAULT_LIMIT = 10, @@ -1594,7 +1593,6 @@ static char *normalize_project_arg(char *project) { /* Forward decls — defined below alongside store resolution. */ static const char *cache_dir(char *buf, size_t bufsz); -static bool is_project_db_file(const char *name, size_t len); /* #1025: agents naturally pass the repo FOLDER name ("codebase-memory-mcp"), * but indexed project names derive from the full path @@ -1626,7 +1624,7 @@ static char *resolve_project_tail(char *project) { while ((entry = cbm_readdir(d)) != NULL) { const char *n = entry->name; size_t len = strlen(n); - if (!is_project_db_file(n, len)) { + if (!cbm_is_project_index_db(n)) { continue; } size_t stem_len = len - MCP_DB_EXT; /* strip ".db" */ @@ -2158,14 +2156,14 @@ static const char *project_db_path(const char *project, char *buf, size_t bufsz) * /corrupt dbs (0-byte file, missing `projects` table, or >1 row). On success * the internal name is copied into name_out; if out_store is non-NULL the open * handle is transferred to the caller (who must cbm_store_close it). On failure - * the store is always closed. Defined after is_project_db_file below. */ + * the store is always closed. Defined below. */ static bool db_internal_project_name(const char *full_path, char *name_out, size_t name_sz, cbm_store_t **out_store); /* #704 fallback: scan the cache dir for the db whose sole internal project name * equals `project`, returning an open store handle (caller owns it) or NULL. * Used only when .db is absent or its internal name differs from the - * passed name (drifted filename). Defined after is_project_db_file below. */ + * passed name (drifted filename). Defined below. */ static cbm_store_t *resolve_store_fallback_scan(const char *project); static bool reserve_unique_corrupt_pending(const char *path, char *pending, size_t pending_size, @@ -2544,9 +2542,6 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { return resolve_store_internal(srv, project, false, false, NULL, false); } -/* Forward decl — definition lives below alongside list_projects. */ -static bool is_project_db_file(const char *name, size_t len); - /* Forward decl — definition lives below in handle_trace_call_path's helpers. */ static void free_node_contents(cbm_node_t *n); @@ -2562,8 +2557,7 @@ static int collect_db_project_names(const char *dir_path, char *out, size_t out_ cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *n = entry->name; - size_t len = strlen(n); - if (!is_project_db_file(n, len)) { + if (!cbm_is_project_index_db(n)) { continue; } /* #704: advertise the db's INTERNAL project name, not its filename, and @@ -2718,23 +2712,6 @@ static bool project_has_adr(cbm_store_t *store, const char *project, const char /* ── Tool handler implementations ─────────────────────────────── */ -/* Return true if filename is a valid project .db file (not temp/internal). - * - * Project names derived from /tmp/... source roots legitimately begin with - * "tmp-" (cbm_project_name_from_path: "/tmp/bench/..." → "tmp-bench-..."; - * see tests/test_pipeline.c fixtures), so the prefix must NOT be excluded. - * The "_" prefix is reserved for internal/hidden DBs, and ":memory:" is the - * SQLite in-memory marker (defensive — never appears as a real file). */ -static bool is_project_db_file(const char *name, size_t len) { - if (len < MCP_MIN_DB_NAME || strcmp(name + len - MCP_DB_EXT, ".db") != 0) { - return false; - } - if (strncmp(name, "_", SLEN("_")) == 0 || strncmp(name, ":memory:", SLEN(":memory:")) == 0) { - return false; - } - return true; -} - /* db_internal_project_name — see forward declaration above resolve_store. */ static bool db_internal_project_name(const char *full_path, char *name_out, size_t name_sz, cbm_store_t **out_store) { @@ -2787,8 +2764,7 @@ static cbm_store_t *resolve_store_fallback_scan(const char *project) { cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *n = entry->name; - size_t len = strlen(n); - if (!is_project_db_file(n, len)) { + if (!cbm_is_project_index_db(n)) { continue; } char full_path[CBM_SZ_2K]; @@ -3002,8 +2978,7 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *name = entry->name; - size_t len = strlen(name); - if (!is_project_db_file(name, len)) { + if (!cbm_is_project_index_db(name)) { continue; } char full_path[CBM_SZ_2K]; @@ -11315,8 +11290,7 @@ static bool index_root_owner_resolve(const char *repo_path, char **owner_out, ch bool ok = true; cbm_dirent_t *entry; while (ok && !derived_exists && (entry = cbm_readdir(d)) != NULL) { - size_t len = strlen(entry->name); - if (!is_project_db_file(entry->name, len)) { + if (!cbm_is_project_index_db(entry->name)) { continue; } mcp_project_record_t record = {0}; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 22af972af..fc6f2c428 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -10,6 +10,7 @@ #include "../src/foundation/log.h" #include "../src/foundation/platform.h" /* cbm_file_size */ #include "../src/foundation/mem.h" /* cbm_mem_set_budget_for_tests — over-budget seam */ +#include "../src/foundation/str_util.h" #include "../src/foundation/subprocess.h" #include "../src/foundation/workspace.h" #include "../src/mcp/compact_out.h" @@ -17734,6 +17735,186 @@ static bool issue704_make_db(const char *dir, const char *filename, const char * return ok; } +/* Cache entry classification must use exact internal names. CI workspace + * paths can produce real project names beginning with one or more underscores. */ +TEST(tool_underscore_projects_visible_in_cache_scans) { + char cache[CBM_SZ_512]; + int path_len = snprintf(cache, sizeof(cache), "%s/cbm-uscore-cache-XXXXXX", cbm_tmpdir()); + ASSERT_TRUE(path_len > 0 && (size_t)path_len < sizeof(cache)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + bool setup = (!saved || saved_copy) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + bool seeded = setup && + issue704_make_db(cache, "__w-org-uscorerepo.db", "__w-org-uscorerepo", + "uscore_target_fn") && + issue704_make_db(cache, "_renamed.db", "uscore-drift", "uscore_drift_fn") && + issue704_make_db(cache, "_config-extra.db", "_config-extra", "uscore_extra_fn") && + issue704_make_db(cache, CBM_CONFIG_DB_FILENAME, "hidden-config", "hidden_fn") && + issue704_make_db(cache, CBM_CROSS_REPO_DB_FILENAME, "hidden-cross", "hidden_fn"); + cbm_mcp_server_t *srv = seeded ? cbm_mcp_server_new(NULL) : NULL; + bool server_created = srv != NULL; + bool listed = false; + bool queried[3] = {false}; + bool hinted = false; + bool hidden = false; + if (srv) { + char *response = cbm_mcp_handle_tool(srv, "list_projects", "{\"format\":\"json\"}"); + char *inner = response ? extract_text_content(response) : NULL; + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + if (doc) { + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *projects = yyjson_obj_get(root, "projects"); + bool found[3] = {false}; + const char *expected[] = {"__w-org-uscorerepo", "uscore-drift", "_config-extra"}; + for (size_t i = 0; i < yyjson_arr_size(projects); i++) { + const char *name = + yyjson_get_str(yyjson_obj_get(yyjson_arr_get(projects, i), "name")); + for (size_t j = 0; j < 3; j++) { + found[j] = found[j] || (name && strcmp(name, expected[j]) == 0); + } + } + listed = yyjson_arr_size(projects) == 3 && + yyjson_get_int(yyjson_obj_get(root, "total")) == 3 && found[0] && found[1] && + found[2]; + yyjson_doc_free(doc); + } + free(inner); + free(response); + const char *args[] = { + "{\"project\":\"__w-org-uscorerepo\",\"name_pattern\":\"uscore_target_fn\"}", + "{\"project\":\"uscorerepo\",\"name_pattern\":\"uscore_target_fn\"}", + "{\"project\":\"uscore-drift\",\"name_pattern\":\"uscore_drift_fn\"}"}; + const char *targets[] = {"uscore_target_fn", "uscore_target_fn", "uscore_drift_fn"}; + for (size_t i = 0; i < 3; i++) { + response = cbm_mcp_handle_tool(srv, "search_graph", args[i]); + queried[i] = response && !strstr(response, "\"isError\":true") && + !strstr(response, "project not found") && strstr(response, targets[i]); + free(response); + } + response = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"unknown-uscore-project\",\"name_pattern\":\".*\"}"); + hinted = response && strstr(response, "project not found") && + strstr(response, "__w-org-uscorerepo") && strstr(response, "uscore-drift") && + strstr(response, "_config-extra") && !strstr(response, "hidden-config") && + !strstr(response, "hidden-cross"); + free(response); + response = cbm_mcp_handle_tool( + srv, "search_graph", "{\"project\":\"hidden-config\",\"name_pattern\":\"hidden_fn\"}"); + hidden = response && strstr(response, "project not found"); + free(response); + } + cbm_mcp_server_free(srv); + bool restored = !setup || (saved_copy ? cbm_setenv("CBM_CACHE_DIR", saved_copy, 1) + : cbm_unsetenv("CBM_CACHE_DIR")) == 0; + free(saved_copy); + bool removed = th_rmtree(cache) == 0; + ASSERT_TRUE(setup && seeded && server_created); + ASSERT_TRUE(restored && removed); + ASSERT_TRUE(listed); + ASSERT_TRUE(queried[0] && queried[1] && queried[2]); + ASSERT_TRUE(hinted && hidden); + PASS(); +} + +/* The fifth cache scan adopts an existing root owner before reindexing. + * Ignoring its underscore name would create a second path-derived project. */ +TEST(tool_reindex_reuses_underscore_root_owner) { + char repo[CBM_SZ_4K]; + char cache[CBM_SZ_4K]; + int repo_len = snprintf(repo, sizeof(repo), "%s/cbm-uscore-repo-XXXXXX", cbm_tmpdir()); + int cache_len = snprintf(cache, sizeof(cache), "%s/cbm-uscore-root-cache-XXXXXX", cbm_tmpdir()); + ASSERT_TRUE(repo_len > 0 && (size_t)repo_len < sizeof(repo)); + ASSERT_TRUE(cache_len > 0 && (size_t)cache_len < sizeof(cache)); + bool repo_created = cbm_mkdtemp(repo) != NULL; + bool cache_created = repo_created && cbm_mkdtemp(cache) != NULL; + char canonical[CBM_SZ_4K]; + bool setup = cache_created && cbm_canonical_path(repo, canonical, sizeof(canonical)); + if (setup) { + cbm_normalize_path_sep(canonical); + } + const char *keys[] = {"CBM_CACHE_DIR", "CBM_INDEX_SUPERVISOR"}; + char *saved[2] = {NULL}; + bool present[2] = {false}; + for (size_t i = 0; i < 2; i++) { + const char *value = getenv(keys[i]); + present[i] = value != NULL; + saved[i] = value ? cbm_strdup(value) : NULL; + setup = setup && (!value || saved[i]); + } + bool env_changed = setup; + if (setup) { + setup = cbm_setenv(keys[0], cache, 1) == 0 && cbm_setenv(keys[1], "0", 1) == 0; + } + char path[CBM_SZ_4K]; + int length = setup ? snprintf(path, sizeof(path), "%s/mod.py", repo) : -1; + FILE *source = length > 0 && (size_t)length < sizeof(path) ? cbm_fopen(path, "w") : NULL; + bool written = source && fputs("def underscore_root_target():\n return 1\n", source) >= 0; + if (source) { + written = fclose(source) == 0 && written; + } + length = setup ? snprintf(path, sizeof(path), "%s/_root-owner.db", cache) : -1; + cbm_store_t *store = + written && length > 0 && (size_t)length < sizeof(path) ? cbm_store_open_path(path) : NULL; + bool seeded = + store && cbm_store_upsert_project(store, "_root-owner", canonical) == CBM_STORE_OK; + if (store) { + cbm_store_close(store); + } + cbm_mcp_server_t *srv = seeded ? cbm_mcp_server_new(NULL) : NULL; + bool server_created = srv != NULL; + bool session_bound = srv && cbm_mcp_server_set_session_context(srv, canonical, canonical); + bool indexed = false; + bool resolved = false; + bool no_duplicate = false; + if (session_bound) { + yyjson_mut_doc *args = yyjson_mut_doc_new(NULL); + yyjson_mut_val *object = args ? yyjson_mut_obj(args) : NULL; + char *serialized = NULL; + if (object) { + yyjson_mut_doc_set_root(args, object); + if (yyjson_mut_obj_add_str(args, object, "repo_path", canonical)) { + serialized = yyjson_mut_write(args, 0, NULL); + } + } + char *response = + serialized ? cbm_mcp_handle_tool(srv, "index_repository", serialized) : NULL; + indexed = + response && !strstr(response, "\"isError\":true") && strstr(response, "_root-owner"); + free(response); + free(serialized); + yyjson_mut_doc_free(args); + response = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"_root-owner\",\"name_pattern\":\"underscore_root_target\"}"); + resolved = response && !strstr(response, "\"isError\":true") && + strstr(response, "underscore_root_target") && + !strstr(response, "project not found"); + free(response); + char *derived = cbm_project_name_from_path(canonical); + length = derived ? snprintf(path, sizeof(path), "%s/%s.db", cache, derived) : -1; + no_duplicate = derived && strcmp(derived, "_root-owner") != 0 && length > 0 && + (size_t)length < sizeof(path) && !cbm_file_exists(path); + free(derived); + } + cbm_mcp_server_free(srv); + bool restored = true; + for (size_t i = 0; i < 2; i++) { + if (env_changed) { + int rc = present[i] ? cbm_setenv(keys[i], saved[i], 1) : cbm_unsetenv(keys[i]); + restored = rc == 0 && restored; + } + free(saved[i]); + } + bool repo_removed = !repo_created || th_rmtree(repo) == 0; + bool cache_removed = !cache_created || th_rmtree(cache) == 0; + ASSERT_TRUE(setup && written && seeded && server_created && session_bound); + ASSERT_TRUE(restored && repo_removed && cache_removed); + ASSERT_TRUE(indexed && resolved && no_duplicate); + PASS(); +} + TEST(tool_resolve_store_by_internal_name_issue704) { char cache[256]; snprintf(cache, sizeof(cache), "/tmp/cbm-issue704-XXXXXX"); @@ -21350,6 +21531,8 @@ SUITE(mcp) { RUN_TEST(snippet_source_invalid_utf8); RUN_TEST(tool_bad_project_name_no_overflow_issue235); RUN_TEST(tool_bad_project_error_valid_json_issue235); + RUN_TEST(tool_underscore_projects_visible_in_cache_scans); + RUN_TEST(tool_reindex_reuses_underscore_root_owner); RUN_TEST(tool_resolve_store_by_internal_name_issue704); RUN_TEST(tool_list_projects_ignores_missed_shadow_issue1044);