diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d9e73206..de4eda9c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -73,6 +73,7 @@ enum { #endif #include #include // int64_t +#include #include #include #include @@ -379,8 +380,9 @@ static const tool_def_t TOOLS[] = { "\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"MUST be an ARRAY of " "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\":" + "that score well on ALL keywords. Requires moderate/full index mode. Semantic-only calls " + "return the primary semantic table (or JSON 'results'); combined calls add a semantic " + "sidecar table (or JSON 'semantic_results').\"},\"limit\":{\"type\":" "\"integer\",\"description\":\"Max results per call. Default 50. 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," @@ -1972,20 +1974,19 @@ 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_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, "semantic_results", sem_results); + yyjson_mut_obj_add_val(doc, root, field, results); } /* Run the semantic_query vector search from raw args. Sets *out_vresults / @@ -1994,10 +1995,13 @@ static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root, * caller should surface to the user). */ static bool run_semantic_query_core(const char *args, cbm_store_t *store, const char *project, int limit, cbm_vector_result_t **out_vresults, int *out_vcount, - bool *out_present) { + int *out_total, bool *out_present) { enum { MAX_KW_SEARCH = 32 }; *out_vresults = NULL; *out_vcount = 0; + if (out_total) { + *out_total = 0; + } if (out_present) { *out_present = false; } @@ -2016,12 +2020,16 @@ static bool run_semantic_query_core(const char *args, cbm_store_t *store, const 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 && + int total = 0; + if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, &vresults, &vcount, + out_total ? &total : NULL) == CBM_STORE_OK && vcount > 0) { *out_vresults = vresults; *out_vcount = vcount; } + if (out_total) { + *out_total = total; + } } if (args_doc) { yyjson_doc_free(args_doc); @@ -2037,14 +2045,20 @@ static bool run_semantic_query(yyjson_mut_doc *doc, yyjson_mut_val *root, const cbm_vector_result_t *vresults = NULL; int vcount = 0; bool type_error = - run_semantic_query_core(args, store, project, limit, &vresults, &vcount, NULL); + run_semantic_query_core(args, store, project, limit, &vresults, &vcount, NULL, NULL); if (vcount > 0) { - emit_semantic_results(doc, root, vresults, vcount); + emit_vector_results(doc, root, "semantic_results", vresults, 0, vcount); cbm_store_free_vector_results(vresults, vcount); } return type_error; } +static int semantic_fetch_limit_for_page(int limit, int offset) { + int page_limit = limit > 0 ? limit : CBM_SZ_16; + long long page_end = (long long)(offset > 0 ? offset : 0) + (long long)page_limit; + return page_end > INT_MAX ? INT_MAX : (int)page_end; +} + /* ── TOON output for search_graph ─────────────────────────────────── * Default response encoding: header+rows tables (compact_out.h). The * verbose per-node JSON objects remain available via format:"json" and @@ -2154,11 +2168,11 @@ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *ou } /* Emit semantic vector-search results as a TOON table. */ -static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, +static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, int start, int vcount) { static const char *const cols[] = {"qn", "label", "file", "score"}; cbm_toon_table_header(sb, "semantic", vcount, cols, 4); - for (int v = 0; v < vcount; v++) { + for (int v = start; v < start + vcount; v++) { cbm_toon_row_begin(sb); cbm_toon_cell_str(sb, vresults[v].qualified_name, true); cbm_toon_cell_str(sb, vresults[v].label, false); @@ -2248,23 +2262,87 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { .max_degree = max_degree, }; + bool has_filters = label || name_pattern || qn_pattern || file_pattern || relationship || + exclude_entry_points || include_connected || min_degree != CBM_NOT_FOUND || + max_degree != CBM_NOT_FOUND; + + if (legacy_json && !has_filters) { + int page_limit = limit > 0 ? limit : CBM_SZ_16; + int semantic_fetch_limit = semantic_fetch_limit_for_page(limit, offset); + cbm_vector_result_t *vresults = NULL; + int vcount = 0; + int vtotal = 0; + bool sq_present = false; + bool sq_type_error = run_semantic_query_core(args, store, project, semantic_fetch_limit, + &vresults, &vcount, &vtotal, &sq_present); + if (sq_type_error) { + 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 (sq_present) { + int start = offset > 0 ? offset : 0; + if (start > vcount) { + start = vcount; + } + int page_count = vcount - start; + if (page_count > page_limit) { + page_count = page_limit; + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_int(doc, root, "total", vtotal); + 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", vtotal > offset + page_count); + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + if (vresults) { + cbm_store_free_vector_results(vresults, vcount); + } + 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; + } + } + if (!legacy_json) { const char *fields[SG_MAX_EXTRA_FIELDS]; yyjson_doc *fields_owner = NULL; int nfields = sg_parse_fields(args, fields, SG_MAX_EXTRA_FIELDS, &fields_owner); + int semantic_fetch_limit = limit > 0 ? limit : CBM_SZ_16; + if (!has_filters) { + semantic_fetch_limit = semantic_fetch_limit_for_page(limit, offset); + } + cbm_vector_result_t *vresults = NULL; int vcount = 0; + int vtotal = 0; bool sq_present = false; bool sq_type_error = - run_semantic_query_core(args, store, project, limit, &vresults, &vcount, &sq_present); + run_semantic_query_core(args, store, project, semantic_fetch_limit, &vresults, &vcount, + has_filters ? NULL : &vtotal, &sq_present); if (!sq_type_error) { /* Semantic-only calls get semantic results only: the legacy * behavior also ran the UNFILTERED regex search and prepended * up to `limit` unrelated enriched nodes to the response. */ - bool has_filters = label || name_pattern || qn_pattern || file_pattern || - relationship || exclude_entry_points || - min_degree != CBM_NOT_FOUND || max_degree != CBM_NOT_FOUND; bool semantic_only = sq_present && !has_filters; cbm_sb_t sb; @@ -2290,14 +2368,27 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } } } - if (vcount > 0) { - emit_semantic_results_toon(&sb, vresults, vcount); - } else if (semantic_only) { - static const char *const sem_cols[] = {"qn", "label", "file", "score"}; - cbm_toon_table_header(&sb, "semantic", 0, sem_cols, 4); - cbm_toon_scalar_str(&sb, "hint", - "No semantic matches. semantic_query needs a moderate/full " - "index; try broader or fewer keywords."); + if (semantic_only) { + int start = offset > 0 ? offset : 0; + if (start > vcount) { + start = vcount; + } + int page_count = vcount - start; + int page_limit = limit > 0 ? limit : CBM_SZ_16; + if (page_count > page_limit) { + page_count = page_limit; + } + cbm_toon_scalar_str(&sb, "search_mode", "semantic"); + cbm_toon_scalar_int(&sb, "total", vtotal); + emit_semantic_results_toon(&sb, vresults, start, page_count); + cbm_toon_scalar_bool(&sb, "has_more", vtotal > offset + page_count); + if (vcount == 0) { + cbm_toon_scalar_str(&sb, "hint", + "No semantic matches. semantic_query needs a moderate/full " + "index; try broader or fewer keywords."); + } + } else if (vcount > 0) { + emit_semantic_results_toon(&sb, vresults, 0, vcount); } if (vcount > 0) { cbm_store_free_vector_results(vresults, vcount); diff --git a/src/store/store.c b/src/store/store.c index 220191e9..baee0862 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -9,6 +9,7 @@ // for ISO timestamp #include +#include #include "foundation/constants.h" #include @@ -6898,11 +6899,48 @@ 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; +} + +static int vs_result_score_desc(const void *lhs, const void *rhs) { + const cbm_vector_result_t *a = lhs; + const cbm_vector_result_t *b = rhs; + if (a->score < b->score) { + return 1; + } + if (a->score > b->score) { + return -1; + } + if (a->node_id < b->node_id) { + return -1; + } + return a->node_id > b->node_id; +} + 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; } @@ -6912,6 +6950,11 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke if (actual_kw == 0) { return CBM_STORE_OK; } + int candidate_total = 0; + if (total_count) { + candidate_total = vs_count_candidates(s, project); + *total_count = candidate_total; + } /* Scan all node vectors, compute per-keyword cosine, take min. * We use the FIRST keyword as the SQL sort (for top-K pre-filter), @@ -6932,8 +6975,14 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke return CBM_STORE_ERR; } - /* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */ - int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5; + /* Semantic pagination needs a stable ordering across pages, so callers + * requesting a total scan the full candidate set before min-score reranking. + * Sidecar searches keep the bounded first-keyword prefilter. */ + int requested_limit = limit > 0 ? limit : CBM_SZ_16; + int fetch_limit = candidate_total; + if (!total_count) { + fetch_limit = requested_limit > INT_MAX / ST_COL_5 ? INT_MAX : requested_limit * ST_COL_5; + } sqlite3_bind_blob(stmt, SKIP_ONE, kw_vecs[0], VS_VEC_DIM, SQLITE_STATIC); sqlite3_bind_text(stmt, ST_COL_2, project, SQLITE_AUTO_LEN, SQLITE_STATIC); sqlite3_bind_int(stmt, ST_COL_3, fetch_limit); @@ -6972,19 +7021,14 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke } sqlite3_finalize(stmt); - /* Re-sort by min-score (SQL sorted by first keyword only) */ - for (int i = 0; i < count - SKIP_ONE; i++) { - for (int j = i + SKIP_ONE; j < count; j++) { - if (results[j].score > results[i].score) { - cbm_vector_result_t tmp = results[i]; - results[i] = results[j]; - results[j] = tmp; - } - } + /* Re-sort by min-score (SQL sorted by first keyword only). Node ID is a + * deterministic tie-breaker so repeated pages keep the same ordering. */ + if (count > 1) { + qsort(results, (size_t)count, sizeof(*results), vs_result_score_desc); } /* Trim to requested limit */ - int final_limit = limit > 0 ? limit : CBM_SZ_16; + int final_limit = requested_limit; if (count > final_limit) { for (int i = final_limit; i < count; i++) { free(results[i].name); diff --git a/src/store/store.h b/src/store/store.h index ad507283..4d5afd62 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -719,8 +719,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 e24ec0d7..f064fb5d 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -14,6 +14,7 @@ #include /* spawn-count hook — #845 in-process guard */ #include #include +#include #include #include #include @@ -946,6 +947,346 @@ 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\"],\"format\":\"json\"," + "\"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\"],\"format\":\"json\"," + "\"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\"],\"format\":\"json\"," + "\"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\"]," + "\"format\":\"json\",\"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\":1," + "\"offset\":1}"); + inner = extract_text_content(resp); + bool toon_keeps_semantic_primary = + inner && strstr(inner, "search_mode: semantic") && strstr(inner, "total: 2") && + strstr(inner, "semantic[1]") && strstr(inner, "CreditPlanner") && + strstr(inner, "has_more: false") && strstr(inner, "AAARegistryKey") == NULL && + strstr(inner, "results[") == NULL; + free(inner); + free(resp); + + cbm_mcp_server_free(srv); + srv = NULL; + st = cbm_store_open_path(db_path); + bool stable_fixture_ok = st != NULL; + int8_t credit_token_vec[CBM_SEM_DIM] = {0}; + credit_token_vec[1] = 127; + if (stable_fixture_ok) { + stable_fixture_ok = semantic915_insert_token_vector(cbm_store_get_db(st), project, "credit", + credit_token_vec) == SQLITE_OK; + } + for (int i = 0; stable_fixture_ok && i < 5; i++) { + char name[32]; + char qn[64]; + char file[32]; + snprintf(name, sizeof(name), "StableDecoy%d", i); + snprintf(qn, sizeof(qn), "issue915.stable.%s", name); + snprintf(file, sizeof(file), "stable_decoy_%d.c", i); + cbm_node_t node = {0}; + node.project = project; + node.label = "Function"; + node.name = name; + node.qualified_name = qn; + node.file_path = file; + node.start_line = i + 1; + node.end_line = i + 1; + int64_t node_id = cbm_store_upsert_node(st, &node); + int8_t vector[CBM_SEM_DIM] = {0}; + vector[0] = (int8_t)(120 + i); + vector[1] = 1; + stable_fixture_ok = + node_id > 0 && semantic915_insert_node_vector(cbm_store_get_db(st), node_id, project, + vector) == SQLITE_OK; + } + if (stable_fixture_ok) { + cbm_node_t balanced = {0}; + balanced.project = project; + balanced.label = "Function"; + balanced.name = "BalancedMatch"; + balanced.qualified_name = "issue915.stable.BalancedMatch"; + balanced.file_path = "balanced.c"; + balanced.start_line = 50; + balanced.end_line = 55; + int64_t balanced_id = cbm_store_upsert_node(st, &balanced); + int8_t balanced_vec[CBM_SEM_DIM] = {0}; + balanced_vec[0] = 100; + balanced_vec[1] = 100; + stable_fixture_ok = + balanced_id > 0 && semantic915_insert_node_vector(cbm_store_get_db(st), balanced_id, + project, balanced_vec) == SQLITE_OK; + } + if (st) { + (void)cbm_store_checkpoint(st); + cbm_store_close(st); + } + ASSERT_TRUE(stable_fixture_ok); + srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\",\"credit\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":0}"); + char *stable_page_one = extract_text_content(resp); + bool stable_page_one_ok = + stable_page_one && response_contains_json_fragment(stable_page_one, "\"total\":8") && + response_contains_json_fragment(stable_page_one, "\"name\":\"BalancedMatch\"") && + response_contains_json_fragment(stable_page_one, "\"has_more\":true"); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\",\"credit\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":1}"); + char *stable_page_two = extract_text_content(resp); + bool stable_page_two_ok = stable_page_two && strstr(stable_page_two, "BalancedMatch") == NULL && + strstr(stable_page_two, "StableDecoy") != NULL; + free(resp); + free(stable_page_one); + free(stable_page_two); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":2147483647}"); + inner = extract_text_content(resp); + bool large_offset_safe = inner && response_contains_json_fragment(inner, "\"results\":[]") && + response_contains_json_fragment(inner, "\"has_more\":false"); + 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(toon_keeps_semantic_primary); + ASSERT_TRUE(stable_page_one_ok); + ASSERT_TRUE(stable_page_two_ok); + ASSERT_TRUE(large_offset_safe); + ASSERT_TRUE(type_error_unchanged); + PASS(); +} + /* MCP discovery methods this server doesn't populate must return EMPTY * lists, not -32601 Method-not-found: clients like Cline probe * resources/list + prompts/list + resources/templates/list on connect and @@ -964,9 +1305,8 @@ TEST(mcp_discovery_methods_return_empty_lists) { }; for (int i = 0; i < 3; i++) { char reqbuf[256]; - snprintf(reqbuf, sizeof(reqbuf), - "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", 100 + i, - cases[i].method); + snprintf(reqbuf, sizeof(reqbuf), "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", + 100 + i, cases[i].method); char *resp = cbm_mcp_server_handle(srv, reqbuf); ASSERT_NOT_NULL(resp); ASSERT_NULL(strstr(resp, "Method not found")); @@ -5659,6 +5999,7 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields); RUN_TEST(tool_output_byte_budgets); RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); + RUN_TEST(tool_search_graph_semantic_only_uses_vector_results_issue915); RUN_TEST(mcp_discovery_methods_return_empty_lists); RUN_TEST(tool_query_graph_basic); RUN_TEST(tool_index_status_no_project);