Skip to content

Commit 2432cc8

Browse files
committed
fix(mcp): resolve project arg by unique name tail when exact lookup misses
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 every graph tool answered 'project not found or not indexed' while list_projects clearly showed the project. get_project_arg now falls back to a cache-dir filename scan when no <project>.db exists: a passed name matching exactly ONE indexed project as a segment-aligned tail (-<name>.db) adopts that project's full name (logged); zero or several matches keep the original name so the existing not-found error with the candidate list fires. Exact names never enter the scan, and internal-name drift stays DeusData#704's fallback in resolve_store. Closes DeusData#1025 Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent db330bf commit 2432cc8

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

‎src/mcp/mcp.c‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,67 @@ static char *normalize_project_arg(char *project) {
840840
return project;
841841
}
842842

843+
/* Forward decls — defined below alongside store resolution. */
844+
static const char *cache_dir(char *buf, size_t bufsz);
845+
static bool is_project_db_file(const char *name, size_t len);
846+
bool cbm_validate_project_name(const char *project);
847+
848+
/* #1025: agents naturally pass the repo FOLDER name ("codebase-memory-mcp"),
849+
* but indexed project names derive from the full path
850+
* (E:\project\graph\x -> "E-project-graph-x"), so the exact lookup fails
851+
* while list_projects clearly shows the project. When no <project>.db exists,
852+
* scan cache-dir FILENAMES for a segment-aligned tail match ("-<project>.db"):
853+
* exactly one match adopts the full name; zero or several keep the original so
854+
* the existing not-found error (which lists all candidates) fires. Filename-
855+
* level only — internal-name drift stays #704's fallback in resolve_store. */
856+
static char *resolve_project_tail(char *project) {
857+
if (!project || !cbm_validate_project_name(project)) {
858+
return project;
859+
}
860+
char dir[CBM_SZ_1K];
861+
cache_dir(dir, sizeof(dir));
862+
char exact[CBM_SZ_2K];
863+
snprintf(exact, sizeof(exact), "%s/%s.db", dir, project);
864+
if (cbm_file_exists(exact)) {
865+
return project; /* exact name — untouched fast path */
866+
}
867+
size_t plen = strlen(project);
868+
char match[CBM_SZ_1K] = "";
869+
int matches = 0;
870+
cbm_dir_t *d = cbm_opendir(dir);
871+
if (!d) {
872+
return project;
873+
}
874+
cbm_dirent_t *entry;
875+
while ((entry = cbm_readdir(d)) != NULL) {
876+
const char *n = entry->name;
877+
size_t len = strlen(n);
878+
if (!is_project_db_file(n, len)) {
879+
continue;
880+
}
881+
size_t stem_len = len - MCP_DB_EXT; /* strip ".db" */
882+
if (stem_len <= plen + 1 || stem_len >= sizeof(match)) {
883+
continue;
884+
}
885+
if (n[stem_len - plen - 1] != '-' || strncmp(n + stem_len - plen, project, plen) != 0) {
886+
continue;
887+
}
888+
matches++;
889+
if (matches > 1) {
890+
break; /* ambiguous — keep the original name */
891+
}
892+
memcpy(match, n, stem_len);
893+
match[stem_len] = '\0';
894+
}
895+
cbm_closedir(d);
896+
if (matches == 1) {
897+
cbm_log_info("mcp.project_tail_resolved", "passed", project, "resolved", match);
898+
free(project);
899+
return heap_strdup(match);
900+
}
901+
return project;
902+
}
903+
843904
/* Resolve the project argument, accepting the canonical "project" key plus the
844905
* aliases a caller naturally reaches for (#640): list_projects surfaces the
845906
* 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) {
857918
if (!p) {
858919
p = cbm_mcp_get_string_arg(args_json, "projectName");
859920
}
860-
return normalize_project_arg(p);
921+
return resolve_project_tail(normalize_project_arg(p));
861922
}
862923

863924
int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val) {

‎tests/test_mcp.c‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,105 @@ TEST(tool_search_graph_accepts_project_name_alias_issue640) {
16341634
PASS();
16351635
}
16361636

1637+
/* #1025: agents pass the repo FOLDER name ("codebase-memory-mcp"), but
1638+
* indexed project names derive from the full path
1639+
* (E:\project\graph\x -> "E-project-graph-x"), so exact lookup fails with
1640+
* "project not found" while list_projects clearly shows the project. A
1641+
* passed name that matches exactly ONE indexed project as a segment-aligned
1642+
* tail ("-<name>" suffix) must resolve to it; zero or several matches keep
1643+
* the existing error. Runs against real cache-dir .db files (the resolution
1644+
* scans filenames), so this test indexes real fixtures under an overridden
1645+
* CBM_CACHE_DIR. */
1646+
static void i1025_write_repo(const char *dir, const char *fn_name) {
1647+
char path[CBM_SZ_512];
1648+
snprintf(path, sizeof(path), "%s/mod.py", dir);
1649+
FILE *f = fopen(path, "w");
1650+
if (!f)
1651+
return;
1652+
fprintf(f, "def %s(x):\n return x + 1\n", fn_name);
1653+
fclose(f);
1654+
}
1655+
1656+
TEST(tool_project_arg_resolves_unique_tail_issue1025) {
1657+
char repo_a[CBM_SZ_256];
1658+
char repo_b[CBM_SZ_256];
1659+
char repo_c[CBM_SZ_256];
1660+
char cache[CBM_SZ_256];
1661+
snprintf(repo_a, sizeof(repo_a), "/tmp/cbm-i1025a-XXXXXX");
1662+
snprintf(repo_b, sizeof(repo_b), "/tmp/cbm-i1025b-XXXXXX");
1663+
snprintf(repo_c, sizeof(repo_c), "/tmp/cbm-i1025c-XXXXXX");
1664+
snprintf(cache, sizeof(cache), "/tmp/cbm-i1025d-XXXXXX");
1665+
if (!cbm_mkdtemp(repo_a) || !cbm_mkdtemp(repo_b) || !cbm_mkdtemp(repo_c) ||
1666+
!cbm_mkdtemp(cache)) {
1667+
FAIL("mkdtemp failed");
1668+
}
1669+
const char *saved_cache = getenv("CBM_CACHE_DIR");
1670+
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
1671+
cbm_setenv("CBM_CACHE_DIR", cache, 1);
1672+
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
1673+
1674+
i1025_write_repo(repo_a, "unique_tail_target");
1675+
i1025_write_repo(repo_b, "amb_one");
1676+
i1025_write_repo(repo_c, "amb_two");
1677+
1678+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
1679+
ASSERT_NOT_NULL(srv);
1680+
1681+
char args[CBM_SZ_1K];
1682+
snprintf(args, sizeof(args),
1683+
"{\"repo_path\":\"%s\",\"name\":\"E-project-graph-suffix1025\"}", repo_a);
1684+
char *r = cbm_mcp_handle_tool(srv, "index_repository", args);
1685+
ASSERT_NOT_NULL(r);
1686+
free(r);
1687+
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"F-alpha-amb1025\"}", repo_b);
1688+
r = cbm_mcp_handle_tool(srv, "index_repository", args);
1689+
ASSERT_NOT_NULL(r);
1690+
free(r);
1691+
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"G-beta-amb1025\"}", repo_c);
1692+
r = cbm_mcp_handle_tool(srv, "index_repository", args);
1693+
ASSERT_NOT_NULL(r);
1694+
free(r);
1695+
1696+
/* 1. Unique tail resolves (RED today: "project not found"). */
1697+
r = cbm_mcp_handle_tool(srv, "search_graph",
1698+
"{\"project\":\"suffix1025\",\"name_pattern\":\".*target.*\"}");
1699+
ASSERT_NOT_NULL(r);
1700+
if (strstr(r, "project not found")) {
1701+
fprintf(stderr, " [1025] FAIL unique tail did not resolve: %.200s\n", r);
1702+
}
1703+
ASSERT_NULL(strstr(r, "project not found"));
1704+
ASSERT_NOT_NULL(strstr(r, "unique_tail_target"));
1705+
free(r);
1706+
1707+
/* 2. Ambiguous tail stays an error (never guess between projects). */
1708+
r = cbm_mcp_handle_tool(srv, "search_graph",
1709+
"{\"project\":\"amb1025\",\"name_pattern\":\".*\"}");
1710+
ASSERT_NOT_NULL(r);
1711+
ASSERT_NOT_NULL(strstr(r, "project not found"));
1712+
free(r);
1713+
1714+
/* 3. Exact full name keeps working unchanged. */
1715+
r = cbm_mcp_handle_tool(srv, "search_graph",
1716+
"{\"project\":\"E-project-graph-suffix1025\","
1717+
"\"name_pattern\":\".*target.*\"}");
1718+
ASSERT_NOT_NULL(r);
1719+
ASSERT_NULL(strstr(r, "project not found"));
1720+
free(r);
1721+
1722+
cbm_mcp_server_free(srv);
1723+
if (saved_cache_copy) {
1724+
cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1);
1725+
free(saved_cache_copy);
1726+
} else {
1727+
cbm_unsetenv("CBM_CACHE_DIR");
1728+
}
1729+
th_rmtree(repo_a);
1730+
th_rmtree(repo_b);
1731+
th_rmtree(repo_c);
1732+
th_rmtree(cache);
1733+
PASS();
1734+
}
1735+
16371736
/* Regression for #604: path scopes architecture totals and content. */
16381737
TEST(tool_get_architecture_path_scoping) {
16391738
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -5679,6 +5778,7 @@ SUITE(mcp) {
56795778
RUN_TEST(tool_get_architecture_rejects_unknown_aspect_pr560);
56805779
RUN_TEST(tool_get_architecture_accepts_project_name_alias_issue640);
56815780
RUN_TEST(tool_search_graph_accepts_project_name_alias_issue640);
5781+
RUN_TEST(tool_project_arg_resolves_unique_tail_issue1025);
56825782
RUN_TEST(tool_get_architecture_path_scoping);
56835783
RUN_TEST(tool_query_graph_missing_query);
56845784

0 commit comments

Comments
 (0)