From 63cdde702d9eb366b58006986e1498a18ae7f7d8 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 f80d97c6e..7b3afec9e 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 95f669852..1608538a8 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -20108,6 +20108,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 @@ -20222,6 +20261,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);