From 2f41ccf2ea1b5f20b6e60918e117dddbabdee4c4 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 30 Jun 2026 21:59:00 -0400 Subject: [PATCH] feat(mcp): warn on stale cypher surfaces Add query_graph response warnings when Cypher queries reference route-derived or semantic-derived graph constructs whose derived view state is explicitly stale. The detector uses existing portable string matching and named derived-view constants, so legacy databases with missing freshness rows remain unchanged. Validated with: git diff --check; bash scripts/check-source-safety.sh; make -j8 -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=mcp ./build/c/test-runner. Signed-off-by: Andrew Hundt --- src/mcp/mcp.c | 46 ++++++++++++++++++++++++++++++++++ tests/test_mcp.c | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7dff04b72..a127b6b84 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -122,6 +122,51 @@ static void add_derived_freshness_warnings(yyjson_mut_doc *doc, yyjson_mut_val * } } +static bool query_mentions_any(const char *query, const char *const *terms, int term_count) { + if (!query || !terms || term_count <= 0) { + return false; + } + for (int i = 0; i < term_count; i++) { + if (terms[i] && cbm_strcasestr(query, terms[i])) { + return true; + } + } + return false; +} + +static bool query_mentions_route_derived_graph(const char *query) { + static const char *const terms[] = {"Route", "HANDLES", "HTTP_CALLS", + "ASYNC_CALLS", "GRPC_CALLS", "GRAPHQL_CALLS", + "TRPC_CALLS", "CROSS_HTTP_CALLS", "CROSS_ASYNC_CALLS", + "CROSS_CHANNEL", "CROSS_GRPC_CALLS", "CROSS_GRAPHQL_CALLS", + "CROSS_TRPC_CALLS"}; + return query_mentions_any(query, terms, (int)(sizeof(terms) / sizeof(terms[0]))); +} + +static bool query_mentions_semantic_derived_graph(const char *query) { + static const char *const terms[] = {"SEMANTICALLY_RELATED", "SIMILAR_TO"}; + return query_mentions_any(query, terms, (int)(sizeof(terms) / sizeof(terms[0]))); +} + +static void add_query_graph_derived_warnings(yyjson_mut_doc *doc, yyjson_mut_val *root, + cbm_store_t *store, const char *project, + const char *query) { + if (!doc || !root || !store || !project || !query) { + return; + } + if (query_mentions_route_derived_graph(query) && + cbm_store_derived_view_is_stale(store, project, CBM_STORE_DERIVED_VIEW_ROUTES)) { + add_response_warning(doc, root, + "routes derived view is stale; query_graph route results may be stale."); + } + if (query_mentions_semantic_derived_graph(query) && + cbm_store_derived_view_is_stale(store, project, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES)) { + add_response_warning( + doc, root, + "semantic_edges derived view is stale; query_graph semantic edges may be stale."); + } +} + /* Default snippet fallback line count (when end_line unknown) */ #define SNIPPET_DEFAULT_LINES 50 @@ -3461,6 +3506,7 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); + add_query_graph_derived_warnings(doc, root, store, project, query); /* columns */ yyjson_mut_val *cols = yyjson_mut_arr(doc); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index dbd5dc052..2d226e784 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -929,6 +929,69 @@ TEST(tool_query_graph_basic) { PASS(); } +TEST(tool_query_graph_warns_on_stale_route_view) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "query-route-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-route-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":114,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-route-stale\"," + "\"query\":\"MATCH (r:Route) RETURN r.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "routes derived view is stale")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_query_graph_warns_on_stale_semantic_edges) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "query-semantic-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-semantic-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":115,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-semantic-stale\"," + "\"query\":\"MATCH (a)-[:SEMANTICALLY_RELATED]->(b) " + "RETURN a.name, b.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "semantic_edges derived view is stale")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_index_status_no_project) { cbm_mcp_server_t *srv = setup_mcp_with_data(); @@ -3166,6 +3229,8 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_query_rejects_bad_semantic_query); RUN_TEST(tool_search_graph_semantic_query_warns_on_stale_semantic_view); RUN_TEST(tool_query_graph_basic); + RUN_TEST(tool_query_graph_warns_on_stale_route_view); + RUN_TEST(tool_query_graph_warns_on_stale_semantic_edges); RUN_TEST(tool_index_status_no_project); RUN_TEST(tool_index_status_includes_git_metadata);