fix(mcp): accept project_name alias + clearer no-store error (#640)

Query tools resolved the project only via the exact "project" key, but
list_projects surfaces the field as "name" and the not-found hint said
"pass the project name", so callers naturally pass "project_name". With no
alias the arg was NULL and resolve_store bailed before opening any .db, so
every query returned "project not found or not indexed" even though the
project was indexed -- the reporter's strace shows the cache dir scanned but
no .db opened. Reproduced on macOS; not Linux-specific.

Add get_project_arg() accepting project | project_name | project_id |
projectName across the 11 query handlers (not bare "name" -- index_repository
uses it for an explicit project-name override). Split the no-store error so a
missing argument reports "missing required argument: project" instead of
masquerading as "not indexed", and name the literal "project" key in the
not-found hint.

Reproduce-first: tool_get_architecture_accepts_project_name_alias_issue640
and tool_search_graph_accepts_project_name_alias_issue640 (RED before ->
GREEN after). Full suite 5724/0, no regressions.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-06-30 12:51:49 +02:00
parent f957dffa80
commit 8b56c52231
2 changed files with 152 additions and 22 deletions
+61 -22
View File
@@ -705,6 +705,26 @@ char *cbm_mcp_get_string_arg(const char *args_json, const char *key) {
return result;
}
/* 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
* "project_name" is the usual guess; "project_id" / "projectName" are accepted
* too. NOT bare "name" — index_repository uses "name" for an explicit
* project-name override. Caller must free() the result. */
static char *get_project_arg(const char *args_json) {
char *p = cbm_mcp_get_string_arg(args_json, "project");
if (!p) {
p = cbm_mcp_get_string_arg(args_json, "project_name");
}
if (!p) {
p = cbm_mcp_get_string_arg(args_json, "project_id");
}
if (!p) {
p = cbm_mcp_get_string_arg(args_json, "projectName");
}
return p;
}
int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val) {
yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0);
if (!doc) {
@@ -1039,7 +1059,8 @@ static char *build_project_list_error(const char *reason) {
if (count > 0) {
snprintf(buf, sizeof(buf),
"{\"error\":\"%s\",\"hint\":\"Use list_projects to see all indexed projects, "
"then pass the project name.\",\"available_projects\":[%s],\"count\":%d}",
"then pass it as the \\\"project\\\" "
"argument.\",\"available_projects\":[%s],\"count\":%d}",
reason, projects, count);
} else {
snprintf(buf, sizeof(buf),
@@ -1050,16 +1071,34 @@ static char *build_project_list_error(const char *reason) {
return heap_strdup(buf);
}
/* Bail with project list when no store is available. */
#define REQUIRE_STORE(store, project) \
do { \
if (!(store)) { \
char *_err = build_project_list_error("project not found or not indexed"); \
char *_res = cbm_mcp_text_result(_err, true); \
free(_err); \
free(project); \
return _res; \
} \
/* Distinct from "unknown project": the caller omitted the project argument
* entirely (no recognized key). Name the literal "project" key so the fix is
* obvious (#640). Caller must free() result. */
static char *build_missing_project_error(void) {
return heap_strdup("{\"error\":\"missing required argument: project\",\"hint\":\"Pass "
"the project as the \\\"project\\\" argument, e.g. "
"{\\\"project\\\":\\\"<name from list_projects>\\\"}. Run "
"list_projects to see indexed projects.\"}");
}
/* Pick the right no-store error: a NULL project means the argument was missing
* (clearer message); a non-NULL project that didn't resolve means it's
* unknown/unindexed (list the available ones). */
static char *build_no_store_error(const char *project) {
return project ? build_project_list_error("project not found or not indexed")
: build_missing_project_error();
}
/* Bail with the right error when no store is available. */
#define REQUIRE_STORE(store, project) \
do { \
if (!(store)) { \
char *_err = build_no_store_error(project); \
char *_res = cbm_mcp_text_result(_err, true); \
free(_err); \
free(project); \
return _res; \
} \
} while (0)
/* ── Tool handler implementations ─────────────────────────────── */
@@ -1200,7 +1239,7 @@ static char *verify_project_indexed(cbm_store_t *store, const char *project) {
}
static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) {
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
REQUIRE_STORE(store, project);
@@ -1668,7 +1707,7 @@ static bool run_semantic_query(yyjson_mut_doc *doc, yyjson_mut_val *root, const
}
static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
REQUIRE_STORE(store, project);
@@ -1813,7 +1852,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) {
char *query = cbm_mcp_get_string_arg(args, "query");
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
int max_rows = cbm_mcp_get_int_arg(args, "max_rows", 0);
@@ -1891,7 +1930,7 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) {
}
static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
REQUIRE_STORE(store, project);
@@ -1935,7 +1974,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
/* delete_project: just erase the .db file (and WAL/SHM). */
static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
char *name = cbm_mcp_get_string_arg(args, "project");
char *name = get_project_arg(args);
if (!name) {
return cbm_mcp_text_result("project is required", true);
}
@@ -2046,7 +2085,7 @@ static void append_cross_repo_summary(yyjson_mut_doc *doc, yyjson_mut_val *root,
}
static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
char *scope_path = cbm_mcp_get_string_arg(args, "path");
cbm_store_t *store = resolve_store(srv, project);
REQUIRE_STORE(store, project);
@@ -2598,7 +2637,7 @@ static void bfs_union_same_name(cbm_store_t *store, const cbm_node_t *nodes, int
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
char *direction = cbm_mcp_get_string_arg(args, "direction");
char *mode = cbm_mcp_get_string_arg(args, "mode");
@@ -3460,7 +3499,7 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node,
static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) {
char *qn = cbm_mcp_get_string_arg(args, "qualified_name");
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
bool include_neighbors = cbm_mcp_get_bool_arg(args, "include_neighbors");
if (!qn) {
@@ -4176,7 +4215,7 @@ static bool compile_path_filter(const char *filter, cbm_regex_t *re) {
static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
char *pattern = cbm_mcp_get_string_arg(args, "pattern");
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
char *file_pattern = cbm_mcp_get_string_arg(args, "file_pattern");
char *path_filter = cbm_mcp_get_string_arg(args, "path_filter");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
@@ -4434,7 +4473,7 @@ static void detect_add_impacted_symbols(cbm_store_t *store, const char *project,
}
static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
char *base_branch = cbm_mcp_get_string_arg(args, "base_branch");
char *since = cbm_mcp_get_string_arg(args, "since");
char *scope = cbm_mcp_get_string_arg(args, "scope");
@@ -4674,7 +4713,7 @@ static char *adr_read_legacy_file(const char *root_path) {
"PATTERNS, TRADEOFFS, PHILOSOPHY."
static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
char *project = cbm_mcp_get_string_arg(args, "project");
char *project = get_project_arg(args);
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
char *content = cbm_mcp_get_string_arg(args, "content");
+91
View File
@@ -894,6 +894,95 @@ TEST(tool_get_architecture_emits_populated_sections) {
PASS();
}
/* Reproduce-first for #640: query handlers must accept the `project_name`
* alias, not only the canonical `project` key. list_projects surfaces the field
* as "name" and the error hint says "pass the project name", so a caller
* naturally passes `project_name`. With no alias, the handler reads key
* "project" -> NULL -> resolve_store bails before opening any .db -> "project
* not found or not indexed" even though the project is indexed. Mirrors
* tool_get_architecture_emits_populated_sections but with the alias key. */
TEST(tool_get_architecture_accepts_project_name_alias_issue640) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "alias640";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/alias640");
cbm_node_t main_fn = {0};
main_fn.project = proj;
main_fn.label = "Function";
main_fn.name = "main";
main_fn.qualified_name = "alias640.cmd.main";
main_fn.file_path = "cmd/main.go";
main_fn.start_line = 1;
main_fn.end_line = 3;
main_fn.properties_json = "{\"is_entry_point\":true}";
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
/* Caller passes `project_name` (the natural guess) instead of `project`. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":640,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project_name\":\"alias640\",\"aspects\":[\"all\"]}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* RED before the alias: inner is the "project not found" error.
* GREEN after: the alias resolves and architecture sections surface. */
ASSERT_NULL(strstr(inner, "project not found"));
ASSERT_NOT_NULL(strstr(inner, "\"entry_points\""));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first for #640: the alias must apply across query handlers, not
* just get_architecture. search_graph with `project_name` must resolve too. */
TEST(tool_search_graph_accepts_project_name_alias_issue640) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "alias640b";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/alias640b");
cbm_node_t fn = {0};
fn.project = proj;
fn.label = "Function";
fn.name = "WidgetHandler";
fn.qualified_name = "alias640b.svc.WidgetHandler";
fn.file_path = "svc/widget.go";
fn.start_line = 1;
fn.end_line = 2;
ASSERT_GT(cbm_store_upsert_node(st, &fn), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":641,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project_name\":\"alias640b\",\"name_pattern\":\"Widget.*\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NULL(strstr(inner, "project not found"));
ASSERT_NOT_NULL(strstr(inner, "WidgetHandler"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
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);
@@ -2488,6 +2577,8 @@ SUITE(mcp) {
RUN_TEST(tool_delete_project_not_found);
RUN_TEST(tool_get_architecture_empty);
RUN_TEST(tool_get_architecture_emits_populated_sections);
RUN_TEST(tool_get_architecture_accepts_project_name_alias_issue640);
RUN_TEST(tool_search_graph_accepts_project_name_alias_issue640);
RUN_TEST(tool_get_architecture_path_scoping);
RUN_TEST(tool_query_graph_missing_query);