fix(mcp): report dirty freshness for source search

Add dirty-file freshness metadata to search_code responses without hiding live source matches. The handler computes dirty counts once and passes them into the existing search_code JSON assembly path, avoiding post-hoc mutation of the outer MCP text wrapper.

Cover the behavior with a focused MCP test that keeps the live source match visible, preserves dirty ledger state, and asserts the source-search-specific graph freshness warning.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-03 17:24:00 -04:00
parent 179b3c4c4d
commit 0c330c5c4d
2 changed files with 75 additions and 6 deletions
+27 -6
View File
@@ -189,8 +189,10 @@ static bool get_dirty_file_counts(cbm_store_t *store, const char *project,
return true;
}
static void add_dirty_file_freshness_counts(yyjson_mut_doc *doc, yyjson_mut_val *root,
int pending, int overlay_ready) {
static void add_dirty_file_freshness_counts_with_warning(yyjson_mut_doc *doc,
yyjson_mut_val *root,
int pending, int overlay_ready,
const char *warning_message) {
if (!doc || !root || (pending <= 0 && overlay_ready <= 0)) {
return;
}
@@ -211,8 +213,15 @@ static void add_dirty_file_freshness_counts(yyjson_mut_doc *doc, yyjson_mut_val
yyjson_mut_obj_add_int(doc, freshness, CBM_MCP_FRESHNESS_DIRTY_OVERLAY_READY_KEY,
overlay_ready);
add_response_warning(doc, root,
"project has dirty files; canonical graph rows remain visible until "
"overlay or reindex completes.");
warning_message
? warning_message
: "project has dirty files; canonical graph rows remain visible until "
"overlay or reindex completes.");
}
static void add_dirty_file_freshness_counts(yyjson_mut_doc *doc, yyjson_mut_val *root,
int pending, int overlay_ready) {
add_dirty_file_freshness_counts_with_warning(doc, root, pending, overlay_ready, NULL);
}
static void add_dirty_file_freshness(yyjson_mut_doc *doc, yyjson_mut_val *root,
@@ -6350,7 +6359,8 @@ static char *assemble_search_output(search_result_t *sr, int sr_count, grep_matc
int raw_count, int gm_count, int limit, int mode,
int context_lines, const char *root_path,
bool warn_literal_pipe, uint64_t elapsed_ms,
const char *search_scope) {
const char *search_scope, int dirty_pending,
int dirty_overlay_ready, const char *dirty_warning) {
enum {
MODE_COMPACT = 0,
MODE_FULL = 1,
@@ -6442,6 +6452,8 @@ static char *assemble_search_output(search_result_t *sr, int sr_count, grep_matc
if (yyjson_mut_arr_size(warnings) > 0) {
yyjson_mut_obj_add_val(doc, root_obj, "warnings", warnings);
}
add_dirty_file_freshness_counts_with_warning(doc, root_obj, dirty_pending,
dirty_overlay_ready, dirty_warning);
char *json = yy_doc_to_str(doc);
if (json) {
@@ -7112,10 +7124,19 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
? "path_filter_exact"
: (scan_mode == SEARCH_CODE_SCAN_GIT_GREP ? "git_worktree"
: "project_recursive");
int dirty_pending = 0;
int dirty_overlay_ready = 0;
if (!get_dirty_file_counts(store, project, &dirty_pending, &dirty_overlay_ready) &&
srv->store && srv->store != store) {
get_dirty_file_counts(srv->store, project, &dirty_pending, &dirty_overlay_ready);
}
char *result =
assemble_search_output(sr, sr_count, raw, raw_count, gm_count, limit, mode, context_lines,
root_path, pat_has_pipe && !use_regex, cbm_now_ms() - search_t0,
search_scope);
search_scope, dirty_pending, dirty_overlay_ready,
"search_code reads live source files, but graph annotations use "
"canonical graph rows; dirty file graph metadata may be absent "
"until overlay or reindex completes.");
free(gm);
free(sr);
free(raw);
+48
View File
@@ -2295,6 +2295,53 @@ TEST(search_code_multi_word) {
PASS();
}
TEST(search_code_reports_dirty_graph_metadata_without_hiding_live_matches) {
char tmp[512];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
cbm_dirty_file_state_t dirty = {.project = "test-project",
.rel_path = "main.go",
.observed_hash = "search-code-dirty-hash",
.observed_generation = 16,
.source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX,
.status = CBM_STORE_DIRTY_STATUS_PENDING};
ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK);
int pending = 0;
int overlay_ready = 0;
ASSERT_EQ(cbm_store_count_dirty_files(st, "test-project", &pending, &overlay_ready),
CBM_STORE_OK);
ASSERT_EQ(pending, 1);
ASSERT_EQ(overlay_ready, 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
pending = 0;
overlay_ready = 0;
ASSERT_EQ(cbm_store_count_dirty_files(cbm_mcp_server_store(srv), "test-project", &pending,
&overlay_ready),
CBM_STORE_OK);
ASSERT_EQ(pending, 1);
ASSERT_EQ(overlay_ready, 0);
ASSERT_NOT_NULL(strstr(inner, "HandleRequest"));
ASSERT_NOT_NULL(strstr(inner, "\"warnings\""));
ASSERT_NOT_NULL(strstr(inner, "search_code reads live source files"));
ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0));
free(inner);
free(resp);
cleanup_snippet_dir(tmp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(search_code_limit_zero_uses_config_default) {
char tmp[512];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
@@ -4097,6 +4144,7 @@ SUITE(mcp) {
RUN_TEST(tool_search_code_missing_pattern);
RUN_TEST(tool_search_code_no_project);
RUN_TEST(search_code_multi_word);
RUN_TEST(search_code_reports_dirty_graph_metadata_without_hiding_live_matches);
RUN_TEST(search_code_limit_zero_uses_config_default);
RUN_TEST(search_code_invalid_regex_errors_issue283);
RUN_TEST(search_code_literal_pipe_warns_issue282);