From 4834ea90b7804a670c36cc002fac4ea21e285e70 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sun, 22 Mar 2026 05:33:25 -0400 Subject: [PATCH] mcp: auto-index on first use + auto-context injection + use-after-free fix Auto-index on first use (REQUIRE_STORE + search_graph): When store is NULL and session_root is a valid directory: 1. If autoindex_active: join background thread, re-resolve store 2. If still NULL: run cbm_pipeline_run() synchronously, then cbm_dep_auto_index() + cbm_pagerank_compute_with_config() Handles all 3 paths: CWD (detect_session), explicit path (Rule 0), MCP roots (future). access(session_root, F_OK) guard prevents triggering on non-existent paths in tests. inject_context_once(): auto-provide architecture/schema on first response. First tool response gets _context header with: status, nodes, edges, node_labels, edge_types, ranked_nodes, pagerank_computed_at, detected_ecosystem. Subsequent responses only get session_project. Fix: use-after-free in inject_context_once (ASAN crash at mcp.c:937). cbm_store_schema_free() freed label/type strings while yyjson still held borrowed pointers. Fix: yyjson_mut_obj_add_strcpy() copies strings into yyjson's allocator before schema is freed. Fix: _hidden_tools count corrected from "12" to "14" (14 tools hidden). Tests: 2 new (first_response_has_context_header, context_has_schema_info). Total: 2141 tests passing. Signed-off-by: Andrew Hundt --- src/mcp/mcp.c | 165 +++++++++++++++++++++++++++++++- tests/test_tool_consolidation.c | 48 ++++++++++ 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 2097b22db..bcaa513c1 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -652,7 +652,7 @@ char *cbm_mcp_tools_list(cbm_mcp_server_t *srv) { yyjson_mut_val *hint_tool = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, hint_tool, "name", "_hidden_tools"); yyjson_mut_obj_add_str(doc, hint_tool, "description", - "12 additional tools available but hidden in streamlined mode. " + "14 additional tools available but hidden in streamlined mode. " "Hidden: index_repository, search_graph, query_graph, get_code_snippet, " "get_graph_schema, get_architecture, search_code, list_projects, " "delete_project, index_status, detect_changes, manage_adr, " @@ -828,8 +828,49 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { } /* Bail with JSON error + hint when no store is available. */ +/* Auto-index on first use: when store is NULL, session_root is set, and + * auto_index_on_first_use is enabled, run the pipeline synchronously. + * This eliminates the need for an explicit index_repository call. + * MCP is strict request-response — synchronous blocking is safe here + * (same pattern used by handle_index_repository at line ~1959). */ #define REQUIRE_STORE(store, project) \ do { \ + if (!(store) && srv->session_root[0] && access(srv->session_root, F_OK) == 0) { \ + /* Try auto-index on first use (only if session_root is a real directory) */ \ + if (srv->autoindex_active) { \ + /* Background thread running — wait for it to complete */ \ + cbm_thread_join(&srv->autoindex_tid); \ + srv->autoindex_active = false; \ + /* Re-resolve store after background index finished */ \ + store = resolve_store(srv, project); \ + } \ + if (!(store)) { \ + /* No background thread or it failed — try sync index */ \ + cbm_pipeline_t *_p = cbm_pipeline_new( \ + srv->session_root, NULL, CBM_MODE_FULL); \ + if (_p) { \ + cbm_log_info("autoindex.sync", "project", srv->session_project); \ + cbm_pipeline_run(_p); \ + cbm_pipeline_free(_p); \ + /* Invalidate + reopen store */ \ + if (srv->owns_store && srv->store) { \ + cbm_store_close(srv->store); \ + srv->store = NULL; \ + } \ + free(srv->current_project); \ + srv->current_project = NULL; \ + store = resolve_store(srv, srv->session_project); \ + /* Also compute PageRank + auto-index deps */ \ + if (store) { \ + cbm_dep_auto_index(srv->session_project, srv->session_root, \ + store, CBM_DEFAULT_AUTO_DEP_LIMIT); \ + cbm_pagerank_compute_with_config(store, srv->session_project, \ + srv->config); \ + } \ + cbm_mem_collect(); \ + } \ + } \ + } \ if (!(store)) { \ free(project); \ return cbm_mcp_text_result( \ @@ -839,6 +880,94 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { } \ } while (0) +/* ── Auto-context injection (Phase 9) ─────────────────────────── */ + +/* Inject _context header into the FIRST tool response after session starts. + * Contains architecture, schema, status — eliminates the need for separate + * get_architecture / get_graph_schema / index_status / list_projects calls. + * Subsequent responses include only session_project (lightweight). */ +static void inject_context_once(yyjson_mut_doc *doc, yyjson_mut_val *root, + cbm_mcp_server_t *srv, cbm_store_t *store) { + /* Always include session_project */ + if (srv->session_project[0]) + yyjson_mut_obj_add_str(doc, root, "session_project", srv->session_project); + + if (srv->context_injected) return; + srv->context_injected = true; + + yyjson_mut_val *ctx = yyjson_mut_obj(doc); + + if (!store) { + yyjson_mut_obj_add_str(doc, ctx, "status", "not_indexed"); + yyjson_mut_obj_add_str(doc, ctx, "hint", + "Project not yet indexed. Use index_repository or set auto_index=true."); + yyjson_mut_obj_add_val(doc, root, "_context", ctx); + return; + } + + yyjson_mut_obj_add_str(doc, ctx, "status", "ready"); + + /* Node/edge counts */ + const char *proj = srv->session_project[0] ? srv->session_project : NULL; + int nodes = cbm_store_count_nodes(store, proj); + int edges = cbm_store_count_edges(store, proj); + yyjson_mut_obj_add_int(doc, ctx, "nodes", nodes); + yyjson_mut_obj_add_int(doc, ctx, "edges", edges); + + /* Schema: node labels + edge types */ + cbm_schema_info_t schema = {0}; + cbm_store_get_schema(store, proj, &schema); + yyjson_mut_val *label_arr = yyjson_mut_arr(doc); + for (int i = 0; i < schema.node_label_count; i++) { + yyjson_mut_val *lbl = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, lbl, "label", schema.node_labels[i].label); + yyjson_mut_obj_add_int(doc, lbl, "count", schema.node_labels[i].count); + yyjson_mut_arr_add_val(label_arr, lbl); + } + yyjson_mut_obj_add_val(doc, ctx, "node_labels", label_arr); + + yyjson_mut_val *type_arr = yyjson_mut_arr(doc); + for (int i = 0; i < schema.edge_type_count; i++) { + yyjson_mut_val *et = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, et, "type", schema.edge_types[i].type); + yyjson_mut_obj_add_int(doc, et, "count", schema.edge_types[i].count); + yyjson_mut_arr_add_val(type_arr, et); + } + yyjson_mut_obj_add_val(doc, ctx, "edge_types", type_arr); + cbm_store_schema_free(&schema); + + /* PageRank stats */ + sqlite3 *db = cbm_store_get_db(store); + if (db && proj) { + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, + "SELECT COUNT(*), MAX(computed_at) FROM pagerank WHERE project = ?1", + -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, proj, -1, SQLITE_TRANSIENT); + if (sqlite3_step(stmt) == SQLITE_ROW) { + int ranked = sqlite3_column_int(stmt, 0); + if (ranked > 0) { + yyjson_mut_obj_add_int(doc, ctx, "ranked_nodes", ranked); + const char *ts = (const char *)sqlite3_column_text(stmt, 1); + if (ts) yyjson_mut_obj_add_strcpy(doc, ctx, "pagerank_computed_at", ts); + } + } + sqlite3_finalize(stmt); + } + } + + /* Detected ecosystem */ + if (srv->session_root[0]) { + cbm_pkg_manager_t eco = cbm_detect_ecosystem(srv->session_root); + if (eco != CBM_PKG_COUNT) { + yyjson_mut_obj_add_str(doc, ctx, "detected_ecosystem", + cbm_pkg_manager_str(eco)); + } + } + + yyjson_mut_obj_add_val(doc, root, "_context", ctx); +} + /* ── Smart project param expansion ─────────────────────────────── */ typedef enum { MATCH_NONE, MATCH_EXACT, MATCH_PREFIX, MATCH_GLOB } match_mode_t; @@ -1167,6 +1296,34 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { db_project = srv->session_project; /* deps are in session db */ } cbm_store_t *store = resolve_store(srv, db_project); + /* Auto-index on first use — same logic as REQUIRE_STORE macro. + * Handles: CWD-based session_root, explicit path via Rule 0, MCP roots. */ + if (!store && srv->session_root[0] && access(srv->session_root, F_OK) == 0) { + if (srv->autoindex_active) { + cbm_thread_join(&srv->autoindex_tid); + srv->autoindex_active = false; + store = resolve_store(srv, db_project); + } + if (!store) { + cbm_pipeline_t *_p = cbm_pipeline_new(srv->session_root, NULL, CBM_MODE_FULL); + if (_p) { + cbm_log_info("autoindex.sync", "project", srv->session_project); + cbm_pipeline_run(_p); + cbm_pipeline_free(_p); + if (srv->owns_store && srv->store) { + cbm_store_close(srv->store); srv->store = NULL; + } + free(srv->current_project); srv->current_project = NULL; + store = resolve_store(srv, srv->session_project); + if (store) { + cbm_dep_auto_index(srv->session_project, srv->session_root, + store, CBM_DEFAULT_AUTO_DEP_LIMIT); + cbm_pagerank_compute_with_config(store, srv->session_project, srv->config); + } + cbm_mem_collect(); + } + } + } if (!store) { free(pe.value); return cbm_mcp_text_result( @@ -1211,9 +1368,9 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, root, "total", out.total); - /* Always include session_project so AI knows the project name */ - if (srv->session_project[0]) - yyjson_mut_obj_add_str(doc, root, "session_project", srv->session_project); + /* Auto-context: first response gets full architecture/schema/_context header. + * Subsequent responses just get session_project. */ + inject_context_once(doc, root, srv, store); if (is_summary) { /* Summary mode: aggregate counts by label and file (top 20) */ diff --git a/tests/test_tool_consolidation.c b/tests/test_tool_consolidation.c index 703dd2150..5599e1f28 100644 --- a/tests/test_tool_consolidation.c +++ b/tests/test_tool_consolidation.c @@ -235,6 +235,51 @@ TEST(index_status_has_session_project) { PASS(); } +/* ── 7. Context injection ─────────────────────────────────── */ + +TEST(first_response_has_context_header) { + /* First search_graph call should include _context with schema/status. + * Uses in-memory store (no session_root) so auto-index won't trigger. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "ctx_test"); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(result); + /* First response should have _context */ + ASSERT_NOT_NULL(strstr(result, "_context")); + ASSERT_NOT_NULL(strstr(result, "status")); + free(result); + + /* Second call should NOT have _context (already injected) */ + char *result2 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test2\"}"); + ASSERT_NOT_NULL(result2); + ASSERT_NULL(strstr(result2, "_context")); + /* But session_project should still be present */ + ASSERT_NOT_NULL(strstr(result2, "session_project")); + free(result2); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(context_has_schema_info) { + /* _context should include node_labels and edge_types arrays */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\"}"); + ASSERT_NOT_NULL(result); + /* In-memory store has schema tables → should see these fields */ + ASSERT_NOT_NULL(strstr(result, "_context")); + ASSERT_NOT_NULL(strstr(result, "node_labels")); + ASSERT_NOT_NULL(strstr(result, "edge_types")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + /* ── Suite registration ──────────────────────────────────── */ SUITE(tool_consolidation) { @@ -257,4 +302,7 @@ SUITE(tool_consolidation) { /* Session context */ RUN_TEST(search_graph_has_session_project); RUN_TEST(index_status_has_session_project); + /* Context injection */ + RUN_TEST(first_response_has_context_header); + RUN_TEST(context_has_schema_info); }