Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -11668,6 +11679,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
Expand Down Expand Up @@ -13828,8 +13850,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);
}
}
}

Expand Down
100 changes: 100 additions & 0 deletions tests/test_mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading