fix(mcp): hydrate context for slug project searches

When search_graph is called on a fresh server with project set to a list_projects slug, resolve_store opened the correct DB but left session_project empty. The first response then injected an empty _context even though results came from the indexed project.

Sync session metadata from the opened project DB only when the session is unset, reuse the existing parent-project DB routing for dependency projects, and share the absolute root-path guard with watcher registration.

Tests: failed-first search_graph_slug_project_sets_session_context, CBM_ONLY_SUITE=tool_consolidation, CBM_ONLY_SUITE=mcp, scripts/check-source-safety.sh, scripts/test-source-safety.sh, git diff --check, make -j8 -f Makefile.cbm cbm.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-01 11:07:46 -04:00
parent 5fc03288fb
commit 8f164dd9fa
2 changed files with 73 additions and 7 deletions
+37 -7
View File
@@ -1494,6 +1494,38 @@ static const char *parent_project_for_db(const char *project, char *buf, size_t
return project; /* no .dep → use as-is */
}
static bool root_path_looks_usable(const char *root_path) {
if (!root_path || !root_path[0]) {
return false;
}
if (root_path[0] == '/' || root_path[0] == '\\') {
return true;
}
return ((root_path[0] >= 'A' && root_path[0] <= 'Z') ||
(root_path[0] >= 'a' && root_path[0] <= 'z')) &&
root_path[1] == ':';
}
static void sync_session_from_open_project(cbm_mcp_server_t *srv, cbm_store_t *store,
const char *db_project,
const cbm_project_t *opened_project) {
if (!srv || srv->session_project[0] || !store || !db_project || !db_project[0]) {
return;
}
const char *root_path = opened_project ? opened_project->root_path : NULL;
cbm_project_t parent = {0};
if (cbm_store_get_project(store, db_project, &parent) == CBM_STORE_OK) {
root_path = parent.root_path;
}
snprintf(srv->session_project, sizeof(srv->session_project), "%s", db_project);
if (root_path_looks_usable(root_path)) {
snprintf(srv->session_root, sizeof(srv->session_root), "%s", root_path);
}
cbm_project_free_fields(&parent);
}
static bool mcp_join_suffix(char *out, size_t out_sz, const char *base, const char *suffix) {
int n = snprintf(out, out_sz, "%s%s", base ? base : "", suffix ? suffix : "");
return n > 0 && (size_t)n < out_sz;
@@ -1629,15 +1661,13 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
return NULL;
}
/* Register newly-accessed project with watcher (root_path from DB).
* Validate the path looks like a real path (starts with '/' or a drive
* letter) before watching — a retained bad-path DB (#557) may store a
* Validate that it looks like an absolute POSIX, UNC, or Windows-drive
* path before watching — a retained bad-path DB (#557) may store a
* numeric/empty root_path that would point the watcher at nothing. */
if (srv->watcher && proj_verify.root_path && proj_verify.root_path[0]) {
char c0 = proj_verify.root_path[0];
if (c0 == '/' || (c0 >= 'A' && c0 <= 'Z') || (c0 >= 'a' && c0 <= 'z')) {
cbm_watcher_watch(srv->watcher, project, proj_verify.root_path);
}
if (srv->watcher && root_path_looks_usable(proj_verify.root_path)) {
cbm_watcher_watch(srv->watcher, project, proj_verify.root_path);
}
sync_session_from_open_project(srv, srv->store, db_project, &proj_verify);
cbm_project_free_fields(&proj_verify);
srv->owns_store = true;
free(srv->current_project);
+36
View File
@@ -722,6 +722,41 @@ TEST(search_graph_has_session_project) {
PASS();
}
TEST(search_graph_slug_project_sets_session_context) {
/* A fresh CLI/MCP server may be called with project=<slug> from
* list_projects. Results already came from that DB, but the first response
* context used to stay empty unless project was passed as a filesystem path. */
const char *proj = "_tc_ctx_slug_";
char db_path[1024];
snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), proj);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, proj, "/tmp/tc_ctx_slug"), CBM_STORE_OK);
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "tc_ctx_slug_fn",
.qualified_name = "_tc_ctx_slug_.tc_ctx_slug_fn",
.file_path = "src/tc_ctx_slug.c"};
ASSERT_GT(cbm_store_upsert_node(s, &n), 0);
cbm_store_close(s);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *result = cbm_mcp_handle_tool(
srv, "search_graph",
"{\"project\":\"_tc_ctx_slug_\",\"name_pattern\":\"tc_ctx_slug_fn\",\"limit\":1}");
ASSERT_NOT_NULL(result);
ASSERT_NOT_NULL(strstr(result, "session_project"));
ASSERT_NOT_NULL(strstr(result, "_tc_ctx_slug_"));
ASSERT_NOT_NULL(strstr(result, "\\\"nodes\\\":1"));
free(result);
cbm_mcp_server_free(srv);
(void)unlink(db_path);
PASS();
}
TEST(index_status_has_session_project) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
@@ -2664,6 +2699,7 @@ SUITE(tool_consolidation) {
RUN_TEST(hidden_tools_still_dispatch);
/* Session context */
RUN_TEST(search_graph_has_session_project);
RUN_TEST(search_graph_slug_project_sets_session_context);
RUN_TEST(index_status_has_session_project);
/* Context injection */
RUN_TEST(first_response_has_context_header);