fix(mcp): report blocked automatic indexing by active tool mode

Previously, build_project_list_error_srv discarded whether automatic indexing was disabled or stopped by auto_index_limit and always recommended index_repository, even when streamlined tools/list hid that tool. Double-quoted path examples could also invalidate the nested structured JSON response.

Record request-thread-owned block state in maybe_auto_index and cbm_mcp_auto_index_within_limit. mcp_index_recovery_action at src/mcp/mcp.c:3937 now uses the existing profile/reveal authorities: classic or revealed streamlined mode calls index_repository directly, unrevealed streamlined mode calls _hidden_tools and refreshes tools/list first, and profiles without that tool use the CLI. Existing synchronous first-use recovery still runs before any user action is emitted. Centralize streamlined/classic config values in src/cli/cli.h:383.

tests/test_mcp.c:9617 covers auto_index=false, bounded file-limit rejection, classic, and revealed streamlined behavior; each response must parse as JSON with structuredContent and must not claim auto_indexing. Verified: focused ASan/UBSan test 1/1; scripts/check-source-safety.sh; git diff --check; git clang-format --diff; staged private-path scan.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-20 00:32:11 -04:00
parent b6e8c8975f
commit 393d3a297f
4 changed files with 244 additions and 17 deletions
+3 -3
View File
@@ -9751,7 +9751,7 @@ typedef struct {
#define PRESET_COUNT(values_) (sizeof(values_) / sizeof((values_)[0]))
static const cbm_config_preset_value_t PRESET_STREAMLINED_QUALITY[] = {
PRESET_VALUE(CBM_CONFIG_TOOL_MODE, "streamlined"),
PRESET_VALUE(CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_STREAMLINED),
PRESET_VALUE(CBM_CONFIG_RANK_ENABLED, "true"),
PRESET_VALUE(CBM_CONFIG_AUTO_INDEX_DEPS, "true"),
PRESET_VALUE(CBM_CONFIG_SIMILARITY_ENABLED, "true"),
@@ -9761,7 +9761,7 @@ static const cbm_config_preset_value_t PRESET_STREAMLINED_QUALITY[] = {
};
static const cbm_config_preset_value_t PRESET_CLASSIC_QUALITY[] = {
PRESET_VALUE(CBM_CONFIG_TOOL_MODE, "classic"),
PRESET_VALUE(CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_CLASSIC),
PRESET_VALUE(CBM_CONFIG_RANK_ENABLED, "true"),
PRESET_VALUE(CBM_CONFIG_AUTO_INDEX_DEPS, "true"),
PRESET_VALUE(CBM_CONFIG_SIMILARITY_ENABLED, "true"),
@@ -9988,7 +9988,7 @@ const cbm_config_entry_t CBM_CONFIG_REGISTRY[] = {
"auto-pushed summary that closes the codebase://architecture pull-only gap. Kept small (10) "
"to keep first-response token cost modest; raise to 20-25 if you want richer upfront context."},
/* ── Tools ── */
{CBM_CONFIG_TOOL_MODE, "streamlined", "CBM_TOOL_MODE", "Tools",
{CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_STREAMLINED, "CBM_TOOL_MODE", "Tools",
"Which tool surface the MCP server lists by default",
"streamlined|classic",
"'streamlined' (default): lists core tools plus _hidden_tools discovery: "
+2
View File
@@ -380,6 +380,8 @@ int cbm_config_apply_preset(cbm_config_t *cfg, const char *name);
#define CBM_CONFIG_SEARCH_LIMIT "search_limit"
#define CBM_CONFIG_QUERY_MAX_ROWS "query_max_rows"
#define CBM_CONFIG_TOOL_MODE "tool_mode"
#define CBM_CONFIG_TOOL_MODE_STREAMLINED "streamlined"
#define CBM_CONFIG_TOOL_MODE_CLASSIC "classic"
#define CBM_CONFIG_DEFAULT_RESPONSE_FORMAT "default_response_format"
#define CBM_DEFAULT_QUERY_MAX_ROWS 100000
#define CBM_DEFAULT_QUERY_MAX_ROWS_STR "100000"
+108 -14
View File
@@ -1435,9 +1435,6 @@ static const tool_def_t STREAMLINED_TOOLS[] = {
};
static const int STREAMLINED_TOOL_COUNT = sizeof(STREAMLINED_TOOLS) / sizeof(STREAMLINED_TOOLS[0]);
/* Config key for tool visibility mode. */
#define CBM_CONFIG_TOOL_MODE "tool_mode"
static const char MCP_TOOL_OUTPUT_SCHEMA[] =
"{\"type\":\"object\",\"additionalProperties\":true}";
static const char MCP_HIDDEN_TOOL_INPUT_SCHEMA[] = "{\"type\":\"object\",\"properties\":{}}";
@@ -2344,6 +2341,13 @@ static char *build_key_functions_sql(const char *exclude_csv, const char **exclu
static bool validate_cbm_db_with_timeout(const char *path, int busy_timeout_ms);
static void *overlay_compaction_thread(void *arg);
typedef enum {
MCP_AUTOINDEX_BLOCK_NONE = 0,
MCP_AUTOINDEX_BLOCK_DISABLED,
MCP_AUTOINDEX_BLOCK_FILE_COUNT,
MCP_AUTOINDEX_BLOCK_FILE_LIMIT,
} mcp_autoindex_block_t;
struct cbm_mcp_server {
cbm_mcp_tool_profile_t tool_profile;
cbm_store_t *store; /* currently open project store (or NULL) */
@@ -2366,6 +2370,12 @@ struct cbm_mcp_server {
bool autoindex_active; /* true if auto-index thread was started */
bool autoindex_failed; /* IX-1: true if last auto-index attempt failed */
bool just_autoindexed; /* IX-3: true after auto-index completes, reset on next search */
/* Request-thread-owned reason startup indexing did not start. This reports
* only this server's decision; it never guesses sibling-process liveness
* from temp files or other crash-stale filesystem artifacts. */
mcp_autoindex_block_t autoindex_block;
int autoindex_observed_files;
int autoindex_file_limit;
bool context_injected; /* true after first _context header sent (Phase 9) */
/* Request-thread-owned tools/list docstring. Graph workers may only mark
* it stale atomically; they must never read, replace, or free the pointer. */
@@ -2425,12 +2435,12 @@ static bool cbm_mcp_tool_mode_is_classic(cbm_mcp_server_t *srv) {
const char *tool_mode = cbm_safe_getenv("CBM_TOOL_MODE", tool_mode_buf,
sizeof(tool_mode_buf), NULL);
if (tool_mode && tool_mode[0] != '\0') {
return strcmp(tool_mode, "classic") == 0;
return strcmp(tool_mode, CBM_CONFIG_TOOL_MODE_CLASSIC) == 0;
}
tool_mode = (srv && srv->config)
? cbm_config_get(srv->config, CBM_CONFIG_TOOL_MODE, "streamlined")
: "streamlined";
return strcmp(tool_mode, "classic") == 0;
tool_mode = (srv && srv->config) ? cbm_config_get(srv->config, CBM_CONFIG_TOOL_MODE,
CBM_CONFIG_TOOL_MODE_STREAMLINED)
: CBM_CONFIG_TOOL_MODE_STREAMLINED;
return strcmp(tool_mode, CBM_CONFIG_TOOL_MODE_CLASSIC) == 0;
}
static cbm_mcp_output_format_t cbm_mcp_response_format(cbm_mcp_server_t *srv,
@@ -2620,17 +2630,29 @@ static int cbm_mcp_auto_index_limit(cbm_mcp_server_t *srv) {
static bool cbm_mcp_auto_index_within_limit(cbm_mcp_server_t *srv, const char *root_path) {
int file_limit = cbm_mcp_auto_index_limit(srv);
if (srv) {
srv->autoindex_block = MCP_AUTOINDEX_BLOCK_NONE;
srv->autoindex_observed_files = 0;
srv->autoindex_file_limit = file_limit;
}
if (file_limit <= 0) {
return true;
}
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL, .ignore_file = NULL, .max_file_size = 0};
int count = 0;
if (cbm_discover_count_bounded(root_path, &opts, file_limit, &count) != 0) {
if (srv) {
srv->autoindex_block = MCP_AUTOINDEX_BLOCK_FILE_COUNT;
}
cbm_log_warn("autoindex.skip", "reason", "file_count_failed", "path",
root_path ? root_path : "");
return false;
}
if (count > file_limit) {
if (srv) {
srv->autoindex_block = MCP_AUTOINDEX_BLOCK_FILE_LIMIT;
srv->autoindex_observed_files = count;
}
char count_buf[CBM_SZ_32];
snprintf(count_buf, sizeof(count_buf), "%d", count);
cbm_log_warn("autoindex.skip", "reason", "too_many_files", "files", count_buf, "limit",
@@ -3905,6 +3927,33 @@ static void add_git_context_json(yyjson_mut_doc *doc, yyjson_mut_val *obj, const
cbm_git_context_free(&ctx);
}
/* Describe the exact indexing call sequence exposed by this server. This is
* reached only after resolve_project_store exhausted permissible automatic
* recovery: it joins local startup work, retries a failed thread launch with a
* synchronous index, and attempts configured first-use indexing. Explicit
* auto_index=false and the resource-protection limit must not be overridden.
* The MCP profile and reveal state are the authorities; never tell a caller to
* invoke a tool that its current tools/list hides. */
static void mcp_index_recovery_action(cbm_mcp_server_t *srv, char *out, size_t out_size) {
if (!out || out_size == 0) {
return;
}
bool allowed = !srv || mcp_tool_allowed(srv->tool_profile, "index_repository");
bool visible = allowed && (!srv || cbm_mcp_advanced_tool_visible(srv, "index_repository"));
if (visible) {
snprintf(out, out_size, "call index_repository with repo_path='/absolute/path/to/repo'.");
} else if (allowed) {
snprintf(out, out_size,
"call _hidden_tools, refresh tools/list, then call index_repository with "
"repo_path='/absolute/path/to/repo'.");
} else {
snprintf(out, out_size,
"this MCP tool profile does not expose index_repository; run "
"codebase-memory-mcp cli index_repository --repo-path "
"'/absolute/path/to/repo'.");
}
}
/* Build a helpful error listing available projects. Caller must free() result. */
static char *build_project_list_error_srv(cbm_mcp_server_t *srv, const char *reason) {
char dir_path[1024];
@@ -3916,9 +3965,46 @@ static char *build_project_list_error_srv(cbm_mcp_server_t *srv, const char *rea
int total_count = collect_db_project_names(dir_path, projects, sizeof(projects),
&listed_count, &projects_truncated);
char index_action[CBM_SZ_512];
mcp_index_recovery_action(srv, index_action, sizeof(index_action));
char recovery_hint[CBM_SZ_1K];
switch (srv ? srv->autoindex_block : MCP_AUTOINDEX_BLOCK_NONE) {
case MCP_AUTOINDEX_BLOCK_DISABLED:
snprintf(recovery_hint, sizeof(recovery_hint),
"Automatic indexing is disabled (auto_index=false). Set auto_index=true and "
"retry, or %s",
index_action);
break;
case MCP_AUTOINDEX_BLOCK_FILE_COUNT:
snprintf(recovery_hint, sizeof(recovery_hint),
"Automatic indexing could not count project files safely. Check project read "
"permissions, then retry; %s",
index_action);
break;
case MCP_AUTOINDEX_BLOCK_FILE_LIMIT:
snprintf(recovery_hint, sizeof(recovery_hint),
"Automatic indexing stopped after more than %d files exceeded "
"auto_index_limit=%d. Check available memory before raising the limit and "
"retrying; if the larger run is intentional, %s",
srv->autoindex_observed_files, srv->autoindex_file_limit, index_action);
break;
case MCP_AUTOINDEX_BLOCK_NONE:
default:
snprintf(recovery_hint, sizeof(recovery_hint),
"No published index is readable. Pass the repository path as project to use "
"configured automatic indexing, or %s",
index_action);
break;
}
/* Optional: session_project and _context fields for richer error context */
char session_frag[256] = "";
char context_frag[512] = "";
char context_frag[CBM_SZ_2K] = "";
const char *context_hint =
total_count == 0
? recovery_hint
: "The requested project has no readable published index. Use list_projects and "
"pass the intended project explicitly.";
if (srv && srv->session_project[0]) {
snprintf(session_frag, sizeof(session_frag),
",\"session_project\":\"%s\"", srv->session_project);
@@ -3927,7 +4013,8 @@ static char *build_project_list_error_srv(cbm_mcp_server_t *srv, const char *rea
if (ctx_enabled && !srv->context_injected) {
snprintf(context_frag, sizeof(context_frag),
",\"_context\":{\"status\":\"not_indexed\","
"\"hint\":\"No project indexed yet. Pass project='/path/to/repo' to index.\"}");
"\"hint\":\"%s\"}",
context_hint);
srv->context_injected = true; /* one-shot: suppress from future successful responses */
}
}
@@ -3950,10 +4037,8 @@ static char *build_project_list_error_srv(cbm_mcp_server_t *srv, const char *rea
}
}
} else {
snprintf(buf, sizeof(buf),
"{\"error\":\"%s\",\"hint\":\"No projects indexed yet. "
"Call index_repository first.\"%s%s}",
reason, session_frag, context_frag);
snprintf(buf, sizeof(buf), "{\"error\":\"%s\",\"hint\":\"%s\"%s%s}", reason, recovery_hint,
session_frag, context_frag);
}
return heap_strdup(buf);
}
@@ -15513,6 +15598,9 @@ static int cbm_mcp_reindex_stale_seconds(cbm_mcp_server_t *srv) {
/* Start auto-indexing if configured and project not yet indexed. */
static void maybe_auto_index(cbm_mcp_server_t *srv) {
srv->autoindex_block = MCP_AUTOINDEX_BLOCK_NONE;
srv->autoindex_observed_files = 0;
srv->autoindex_file_limit = cbm_mcp_auto_index_limit(srv);
if (srv->session_root[0] == '\0') {
return; /* no session root detected */
}
@@ -15563,6 +15651,7 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
bool auto_index = cbm_mcp_auto_index_enabled(srv);
if (!auto_index) {
srv->autoindex_block = MCP_AUTOINDEX_BLOCK_DISABLED;
cbm_log_info("autoindex.skip", "reason", "disabled", "hint",
"export CBM_AUTO_INDEX=true OR codebase-memory-mcp config set auto_index true");
return;
@@ -15577,6 +15666,11 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
/* Launch auto-index in background */
if (cbm_thread_create(&srv->autoindex_tid, 0, autoindex_thread, srv) == 0) {
srv->autoindex_active = true;
} else {
/* Do not turn a transient thread-launch failure into a user task. The
* first store-backed request runs the existing synchronous first-use
* path before REQUIRE_STORE_EX can build an error. */
cbm_log_warn("autoindex.skip", "reason", "thread_start_failed");
}
}
+131
View File
@@ -9570,6 +9570,136 @@ TEST(first_search_code_call_waits_for_startup_index_instead_of_reporting_not_ind
PASS();
}
static char *request_missing_index_with_mode(cbm_config_t *config, int request_id,
bool reveal_hidden_tools) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
return NULL;
}
cbm_mcp_server_set_config(srv, config);
char *initialize = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
if (!initialize) {
cbm_mcp_server_free(srv);
return NULL;
}
free(initialize);
if (reveal_hidden_tools) {
char *reveal = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}");
if (!reveal) {
cbm_mcp_server_free(srv);
return NULL;
}
free(reveal);
}
char request[CBM_SZ_1K];
int written = snprintf(request, sizeof(request),
"{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"name_pattern\":\"blocked_index_target\"}}}",
request_id);
char *response = written > 0 && (size_t)written < sizeof(request)
? cbm_mcp_server_handle(srv, request)
: NULL;
cbm_mcp_server_free(srv);
return response;
}
static bool response_has_structured_content(const char *response) {
yyjson_doc *doc = response ? yyjson_read(response, strlen(response), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
yyjson_val *result = root ? yyjson_obj_get(root, "result") : NULL;
bool found = result && yyjson_is_obj(yyjson_obj_get(result, "structuredContent"));
yyjson_doc_free(doc);
return found;
}
TEST(first_search_reports_automatic_index_block_reason) {
char repo[CBM_SZ_256];
char cache[CBM_SZ_256];
snprintf(repo, sizeof(repo), "/tmp/cbm-index-block-repo-XXXXXX");
snprintf(cache, sizeof(cache), "/tmp/cbm-index-block-cache-XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char source_path[CBM_SZ_512];
snprintf(source_path, sizeof(source_path), "%s/blocked.py", repo);
FILE *source = fopen(source_path, "w");
ASSERT_NOT_NULL(source);
fputs("def blocked_index_target():\n return 42\n", source);
fclose(source);
char second_source_path[CBM_SZ_512];
snprintf(second_source_path, sizeof(second_source_path), "%s/also_blocked.py", repo);
source = fopen(second_source_path, "w");
ASSERT_NOT_NULL(source);
fputs("def second_blocked_target():\n return 43\n", source);
fclose(source);
char old_cwd[CBM_SZ_1K];
ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd)));
ASSERT_EQ(cbm_chdir(repo), 0);
cbm_config_t *config = cbm_config_open(cache);
ASSERT_NOT_NULL(config);
ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "false"), 0);
char *response = request_missing_index_with_mode(config, 65, false);
ASSERT_NOT_NULL(response);
ASSERT_TRUE(response_has_structured_content(response));
ASSERT_NOT_NULL(strstr(response, "auto_index=false"));
ASSERT_NOT_NULL(strstr(response, "_hidden_tools"));
ASSERT_NOT_NULL(strstr(response, "tools/list"));
ASSERT_NOT_NULL(strstr(response, "index_repository"));
ASSERT_NOT_NULL(strstr(response, "repo_path"));
ASSERT_NULL(strstr(response, "\"status\":\"auto_indexing\""));
free(response);
ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true"), 0);
ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX_LIMIT, "1"), 0);
response = request_missing_index_with_mode(config, 67, false);
ASSERT_NOT_NULL(response);
ASSERT_TRUE(response_has_structured_content(response));
ASSERT_NOT_NULL(strstr(response, "auto_index_limit"));
ASSERT_NOT_NULL(strstr(response, "_hidden_tools"));
ASSERT_NOT_NULL(strstr(response, "tools/list"));
ASSERT_NOT_NULL(strstr(response, "index_repository"));
ASSERT_NOT_NULL(strstr(response, "repo_path"));
ASSERT_NULL(strstr(response, "\"status\":\"auto_indexing\""));
free(response);
ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "false"), 0);
ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_CLASSIC), 0);
response = request_missing_index_with_mode(config, 69, false);
ASSERT_NOT_NULL(response);
ASSERT_TRUE(response_has_structured_content(response));
ASSERT_NOT_NULL(strstr(response, "call index_repository"));
ASSERT_NOT_NULL(strstr(response, "repo_path"));
ASSERT_NULL(strstr(response, "_hidden_tools"));
free(response);
ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_STREAMLINED), 0);
response = request_missing_index_with_mode(config, 71, true);
ASSERT_NOT_NULL(response);
ASSERT_TRUE(response_has_structured_content(response));
ASSERT_NOT_NULL(strstr(response, "call index_repository"));
ASSERT_NOT_NULL(strstr(response, "repo_path"));
ASSERT_NULL(strstr(response, "_hidden_tools"));
ASSERT_NULL(strstr(response, "refresh tools/list"));
free(response);
cbm_config_close(config);
ASSERT_EQ(cbm_chdir(old_cwd), 0);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
th_rmtree(repo);
th_rmtree(cache);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* POLL/GETLINE FILE* BUFFERING FIX
* ══════════════════════════════════════════════════════════════════ */
@@ -12618,6 +12748,7 @@ SUITE(mcp) {
RUN_TEST(server_handle_unknown_tool_preserves_string_id);
RUN_TEST(first_graph_call_waits_for_startup_index_and_returns_ready_context);
RUN_TEST(first_search_code_call_waits_for_startup_index_instead_of_reporting_not_indexed);
RUN_TEST(first_search_reports_automatic_index_block_reason);
/* Tool handlers */
RUN_TEST(tool_list_projects_empty);