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 <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-06-30 21:59:00 -04:00
parent 48c6a38a04
commit 2f41ccf2ea
2 changed files with 111 additions and 0 deletions
+46
View File
@@ -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);
+65
View File
@@ -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);