From ce9593da09d28829ba0a309612593a4adff6776b Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sun, 19 Jul 2026 08:53:37 -0400 Subject: [PATCH] test(mcp): pin dynamic query schema freshness tests/test_tool_consolidation.c adds query_graph_description_uses_ready_overlay_view, which proves tools/list describes the ready overlay that query_graph executes instead of canonical rows replaced for the same file. tests/test_mcp.c adds mcp_published_schema_refreshes_description_once and extends mcp_delete_project_sends_list_changed. These protocol fixtures prove publication reopens the store, repeated publication emits one notifications/tools/list_changed event, and project deletion removes cached vocabulary from the next tools/list response. Verification: 590 MCP/consolidation tests; full ASan/UBSan suite (6867 passed, 1 skipped); TSan suite (449 passed) plus all three exact freshness cases; macOS leaks reports 0 leaks for 0 bytes for each exact case. Signed-off-by: Andrew Hundt --- tests/test_mcp.c | 90 ++++++++++++++++++++++++++++++++- tests/test_tool_consolidation.c | 49 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 2ce1eb541..184293e87 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -8431,6 +8431,89 @@ TEST(mcp_notify_index_published_sends_list_changed_once) { PASS(); } +/* A publication invalidates both the cached query_graph description and the + * cached SQLite handle. The next protocol tools/list must reopen the store, + * advertise newly published vocabulary, and coalesce repeated publication + * signals into one post-response notification. */ +TEST(mcp_published_schema_refreshes_description_once) { + const char *project = "mcp_published_schema_refresh_fixture"; + const char *cache = cbm_resolve_cache_dir(); + char db_path[CBM_SZ_4K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + + cbm_store_t *seed = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(seed); + ASSERT_EQ(cbm_store_upsert_project(seed, project, "/tmp/published-schema-refresh"), + CBM_STORE_OK); + cbm_node_t initial = {.project = project, + .label = "InitialSchemaLabel", + .name = "initial", + .qualified_name = "mcp_published_schema_refresh_fixture.initial", + .file_path = "initial.c"}; + ASSERT_GT(cbm_store_upsert_node(seed, &initial), 0); + cbm_store_close(seed); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, project); + char *before = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(before); + ASSERT_NOT_NULL(strstr(before, "InitialSchemaLabel")); + ASSERT_NULL(strstr(before, "PublishedOnlyLabel")); + free(before); + + cbm_store_t *writer = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(writer); + cbm_node_t published = {.project = project, + .label = "PublishedOnlyLabel", + .name = "published", + .qualified_name = "mcp_published_schema_refresh_fixture.published", + .file_path = "published.c"}; + ASSERT_GT(cbm_store_upsert_node(writer, &published), 0); + cbm_store_close(writer); + + cbm_mcp_server_notify_index_published(srv); + cbm_mcp_server_notify_index_published(srv); + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_EQ(written, (ssize_t)strlen(msgs)); + close(fds[1]); + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_EQ(count_substr_mcp(buf, "PublishedOnlyLabel"), 2); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + cleanup_project_db(cache, project); + PASS(); +} + /* Inverse of the above: the handshake-proxy's actual promise is that a * session which publishes but never serves a tools/list emits ZERO * notifications — untested until now. A single non-tools/list request @@ -8494,7 +8577,7 @@ TEST(mcp_delete_project_sends_list_changed) { ASSERT_EQ(cbm_store_upsert_project(seed, project, "/tmp/delete-project-fixture"), CBM_STORE_OK); cbm_node_t seed_node = {.project = project, - .label = "Function", + .label = "DeletedProjectOnlyLabel", .name = "seed_fn", .qualified_name = "mcp_delete_project_sends_list_changed_fixture.seed_fn", .file_path = "seed.go"}; @@ -8523,6 +8606,7 @@ TEST(mcp_delete_project_sends_list_changed) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, project); signal(SIGALRM, alarm_handler); alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); @@ -8545,6 +8629,9 @@ TEST(mcp_delete_project_sends_list_changed) { ASSERT_NOT_NULL(strstr(buf, "\"status\":\"deleted\"")); ASSERT_NOT_NULL(strstr(buf, "\"id\":3")); ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + /* The first tools/list advertises the seeded schema; after deletion the + * relist must not reuse that cached description. */ + ASSERT_EQ(count_substr_mcp(buf, "DeletedProjectOnlyLabel"), 1); free(buf); cbm_mcp_server_free(srv); @@ -11075,6 +11162,7 @@ SUITE(mcp) { RUN_TEST(mcp_hidden_tools_reveal_sends_list_changed); RUN_TEST(mcp_hidden_tools_reveal_frames_list_changed); RUN_TEST(mcp_notify_index_published_sends_list_changed_once); + RUN_TEST(mcp_published_schema_refreshes_description_once); RUN_TEST(mcp_notify_before_any_tools_list_suppressed); RUN_TEST(mcp_delete_project_sends_list_changed); RUN_TEST(mcp_delete_project_noop_sends_no_list_changed); diff --git a/tests/test_tool_consolidation.c b/tests/test_tool_consolidation.c index 30ad1e649..e26069f15 100644 --- a/tests/test_tool_consolidation.c +++ b/tests/test_tool_consolidation.c @@ -274,6 +274,54 @@ TEST(query_graph_description_repeats_current_executable_schema) { PASS(); } +/* The schema embedded in query_graph must describe the same active overlay + * view that query_graph executes. A ready overlay replaces canonical rows for + * its file; advertising both labels would direct clients to stale vocabulary. */ +TEST(query_graph_description_uses_ready_overlay_view) { + enum { BASE_GENERATION = 1 }; + const char *project = "schema_overlay_view"; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/schema-overlay-view"), CBM_STORE_OK); + + cbm_node_t canonical = {.project = project, + .label = "CanonicalOnlyLabel", + .name = "old", + .qualified_name = "schema_overlay_view.old", + .file_path = "changed.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &canonical), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ( + cbm_store_reserve_overlay_generation(store, project, BASE_GENERATION, &overlay_generation), + CBM_STORE_OK); + cbm_node_t replacement = {.project = project, + .label = "OverlayOnlyLabel", + .name = "new", + .qualified_name = "schema_overlay_view.new", + .file_path = "changed.c"}; + cbm_store_file_delta_t delta = {.project = project, + .rel_path = "changed.c", + .generation = BASE_GENERATION, + .nodes = &replacement, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(store, &delta, overlay_generation), + CBM_STORE_OK); + + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + ASSERT_NOT_NULL(strstr(tools, "OverlayOnlyLabel")); + ASSERT_NULL(strstr(tools, "CanonicalOnlyLabel")); + free(tools); + + cbm_mcp_server_free(srv); + PASS(); +} + /* Regression (dogfood 2026-07-18): the very first tools/list of a fresh * session for an already-indexed project must show the real schema, not * an empty one. build_query_graph_tool_description previously trusted @@ -3355,6 +3403,7 @@ SUITE(tool_consolidation) { /* Tool visibility */ RUN_TEST(streamlined_mode_shows_default_user_tools); RUN_TEST(query_graph_description_repeats_current_executable_schema); + RUN_TEST(query_graph_description_uses_ready_overlay_view); RUN_TEST(query_graph_description_populated_on_cold_start_tools_list); RUN_TEST(query_graph_zero_row_hint_names_no_hidden_tool); RUN_TEST(query_graph_zero_row_hint_walks_post_with_stage);