Merge pull request #1054 from DeusData/fix/1025-project-suffix-resolution

fix(mcp): resolve project arg by unique name tail when exact lookup misses
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-07-12 20:14:03 +02:00
commit 71cd27f720
2 files changed
+162 -1

No files matched your search

+62 -1
View File
@@ -840,6 +840,67 @@ static char *normalize_project_arg(char *project) {
return 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);
bool cbm_validate_project_name(const char *project);
/* #1025: agents naturally pass the repo FOLDER name ("codebase-memory-mcp"),
* but indexed project names derive from the full path
* (E:\project\graph\x -> "E-project-graph-x"), so the exact lookup fails
* while list_projects clearly shows the project. When no <project>.db exists,
* scan cache-dir FILENAMES for a segment-aligned tail match ("-<project>.db"):
* exactly one match adopts the full name; zero or several keep the original so
* the existing not-found error (which lists all candidates) fires. Filename-
* level only — internal-name drift stays #704's fallback in resolve_store. */
static char *resolve_project_tail(char *project) {
if (!project || !cbm_validate_project_name(project)) {
return project;
}
char dir[CBM_SZ_1K];
cache_dir(dir, sizeof(dir));
char exact[CBM_SZ_2K];
snprintf(exact, sizeof(exact), "%s/%s.db", dir, project);
if (cbm_file_exists(exact)) {
return project; /* exact name — untouched fast path */
}
size_t plen = strlen(project);
char match[CBM_SZ_1K] = "";
int matches = 0;
cbm_dir_t *d = cbm_opendir(dir);
if (!d) {
return 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)) {
continue;
}
size_t stem_len = len - MCP_DB_EXT; /* strip ".db" */
if (stem_len <= plen + 1 || stem_len >= sizeof(match)) {
continue;
}
if (n[stem_len - plen - 1] != '-' || strncmp(n + stem_len - plen, project, plen) != 0) {
continue;
}
matches++;
if (matches > 1) {
break; /* ambiguous — keep the original name */
}
memcpy(match, n, stem_len);
match[stem_len] = '\0';
}
cbm_closedir(d);
if (matches == 1) {
cbm_log_info("mcp.project_tail_resolved", "passed", project, "resolved", match);
free(project);
return heap_strdup(match);
}
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
@@ -857,7 +918,7 @@ static char *get_project_arg(const char *args_json) {
if (!p) {
p = cbm_mcp_get_string_arg(args_json, "projectName");
}
return normalize_project_arg(p);
return resolve_project_tail(normalize_project_arg(p));
}
int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val) {
+100
View File
@@ -1634,6 +1634,105 @@ TEST(tool_search_graph_accepts_project_name_alias_issue640) {
PASS();
}
/* #1025: agents pass the repo FOLDER name ("codebase-memory-mcp"), but
* indexed project names derive from the full path
* (E:\project\graph\x -> "E-project-graph-x"), so exact lookup fails with
* "project not found" while list_projects clearly shows the project. A
* passed name that matches exactly ONE indexed project as a segment-aligned
* tail ("-<name>" suffix) must resolve to it; zero or several matches keep
* the existing error. Runs against real cache-dir .db files (the resolution
* scans filenames), so this test indexes real fixtures under an overridden
* CBM_CACHE_DIR. */
static void i1025_write_repo(const char *dir, const char *fn_name) {
char path[CBM_SZ_512];
snprintf(path, sizeof(path), "%s/mod.py", dir);
FILE *f = fopen(path, "w");
if (!f)
return;
fprintf(f, "def %s(x):\n return x + 1\n", fn_name);
fclose(f);
}
TEST(tool_project_arg_resolves_unique_tail_issue1025) {
char repo_a[CBM_SZ_256];
char repo_b[CBM_SZ_256];
char repo_c[CBM_SZ_256];
char cache[CBM_SZ_256];
snprintf(repo_a, sizeof(repo_a), "/tmp/cbm-i1025a-XXXXXX");
snprintf(repo_b, sizeof(repo_b), "/tmp/cbm-i1025b-XXXXXX");
snprintf(repo_c, sizeof(repo_c), "/tmp/cbm-i1025c-XXXXXX");
snprintf(cache, sizeof(cache), "/tmp/cbm-i1025d-XXXXXX");
if (!cbm_mkdtemp(repo_a) || !cbm_mkdtemp(repo_b) || !cbm_mkdtemp(repo_c) ||
!cbm_mkdtemp(cache)) {
FAIL("mkdtemp failed");
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
i1025_write_repo(repo_a, "unique_tail_target");
i1025_write_repo(repo_b, "amb_one");
i1025_write_repo(repo_c, "amb_two");
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[CBM_SZ_1K];
snprintf(args, sizeof(args),
"{\"repo_path\":\"%s\",\"name\":\"E-project-graph-suffix1025\"}", repo_a);
char *r = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(r);
free(r);
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"F-alpha-amb1025\"}", repo_b);
r = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(r);
free(r);
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"G-beta-amb1025\"}", repo_c);
r = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(r);
free(r);
/* 1. Unique tail resolves (RED today: "project not found"). */
r = cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"suffix1025\",\"name_pattern\":\".*target.*\"}");
ASSERT_NOT_NULL(r);
if (strstr(r, "project not found")) {
fprintf(stderr, " [1025] FAIL unique tail did not resolve: %.200s\n", r);
}
ASSERT_NULL(strstr(r, "project not found"));
ASSERT_NOT_NULL(strstr(r, "unique_tail_target"));
free(r);
/* 2. Ambiguous tail stays an error (never guess between projects). */
r = cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"amb1025\",\"name_pattern\":\".*\"}");
ASSERT_NOT_NULL(r);
ASSERT_NOT_NULL(strstr(r, "project not found"));
free(r);
/* 3. Exact full name keeps working unchanged. */
r = cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"E-project-graph-suffix1025\","
"\"name_pattern\":\".*target.*\"}");
ASSERT_NOT_NULL(r);
ASSERT_NULL(strstr(r, "project not found"));
free(r);
cbm_mcp_server_free(srv);
if (saved_cache_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1);
free(saved_cache_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
th_rmtree(repo_a);
th_rmtree(repo_b);
th_rmtree(repo_c);
th_rmtree(cache);
PASS();
}
/* Regression for #604: path scopes architecture totals and content. */
TEST(tool_get_architecture_path_scoping) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -5679,6 +5778,7 @@ SUITE(mcp) {
RUN_TEST(tool_get_architecture_rejects_unknown_aspect_pr560);
RUN_TEST(tool_get_architecture_accepts_project_name_alias_issue640);
RUN_TEST(tool_search_graph_accepts_project_name_alias_issue640);
RUN_TEST(tool_project_arg_resolves_unique_tail_issue1025);
RUN_TEST(tool_get_architecture_path_scoping);
RUN_TEST(tool_query_graph_missing_query);