From 7e111f877b85b79efdd371475d7f71e61174f95e Mon Sep 17 00:00:00 2001 From: Hubert Tarnacki Date: Wed, 9 Sep 2026 22:43:55 +0200 Subject: [PATCH] fix(search): honor the label filter in BM25 keyword search `search_graph` accepted a `label` argument, but the BM25 keyword branch ignored it and returned nodes of every label. Bind the label into both BM25 queries so keyword results respect the same filter as the other search paths. Signed-off-by: Hubert Tarnacki --- src/mcp/mcp.c | 23 ++++++++++++++++++++--- tests/test_mcp.c | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 1b0bce021..9d6873746 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -3762,6 +3762,7 @@ enum { BM25_BIND_OFFSET = 4, BM25_BIND_INNER = 5, BM25_BIND_FILE = 6, + BM25_BIND_LABEL = 7, BM25_SQL_AUTO_LEN = -1, /* Inner FTS5 candidate cap. SQLite can early-terminate a plain FTS5 query * (no JOIN/WHERE on outer table) of the form: @@ -4019,8 +4020,8 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total, * Returns NULL if FTS5 is unavailable or the query produced no usable tokens, * in which case the caller falls back to the regex-based search path. */ static char *bm25_search(cbm_store_t *store, const char *project, const char *query, - const char *file_pattern, int limit, int offset, bool tree_format, - size_t max_output_bytes) { + const char *file_pattern, const char *label_filter, int limit, int offset, + bool tree_format, size_t max_output_bytes) { sqlite3 *db = cbm_store_get_db(store); if (!db) { return NULL; @@ -4070,6 +4071,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu * must be changed together or results desynchronise from counts. */ " AND n.label NOT IN ('File','Folder','Variable','Project') " " AND (?6 IS NULL OR n.file_path LIKE ?6) " + " AND (?7 IS NULL OR n.label = ?7) " /* rank ties are common (boosted floats) — the id tie-break makes * offset pages contractually stable across calls. */ "ORDER BY rank, n.id " @@ -4090,6 +4092,12 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu } else { sqlite3_bind_null(stmt, BM25_BIND_FILE); } + if (label_filter) { + sqlite3_bind_text(stmt, BM25_BIND_LABEL, label_filter, BM25_SQL_AUTO_LEN, + MCP_SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(stmt, BM25_BIND_LABEL); + } /* Count hits within the same inner-limit window — capped at BM25_INNER_LIMIT. * Uses the identical subquery structure so the FTS5 early-exit applies here too. */ @@ -4107,6 +4115,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu * not describe the rows returned. */ " AND n.label NOT IN ('File','Folder','Variable','Project')" " AND (?6 IS NULL OR n.file_path LIKE ?6)" + " AND (?7 IS NULL OR n.label = ?7)" ")"; sqlite3_stmt *cs = NULL; if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) == SQLITE_OK) { @@ -4121,6 +4130,12 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu } else { sqlite3_bind_null(cs, BM25_BIND_FILE); } + if (label_filter) { + sqlite3_bind_text(cs, BM25_BIND_LABEL, label_filter, BM25_SQL_AUTO_LEN, + MCP_SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(cs, BM25_BIND_LABEL); + } if (sqlite3_step(cs) == SQLITE_ROW) { total = sqlite3_column_int(cs, 0); } @@ -5089,9 +5104,11 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } if (query && query[0]) { char *q_file_pattern = cbm_mcp_get_string_arg(args, "file_pattern"); - char *bm25_json = bm25_search(store, project, query, q_file_pattern, limit, offset, + char *q_label = cbm_mcp_get_string_arg(args, "label"); + char *bm25_json = bm25_search(store, project, query, q_file_pattern, q_label, limit, offset, !json_format, max_output_bytes); free(q_file_pattern); + free(q_label); if (bm25_json) { free(query); free(project); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 10d5b9e6e..ed2227487 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -20005,6 +20005,45 @@ TEST(bm25_results_and_total_stay_consistent_issue518) { PASS(); } +TEST(bm25_honors_label_filter) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "bm25-label"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/bm25-label"); + + cbm_node_t route = {.project = proj, + .label = "Route", + .name = "createInvoice", + .qualified_name = "bm25-label.route.createInvoice", + .file_path = "InvoiceController.scala"}; + cbm_node_t function = {.project = proj, + .label = "Function", + .name = "createInvoice", + .qualified_name = "bm25-label.function.createInvoice", + .file_path = "InvoiceHandler.scala"}; + ASSERT_TRUE(cbm_store_upsert_node(st, &route) > 0); + ASSERT_TRUE(cbm_store_upsert_node(st, &function) > 0); + ASSERT_EQ(cbm_store_fts_rebuild(st, NULL, 0), CBM_STORE_OK); + + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"bm25-label\",\"query\":\"create invoice\",\"label\":\"Route\"," + "\"format\":\"json\",\"limit\":10}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"total\":1")); + ASSERT_NOT_NULL(strstr(inner, "bm25-label.route.createInvoice")); + ASSERT_NULL(strstr(inner, "bm25-label.function.createInvoice")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + TEST(bm25_identifier_match_outranks_prose_only_match_issue518) { /* The 0.3 body weight is what keeps prose from drowning identifiers. Both * candidates carry the same label boost, so the ordering here is decided by @@ -20119,6 +20158,7 @@ SUITE(mcp) { RUN_TEST(bm25_finds_section_by_its_prose_issue518); RUN_TEST(bm25_finds_module_by_promoted_description_issue519); RUN_TEST(bm25_results_and_total_stay_consistent_issue518); + RUN_TEST(bm25_honors_label_filter); RUN_TEST(bm25_identifier_match_outranks_prose_only_match_issue518); RUN_TEST(bm25_searches_legacy_four_column_fts_without_error_issue518); RUN_TEST(mcp_path_within_root_rejects_escape);