Merge pull request #2526 from DeusData/fix/underscore-project-discovery-v2

fix(mcp): discover projects with underscore-prefixed names
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-04 16:24:34 +02:00
commit b7f67c29a7
2 files changed
+192 -35

No files matched your search

+9 -35
View File
@@ -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,
@@ -1600,7 +1599,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
@@ -1632,7 +1630,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" */
@@ -2171,14 +2169,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 <project>.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,
@@ -2557,9 +2555,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);
@@ -2575,8 +2570,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
@@ -2734,23 +2728,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) {
@@ -2803,8 +2780,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];
@@ -3018,8 +2994,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];
@@ -11401,8 +11376,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};
+183
View File
@@ -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"
@@ -18334,6 +18335,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");
@@ -21998,6 +22179,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);