diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 807cc42da..a681a7911 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 41a07fddc..c24cd7014 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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\","