From 0e24f84d45f46500ba2c26eaaf729ccb9dc0d22e Mon Sep 17 00:00:00 2001 From: Dustin Persek Date: Tue, 7 Jul 2026 12:35:25 -0400 Subject: [PATCH] fix(mcp): return semantic-only graph results as primary Signed-off-by: Dustin Persek --- src/mcp/mcp.c | 173 +++++++++++++++++++++++---------- src/store/store.c | 29 +++++- src/store/store.h | 4 +- tests/test_mcp.c | 237 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 53 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 9330f31b..29b5f51b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -40,6 +40,7 @@ enum { #include "mcp/mcp.h" #include "store/store.h" #include +#include #include "cypher/cypher.h" #include "pipeline/pipeline.h" #include "pipeline/pass_cross_repo.h" @@ -365,7 +366,8 @@ static const tool_def_t TOOLS[] = { "keyword strings (e.g. [\\\"send\\\",\\\"pubsub\\\",\\\"publish\\\"]) — NOT a single string. " "Each keyword is scored independently via per-keyword min-cosine; results reflect functions " "that score well on ALL keywords. Requires moderate/full index mode. Results appear in the " - "'semantic_results' field (separate from 'results').\"},\"limit\":{\"type\":" + "primary 'results' field for semantic-only calls and in 'semantic_results' when combined " + "with structural filters.\"},\"limit\":{\"type\":" "\"integer\",\"description\":\"Max results per call. Default 200. Response carries " "'total' (full match count) and 'has_more' (true if truncated) so callers can " "detect the limit and paginate.\"},\"offset\":{\"type\":\"integer\",\"default\":0," @@ -1861,51 +1863,94 @@ static int extract_semantic_keywords(yyjson_val *sq_val, const char **keywords, return ki; } -/* Emit cbm_vector_result_t entries as a "semantic_results" array on the doc. */ -static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root, - cbm_vector_result_t *vresults, int vcount) { - yyjson_mut_val *sem_results = yyjson_mut_arr(doc); - for (int v = 0; v < vcount; v++) { +static void emit_vector_results(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *field, + cbm_vector_result_t *vresults, int start, int vcount) { + yyjson_mut_val *results = yyjson_mut_arr(doc); + for (int v = start; v < start + vcount; v++) { yyjson_mut_val *vitem = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, vitem, "name", vresults[v].name); - yyjson_mut_obj_add_strcpy(doc, vitem, "qualified_name", vresults[v].qualified_name); - yyjson_mut_obj_add_strcpy(doc, vitem, "label", vresults[v].label); - yyjson_mut_obj_add_strcpy(doc, vitem, "file_path", vresults[v].file_path); + yyjson_mut_obj_add_str(doc, vitem, "name", vresults[v].name); + yyjson_mut_obj_add_str(doc, vitem, "qualified_name", vresults[v].qualified_name); + yyjson_mut_obj_add_str(doc, vitem, "label", vresults[v].label); + yyjson_mut_obj_add_str(doc, vitem, "file_path", vresults[v].file_path); yyjson_mut_obj_add_real(doc, vitem, "score", vresults[v].score); - yyjson_mut_arr_add_val(sem_results, vitem); + yyjson_mut_arr_add_val(results, vitem); + } + yyjson_mut_obj_add_val(doc, root, field, results); +} + +static void emit_semantic_search_results(yyjson_mut_doc *doc, yyjson_mut_val *root, + cbm_vector_result_t *vresults, int vcount, int total, + int limit, int offset) { + int sem_limit = limit > 0 ? limit : CBM_SZ_16; + int start = 0; + if (offset > 0) { + start = offset < vcount ? offset : vcount; } - yyjson_mut_obj_add_val(doc, root, "semantic_results", sem_results); + int page_count = vcount - start; + if (page_count > sem_limit) { + page_count = sem_limit; + } + yyjson_mut_obj_add_int(doc, root, "total", total); + yyjson_mut_obj_add_str(doc, root, "search_mode", "semantic"); + emit_vector_results(doc, root, "results", vresults, start, page_count); + yyjson_mut_obj_add_bool(doc, root, "has_more", total > offset + page_count); } -/* Append the semantic_query vector-search results onto the doc. Returns - * true if semantic_query was provided as a non-array (type error — caller - * should surface to the user). */ -static bool run_semantic_query(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *args, - cbm_store_t *store, const char *project, int limit) { +typedef struct { + bool requested; + bool type_error; + cbm_vector_result_t *results; + int count; + int total; +} semantic_query_output_t; + +/* Run semantic_query once so semantic-only calls can use vector-ranked hits as + * primary results while combined calls can keep the legacy sidecar field. */ +static semantic_query_output_t collect_semantic_query_results(const char *args, cbm_store_t *store, + const char *project, int limit) { + semantic_query_output_t out = {0}; enum { MAX_KW_SEARCH = 32 }; yyjson_doc *args_doc = yyjson_read(args, strlen(args), 0); yyjson_val *args_root = args_doc ? yyjson_doc_get_root(args_doc) : NULL; yyjson_val *sq_val = args_root ? yyjson_obj_get(args_root, "semantic_query") : NULL; - bool type_error = false; if (sq_val && !yyjson_is_arr(sq_val)) { - type_error = true; + out.type_error = true; } else if (sq_val && yyjson_arr_size(sq_val) > 0) { const char *keywords[MAX_KW_SEARCH]; int ki = extract_semantic_keywords(sq_val, keywords, MAX_KW_SEARCH); - cbm_vector_result_t *vresults = NULL; - int vcount = 0; - int sem_limit = limit > 0 ? limit : CBM_SZ_16; - if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, &vresults, &vcount) == - CBM_STORE_OK && - vcount > 0) { - emit_semantic_results(doc, root, vresults, vcount); - cbm_store_free_vector_results(vresults, vcount); + if (ki > 0) { + int sem_limit = limit > 0 ? limit : CBM_SZ_16; + out.requested = true; + if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, &out.results, + &out.count, &out.total) != CBM_STORE_OK) { + out.results = NULL; + out.count = 0; + out.total = 0; + } } } if (args_doc) { yyjson_doc_free(args_doc); } - return type_error; + return out; +} + +static void semantic_query_output_free(semantic_query_output_t *out) { + if (out && out->results) { + cbm_store_free_vector_results(out->results, out->count); + out->results = NULL; + out->count = 0; + } +} + +static bool search_graph_requires_structural_path(const cbm_search_params_t *params) { + return (params->label && params->label[0]) || + (params->name_pattern && params->name_pattern[0]) || + (params->qn_pattern && params->qn_pattern[0]) || + (params->file_pattern && params->file_pattern[0]) || + (params->relationship && params->relationship[0]) || params->exclude_entry_points || + params->include_connected || params->min_degree != CBM_NOT_FOUND || + params->max_degree != CBM_NOT_FOUND; } static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { @@ -1977,6 +2022,54 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { .max_degree = max_degree, }; + bool structural_filter = search_graph_requires_structural_path(¶ms); + int sem_limit = limit > 0 ? limit : CBM_SZ_16; + int semantic_fetch_limit = sem_limit; + if (!structural_filter) { + long long page_end = (long long)(offset > 0 ? offset : 0) + (long long)sem_limit + 1LL; + semantic_fetch_limit = page_end > INT_MAX ? INT_MAX : (int)page_end; + } + + semantic_query_output_t sem = + collect_semantic_query_results(args, store, project, semantic_fetch_limit); + if (sem.type_error) { + semantic_query_output_free(&sem); + free(project); + free(label); + free(name_pattern); + free(qn_pattern); + free(file_pattern); + free(relationship); + return cbm_mcp_text_result( + "semantic_query must be an array of keyword strings, e.g. " + "[\"send\",\"pubsub\",\"publish\"] — not a single string. Split your query " + "into individual keywords; each is scored independently via per-keyword " + "min-cosine.", + true); + } + + if (sem.requested && !structural_filter) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + emit_semantic_search_results(doc, root, sem.results, sem.count, sem.total, limit, offset); + + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + semantic_query_output_free(&sem); + + free(project); + free(label); + free(name_pattern); + free(qn_pattern); + free(file_pattern); + free(relationship); + + char *result = cbm_mcp_text_result(json, false); + free(json); + return result; + } + cbm_search_output_t out = {0}; cbm_store_search(store, ¶ms, &out); @@ -2007,27 +2100,8 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } } - bool sq_type_error = run_semantic_query(doc, root, args, store, project, limit); - - if (sq_type_error) { - for (int pi = 0; pi < props_doc_count; pi++) { - yyjson_doc_free(props_docs[pi]); - } - free(props_docs); - yyjson_mut_doc_free(doc); - cbm_store_search_free(&out); - free(project); - free(label); - free(name_pattern); - free(qn_pattern); - free(file_pattern); - free(relationship); - return cbm_mcp_text_result( - "semantic_query must be an array of keyword strings, e.g. " - "[\"send\",\"pubsub\",\"publish\"] — not a single string. Split your query " - "into individual keywords; each is scored independently via per-keyword " - "min-cosine.", - true); + if (sem.requested && sem.count > 0) { + emit_vector_results(doc, root, "semantic_results", sem.results, 0, sem.count); } char *json = yy_doc_to_str(doc); @@ -2039,6 +2113,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { free(props_docs); yyjson_mut_doc_free(doc); cbm_store_search_free(&out); + semantic_query_output_free(&sem); free(project); free(label); diff --git a/src/store/store.c b/src/store/store.c index 33a9453c..cf416b36 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -6319,11 +6319,33 @@ static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int * return results; } +static int vs_count_candidates(cbm_store_t *s, const char *project) { + const char *sql = "SELECT count(*)" + " FROM node_vectors v" + " INNER JOIN nodes n ON n.id = v.node_id" + " WHERE v.project = ?1" + " AND n.label IN ('Function','Method','Class')"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(stmt, SKIP_ONE, project, SQLITE_AUTO_LEN, SQLITE_STATIC); + int total = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) { + total = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return total; +} + int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int limit, cbm_vector_result_t **out, - int *out_count) { + int keyword_count, int limit, cbm_vector_result_t **out, int *out_count, + int *total_count) { *out = NULL; *out_count = 0; + if (total_count) { + *total_count = 0; + } if (!s || !project || !keywords || keyword_count <= 0) { return CBM_STORE_ERR; } @@ -6333,6 +6355,9 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke if (actual_kw == 0) { return CBM_STORE_OK; } + if (total_count) { + *total_count = vs_count_candidates(s, project); + } /* Scan all node vectors, compute per-keyword cosine, take min. * We use the FIRST keyword as the SQL sort (for top-K pre-filter), diff --git a/src/store/store.h b/src/store/store.h index a4541e2d..e66bec9f 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -682,8 +682,8 @@ typedef struct { * the cbm_cosine_i8 SQL function joined with the nodes table. * Returns results sorted by score DESC. Caller must free with cbm_store_free_vector_results. */ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int limit, cbm_vector_result_t **out, - int *out_count); + int keyword_count, int limit, cbm_vector_result_t **out, int *out_count, + int *total_count); /* Free vector search results. */ void cbm_store_free_vector_results(cbm_vector_result_t *results, int count); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index a70b1842..d9a6e365 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -13,6 +13,7 @@ #include /* spawn-count hook — #845 in-process guard */ #include #include +#include #include #include #include @@ -20,7 +21,9 @@ #include #include #include +#include #include /* chmod / stat for read-only query reproductions */ +#include #ifdef _WIN32 #include #define cbm_chdir _chdir @@ -814,6 +817,239 @@ TEST(tool_search_graph_query_honors_file_pattern_issue552) { PASS(); } +static int semantic915_insert_node_vector(sqlite3 *db, int64_t node_id, const char *project, + const int8_t vector[CBM_SEM_DIM]) { + const char *sql = + "INSERT INTO node_vectors(node_id, project, vector) VALUES (?1, ?2, ?3)"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + return SQLITE_ERROR; + } + sqlite3_bind_int64(stmt, 1, node_id); + sqlite3_bind_text(stmt, 2, project, -1, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 3, vector, CBM_SEM_DIM, SQLITE_STATIC); + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? SQLITE_OK : rc; +} + +static int semantic915_insert_token_vector(sqlite3 *db, const char *project, const char *token, + const int8_t vector[CBM_SEM_DIM]) { + const char *sql = + "INSERT INTO token_vectors(project, token, vector, idf) VALUES (?1, ?2, ?3, ?4)"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + return SQLITE_ERROR; + } + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, token, -1, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 3, vector, CBM_SEM_DIM, SQLITE_STATIC); + sqlite3_bind_int(stmt, 4, 1); + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? SQLITE_OK : rc; +} + +TEST(tool_search_graph_semantic_only_uses_vector_results_issue915) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-sem915-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + FAIL("mkdtemp cache failed"); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "issue-915-semantic"; + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + + bool setup_ok = true; + cbm_store_t *st = cbm_store_open_path(db_path); + if (!st) { + setup_ok = false; + } + if (setup_ok && cbm_store_upsert_project(st, project, "/tmp/issue-915-semantic") != + CBM_STORE_OK) { + setup_ok = false; + } + + int64_t registry_id = 0; + int64_t debt_id = 0; + int64_t credit_id = 0; + if (setup_ok) { + cbm_node_t registry = {0}; + registry.project = project; + registry.label = "Function"; + registry.name = "AAARegistryKey"; + registry.qualified_name = "issue915.registry.AAARegistryKey"; + registry.file_path = "registry.c"; + registry.start_line = 1; + registry.end_line = 3; + registry_id = cbm_store_upsert_node(st, ®istry); + + cbm_node_t debt = {0}; + debt.project = project; + debt.label = "Function"; + debt.name = "DebtCalculator"; + debt.qualified_name = "issue915.finance.DebtCalculator"; + debt.file_path = "debt.c"; + debt.start_line = 10; + debt.end_line = 20; + debt_id = cbm_store_upsert_node(st, &debt); + + cbm_node_t credit = {0}; + credit.project = project; + credit.label = "Function"; + credit.name = "CreditPlanner"; + credit.qualified_name = "issue915.finance.CreditPlanner"; + credit.file_path = "credit.c"; + credit.start_line = 30; + credit.end_line = 40; + credit_id = cbm_store_upsert_node(st, &credit); + setup_ok = registry_id > 0 && debt_id > 0 && credit_id > 0; + } + + if (setup_ok) { + setup_ok = cbm_store_exec(st, "CREATE TABLE node_vectors (node_id INTEGER PRIMARY KEY, " + "project TEXT NOT NULL, vector BLOB NOT NULL)") == + CBM_STORE_OK; + } + if (setup_ok) { + setup_ok = cbm_store_exec(st, "CREATE TABLE token_vectors (id INTEGER PRIMARY KEY, " + "project TEXT NOT NULL, token TEXT NOT NULL, " + "vector BLOB NOT NULL, idf INTEGER NOT NULL)") == + CBM_STORE_OK; + } + int8_t debt_vec[CBM_SEM_DIM] = {0}; + debt_vec[0] = 127; + if (setup_ok) { + setup_ok = + semantic915_insert_token_vector(cbm_store_get_db(st), project, "debt", debt_vec) == + SQLITE_OK; + } + if (setup_ok) { + setup_ok = semantic915_insert_node_vector(cbm_store_get_db(st), debt_id, project, debt_vec) == + SQLITE_OK; + } + if (setup_ok) { + int8_t credit_vec[CBM_SEM_DIM] = {0}; + credit_vec[1] = 127; + setup_ok = + semantic915_insert_node_vector(cbm_store_get_db(st), credit_id, project, credit_vec) == + SQLITE_OK; + } + if (st) { + (void)cbm_store_checkpoint(st); + cbm_store_close(st); + } + if (!setup_ok) { + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + FAIL("semantic fixture setup failed"); + } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"],\"limit\":6}"); + char *inner = extract_text_content(resp); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *mode = root ? yyjson_obj_get(root, "search_mode") : NULL; + yyjson_val *total = root ? yyjson_obj_get(root, "total") : NULL; + yyjson_val *has_more = root ? yyjson_obj_get(root, "has_more") : NULL; + yyjson_val *results = root ? yyjson_obj_get(root, "results") : NULL; + yyjson_val *first = results ? yyjson_arr_get(results, 0) : NULL; + yyjson_val *first_name = first ? yyjson_obj_get(first, "name") : NULL; + + bool semantic_mode = mode && yyjson_is_str(mode) && strcmp(yyjson_get_str(mode), "semantic") == 0; + bool total_two = total && yyjson_is_int(total) && yyjson_get_int(total) == 2; + bool has_more_false = has_more && yyjson_is_bool(has_more) && !yyjson_get_bool(has_more); + bool two_results = results && yyjson_is_arr(results) && yyjson_arr_size(results) == 2; + bool debt_primary = first_name && yyjson_is_str(first_name) && + strcmp(yyjson_get_str(first_name), "DebtCalculator") == 0; + bool credit_included = inner && strstr(inner, "CreditPlanner") != NULL; + bool registry_absent = inner && strstr(inner, "AAARegistryKey") == NULL; + bool semantic_results_absent = root && yyjson_obj_get(root, "semantic_results") == NULL; + + if (doc) { + yyjson_doc_free(doc); + } + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"],\"limit\":1," + "\"offset\":0}"); + inner = extract_text_content(resp); + bool limited_page_has_more = + inner && response_contains_json_fragment(inner, "\"total\":2") && + response_contains_json_fragment(inner, "\"has_more\":true") && + response_contains_json_fragment(inner, "\"name\":\"DebtCalculator\"") && + strstr(inner, "AAARegistryKey") == NULL; + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"],\"limit\":1," + "\"offset\":1}"); + inner = extract_text_content(resp); + bool offset_keeps_semantic_primary = + inner && response_contains_json_fragment(inner, "\"search_mode\":\"semantic\"") && + response_contains_json_fragment(inner, "\"total\":2") && + response_contains_json_fragment(inner, "\"has_more\":false") && + response_contains_json_fragment(inner, "\"name\":\"CreditPlanner\"") && + strstr(inner, "AAARegistryKey") == NULL; + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"label\":\"Function\",\"semantic_query\":[\"debt\"]," + "\"limit\":6}"); + inner = extract_text_content(resp); + bool combined_keeps_semantic_results = + inner && response_contains_json_fragment(inner, "\"semantic_results\"") && + response_contains_json_fragment(inner, "\"name\":\"DebtCalculator\""); + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":\"debt\",\"limit\":6}"); + bool type_error_unchanged = + resp && strstr(resp, "semantic_query must be an array of keyword strings") != NULL; + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(semantic_mode); + ASSERT_TRUE(total_two); + ASSERT_TRUE(has_more_false); + ASSERT_TRUE(two_results); + ASSERT_TRUE(debt_primary); + ASSERT_TRUE(credit_included); + ASSERT_TRUE(registry_absent); + ASSERT_TRUE(semantic_results_absent); + ASSERT_TRUE(limited_page_has_more); + ASSERT_TRUE(offset_keeps_semantic_primary); + ASSERT_TRUE(combined_keeps_semantic_results); + ASSERT_TRUE(type_error_unchanged); + PASS(); +} + TEST(tool_query_graph_basic) { cbm_mcp_server_t *srv = setup_mcp_with_data(); @@ -4955,6 +5191,7 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_basic); RUN_TEST(tool_search_graph_includes_node_properties); RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); + RUN_TEST(tool_search_graph_semantic_only_uses_vector_results_issue915); RUN_TEST(tool_query_graph_basic); RUN_TEST(tool_index_status_no_project); RUN_TEST(tool_index_status_includes_git_metadata);