From 22e0335d94552dc1a9d9ef0ee8221e7665608209 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Tue, 8 Sep 2026 22:08:19 +0800 Subject: [PATCH 1/2] fix(mcp): reject stale AST coordinates after source changes Signed-off-by: lorenzozanee --- src/mcp/mcp.c | 21 +++++++++- tests/test_mcp.c | 100 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7aa40605c..ca66933f7 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -11668,6 +11668,17 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, cbm_node_t *alternatives, int alt_count, const char *args) { char *root_path = get_project_root(srv, node->project); + bool outside = false; + const char *freshness = coverage_path_freshness(srv->store, node->project, root_path, + node->file_path, &outside); + if (strcmp(freshness, "metadata_match") != 0 && strcmp(freshness, "not_tracked") != 0) { + free(root_path); + return cbm_mcp_text_result( + "source changed since indexing; re-index the project before requesting this " + "snippet", + true); + } + int original_start = node->start_line > 0 ? node->start_line : SKIP_ONE; /* A one-line symbol legitimately has end == start. Treat only missing or * inverted end metadata as unknown; expanding a valid one-line node by 50 @@ -13828,8 +13839,14 @@ static bool scan_and_classify_grep_matches( break; } if (store) { - (void)cbm_store_find_nodes_by_file(store, project, file, &file_nodes, - &file_node_count); + bool outside = false; + const char *freshness = + coverage_path_freshness(store, project, root_path, file, &outside); + if (strcmp(freshness, "metadata_match") == 0 || + strcmp(freshness, "not_tracked") == 0) { + (void)cbm_store_find_nodes_by_file(store, project, file, &file_nodes, + &file_node_count); + } } } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 6fbaaf66c..99dc78895 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2866,6 +2866,104 @@ TEST(tool_get_architecture_cycles_detects_scc) { PASS(); } +/* A source edit after indexing invalidates stored line coordinates. The read + * tools must not present those coordinates as current source truth. */ +TEST(tool_get_code_snippet_rejects_changed_source_coordinates_issue1750) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + + char source_path[512]; + snprintf(source_path, sizeof(source_path), "%s/project/main.go", tmp); + struct stat source_stat; + ASSERT_EQ(stat(source_path, &source_stat), 0); +#ifdef __APPLE__ + int64_t source_mtime_ns = + ((int64_t)source_stat.st_mtimespec.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + + (int64_t)source_stat.st_mtimespec.tv_nsec; +#elif defined(_WIN32) + int64_t source_mtime_ns = (int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC; +#else + int64_t source_mtime_ns = ((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + + (int64_t)source_stat.st_mtim.tv_nsec; +#endif + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "fixture", + source_mtime_ns, source_stat.st_size), + CBM_STORE_OK); + + FILE *fp = fopen(source_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("// inserted\n// inserted\npackage main\n\n" + "func HandleRequest() error {\n\treturn nil\n}\n\n" + "func ProcessOrder(id int) {\n\t// process\n}\n\n" + "func Run() {\n\t// server\n}\n", + fp); + fclose(fp); + + char *response = cbm_mcp_handle_tool( + srv, "get_code_snippet", + "{\"project\":\"test-project\",\"qualified_name\":\"" + "test-project.cmd.server.main.ProcessOrder\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "isError")); + ASSERT_NOT_NULL(strstr(response, "changed since indexing")); + ASSERT_NULL(strstr(response, "// process")); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(tool_search_code_does_not_map_changed_source_to_stale_nodes_issue1750) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + + char source_path[512]; + snprintf(source_path, sizeof(source_path), "%s/project/main.go", tmp); + struct stat source_stat; + ASSERT_EQ(stat(source_path, &source_stat), 0); +#ifdef __APPLE__ + int64_t source_mtime_ns = + ((int64_t)source_stat.st_mtimespec.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + + (int64_t)source_stat.st_mtimespec.tv_nsec; +#elif defined(_WIN32) + int64_t source_mtime_ns = (int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC; +#else + int64_t source_mtime_ns = ((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + + (int64_t)source_stat.st_mtim.tv_nsec; +#endif + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "fixture", + source_mtime_ns, source_stat.st_size), + CBM_STORE_OK); + + FILE *fp = fopen(source_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("// inserted\n// inserted\npackage main\n\n" + "func HandleRequest() error {\n\treturn nil\n}\n\n" + "func ProcessOrder(id int) {\n\t// process\n}\n\n" + "func Run() {\n\t// server\n}\n", + fp); + fclose(fp); + + char *response = cbm_mcp_handle_tool( + srv, "search_code", + "{\"project\":\"test-project\",\"pattern\":\"process\"," + "\"mode\":\"full\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "raw_matches")); + ASSERT_NOT_NULL(strstr(response, "// process")); + ASSERT_NULL(strstr(response, "test-project.cmd.server.main.ProcessOrder")); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + /* Context-bomb guard: get_code_snippet on a whole-file node (a Module/File * span) used to read the ENTIRE file into one response — a field-eval agent * that fell back to a Module snippet pulled ~400KB in a single call. The read @@ -20087,6 +20185,7 @@ SUITE(mcp) { RUN_TEST(tool_trace_totals_respect_test_filter_tests_root_subtree_issue1294); RUN_TEST(tool_get_architecture_cycles_detects_scc); RUN_TEST(tool_get_code_snippet_clips_whole_file_node); + RUN_TEST(tool_get_code_snippet_rejects_changed_source_coordinates_issue1750); RUN_TEST(tool_get_code_snippet_omits_over_budget_whole_line); RUN_TEST(tool_get_code_snippet_pages_outline_rows_to_exact_budget); RUN_TEST(tool_search_graph_includes_node_properties); @@ -20167,6 +20266,7 @@ SUITE(mcp) { RUN_TEST(tool_search_code_limit_declares_a_minimum_issue1511); RUN_TEST(tool_search_code_declares_independent_result_and_raw_content_paging); RUN_TEST(tool_search_code_no_project); + RUN_TEST(tool_search_code_does_not_map_changed_source_to_stale_nodes_issue1750); RUN_TEST(search_code_multi_word); RUN_TEST(search_code_full_preserves_utf8_source); RUN_TEST(search_code_raw_match_preserves_utf8_content); From dcb10fe902905926eaf9325bb03254dde9cea6ab Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Tue, 8 Sep 2026 23:22:03 +0800 Subject: [PATCH 2/2] fix(mcp): reject stale AST coordinates after source changes Signed-off-by: lorenzozanee --- src/mcp/mcp.c | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index ca66933f7..79925b208 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6306,7 +6306,18 @@ static const char *coverage_path_freshness(cbm_store_t *store, const char *proje if (rc != CBM_STORE_OK) { return "unavailable"; } - bool matches = hash.mtime_ns == coverage_stat_mtime_ns(&st) && hash.size == st.st_size; + bool matches; +#ifdef _WIN32 + cbm_path_info_t path_info = {0}; + matches = cbm_path_info_utf8(abs_path, &path_info) == CBM_PATH_INFO_OK && + hash.mtime_ns == path_info.mtime_ns && hash.size == path_info.size; + /* Imported artifacts may have been restamped from Windows stat() values. */ + if (!matches && hash.mtime_ns >= 0 && hash.mtime_ns % CBM_NSEC_PER_SEC == 0) { + matches = hash.mtime_ns == coverage_stat_mtime_ns(&st) && hash.size == st.st_size; + } +#else + matches = hash.mtime_ns == coverage_stat_mtime_ns(&st) && hash.size == st.st_size; +#endif cbm_store_clear_file_hash(&hash); return matches ? "metadata_match" : "metadata_changed"; } @@ -11669,8 +11680,8 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, char *root_path = get_project_root(srv, node->project); bool outside = false; - const char *freshness = coverage_path_freshness(srv->store, node->project, root_path, - node->file_path, &outside); + const char *freshness = + coverage_path_freshness(srv->store, node->project, root_path, node->file_path, &outside); if (strcmp(freshness, "metadata_match") != 0 && strcmp(freshness, "not_tracked") != 0) { free(root_path); return cbm_mcp_text_result(