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
23 changes: 20 additions & 3 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 "
Expand All @@ -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. */
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions tests/test_mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading