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 <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-19 08:53:37 -04:00
parent 77c38ce863
commit ce9593da09
2 changed files with 138 additions and 1 deletions
+89 -1
View File
@@ -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);
+49
View File
@@ -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);