mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-11 09:25:35 +08:00
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:
+34
-10
@@ -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
@@ -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\","
|
||||
|
||||
Reference in New Issue
Block a user