diff --git a/README.md b/README.md index 46fb6317b..f725c50bd 100644 --- a/README.md +++ b/README.md @@ -977,6 +977,39 @@ Static `CALLS` indexing omits direct self-call edges; traces that include `CALLS nodes, so a recursive function can have zero callees. Recursion metadata remains visible on continuation pages and when an output budget prevents rows from fitting. +### Publication-bound observations + +`query_graph`, `search_graph`, `trace_path`, `get_code_snippet`, `search_code` +and `check_index_coverage` accept `snapshot: true`. A successful request pins +its structural database reads to one publication and returns this JSON envelope +through both MCP and the CLI: + +```json +{"store_generation":"u0123456789abcdefg7","snapshot_consistent":true,"data":{}} +``` + +`data` contains the original tool result: an object with `format: "json"`, or +its original text with `format: "tree"`. Compare `store_generation` across all +observations and continuation pages used together; retry when it changes. +Existing output-token budgets apply to `data`; the envelope adds metadata and +JSON quoting overhead. +Failure to pin the database, unavailable generation metadata, cancellation or a +lost read scope produces an error. Cross-service tracing reads multiple project +stores and cannot use this single-publication option. Omit `snapshot` to retain +the unwrapped response. + +The pin covers indexed structure, not the live filesystem. Snippet and coverage +content-verification fields remain necessary. `search_code` match coordinates +are explicitly identified by `match_source: "live_filesystem"`; they describe +the scan, not indexed source locations. Full/context source cells report +`content_verified`. Known drift adds `source_drift` and withholds source; verified +source is sliced from the exact captured bytes checked against the indexed hash. +Search shares captures by file path with a 64 MiB retained-buffer budget. If +capture exceeds that budget, `source_unavailable: "capture_budget"` withholds +source; narrow the query or use `get_code_snippet` for an individual symbol. +Unverifiable source remains explicitly unverified. `metadata.generation` still +identifies the coverage-recording run, separately from `store_generation`. + ## Graph Data Model ### Node Labels diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7097fd062..0f489ed67 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -571,7 +571,8 @@ static const tool_def_t TOOLS[] = { {"search_graph", "Find symbols via BM25 query, regex name/qn filters, or semantic_query. Rows keep " "qn/file/lines and in/out over CALLS/USAGE/CALL_REFERENCE/INHERITS/IMPLEMENTS.", - "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," + "{\"type\":\"object\",\"properties\":{\"snapshot\":{\"type\":\"boolean\"},\"project\":{" + "\"type\":\"string\"}," "\"query\":{\"type\":\"string\"}," "\"label\":{\"type\":\"string\"},\"name_pattern\":{\"type\":\"string\"},\"qn_pattern\":{" "\"type\":\"string\"},\"file_pattern\":{\"type\":\"string\"}," @@ -596,12 +597,11 @@ static const tool_def_t TOOLS[] = { "\"required\":[\"project\"]}"}, {"query_graph", - "Read-only Cypher for multi-hop, aggregation, complexity, or cross-service analysis. " - "Default: 200 visible rows with " - "exact/lower-bound totals and truncation; continue safely with next_cursor. " - "graph=missed is a file tree of flagged coverage gaps; absence is not proof of completeness. " - "Use get_graph_schema(diagnostics=full) for properties.", - "{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"Cypher " + "Read-only Cypher. Default 200 rows, exact/lower-bound totals, truncation and " + "next_cursor. graph=missed lists coverage gaps; absence is not proof of completeness. " + "Properties: get_graph_schema(diagnostics=full).", + "{\"type\":\"object\",\"properties\":{\"snapshot\":{\"type\":\"boolean\"},\"query\":{\"type\":" + "\"string\",\"description\":\"Cypher " "query\"},\"project\":{\"type\":\"string\"}," "\"graph\":{\"type\":\"string\",\"enum\":[\"code\",\"missed\"],\"default\":\"code\"," "\"description\":\"code graph (default) or missed coverage-gap file tree.\"}," @@ -622,7 +622,8 @@ static const tool_def_t TOOLS[] = { {"trace_path", "Trace callers/callees, data flow, or cross-service paths. Defaults exclude tests and " "resolver evidence. Rows keep qn/hop with explicit totals, relations, and continuations.", - "{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"project\":{" + "{\"type\":\"object\",\"properties\":{\"snapshot\":{\"type\":\"boolean\"},\"function_name\":{" + "\"type\":\"string\"},\"project\":{" "\"type\":\"string\"},\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\"," "\"both\"],\"default\":\"both\"},\"depth\":{\"type\":\"integer\",\"default\":3," "\"minimum\":1,\"maximum\":15}," @@ -655,7 +656,8 @@ static const tool_def_t TOOLS[] = { {"get_code_snippet", "Read a search_graph symbol. auto bounds source and outlines large containers; full " "restores up to 500 lines. Source/outline pages continue; coverage_note marks gaps.", - "{\"type\":\"object\",\"properties\":{\"qualified_name\":{\"type\":\"string\",\"description\":" + "{\"type\":\"object\",\"properties\":{\"snapshot\":{\"type\":\"boolean\"},\"qualified_name\":{" + "\"type\":\"string\",\"description\":" "\"search_graph qn, or short name.\"},\"project\":{" "\"type\":\"string\"},\"include_neighbors\":{" "\"type\":\"boolean\",\"default\":false}," @@ -733,7 +735,8 @@ static const tool_def_t TOOLS[] = { {"search_code", "Graph-ranked text search: compact symbols, full bounded source, or file paths.", - "{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":" + "{\"type\":\"object\",\"properties\":{\"snapshot\":{\"type\":\"boolean\"},\"pattern\":{" + "\"type\":\"string\"},\"project\":{\"type\":" "\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob on file name; " "with '/', on repo-relative path (** = any dirs).\"}," "\"path_filter\":{\"type\":\"string\",\"description\":\"Regex on repo-relative path.\"}," @@ -795,7 +798,7 @@ static const tool_def_t TOOLS[] = { {"check_index_coverage", "Best-effort exact-path/scope coverage and freshness, paged separately. full diagnostics " "adds raw detail. Clean is not proof of completeness.", - "{\"type\":\"object\",\"properties\":{" + "{\"type\":\"object\",\"properties\":{\"snapshot\":{\"type\":\"boolean\"}," "\"project\":{\"type\":\"string\"}," "\"paths\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"maxItems\":128}," "\"path_limit\":{\"type\":\"integer\",\"default\":20,\"minimum\":1,\"maximum\":128}," @@ -1911,6 +1914,7 @@ struct cbm_mcp_server { void *snapshot_read_test_context; cbm_mcp_snippet_capture_test_hook_fn snippet_capture_test_hook; void *snippet_capture_test_context; + size_t search_capture_test_budget; #endif bool read_batch_tool; /* this request's store reads run in a read batch */ size_t search_output_limit_override; @@ -2291,6 +2295,11 @@ void cbm_mcp_server_set_snippet_capture_test_hook(cbm_mcp_server_t *srv, srv->snippet_capture_test_hook = hook; srv->snippet_capture_test_context = context; } + +void cbm_mcp_server_set_search_capture_budget_for_test(cbm_mcp_server_t *srv, size_t bytes) { + if (srv) + srv->search_capture_test_budget = bytes; +} #endif /* ── Cache dir + project DB path helpers ───────────────────────── */ @@ -7161,18 +7170,33 @@ static bool mcp_recorded_sha256(const char *sha) { /* #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) { +static bool mcp_read_file_bytes(const char *abs_path, cbm_sb_t *bytes, size_t max_bytes, + bool *limited) { cbm_sb_init(bytes); + if (limited) { + *limited = false; + } FILE *file = cbm_fopen(abs_path, "rb"); if (!file) { return false; } char buf[CBM_SZ_4K]; size_t len; + bool hit_limit = false; while ((len = fread(buf, 1, sizeof(buf), file)) > 0) { + if (len > max_bytes - bytes->len) { + hit_limit = true; + if (limited) { + *limited = true; + } + break; + } cbm_sb_append_n(bytes, buf, len); + if (bytes->oom) { + break; + } } - bool valid = !ferror(file) && !bytes->oom; + bool valid = !ferror(file) && !bytes->oom && !hit_limit; if (fclose(file) != 0) { valid = false; } @@ -7184,7 +7208,7 @@ static bool mcp_read_file_bytes(const char *abs_path, cbm_sb_t *bytes) { /* 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) { +static char *mcp_slice_lines(const cbm_sb_t *bytes, int start, int end) { size_t begin = bytes->len; size_t stop = bytes->len; int lineno = 1; @@ -7241,12 +7265,24 @@ static bool coverage_freshness_current(const char *freshness, bool content_verif * 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) { +static bool mcp_capture_file_sha256(const char *path, cbm_sb_t *captured, size_t max_bytes, + bool *limited, char current[CBM_SHA256_HEX_LEN + SKIP_ONE]) { + if (!mcp_read_file_bytes(path, captured, max_bytes, limited)) { + return false; + } + cbm_sha256_hex(captured->buf ? captured->buf : "", captured->len, current); + return true; +} + +static const char *coverage_path_freshness_capture_bounded( + 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, + size_t max_capture_bytes, bool *limited) { *outside = false; *content_verified = false; + if (limited) { + *limited = false; + } if (captured) { cbm_sb_init(captured); *captured_ok = false; @@ -7286,11 +7322,9 @@ static const char *coverage_path_freshness_capture(cbm_store_t *store, const cha 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; - } + *captured_ok = + mcp_capture_file_sha256(abs_path, captured, max_capture_bytes, limited, current); + hashed = *captured_ok; } else { hashed = mcp_recorded_sha256(hash.sha256) && mcp_file_sha256_hex(abs_path, current); } @@ -7307,6 +7341,15 @@ static const char *coverage_path_freshness_capture(cbm_store_t *store, const cha return same_bytes ? "content_match" : "metadata_changed"; } +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) { + return coverage_path_freshness_capture_bounded(store, project, root_path, rel_path, outside, + content_verified, captured, captured_ok, + SIZE_MAX, NULL); +} + 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) { @@ -15171,6 +15214,13 @@ typedef struct { int match_count; /* exact total across the complete grep stream */ int match_stored_count; /* bounded evidence retained for rendering */ int match_capacity; + cbm_sb_t captured_source; + const char *source_freshness; + bool captured_source_ok; + bool source_content_verified; + bool source_drift; + bool captured_source_owner; + bool source_capture_limited; } search_result_t; typedef struct { @@ -15213,6 +15263,9 @@ static void free_search_results(search_result_t *results, int count) { free(results[i].qualified_name); free(results[i].file); free(results[i].match_lines); + if (results[i].captured_source_owner) { + cbm_sb_free(&results[i].captured_source); + } } free(results); } @@ -15521,26 +15574,163 @@ static yyjson_mut_val *build_dedup_files_array(yyjson_mut_doc *doc, search_resul return files_arr; } +/* Capture once before rendering: budget retries must not reread a changing file. + * Match locations came from the live scan; only source attribution is verified. */ +static void capture_search_source(cbm_mcp_server_t *srv, cbm_store_t *store, const char *project, + const char *root_path, search_result_t *results, int start, + int count) { + (void)srv; /* Production builds omit the test-only hooks. */ + enum { CAPTURE_BUDGET = 64 * 1024 * 1024 }; + size_t remaining = CAPTURE_BUDGET; +#ifdef CBM_ENABLE_TEST_SEAMS + if (srv->search_capture_test_budget) + remaining = srv->search_capture_test_budget; +#endif + for (int i = start; i < start + count; i++) { + search_result_t *r = &results[i]; + bool cached = false; + for (int j = start; j < i; j++) { + const search_result_t *prior = &results[j]; + if (strcmp(r->file, prior->file) != 0) { + continue; + } + r->captured_source = prior->captured_source; + r->captured_source_ok = prior->captured_source_ok; + r->source_freshness = prior->source_freshness; + r->source_content_verified = prior->source_content_verified; + r->source_drift = prior->source_drift; + r->source_capture_limited = prior->source_capture_limited; + cached = true; + break; + } + if (cached) { + continue; + } + bool outside = false; + r->captured_source_owner = true; + r->source_freshness = coverage_path_freshness_capture_bounded( + store, project, root_path, r->file, &outside, &r->source_content_verified, + &r->captured_source, &r->captured_source_ok, remaining, &r->source_capture_limited); + r->source_drift = + r->source_content_verified && + !coverage_freshness_current(r->source_freshness, r->source_content_verified); + if (r->captured_source_ok && r->captured_source.cap > remaining) { + r->source_capture_limited = true; + } + if (r->source_capture_limited || r->source_drift) { + cbm_sb_free(&r->captured_source); + r->captured_source_ok = false; + } else if (r->captured_source_ok) { + remaining -= r->captured_source.cap; + } +#ifdef CBM_ENABLE_TEST_SEAMS + if (srv->snippet_capture_test_hook) + srv->snippet_capture_test_hook(srv->snippet_capture_test_context, r->file); +#endif + } +} + /* Attach source or context lines to a search result JSON item. */ -static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, search_result_t *r, - int mode, int context_lines, int source_max_lines, - const char *root_path, root_containment_t *containment) { +static void attach_full_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, + const search_result_t *r, int source_max_lines, + const char *abs_path) { + /* Cap each hit's source at a match-anchored window: uncapped + * whole-symbol dumps ran to 5.7KB × N hits (142KB responses). The + * complete symbol stays one get_code_snippet call away; + * source_start/source_truncated make the cut explicit. */ + enum { SC_FULL_LEAD = 5 }; + if (source_max_lines <= 0) { + yyjson_mut_obj_add_bool(doc, item, "source_omitted", true); + return; + } + int s = r->start_line; + int e = r->end_line; + bool truncated = false; + if (e - s + SKIP_ONE > source_max_lines) { + if (r->match_count > 0 && r->match_lines[0] - SC_FULL_LEAD > s) { + s = r->match_lines[0] - SC_FULL_LEAD; + } + e = s + source_max_lines - SKIP_ONE; + if (e > r->end_line) { + e = r->end_line; + } + truncated = true; + } + char *source = r->captured_source_ok ? mcp_slice_lines(&r->captured_source, s, e) + : read_file_lines(abs_path, s, e); + if (source) { + char *safe_source = sanitize_utf8_lossy(source); + if (safe_source) { + yyjson_mut_obj_add_strcpy(doc, item, "source", safe_source); + free(safe_source); + } + free(source); + if (truncated) { + yyjson_mut_obj_add_int(doc, item, "source_start", s); + yyjson_mut_obj_add_bool(doc, item, "source_truncated", true); + } + } +} + +static void attach_context_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, + const search_result_t *r, int context_lines, + const char *abs_path) { + /* Bounded here as well as at the handler: the window must stay + * match ± MCP_SEARCH_CONTEXT_MAX_LINES for any caller, and the + * arithmetic must not overflow for any value. */ + int ctx_lines = + context_lines > MCP_SEARCH_CONTEXT_MAX_LINES ? MCP_SEARCH_CONTEXT_MAX_LINES : context_lines; + int first_match = r->match_lines[0]; + int last_match = r->match_lines[r->match_count - SKIP_ONE]; + int ctx_start = first_match > ctx_lines ? first_match - ctx_lines : SKIP_ONE; + int ctx_end = last_match > INT_MAX - ctx_lines ? INT_MAX : last_match + ctx_lines; + char *ctx = r->captured_source_ok ? mcp_slice_lines(&r->captured_source, ctx_start, ctx_end) + : read_file_lines(abs_path, ctx_start, ctx_end); + if (ctx) { + char *safe_context = sanitize_utf8_lossy(ctx); + if (safe_context) { + yyjson_mut_obj_add_strcpy(doc, item, "context", safe_context); + free(safe_context); + } + yyjson_mut_obj_add_int(doc, item, "context_start", ctx_start); + free(ctx); + } +} + +static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, + const search_result_t *r, int mode, int context_lines, + int source_max_lines, const char *root_path, + root_containment_t *containment) { enum { MODE_FULL = 1 }; + yyjson_mut_obj_add_bool(doc, item, "content_verified", r->source_content_verified); + if (r->source_freshness) { + yyjson_mut_obj_add_str(doc, item, "freshness", r->source_freshness); + } + if (r->source_drift) { + yyjson_mut_obj_add_bool(doc, item, "source_drift", true); + yyjson_mut_obj_add_str(doc, item, "recommended_action", "reindex_then_retry"); + return; + } + if (r->source_capture_limited) { + yyjson_mut_obj_add_str(doc, item, "source_unavailable", "capture_budget"); + yyjson_mut_obj_add_str(doc, item, "recommended_action", "use_get_code_snippet"); + return; + } if (r->start_line <= 0 || r->end_line <= 0) { return; } size_t root_length = strlen(root_path); size_t file_length = strlen(r->file); - if (root_length > SIZE_MAX - file_length - 2U) { + if (root_length > SIZE_MAX - file_length - PAIR_LEN) { return; } - char *abs_path = malloc(root_length + file_length + 2U); + char *abs_path = malloc(root_length + file_length + PAIR_LEN); if (!abs_path) { return; } memcpy(abs_path, root_path, root_length); abs_path[root_length] = '/'; - memcpy(abs_path + root_length + 1U, r->file, file_length + 1U); + memcpy(abs_path + root_length + SKIP_ONE, r->file, file_length + SKIP_ONE); /* Containment: a search result whose indexed path resolves outside the * project root (a `..` segment, or a symlink/junction that discovery @@ -15552,62 +15742,9 @@ static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, sear } if (mode == MODE_FULL) { - /* Cap each hit's source at a match-anchored window: uncapped - * whole-symbol dumps ran to 5.7KB × N hits (142KB responses). The - * complete symbol stays one get_code_snippet call away; - * source_start/source_truncated make the cut explicit. */ - enum { SC_FULL_LEAD = 5 }; - if (source_max_lines <= 0) { - yyjson_mut_obj_add_bool(doc, item, "source_omitted", true); - free(abs_path); - return; - } - int s = r->start_line; - int e = r->end_line; - bool truncated = false; - if (e - s + 1 > source_max_lines) { - if (r->match_count > 0 && r->match_lines[0] - SC_FULL_LEAD > s) { - s = r->match_lines[0] - SC_FULL_LEAD; - } - e = s + source_max_lines - 1; - if (e > r->end_line) { - e = r->end_line; - } - truncated = true; - } - char *source = read_file_lines(abs_path, s, e); - if (source) { - char *safe_source = sanitize_utf8_lossy(source); - if (safe_source) { - yyjson_mut_obj_add_strcpy(doc, item, "source", safe_source); - free(safe_source); - } - free(source); - if (truncated) { - yyjson_mut_obj_add_int(doc, item, "source_start", s); - yyjson_mut_obj_add_bool(doc, item, "source_truncated", true); - } - } + attach_full_result_source(doc, item, r, source_max_lines, abs_path); } else if (context_lines > 0 && r->match_count > 0) { - /* Bounded here as well as at the handler: the window must stay - * match ± MCP_SEARCH_CONTEXT_MAX_LINES for any caller, and the - * arithmetic must not overflow for any value. */ - int ctx_lines = context_lines > MCP_SEARCH_CONTEXT_MAX_LINES ? MCP_SEARCH_CONTEXT_MAX_LINES - : context_lines; - int first_match = r->match_lines[0]; - int last_match = r->match_lines[r->match_count - SKIP_ONE]; - int ctx_start = first_match > ctx_lines ? first_match - ctx_lines : SKIP_ONE; - int ctx_end = last_match > INT_MAX - ctx_lines ? INT_MAX : last_match + ctx_lines; - char *ctx = read_file_lines(abs_path, ctx_start, ctx_end); - if (ctx) { - char *safe_context = sanitize_utf8_lossy(ctx); - if (safe_context) { - yyjson_mut_obj_add_strcpy(doc, item, "context", safe_context); - free(safe_context); - } - yyjson_mut_obj_add_int(doc, item, "context_start", ctx_start); - free(ctx); - } + attach_context_result_source(doc, item, r, context_lines, abs_path); } free(abs_path); } @@ -15871,6 +16008,7 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep } free_search_dirs(directories, dir_n); + cbm_tree_scalar_str(&sb, "match_source", "live_filesystem"); cbm_tree_scalar_int(&sb, "total_grep_matches", gm_count); cbm_tree_scalar_int(&sb, "total_results", sr_count); cbm_tree_scalar_int(&sb, "raw_match_count", raw_count); @@ -15961,6 +16099,7 @@ static char *assemble_search_output( yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root_obj = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root_obj); + yyjson_mut_obj_add_str(doc, root_obj, "match_source", "live_filesystem"); if (mode == MODE_FILES) { yyjson_mut_val *files = build_dedup_files_array(doc, sr, result_start, output_count, raw, @@ -18173,6 +18312,10 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { * containment for the request keeps that to one resolution per file. */ root_containment_t request_containment; root_containment_init(&request_containment, root_path); + enum { SOURCE_MODE_COMPACT = 0, SOURCE_MODE_FULL = 1 }; + if (mode == SOURCE_MODE_FULL || (mode == SOURCE_MODE_COMPACT && context_lines > 0)) { + capture_search_source(srv, store, project, root_path, sr, result_start, output_count); + } char *payload = render_search_payload( sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, @@ -21772,6 +21915,132 @@ static void release_request_store(cbm_mcp_server_t *srv) { cbm_mem_collect(); } +/* #2585: a snapshot is opt-in so ordinary query latency and output stay + * compatible. It pins the database, not the live filesystem. */ +static bool mcp_snapshot_tool(const char *name) { + return name && (!strcmp(name, "search_graph") || !strcmp(name, "query_graph") || + !strcmp(name, "trace_path") || !strcmp(name, "get_code_snippet") || + !strcmp(name, "search_code") || !strcmp(name, "check_index_coverage")); +} + +static bool mcp_snapshot_cancelled(void *context) { + return mcp_request_cancelled((const cbm_mcp_server_t *)context); +} + +/* Keep the tool's original format inside data. A JSON envelope makes the same + * publication identity observable through MCP structuredContent and the CLI. */ +static char *mcp_snapshot_result(char *result, const char *generation) { + yyjson_doc *wire = result ? yyjson_read(result, strlen(result), 0) : NULL; + yyjson_val *root = wire ? yyjson_doc_get_root(wire) : NULL; + if (root && yyjson_get_bool(yyjson_obj_get(root, "isError"))) { + yyjson_doc_free(wire); + return result; + } + yyjson_val *text = yyjson_obj_get(yyjson_arr_get(yyjson_obj_get(root, "content"), 0), "text"); + yyjson_val *data = yyjson_obj_get(root, "structuredContent"); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *envelope = doc ? yyjson_mut_obj(doc) : NULL; + yyjson_mut_val *value = NULL; + if (doc) { + if (data) { + value = yyjson_val_mut_copy(doc, data); + } else { + value = yyjson_mut_strcpy(doc, yyjson_get_str(text)); + } + } + bool ok = envelope && value && + yyjson_mut_obj_add_strcpy(doc, envelope, "store_generation", generation) && + yyjson_mut_obj_add_bool(doc, envelope, "snapshot_consistent", true) && + yyjson_mut_obj_add_val(doc, envelope, "data", value); + if (ok) { + yyjson_mut_doc_set_root(doc, envelope); + } + char *payload = ok ? yy_doc_to_str(doc) : NULL; + char *wrapped = payload ? cbm_mcp_text_result(payload, false) : NULL; + free(payload); + yyjson_mut_doc_free(doc); + yyjson_doc_free(wire); + free(result); + return wrapped ? wrapped + : cbm_mcp_text_result("snapshot_unavailable: cannot encode evidence", true); +} + +typedef struct { + bool enabled; + bool discard_store; + const char *error; + cbm_store_read_scope_t *scope; + char generation[QUERY_GRAPH_GENERATION_CAP]; +} mcp_snapshot_request_t; + +static void mcp_snapshot_open(cbm_mcp_server_t *srv, const char *args, + mcp_snapshot_request_t *snapshot) { + char *project = get_project_arg(args); + cbm_store_t *store = project ? resolve_store(srv, project) : NULL; + int opened = + store ? cbm_store_read_scope_open(store, mcp_snapshot_cancelled, srv, &snapshot->scope) + : CBM_STORE_ERR; + snapshot->discard_store = opened == CBM_STORE_SCOPE_DISCARD; + if (opened != CBM_STORE_OK) { + snapshot->error = "snapshot_unavailable: cannot pin the requested project"; + } else if (cbm_store_generation(store, snapshot->generation, sizeof(snapshot->generation)) != + CBM_STORE_OK || + !snapshot->generation[0] || strcmp(snapshot->generation, "legacy") == 0) { + snapshot->error = "snapshot_unavailable: reindex to establish publication identity"; + } + free(project); +} + +static void mcp_snapshot_prepare(cbm_mcp_server_t *srv, const char *tool_name, const char *args, + mcp_snapshot_request_t *snapshot) { + yyjson_doc *doc = args ? yyjson_read(args, strlen(args), 0) : NULL; + yyjson_val *input = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *value = yyjson_obj_get(input, "snapshot"); + snapshot->enabled = value && yyjson_is_true(value); + if (value && !yyjson_is_bool(value)) { + snapshot->error = "snapshot must be true or false"; + } else if (snapshot->enabled && (!srv || !mcp_snapshot_tool(tool_name))) { + snapshot->error = "snapshot is supported by the graph, snippet, coverage and search tools"; + } else if (snapshot->enabled && strcmp(tool_name, "trace_path") == 0) { + const char *mode = yyjson_get_str(yyjson_obj_get(input, "mode")); + if (mode && strcmp(mode, "cross_service") == 0) { + snapshot->error = "snapshot_unavailable: cross_service reads multiple project stores"; + } + } + yyjson_doc_free(doc); + if (srv) { + srv->read_batch_tool = !snapshot->enabled && mcp_tool_reads_in_batch(tool_name); + } + if (snapshot->enabled && !snapshot->error) { + mcp_snapshot_open(srv, args, snapshot); + } +} + +static char *mcp_snapshot_finish(cbm_mcp_server_t *srv, mcp_snapshot_request_t *snapshot, + char *result) { + if (snapshot->scope) { + int checked = cbm_store_read_scope_check(snapshot->scope); + int closed = cbm_store_read_scope_close(snapshot->scope); + snapshot->discard_store = closed == CBM_STORE_SCOPE_DISCARD; + if (checked != CBM_STORE_OK || closed != CBM_STORE_OK) { + free(result); + result = + cbm_mcp_text_result("snapshot_unavailable: read scope lost or cancelled", true); + } else if (!snapshot->error) { + result = mcp_snapshot_result(result, snapshot->generation); + } + } + if (snapshot->discard_store && srv) { + if (srv->owns_store) { + cbm_store_close(srv->store); + } + srv->store = NULL; + free(srv->current_project); + srv->current_project = NULL; + } + return result; +} + char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { /* Phase marks bracket the WHOLE request with no unlabelled gap, so growth * cannot hide between them (CBM_MEM_PHASES=1; see foundation/mem.h). The @@ -21785,10 +22054,11 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch return cbm_mcp_text_result("request cancellation scope unavailable", true); } cbm_mem_phase_mark("request.dispatch_tool"); - if (srv) { - srv->read_batch_tool = mcp_tool_reads_in_batch(tool_name); - } - char *result = dispatch_tool(srv, tool_name, args_json); + mcp_snapshot_request_t snapshot = {0}; + mcp_snapshot_prepare(srv, tool_name, args_json, &snapshot); + char *result = snapshot.error ? cbm_mcp_text_result(snapshot.error, true) + : dispatch_tool(srv, tool_name, args_json); + result = mcp_snapshot_finish(srv, &snapshot, result); if (srv) { /* An embedded store outlives the request: end its batch here. */ srv->read_batch_tool = false; diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 5df164b1a..e13e1f741 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -155,6 +155,9 @@ void cbm_mcp_server_set_snippet_capture_test_hook(cbm_mcp_server_t *srv, cbm_mcp_snippet_capture_test_hook_fn hook, void *context); +/* Lower the per-request search capture budget; zero restores the production limit. */ +void cbm_mcp_server_set_search_capture_budget_for_test(cbm_mcp_server_t *srv, size_t bytes); + /* 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. */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 11be4581a..9600c506a 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7783,6 +7783,297 @@ TEST(tool_get_code_snippet_serves_the_bytes_it_verified_issue2585) { } #endif +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(tool_search_code_serves_captured_bytes_issue2585) { + const char *modes[] = {"full", "compact"}; + const char *fields[] = {"source", "context"}; + for (int i = 0; i < 2; i++) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + ASSERT_EQ(record_main_go_hash(cbm_mcp_server_store(srv), tmp, true, true), CBM_STORE_OK); + char file[512], args[512]; + snprintf(file, sizeof(file), "%s/project/main.go", tmp); + cbm_mcp_server_set_snippet_capture_test_hook(srv, snippet_capture_rewrite, file); + snprintf(args, sizeof(args), + "{\"project\":\"test-project\",\"pattern\":\"ProcessOrder\",\"mode\":\"%s\"," + "\"context\":1,\"format\":\"json\"}", + modes[i]); + char *response = cbm_mcp_handle_tool(srv, "search_code", args); + cbm_mcp_server_set_snippet_capture_test_hook(srv, NULL, NULL); + char *inner = extract_text_content(response); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + ASSERT_NOT_NULL(doc); + yyjson_val *row = yyjson_arr_get_first(yyjson_obj_get(yyjson_doc_get_root(doc), "rows")); + yyjson_val *cell = yyjson_arr_get(row, 8); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(cell, "content_verified"))); + const char *source = yyjson_get_str(yyjson_obj_get(cell, fields[i])); + ASSERT_NOT_NULL(source); + ASSERT_NOT_NULL(strstr(source, "ProcessOrder")); + ASSERT_NULL(strstr(source, "Intruder")); + yyjson_doc_free(doc); + free(inner); + free(response); + /* The live scanner still finds the new text, but cannot attribute its + * source bytes to the old indexed function. */ + response = cbm_mcp_handle_tool( + srv, "search_code", + "{\"project\":\"test-project\",\"pattern\":\"Intruder\",\"mode\":\"full\"," + "\"format\":\"json\"}"); + inner = extract_text_content(response); + doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + row = yyjson_arr_get_first(yyjson_obj_get(yyjson_doc_get_root(doc), "rows")); + ASSERT_NOT_NULL(row); + cell = yyjson_arr_get(row, 8); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(cell, "source_drift"))); + ASSERT_NULL(yyjson_obj_get(cell, "source")); + yyjson_doc_free(doc); + free(inner); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + } + PASS(); +} + +static void count_search_capture(void *context, const char *path) { + (void)path; + (*(int *)context)++; +} + +TEST(tool_search_code_shares_and_bounds_captured_files_issue2585) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + ASSERT_EQ(record_main_go_hash(cbm_mcp_server_store(srv), tmp, true, true), CBM_STORE_OK); + int captures = 0; + cbm_mcp_server_set_snippet_capture_test_hook(srv, count_search_capture, &captures); + const char *args = "{\"project\":\"test-project\",\"pattern\":\"func\",\"mode\":\"full\"," + "\"format\":\"json\"}"; + for (int limited = 0; limited < 2; limited++) { + captures = 0; + cbm_mcp_server_set_search_capture_budget_for_test(srv, limited ? 8 : 0); + char *response = cbm_mcp_handle_tool(srv, "search_code", args); + char *inner = extract_text_content(response); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + ASSERT_NOT_NULL(doc); + yyjson_val *rows = yyjson_obj_get(yyjson_doc_get_root(doc), "rows"); + ASSERT_GT(yyjson_arr_size(rows), 1); + ASSERT_EQ(captures, 1); + size_t i, n; + yyjson_val *row; + yyjson_arr_foreach(rows, i, n, row) { + yyjson_val *source = yyjson_arr_get(row, 8); + ASSERT_EQ(yyjson_get_bool(yyjson_obj_get(source, "content_verified")), !limited); + if (limited) { + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(source, "source_unavailable")), + "capture_budget"); + ASSERT_NULL(yyjson_obj_get(source, "source")); + } else + ASSERT_NOT_NULL(yyjson_obj_get(source, "source")); + } + yyjson_doc_free(doc); + free(inner); + free(response); + } + captures = 0; + char *response = cbm_mcp_handle_tool(srv, "search_code", + "{\"project\":\"test-project\",\"pattern\":\"func\"," + "\"mode\":\"files\",\"context\":1,\"format\":\"json\"}"); + ASSERT_NOT_NULL(response); + ASSERT_EQ(captures, 0); + free(response); + cbm_mcp_server_set_snippet_capture_test_hook(srv, NULL, NULL); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +typedef struct { + cbm_store_t *reader, *writer; + const char *root; + int written; +} snapshot_publication_probe_t; + +static void snapshot_publish_during_capture(void *context, const char *path) { + (void)path; + snapshot_publication_probe_t *probe = context; + /* Deterministically cross the ordinary batch's renewal boundary. */ + cbm_store_read_batch_expire_for_test(probe->reader); + probe->written = cbm_store_upsert_project(probe->writer, "test-project", probe->root); +} + +TEST(tool_snapshot_pins_publication_across_writer_issue2585) { + char tmp[256]; + cbm_mcp_server_t *memory = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(memory); + cbm_store_t *memory_store = cbm_mcp_server_store(memory); + ASSERT_EQ(record_main_go_hash(memory_store, tmp, true, true), CBM_STORE_OK); + char database[512], project_root[512]; + snprintf(database, sizeof(database), "%s/test-project.db", tmp); + snprintf(project_root, sizeof(project_root), "%s/project", tmp); + cbm_store_t *disk = cbm_store_open_path(database); + ASSERT_NOT_NULL(disk); + sqlite3_backup *backup = + sqlite3_backup_init(cbm_store_get_db(disk), "main", cbm_store_get_db(memory_store), "main"); + ASSERT_NOT_NULL(backup); + ASSERT_EQ(sqlite3_backup_step(backup, -1), SQLITE_DONE); + ASSERT_EQ(sqlite3_backup_finish(backup), SQLITE_OK); + cbm_store_close(disk); + cbm_mcp_server_free(memory); + char *saved = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL; + cbm_setenv("CBM_CACHE_DIR", tmp, 1); + cbm_mcp_server_t *srv = cbm_mcp_server_new("test-project"); + ASSERT_NOT_NULL(srv); + cbm_store_t *reader = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(reader); + cbm_store_t *writer = cbm_store_open_path(database); + ASSERT_NOT_NULL(writer); + snapshot_publication_probe_t probe = {reader, writer, project_root, CBM_STORE_ERR}; + char before[96], after[96]; + ASSERT_EQ(cbm_store_generation(reader, before, sizeof(before)), CBM_STORE_OK); + cbm_mcp_server_set_snippet_capture_test_hook(srv, snapshot_publish_during_capture, &probe); + char *response = cbm_mcp_handle_tool( + srv, "get_code_snippet", + "{\"project\":\"test-project\",\"qualified_name\":\"ProcessOrder\",\"snapshot\":true," + "\"format\":\"json\"}"); + cbm_mcp_server_set_snippet_capture_test_hook(srv, NULL, NULL); + ASSERT_EQ(probe.written, CBM_STORE_OK); + ASSERT_EQ(cbm_store_generation(writer, after, sizeof(after)), CBM_STORE_OK); + ASSERT_STR_NEQ(before, after); + char *inner = extract_text_content(response); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "store_generation")), before); + yyjson_val *data = yyjson_obj_get(root, "data"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(data, "store_generation")), before); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(data, "content_verified"))); + yyjson_doc_free(doc); + free(inner); + free(response); + cbm_store_close(writer); + cbm_mcp_server_free(srv); + restore_cache_dir(saved); + free(saved); + cleanup_project_db(tmp, "test-project"); + cleanup_snippet_dir(tmp); + PASS(); +} +#endif + +/* #2585: every opt-in structural observation names its pinned publication. + * The original format is retained inside data; default requests stay unwrapped. */ +TEST(tool_snapshot_envelope_covers_evidence_tools_issue2585) { + 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_EQ(record_main_go_hash(store, tmp, true, true), CBM_STORE_OK); + char generation[96]; + ASSERT_EQ(cbm_store_generation(store, generation, sizeof(generation)), CBM_STORE_OK); + const char *tools[] = {"query_graph", "search_graph", "trace_path", + "get_code_snippet", "check_index_coverage", "search_code"}; + const char *questions[] = {"\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 1\"", + "\"name_pattern\":\"ProcessOrder\"", + "\"function_name\":\"ProcessOrder\",\"direction\":\"inbound\"", + "\"qualified_name\":\"ProcessOrder\"", + "\"paths\":[\"main.go\"]", + "\"pattern\":\"ProcessOrder\",\"mode\":\"full\""}; + for (size_t i = 0; i < sizeof(tools) / sizeof(tools[0]); i++) { + char args[1024]; + snprintf(args, sizeof(args), + "{\"project\":\"test-project\",\"format\":\"json\",\"snapshot\":true,%s}", + questions[i]); + char *response = cbm_mcp_handle_tool(srv, tools[i], args); + ASSERT_NOT_NULL(response); + ASSERT_FALSE(response_contains_json_fragment(response, "\"isError\":true")); + char *inner = extract_text_content(response); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "store_generation")), generation); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "snapshot_consistent"))); + ASSERT_TRUE(yyjson_is_obj(yyjson_obj_get(root, "data"))); + yyjson_doc_free(doc); + free(inner); + free(response); + } + char *response = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"test-project\",\"name_pattern\":\"ProcessOrder\",\"snapshot\":true}"); + char *inner = extract_text_content(response); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + ASSERT_TRUE(yyjson_is_str(yyjson_obj_get(yyjson_doc_get_root(doc), "data"))); + yyjson_doc_free(doc); + free(inner); + free(response); + response = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"test-project\",\"format\":\"json\",\"snapshot\":false}"); + inner = extract_text_content(response); + doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + ASSERT_NULL(yyjson_obj_get(yyjson_doc_get_root(doc), "snapshot_consistent")); + yyjson_doc_free(doc); + free(inner); + free(response); + response = cbm_mcp_handle_tool(srv, "query_graph", "{\"snapshot\":\"true\"}"); + ASSERT_TRUE(strstr(response, "snapshot must be true or false") != NULL); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(tool_search_code_withholds_source_drift_issue2585) { + 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); + /* The existing fixture records a same-size, same-metadata wrong hash. */ + ASSERT_EQ(record_main_go_hash(store, tmp, false, true), CBM_STORE_OK); + char *response = cbm_mcp_handle_tool( + srv, "search_code", + "{\"project\":\"test-project\",\"pattern\":\"ProcessOrder\",\"mode\":\"full\"," + "\"format\":\"json\"}"); + char *inner = extract_text_content(response); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "match_source")), "live_filesystem"); + yyjson_val *row = yyjson_arr_get(yyjson_obj_get(root, "rows"), 0); + ASSERT_NOT_NULL(row); + yyjson_val *source = yyjson_arr_get(row, 8); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(source, "source_drift"))); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(source, "content_verified"))); + ASSERT_NULL(yyjson_obj_get(source, "source")); + yyjson_doc_free(doc); + free(inner); + free(response); + /* A byte-identical file yields source from those verified bytes. */ + ASSERT_EQ(record_main_go_hash(store, tmp, true, true), CBM_STORE_OK); + response = cbm_mcp_handle_tool( + srv, "search_code", + "{\"project\":\"test-project\",\"pattern\":\"ProcessOrder\",\"mode\":\"full\"," + "\"format\":\"json\"}"); + inner = extract_text_content(response); + doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + row = yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "rows"), 0); + source = yyjson_arr_get(row, 8); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(source, "content_verified"))); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(source, "source")), "ProcessOrder")); + yyjson_doc_free(doc); + free(inner); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + /* #2585: metadata.generation names the coverage-recording run and does not * change on incremental re-index, so it cannot tell two publications apart. * store_generation does, and every response that carries it agrees. */ @@ -28071,6 +28362,13 @@ SUITE(mcp) { RUN_TEST(tool_get_code_snippet_serves_the_bytes_it_verified_issue2585); #endif RUN_TEST(tool_responses_carry_store_generation_issue2585); + RUN_TEST(tool_snapshot_envelope_covers_evidence_tools_issue2585); + RUN_TEST(tool_search_code_withholds_source_drift_issue2585); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(tool_search_code_serves_captured_bytes_issue2585); + RUN_TEST(tool_search_code_shares_and_bounds_captured_files_issue2585); + RUN_TEST(tool_snapshot_pins_publication_across_writer_issue2585); +#endif RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors); RUN_TEST(tool_index_status_includes_git_metadata); RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity);