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);