fix(mcp): refresh inline schema after external publication

A long-lived server cached query_graph's tools/list description after opening a project store. When a sibling CLI, MCP, or HTTP worker atomically replaced that database, graph queries reopened the new file but tools/list could continue advertising the old labels indefinitely because no in-process publication flag was available.

query_graph_tool_description at src/mcp/mcp.c:2959 now asks resolve_store to perform the existing O(1) backing-file identity check before serving a cached description. resolve_store marks the description stale on a replacement, then the request-owning thread closes the old read-only handle and rebuilds schema; unchanged relists perform no graph scan, database copy, write, or cross-process lock. mcp_copy_schema_project_name copies only the bounded project name so reopening cannot invalidate the argument pointer.

tests/test_mcp.c:11664 extends the atomic-publication fixture with distinct before/after labels and relists before any graph query. The test failed because AfterExternalLabel was absent, then passed 1/1 under ASan/UBSan. Verified scripts/check-source-safety.sh, git diff --check, git clang-format --diff, and staged private-path scan.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-20 00:43:20 -04:00
parent 393d3a297f
commit bf3d27c130
2 changed files with 55 additions and 16 deletions
+34 -10
View File
@@ -2824,6 +2824,23 @@ static int mcp_get_current_schema(cbm_store_t *store, const char *project,
: cbm_store_get_schema_counts(store, project, out);
}
/* Copy only the selected project name into caller-owned stack storage. This is
* not a database/graph snapshot: the bounded copy keeps the name valid if
* resolve_store closes a replaced database and frees current_project. */
static const char *mcp_copy_schema_project_name(cbm_mcp_server_t *srv, char *out, size_t out_size) {
if (!srv || !out || out_size == 0) {
return NULL;
}
const char *source = srv->current_project && srv->current_project[0]
? srv->current_project
: (srv->session_project[0] ? srv->session_project : NULL);
if (!source) {
return NULL;
}
int written = snprintf(out, out_size, "%s", source);
return written >= 0 && (size_t)written < out_size ? out : NULL;
}
/* Build the query_graph docstring once per published graph state. It belongs in
* MCP tools/list so reconnects and relists can restore actionable schema after
* context compaction; it must never be appended to tools/call results. Full
@@ -2867,16 +2884,7 @@ static char *build_query_graph_tool_description(cbm_mcp_server_t *srv,
* next use). Passing the live pointer through would free the very
* string resolve_store is still reading (use-after-free). */
char project_buf[CBM_SZ_256];
const char *project = NULL;
if (srv) {
const char *src = srv->current_project && srv->current_project[0]
? srv->current_project
: (srv->session_project[0] ? srv->session_project : NULL);
if (src) {
snprintf(project_buf, sizeof(project_buf), "%s", src);
project = project_buf;
}
}
const char *project = mcp_copy_schema_project_name(srv, project_buf, sizeof(project_buf));
/* Lazily resolve the project's real store rather than trusting
* srv->store as-is: on the very first tools/list of a fresh session
* (before any tool call has resolved a project), srv->store is still
@@ -2953,6 +2961,18 @@ static const char *query_graph_tool_description(cbm_mcp_server_t *srv,
if (!srv) {
return tool_def->description;
}
if (srv->query_graph_tool_description) {
/* A sibling CLI/MCP/HTTP worker cannot flip this process's stale bit.
* Probe the cached query-only store's stable file identity on each
* relist so an atomic database replacement invalidates the inline
* schema before it is served. resolve_store is O(1) on the unchanged
* fast path and closes/reopens only on this request-owning thread. */
char project_buf[CBM_SZ_256];
const char *project = mcp_copy_schema_project_name(srv, project_buf, sizeof(project_buf));
if (project) {
(void)resolve_store(srv, project);
}
}
if (atomic_exchange(&srv->query_graph_tool_description_stale, false)) {
free(srv->query_graph_tool_description);
srv->query_graph_tool_description = NULL;
@@ -3687,6 +3707,10 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
if (!cbm_store_backing_file_replaced(srv->store)) {
return srv->store;
}
/* The same identity mismatch that invalidates query rows also
* invalidates cached tools/list schema. This is request-thread-owned;
* sibling liveness is never inferred and no cross-thread free occurs. */
atomic_store(&srv->query_graph_tool_description_stale, true);
if (srv->owns_store) {
cbm_store_close(srv->store);
}
+21 -6
View File
@@ -210,7 +210,7 @@ static int mcp_project_db_path(char *out, size_t out_sz, const char *cache,
}
static bool mcp_create_generation_db(const char *db_path, const char *project,
const char *node_name) {
const char *node_label, const char *node_name) {
cbm_store_t *store = cbm_store_open_path(db_path);
if (!store) {
return false;
@@ -218,7 +218,7 @@ static bool mcp_create_generation_db(const char *db_path, const char *project,
char qualified_name[CBM_PATH_MAX];
int n = snprintf(qualified_name, sizeof(qualified_name), "%s.%s", project, node_name);
cbm_node_t node = {.project = project,
.label = "Function",
.label = node_label,
.name = node_name,
.qualified_name = qualified_name,
.file_path = "src/generation.c",
@@ -11626,7 +11626,7 @@ TEST(watcher_publication_reopens_cached_store_generation) {
char next_path[CBM_PATH_MAX];
ASSERT_EQ(mcp_project_db_path(live_path, sizeof(live_path), cache, project), CBM_STORE_OK);
snprintf(next_path, sizeof(next_path), "%s/next-generation.db", cache);
ASSERT_TRUE(mcp_create_generation_db(live_path, project, "BeforePublication"));
ASSERT_TRUE(mcp_create_generation_db(live_path, project, "Function", "BeforePublication"));
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
@@ -11638,7 +11638,7 @@ TEST(watcher_publication_reopens_cached_store_generation) {
ASSERT_NOT_NULL(strstr(before, "BeforePublication"));
free(before);
ASSERT_TRUE(mcp_create_generation_db(next_path, project, "AfterPublication"));
ASSERT_TRUE(mcp_create_generation_db(next_path, project, "Function", "AfterPublication"));
cbm_remove_db_sidecars(live_path);
ASSERT_EQ(cbm_replace_file(next_path, live_path), 0);
@@ -11669,7 +11669,8 @@ TEST(external_process_publication_reopens_cached_store_generation) {
char next_path[CBM_PATH_MAX];
ASSERT_EQ(mcp_project_db_path(live_path, sizeof(live_path), cache, project), CBM_STORE_OK);
snprintf(next_path, sizeof(next_path), "%s/external-next-generation.db", cache);
ASSERT_TRUE(mcp_create_generation_db(live_path, project, "BeforeExternalPublication"));
ASSERT_TRUE(mcp_create_generation_db(live_path, project, "BeforeExternalLabel",
"BeforeExternalPublication"));
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
@@ -11681,12 +11682,26 @@ TEST(external_process_publication_reopens_cached_store_generation) {
ASSERT_NOT_NULL(strstr(before, "BeforeExternalPublication"));
free(before);
ASSERT_TRUE(mcp_create_generation_db(next_path, project, "AfterExternalPublication"));
char *before_list = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{}}");
ASSERT_NOT_NULL(before_list);
ASSERT_NOT_NULL(strstr(before_list, "BeforeExternalLabel"));
free(before_list);
ASSERT_TRUE(mcp_create_generation_db(next_path, project, "AfterExternalLabel",
"AfterExternalPublication"));
cbm_remove_db_sidecars(live_path);
ASSERT_EQ(cbm_replace_file(next_path, live_path), 0);
/* Deliberately no cbm_mcp_server_notify_index_published(): a sibling
* process has no access to this server's in-memory notification flag. */
char *after_list = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":202,\"method\":\"tools/list\",\"params\":{}}");
ASSERT_NOT_NULL(after_list);
ASSERT_NOT_NULL(strstr(after_list, "AfterExternalLabel"));
ASSERT_NULL(strstr(after_list, "BeforeExternalLabel"));
free(after_list);
char *after =
cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"external-generation-project\","