From 0c330c5c4da0f3041a274ea1945d310fd20a3c51 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Fri, 3 Jul 2026 17:24:00 -0400 Subject: [PATCH] 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 --- src/mcp/mcp.c | 33 +++++++++++++++++++++++++++------ tests/test_mcp.c | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index b04fa5b62..654926cc8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 77ed554f9..8908c8ece 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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);