Skip to content
Open
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
267 changes: 251 additions & 16 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -1901,6 +1901,8 @@ struct cbm_mcp_server {
void *auto_index_count_test_context;
cbm_mcp_snapshot_read_test_hook_fn snapshot_read_test_hook;
void *snapshot_read_test_context;
cbm_mcp_snippet_capture_test_hook_fn snippet_capture_test_hook;
void *snippet_capture_test_context;
#endif
bool read_batch_tool; /* this request's store reads run in a read batch */
size_t search_output_limit_override;
Expand Down Expand Up @@ -2271,6 +2273,16 @@ void cbm_mcp_server_set_snapshot_read_test_hook(cbm_mcp_server_t *srv,
srv->snapshot_read_test_hook = hook;
srv->snapshot_read_test_context = context;
}

void cbm_mcp_server_set_snippet_capture_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_snippet_capture_test_hook_fn hook,
void *context) {
if (!srv) {
return;
}
srv->snippet_capture_test_hook = hook;
srv->snippet_capture_test_context = context;
}
#endif

/* ── Cache dir + project DB path helpers ───────────────────────── */
Expand Down Expand Up @@ -7047,10 +7059,161 @@ static coverage_path_result_t coverage_normalize_rel(const char *input, bool all
return written > 0U || allow_root ? COVERAGE_PATH_OK : COVERAGE_PATH_INVALID;
}

static const char *coverage_path_freshness(cbm_store_t *store, const char *project,
const char *root_path, const char *rel_path,
bool *outside) {
/* #2585: the store generation ("u<db_uid>g<mutation_gen>") advances on every
* index run, incremental ones included, and on every publication. It is read
* when the response is built and names the publication current then; it does
* not make the response one atomic read of that publication.
* metadata.generation names the coverage-recording run and stays fixed across
* incremental re-indexes. Empty when unreadable. */
static void mcp_add_store_generation(yyjson_mut_doc *doc, yyjson_mut_val *obj, cbm_store_t *store) {
char generation[QUERY_GRAPH_GENERATION_CAP] = "";
if (!store || cbm_store_generation(store, generation, sizeof(generation)) != CBM_STORE_OK) {
generation[0] = '\0';
}
yyjson_mut_obj_add_strcpy(doc, obj, "store_generation", generation);
}

/* #2585: file_hashes.sha256 is the SHA-256 of the raw bytes the indexer read
* (pipeline_incremental.c semantic_manifest_hash_file). Recomputing it is the
* only way to say the bytes on disk are the bytes that were indexed: mtime and
* size miss a same-size edit whose timestamp was preserved (cp -p, rsync -t,
* archive extraction), and go stale when identical bytes are rewritten. */
static bool mcp_file_sha256_hex(const char *abs_path, char out[CBM_SHA256_HEX_LEN + 1]) {
FILE *file = cbm_fopen(abs_path, "rb");
if (!file) {
return false;
}
cbm_sha256_ctx sha;
cbm_sha256_init(&sha);
unsigned char buf[CBM_SZ_4K];
size_t len;
while ((len = fread(buf, 1, sizeof(buf), file)) > 0) {
cbm_sha256_update(&sha, buf, len);
}
bool valid = !ferror(file);
if (fclose(file) != 0) {
valid = false;
}
if (!valid) {
return false;
}
uint8_t digest[CBM_SHA256_DIGEST_LEN];
cbm_sha256_final(&sha, digest);
static const char hex[] = "0123456789abcdef";
for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) {
out[i * 2] = hex[digest[i] >> 4];
out[i * 2 + 1] = hex[digest[i] & 0x0f];
}
out[CBM_SHA256_HEX_LEN] = '\0';
return true;
}

/* True when the recorded content hash is a well-formed SHA-256 the current
* bytes can be compared against. */
static bool mcp_recorded_sha256(const char *sha) {
if (!sha || strlen(sha) != CBM_SHA256_HEX_LEN) {
return false;
}
for (int i = 0; i < CBM_SHA256_HEX_LEN; i++) {
if (!((sha[i] >= '0' && sha[i] <= '9') || (sha[i] >= 'a' && sha[i] <= 'f'))) {
return false;
}
}
return true;
}

/* #2585: read a whole file into *bytes (released with cbm_sb_free), so that
* the bytes a response serves are the bytes whose hash was checked. */
static bool mcp_read_file_bytes(const char *abs_path, cbm_sb_t *bytes) {
cbm_sb_init(bytes);
FILE *file = cbm_fopen(abs_path, "rb");
if (!file) {
return false;
}
char buf[CBM_SZ_4K];
size_t len;
while ((len = fread(buf, 1, sizeof(buf), file)) > 0) {
cbm_sb_append_n(bytes, buf, len);
}
bool valid = !ferror(file) && !bytes->oom;
if (fclose(file) != 0) {
valid = false;
}
if (!valid) {
cbm_sb_free(bytes);
}
return valid;
}

/* Lines [start, end] of an in-memory file, with read_file_lines' semantics
* (text-mode line endings, NULL when the range is empty). */
static char *mcp_slice_lines(cbm_sb_t *bytes, int start, int end) {
size_t begin = bytes->len;
size_t stop = bytes->len;
int lineno = 1;
for (size_t i = 0; i < bytes->len; i++) {
if (lineno >= start && begin == bytes->len) {
begin = i;
}
if (bytes->buf[i] == '\n') {
if (lineno >= end) {
stop = i + 1;
break;
}
lineno++;
}
}
if (begin >= stop) {
return NULL;
}
char saved = bytes->buf[stop];
bytes->buf[stop] = '\0';
char *slice = heap_strdup(bytes->buf + begin);
bytes->buf[stop] = saved;
#ifdef _WIN32
/* read_file_lines opens in text mode. */
if (slice) {
char *out = slice;
for (const char *in = slice; *in; in++) {
if (!(in[0] == '\r' && in[1] == '\n')) {
*out++ = *in;
}
}
*out = '\0';
}
#endif
return slice;
}

/* A path is current when its bytes were verified to be the indexed bytes,
* whatever its recorded metadata says. A metadata match whose content could not
* be verified is not: it is the verdict the hash check exists to replace. */
static bool coverage_freshness_current(const char *freshness, bool content_verified) {
return content_verified && freshness &&
(strcmp(freshness, "metadata_match") == 0 || strcmp(freshness, "content_match") == 0);
}

/* Freshness of one tracked path. With a recorded content hash the verdict is
* by content: "metadata_match" (metadata and bytes agree), "content_changed"
* (metadata agrees, bytes differ), "content_match" (metadata is stale, bytes
* are identical) or "metadata_changed" (both differ). Without one it falls
* back to metadata alone and *content_verified stays false.
*
* When captured is non-NULL, the file is read once into it (released with
* cbm_sb_free) and the verdict is about exactly those bytes, so a caller
* serving them cannot race a concurrent write. *captured_ok stays false when
* the path never reached the file (missing, outside, not tracked,
* unreadable). */
static const char *coverage_path_freshness_capture(cbm_store_t *store, const char *project,
const char *root_path, const char *rel_path,
bool *outside, bool *content_verified,
cbm_sb_t *captured, bool *captured_ok) {
*outside = false;
*content_verified = false;
if (captured) {
cbm_sb_init(captured);
*captured_ok = false;
}
if (!root_path || !root_path[0]) {
return "unavailable";
}
Expand Down Expand Up @@ -7083,8 +7246,35 @@ static const char *coverage_path_freshness(cbm_store_t *store, const char *proje
return "unavailable";
}
bool matches = hash.mtime_ns == info.mtime_ns && hash.size == info.size;
char current[CBM_SHA256_HEX_LEN + 1] = "";
bool hashed = false;
if (captured) {
*captured_ok = mcp_read_file_bytes(abs_path, captured);
if (*captured_ok) {
cbm_sha256_hex(captured->buf ? captured->buf : "", captured->len, current);
hashed = true;
}
} else {
hashed = mcp_recorded_sha256(hash.sha256) && mcp_file_sha256_hex(abs_path, current);
}
bool verifiable = mcp_recorded_sha256(hash.sha256) && hashed;
bool same_bytes = verifiable && strcmp(current, hash.sha256) == 0;
cbm_store_clear_file_hash(&hash);
return matches ? "metadata_match" : "metadata_changed";
if (!verifiable) {
return matches ? "metadata_match" : "metadata_changed";
}
*content_verified = true;
if (matches) {
return same_bytes ? "metadata_match" : "content_changed";
}
return same_bytes ? "content_match" : "metadata_changed";
}

static const char *coverage_path_freshness(cbm_store_t *store, const char *project,
const char *root_path, const char *rel_path,
bool *outside, bool *content_verified) {
return coverage_path_freshness_capture(store, project, root_path, rel_path, outside,
content_verified, NULL, NULL);
}

/* Read an "start-end,start-end,...[,+<N>]" string into a JSON ranges array.
Expand Down Expand Up @@ -7229,8 +7419,9 @@ static const char *coverage_status(const cbm_coverage_row_t *rows, int count,
return "no_recorded_issue";
}

static const char *coverage_recommended_action(const char *status, const char *freshness) {
if (!freshness || strcmp(freshness, "metadata_match") != 0) {
static const char *coverage_recommended_action(const char *status, const char *freshness,
bool content_verified) {
if (!coverage_freshness_current(freshness, content_verified)) {
return "read_source_and_reindex";
}
if (strcmp(status, "partial") == 0) {
Expand Down Expand Up @@ -7322,6 +7513,7 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
yyjson_mut_obj_add_int(doc, meta_obj, "coverage_version",
have_meta ? meta.coverage_version : 0);
yyjson_mut_obj_add_bool(doc, meta_obj, "generation_matches", generation_matches);
mcp_add_store_generation(doc, meta_obj, store);
yyjson_mut_obj_add_val(doc, root, "metadata", meta_obj);

yyjson_mut_val *path_results = yyjson_mut_arr(doc);
Expand Down Expand Up @@ -7361,18 +7553,22 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
yyjson_mut_obj_add_str(doc, item, "coverage_lookup", "error");
}
bool outside = false;
const char *freshness = coverage_path_freshness(
store, project, have_project ? proj.root_path : NULL, rel, &outside);
bool exact_path_verified =
have_meta && meta.hash_records_complete && strcmp(freshness, "metadata_match") == 0;
bool content_verified = false;
const char *freshness =
coverage_path_freshness(store, project, have_project ? proj.root_path : NULL, rel,
&outside, &content_verified);
bool exact_path_verified = have_meta && meta.hash_records_complete &&
coverage_freshness_current(freshness, content_verified);
const char *status =
outside ? "outside_project"
: coverage_status(rows, row_count, rel, recording_status,
generation_matches, lookup_ok, exact_path_verified);
yyjson_mut_obj_add_strcpy(doc, item, "status", status);
yyjson_mut_obj_add_strcpy(doc, item, "freshness", freshness);
yyjson_mut_obj_add_strcpy(doc, item, "recommended_action",
coverage_recommended_action(status, freshness));
yyjson_mut_obj_add_bool(doc, item, "content_verified", content_verified);
yyjson_mut_obj_add_strcpy(
doc, item, "recommended_action",
coverage_recommended_action(status, freshness, content_verified));
yyjson_mut_val *coverage = yyjson_mut_arr(doc);
for (int i = 0; i < row_count; i++) {
coverage_add_row_json(doc, coverage, &rows[i], rel, diagnostics_full);
Expand Down Expand Up @@ -13060,8 +13256,10 @@ static char *snippet_pdf_text(const cbm_node_t *node) {
return out;
}

/* #2585: when captured is non-NULL the lines come from those already-read
* bytes instead of a fresh read of the file. */
static char *resolve_snippet_source(const char *root_path, const char *file_path, int start,
int end, char **out_abs_path) {
int end, cbm_sb_t *captured, char **out_abs_path) {
*out_abs_path = NULL;
if (!root_path || !file_path) {
return NULL;
Expand All @@ -13072,7 +13270,8 @@ static char *resolve_snippet_source(const char *root_path, const char *file_path

*out_abs_path = abs_path;
if (cbm_path_within_root(root_path, abs_path)) {
return read_file_lines(abs_path, start, end);
return captured ? mcp_slice_lines(captured, start, end)
: read_file_lines(abs_path, start, end);
}
return NULL;
}
Expand Down Expand Up @@ -13557,15 +13756,44 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node,
if (!outline && end < original_end) {
snippet_clipped = true;
}
/* #2585: start/end are indexed line numbers. Slice the file only when its
* bytes are the indexed bytes; otherwise the same range can hold another
* symbol's code. The file is read once: the slice comes from the same
* bytes whose hash was checked, so a concurrent write cannot slip in
* between. Unverifiable files keep the previous behavior and say so
* through content_verified. */
const char *source_freshness = NULL;
bool source_content_verified = false;
bool source_drift = false;
cbm_sb_t captured;
cbm_sb_init(&captured);
bool captured_ok = false;
if (!outline && !snippet_is_pdf(node->file_path) && root_path && node->file_path) {
bool outside = false;
source_freshness = coverage_path_freshness_capture(
srv->store, node->project, root_path, node->file_path, &outside,
&source_content_verified, &captured, &captured_ok);
source_drift = source_content_verified &&
!coverage_freshness_current(source_freshness, source_content_verified);
#ifdef CBM_ENABLE_TEST_SEAMS
if (srv->snippet_capture_test_hook) {
srv->snippet_capture_test_hook(srv->snippet_capture_test_context, node->file_path);
}
#endif
}
char *abs_path = NULL;
char *source = NULL;
if (outline) {
if (outline || source_drift) {
source = NULL;
} else if (snippet_is_pdf(node->file_path)) {
source = snippet_pdf_text(node); /* never the file's bytes */
} else {
source = resolve_snippet_source(root_path, node->file_path, start, end, &abs_path);
/* Without captured bytes (not tracked, unreadable) the file is read
* here, and the snippet is never content-verified. */
source = resolve_snippet_source(root_path, node->file_path, start, end,
captured_ok ? &captured : NULL, &abs_path);
}
cbm_sb_free(&captured);

yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
Expand All @@ -13585,6 +13813,13 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node,
yyjson_mut_obj_add_str(doc, root_obj, "file_path", display_path);
yyjson_mut_obj_add_int(doc, root_obj, "start_line", start);
yyjson_mut_obj_add_int(doc, root_obj, "end_line", end);
mcp_add_store_generation(doc, root_obj, srv->store);
yyjson_mut_obj_add_bool(doc, root_obj, "content_verified", source_content_verified);
if (source_drift) {
yyjson_mut_obj_add_bool(doc, root_obj, "source_drift", true);
yyjson_mut_obj_add_strcpy(doc, root_obj, "freshness", source_freshness);
yyjson_mut_obj_add_str(doc, root_obj, "recommended_action", "reindex_then_retry");
}
if (snippet_clipped) {
yyjson_mut_obj_add_bool(doc, root_obj, "source_truncated", true);
yyjson_mut_obj_add_bool(doc, root_obj, "source_clipped", true); /* compatibility */
Expand Down
7 changes: 7 additions & 0 deletions src/mcp/mcp_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ void cbm_mcp_server_set_snapshot_read_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_snapshot_read_test_hook_fn hook,
void *context);

/* Runs after get_code_snippet has read and verified a file's bytes and before
* it slices them, so a test can write the file at exactly that point (#2585). */
typedef void (*cbm_mcp_snippet_capture_test_hook_fn)(void *context, const char *rel_path);
void cbm_mcp_server_set_snippet_capture_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_snippet_capture_test_hook_fn hook,
void *context);

/* Filesystem PATH_MAX prevents a portable end-to-end fixture for multi-KiB
* stored paths. This seam drives the normal search result ownership,
* directory aggregation, and renderer pipeline with synthetic identities. */
Expand Down
Loading
Loading