From 800476f28433e94cee27c534b85fc9e705f9f5cd Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 01:37:41 +0200
Subject: [PATCH 1/8] feat(mcp): add structured tool metadata
Signed-off-by: Martin Vogel
---
src/mcp/mcp.c | 92 ++++++++++++++++++++++++++++++++++++------------
src/mcp/mcp.h | 4 +++
tests/test_mcp.c | 38 ++++++++++++++++++--
3 files changed, 110 insertions(+), 24 deletions(-)
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 368d73f3e..ccb81f5fe 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -249,29 +249,66 @@ char *cbm_mcp_text_result(const char *text, bool is_error) {
yyjson_mut_val *content = yyjson_mut_arr(doc);
yyjson_mut_val *item = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, item, "type", "text");
- yyjson_mut_obj_add_str(doc, item, "text", text);
+ yyjson_mut_obj_add_str(doc, item, "text", text ? text : "");
yyjson_mut_arr_add_val(content, item);
yyjson_mut_obj_add_val(doc, root, "content", content);
- if (is_error) {
- yyjson_mut_obj_add_bool(doc, root, "isError", true);
+ if (!is_error && text) {
+ yyjson_doc *structured_doc = yyjson_read(text, strlen(text), 0);
+ if (structured_doc) {
+ yyjson_val *structured_root = yyjson_doc_get_root(structured_doc);
+ if (yyjson_is_obj(structured_root)) {
+ yyjson_mut_val *structured = yyjson_val_mut_copy(doc, structured_root);
+ yyjson_mut_obj_add_val(doc, root, "structuredContent", structured);
+ }
+ yyjson_doc_free(structured_doc);
+ }
}
+ yyjson_mut_obj_add_bool(doc, root, "isError", is_error);
char *out = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return out;
}
+bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
+ const char *active_id_str) {
+ if (!params_json) {
+ return false;
+ }
+
+ yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0);
+ if (!doc) {
+ return false;
+ }
+
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ yyjson_val *request_id = yyjson_obj_get(root, "requestId");
+ bool matches = false;
+ if (request_id) {
+ if (active_id_str) {
+ matches =
+ yyjson_is_str(request_id) && strcmp(yyjson_get_str(request_id), active_id_str) == 0;
+ } else {
+ matches = yyjson_is_int(request_id) && yyjson_get_int(request_id) == active_id;
+ }
+ }
+
+ yyjson_doc_free(doc);
+ return matches;
+}
+
/* ── Tool definitions ─────────────────────────────────────────── */
typedef struct {
const char *name;
+ const char *title;
const char *description;
const char *input_schema; /* JSON string */
} tool_def_t;
static const tool_def_t TOOLS[] = {
- {"index_repository",
+ {"index_repository", "Index repository",
"Index a repository into the knowledge graph. "
"Special mode 'cross-repo-intelligence': skip extraction, only match Routes/Channels "
"across projects to create CROSS_HTTP_CALLS/CROSS_ASYNC_CALLS/CROSS_CHANNEL edges. "
@@ -292,7 +329,7 @@ static const tool_def_t TOOLS[] = {
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
"},\"required\":[\"repo_path\"]}"},
- {"search_graph",
+ {"search_graph", "Search graph",
"Search the code knowledge graph for functions, classes, routes, and variables. Use INSTEAD "
"OF grep/glob when finding code definitions, implementations, or relationships. Three search "
"modes: (1) query='update settings' for BM25 ranked full-text search with camelCase "
@@ -329,7 +366,7 @@ static const tool_def_t TOOLS[] = {
"increment offset by limit and re-call while has_more is true.\"}},"
"\"required\":[\"project\"]}"},
- {"query_graph",
+ {"query_graph", "Query graph",
"Execute a Cypher query against the knowledge graph for complex multi-hop patterns, "
"aggregations, and cross-service analysis. The response includes 'total' (returned "
"row count). There is a hard 100k row ceiling — for broad queries add LIMIT in the "
@@ -352,7 +389,7 @@ static const tool_def_t TOOLS[] = {
"ceiling. No offset support — use search_graph for paginated browsing.\"}},"
"\"required\":[\"query\",\"project\"]}"},
- {"trace_path",
+ {"trace_path", "Trace path",
"Trace paths through the code graph. Modes: calls (callers/callees), data_flow (value "
"propagation with args at each hop), cross_service (through HTTP/async Route nodes). "
"Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing.",
@@ -373,7 +410,7 @@ static const tool_def_t TOOLS[] = {
"filtered out. When true, test nodes are included with is_test=true marker."
"\"}},\"required\":[\"function_name\",\"project\"]}"},
- {"get_code_snippet",
+ {"get_code_snippet", "Get code snippet",
"Read source code for a function/class/symbol. IMPORTANT: First call search_graph to find the "
"exact qualified_name, then pass it here. This is a read tool, not a search tool. Accepts "
"full qualified_name (exact match) or short function name (returns suggestions if ambiguous).",
@@ -382,11 +419,12 @@ static const tool_def_t TOOLS[] = {
"\"type\":\"string\"},\"include_neighbors\":{"
"\"type\":\"boolean\",\"default\":false}},\"required\":[\"qualified_name\",\"project\"]}"},
- {"get_graph_schema", "Get the schema of the knowledge graph (node labels, edge types)",
+ {"get_graph_schema", "Get graph schema",
+ "Get the schema of the knowledge graph (node labels, edge types)",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
- {"get_architecture",
+ {"get_architecture", "Get architecture",
"Get high-level architecture overview — packages, services, dependencies, and project "
"structure at a glance. Includes 'clusters': Leiden community detection over the call/import "
"graph, surfacing the de-facto modules (each with a label, member count, cohesion score, "
@@ -399,7 +437,7 @@ static const tool_def_t TOOLS[] = {
"\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
"}"},
- {"search_code",
+ {"search_code", "Search code",
"Graph-augmented code search. Finds text patterns via grep, then enriches results with "
"the knowledge graph: deduplicates matches into containing functions, ranks by structural "
"importance (definitions first, popular functions next, tests last). "
@@ -423,17 +461,17 @@ static const tool_def_t TOOLS[] = {
"offset parameter — raise limit or narrow with file_pattern / path_filter to see more."
"\",\"default\":10}},\"required\":[\"pattern\",\"project\"]}"},
- {"list_projects", "List all indexed projects", "{\"type\":\"object\",\"properties\":{}}"},
-
- {"delete_project", "Delete a project from the index",
+ {"list_projects", "List projects", "List all indexed projects",
+ "{\"type\":\"object\",\"properties\":{}}"},
+ {"delete_project", "Delete project", "Delete a project from the index",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
- {"index_status", "Get the indexing status of a project",
+ {"index_status", "Index status", "Get the indexing status of a project",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
- {"detect_changes", "Detect code changes and their impact",
+ {"detect_changes", "Detect changes", "Detect code changes and their impact",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":"
"\"string\"},\"depth\":{\"type\":\"integer\",\"default\":2},\"base_branch\":{\"type\":"
"\"string\",\"default\":\"main\"},\"since\":{\"type\":\"string\",\"description\":"
@@ -441,13 +479,13 @@ static const tool_def_t TOOLS[] = {
"\"required\":"
"[\"project\"]}"},
- {"manage_adr", "Create or update Architecture Decision Records",
+ {"manage_adr", "Manage ADR", "Create or update Architecture Decision Records",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":"
"\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"content\":{\"type\":\"string\"},"
"\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
"}"},
- {"ingest_traces", "Ingest runtime traces to enhance the knowledge graph",
+ {"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph",
"{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":"
"\"object\"}},\"project\":{\"type\":"
"\"string\"}},\"required\":[\"traces\",\"project\"]}"},
@@ -465,6 +503,7 @@ char *cbm_mcp_tools_list(void) {
for (int i = 0; i < TOOL_COUNT; i++) {
yyjson_mut_val *tool = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
+ yyjson_mut_obj_add_str(doc, tool, "title", TOOLS[i].title);
yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
/* Parse input schema JSON and embed */
@@ -531,6 +570,7 @@ char *cbm_mcp_initialize_response(const char *params_json) {
yyjson_mut_val *caps = yyjson_mut_obj(doc);
yyjson_mut_val *tools_cap = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_bool(doc, tools_cap, "listChanged", false);
yyjson_mut_obj_add_val(doc, caps, "tools", tools_cap);
yyjson_mut_obj_add_val(doc, root, "capabilities", caps);
@@ -644,6 +684,7 @@ struct cbm_mcp_server {
/* Active pipeline tracking for cancellation support */
cbm_pipeline_t *active_pipeline; /* non-NULL while index_repository runs */
int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */
+ char *active_request_id_str; /* string JSON-RPC id of the in-progress tool call */
};
cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) {
@@ -703,6 +744,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) {
cbm_store_close(srv->store);
}
free(srv->current_project);
+ free(srv->active_request_id_str);
free(srv);
}
@@ -4816,11 +4858,11 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
/* Notifications (no id) → handle cancellation, then no response */
if (!req.has_id) {
if (req.method && strcmp(req.method, "notifications/cancelled") == 0) {
- /* MCP cancellation: cancel the active pipeline if request ID matches */
- if (srv->active_pipeline) {
+ if (srv->active_pipeline &&
+ cbm_mcp_cancel_request_matches(req.params_raw, srv->active_request_id,
+ srv->active_request_id_str)) {
cbm_pipeline_cancel(srv->active_pipeline);
- cbm_log_info("mcp.cancelled", "request_id_active",
- srv->active_request_id > 0 ? "yes" : "none");
+ cbm_log_info("mcp.cancelled", "match", "true");
}
}
cbm_jsonrpc_request_free(&req);
@@ -4842,10 +4884,16 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
char *tool_name = req.params_raw ? cbm_mcp_get_tool_name(req.params_raw) : NULL;
char *tool_args =
req.params_raw ? cbm_mcp_get_arguments(req.params_raw) : heap_strdup("{}");
+ srv->active_request_id = req.id;
+ free(srv->active_request_id_str);
+ srv->active_request_id_str = req.id_str ? heap_strdup(req.id_str) : NULL;
struct timespec t0;
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
result_json = cbm_mcp_handle_tool(srv, tool_name, tool_args);
+ srv->active_request_id = CBM_NOT_FOUND;
+ free(srv->active_request_id_str);
+ srv->active_request_id_str = NULL;
struct timespec t1;
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
long long dur_us = ((long long)(t1.tv_sec - t0.tv_sec) * MCP_S_TO_US) +
diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h
index 121038cbd..e4cf4c5c9 100644
--- a/src/mcp/mcp.h
+++ b/src/mcp/mcp.h
@@ -54,6 +54,10 @@ char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message);
/* Format an MCP tool result with text content. Returns heap-allocated JSON. */
char *cbm_mcp_text_result(const char *text, bool is_error);
+/* Return true when notifications/cancelled params target the active request. */
+bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
+ const char *active_id_str);
+
/* Format the tools/list response. Returns heap-allocated JSON. */
char *cbm_mcp_tools_list(void);
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index 7dc7c1144..ac19b1d99 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -144,6 +144,7 @@ TEST(mcp_initialize_response) {
ASSERT_NOT_NULL(strstr(json, "codebase-memory-mcp"));
ASSERT_NOT_NULL(strstr(json, "capabilities"));
ASSERT_NOT_NULL(strstr(json, "tools"));
+ ASSERT_NOT_NULL(strstr(json, "\"listChanged\":false"));
ASSERT_NOT_NULL(strstr(json, "2025-11-25"));
free(json);
@@ -188,6 +189,15 @@ TEST(mcp_tools_list) {
PASS();
}
+TEST(mcp_tools_list_latest_metadata) {
+ char *json = cbm_mcp_tools_list();
+ ASSERT_NOT_NULL(json);
+ ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\""));
+ ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\""));
+ free(json);
+ PASS();
+}
+
TEST(mcp_tools_array_schemas_have_items) {
/* VS Code 1.112+ rejects array schemas without "items" (see
* https://github.com/microsoft/vscode/issues/248810).
@@ -223,11 +233,32 @@ TEST(mcp_text_result) {
ASSERT_NOT_NULL(strstr(json, "\"type\":\"text\""));
/* The text value is JSON-escaped inside the "text" field */
ASSERT_NOT_NULL(strstr(json, "total"));
+ ASSERT_NOT_NULL(strstr(json, "\"structuredContent\":{\"total\":5}"));
+ ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
ASSERT_NULL(strstr(json, "\"isError\":true"));
free(json);
PASS();
}
+TEST(mcp_text_result_skips_structured_content_for_plain_text) {
+ char *json = cbm_mcp_text_result("plain text", false);
+ ASSERT_NOT_NULL(json);
+ ASSERT_NULL(strstr(json, "\"structuredContent\""));
+ ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
+ free(json);
+ PASS();
+}
+
+TEST(mcp_cancel_matches_request_id) {
+ ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", 7, NULL));
+ ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":8}", 7, NULL));
+ ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-1\"}", -1, "call-1"));
+ ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-2\"}", -1, "call-1"));
+ ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", -1, "7"));
+ ASSERT_FALSE(cbm_mcp_cancel_request_matches("{}", 7, NULL));
+ PASS();
+}
+
TEST(mcp_text_result_error) {
char *json = cbm_mcp_text_result("something failed", true);
ASSERT_NOT_NULL(json);
@@ -1156,7 +1187,7 @@ TEST(tool_manage_adr_get_with_existing_adr) {
/* ADR content must appear in response */
ASSERT_NOT_NULL(strstr(resp, "PURPOSE"));
/* Must not be an error */
- ASSERT_NULL(strstr(resp, "isError"));
+ ASSERT_NULL(strstr(resp, "\"isError\":true"));
free(resp);
/* Clean up */
@@ -1202,7 +1233,7 @@ TEST(tool_manage_adr_unified_backend_issue256) {
"\"mode\":\"get\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend."));
- ASSERT_NULL(strstr(resp, "isError"));
+ ASSERT_NULL(strstr(resp, "\"isError\":true"));
free(resp);
cbm_mcp_server_free(srv);
@@ -2324,8 +2355,11 @@ SUITE(mcp) {
/* MCP protocol helpers */
RUN_TEST(mcp_initialize_response);
RUN_TEST(mcp_tools_list);
+ RUN_TEST(mcp_tools_list_latest_metadata);
RUN_TEST(mcp_tools_array_schemas_have_items);
RUN_TEST(mcp_text_result);
+ RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text);
+ RUN_TEST(mcp_cancel_matches_request_id);
RUN_TEST(mcp_text_result_error);
/* Argument extraction */
From 0d3e24c77a935c96e1d8f8bd86d6c1ea848c6a53 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 02:02:29 +0200
Subject: [PATCH 2/8] feat(mcp): add tool schemas and pagination
Signed-off-by: Martin Vogel
---
src/mcp/mcp.c | 106 ++++++++++++++++++++++++++++++++++++++++-------
tests/test_mcp.c | 28 +++++++++++++
2 files changed, 118 insertions(+), 16 deletions(-)
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index ccb81f5fe..65c8233da 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -32,6 +32,7 @@ enum {
MCP_URI_PREFIX = 7, /* strlen("file://") */
MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */
MCP_RETURN_2 = 2,
+ MCP_TOOLS_PAGE_SIZE = 8,
};
#define MCP_MS_TO_US 1000LL
#define MCP_S_TO_US 1000000LL
@@ -493,38 +494,111 @@ static const tool_def_t TOOLS[] = {
static const int TOOL_COUNT = sizeof(TOOLS) / sizeof(TOOLS[0]);
-char *cbm_mcp_tools_list(void) {
+static const char MCP_TOOL_OUTPUT_SCHEMA[] = "{\"type\":\"object\",\"additionalProperties\":true}";
+
+static void mcp_add_json_schema(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *key,
+ const char *schema_json) {
+ yyjson_doc *schema_doc = yyjson_read(schema_json, strlen(schema_json), 0);
+ if (schema_doc) {
+ yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc));
+ if (schema) {
+ yyjson_mut_obj_add_val(doc, obj, key, schema);
+ }
+ yyjson_doc_free(schema_doc);
+ }
+}
+
+static void mcp_add_tool_def(yyjson_mut_doc *doc, yyjson_mut_val *tools, int i) {
+ yyjson_mut_val *tool = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
+ yyjson_mut_obj_add_str(doc, tool, "title", TOOLS[i].title);
+ yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
+
+ mcp_add_json_schema(doc, tool, "inputSchema", TOOLS[i].input_schema);
+ mcp_add_json_schema(doc, tool, "outputSchema", MCP_TOOL_OUTPUT_SCHEMA);
+
+ yyjson_mut_arr_add_val(tools, tool);
+}
+
+static char *cbm_mcp_tools_list_range(int offset, int limit, bool include_next_cursor) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_val *tools = yyjson_mut_arr(doc);
- for (int i = 0; i < TOOL_COUNT; i++) {
- yyjson_mut_val *tool = yyjson_mut_obj(doc);
- yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
- yyjson_mut_obj_add_str(doc, tool, "title", TOOLS[i].title);
- yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
+ if (offset < 0) {
+ offset = 0;
+ }
+ if (offset > TOOL_COUNT) {
+ offset = TOOL_COUNT;
+ }
+ if (limit < 0 || limit > TOOL_COUNT) {
+ limit = TOOL_COUNT;
+ }
- /* Parse input schema JSON and embed */
- yyjson_doc *schema_doc =
- yyjson_read(TOOLS[i].input_schema, strlen(TOOLS[i].input_schema), 0);
- if (schema_doc) {
- yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc));
- yyjson_mut_obj_add_val(doc, tool, "inputSchema", schema);
- yyjson_doc_free(schema_doc);
- }
+ int end = offset + limit;
+ if (end > TOOL_COUNT) {
+ end = TOOL_COUNT;
+ }
- yyjson_mut_arr_add_val(tools, tool);
+ for (int i = offset; i < end; i++) {
+ mcp_add_tool_def(doc, tools, i);
}
yyjson_mut_obj_add_val(doc, root, "tools", tools);
+ if (include_next_cursor && end < TOOL_COUNT) {
+ char cursor[32];
+ snprintf(cursor, sizeof(cursor), "%d", end);
+ yyjson_mut_obj_add_strcpy(doc, root, "nextCursor", cursor);
+ }
char *out = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return out;
}
+char *cbm_mcp_tools_list(void) {
+ return cbm_mcp_tools_list_range(0, TOOL_COUNT, false);
+}
+
+static int mcp_tools_cursor_offset(const char *params_json) {
+ if (!params_json) {
+ return 0;
+ }
+
+ yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0);
+ if (!doc) {
+ return 0;
+ }
+
+ int offset = 0;
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ yyjson_val *cursor = root ? yyjson_obj_get(root, "cursor") : NULL;
+ if (cursor) {
+ offset = TOOL_COUNT;
+ if (yyjson_is_str(cursor)) {
+ const char *cursor_str = yyjson_get_str(cursor);
+ if (cursor_str && *cursor_str != '\0') {
+ char *endptr = NULL;
+ errno = 0;
+ long parsed = strtol(cursor_str, &endptr, 10);
+ if (endptr && *endptr == '\0' && errno == 0 && parsed >= 0) {
+ offset = parsed > TOOL_COUNT ? TOOL_COUNT : (int)parsed;
+ }
+ }
+ }
+ }
+
+ yyjson_doc_free(doc);
+ return offset;
+}
+
+static char *cbm_mcp_tools_list_page(const char *params_json) {
+ return cbm_mcp_tools_list_range(mcp_tools_cursor_offset(params_json), MCP_TOOLS_PAGE_SIZE,
+ true);
+}
+
/* Supported protocol versions, newest first. The server picks the newest
* version that it shares with the client (per MCP spec version negotiation). */
static const char *SUPPORTED_PROTOCOL_VERSIONS[] = {
@@ -4879,7 +4953,7 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
} else if (strcmp(req.method, "ping") == 0) {
result_json = heap_strdup("{}");
} else if (strcmp(req.method, "tools/list") == 0) {
- result_json = cbm_mcp_tools_list();
+ result_json = cbm_mcp_tools_list_page(req.params_raw);
} else if (strcmp(req.method, "tools/call") == 0) {
char *tool_name = req.params_raw ? cbm_mcp_get_tool_name(req.params_raw) : NULL;
char *tool_args =
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index ac19b1d99..cf9992731 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -194,6 +194,8 @@ TEST(mcp_tools_list_latest_metadata) {
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\""));
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\""));
+ ASSERT_NOT_NULL(strstr(json, "\"outputSchema\":{\"type\":\"object\""));
+ ASSERT_NOT_NULL(strstr(json, "\"additionalProperties\":true"));
free(json);
PASS();
}
@@ -379,6 +381,31 @@ TEST(server_handle_tools_list) {
PASS();
}
+TEST(server_handle_tools_list_paginates) {
+ cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
+
+ char *resp =
+ cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":200,\"method\":\"tools/list\"}");
+ ASSERT_NOT_NULL(resp);
+ ASSERT_NOT_NULL(strstr(resp, "\"id\":200"));
+ ASSERT_NOT_NULL(strstr(resp, "\"nextCursor\":\"8\""));
+ ASSERT_NOT_NULL(strstr(resp, "index_repository"));
+ ASSERT_NULL(strstr(resp, "manage_adr"));
+ free(resp);
+
+ resp = cbm_mcp_server_handle(
+ srv,
+ "{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{\"cursor\":\"8\"}}");
+ ASSERT_NOT_NULL(resp);
+ ASSERT_NOT_NULL(strstr(resp, "\"id\":201"));
+ ASSERT_NULL(strstr(resp, "\"nextCursor\""));
+ ASSERT_NOT_NULL(strstr(resp, "manage_adr"));
+ free(resp);
+
+ cbm_mcp_server_free(srv);
+ PASS();
+}
+
TEST(server_handle_unknown_method) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -2388,6 +2415,7 @@ SUITE(mcp) {
RUN_TEST(server_handle_initialize);
RUN_TEST(server_handle_initialized_notification);
RUN_TEST(server_handle_tools_list);
+ RUN_TEST(server_handle_tools_list_paginates);
RUN_TEST(server_handle_unknown_method);
/* Server handle — edge cases */
From 59b836e441b70f48d9ce577a854cf6bd957f5aff Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 02:32:05 +0200
Subject: [PATCH 3/8] feat(logging): add operational server logs
Signed-off-by: Martin Vogel
---
src/foundation/log.c | 313 +++++++++++++++++++++++++++++++++++--------
src/foundation/log.h | 34 ++++-
src/main.c | 2 +-
src/mcp/mcp.c | 20 +++
src/ui/http_server.c | 7 +
src/ui/httpd.c | 12 ++
src/ui/httpd.h | 2 +
tests/test_httpd.c | 43 ++++++
tests/test_log.c | 90 +++++++++++++
tests/test_mcp.c | 36 +++++
10 files changed, 502 insertions(+), 57 deletions(-)
diff --git a/src/foundation/log.c b/src/foundation/log.c
index 011ee5d4f..3e5f90c43 100644
--- a/src/foundation/log.c
+++ b/src/foundation/log.c
@@ -12,47 +12,74 @@
#include
static CBMLogLevel g_log_level = CBM_LOG_INFO;
+static CBMLogFormat g_log_format = CBM_LOG_FORMAT_TEXT;
static cbm_log_sink_fn g_log_sink = NULL;
+static CBMLogSinkMode g_log_sink_mode = CBM_LOG_SINK_REPLACE;
/* CBM_LOG_LEVEL support — distilled from #414 (closes #413, thanks @santanusinha). */
void cbm_log_init_from_env(void) {
/* getenv() is safe here: this runs at startup before any thread is created,
* so there is no concurrent setenv() to race against. */
const char *raw = getenv("CBM_LOG_LEVEL");
- if (!raw || raw[0] == '\0') {
- return; /* unset/empty: keep the current (default) level — fail-open */
- }
-
- /* Textual form, case-insensitive. Index of each name == its enum value. */
- static const char *const names[] = {"debug", "info", "warn", "error", "none"};
- char lower[8];
- size_t i = 0;
- for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
- lower[i] = (char)tolower((unsigned char)raw[i]);
- }
- lower[i] = '\0';
- if (raw[i] == '\0') { /* fully consumed — candidate textual match */
- for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
- if (strcmp(lower, names[lvl]) == 0) {
- cbm_log_set_level((CBMLogLevel)lvl);
- return;
+ if (raw && raw[0] != '\0') {
+ /* Textual form, case-insensitive. Index of each name == its enum value. */
+ static const char *const names[] = {"debug", "info", "warn", "error", "none"};
+ char lower[8];
+ size_t i = 0;
+ for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
+ lower[i] = (char)tolower((unsigned char)raw[i]);
+ }
+ lower[i] = '\0';
+ if (raw[i] == '\0') { /* fully consumed — candidate textual match */
+ for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
+ if (strcmp(lower, names[lvl]) == 0) {
+ cbm_log_set_level((CBMLogLevel)lvl);
+ goto parse_format;
+ }
}
}
+
+ /* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
+ char *end = NULL;
+ long n = strtol(raw, &end, CBM_DECIMAL_BASE);
+ if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
+ cbm_log_set_level((CBMLogLevel)n);
+ }
}
- /* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
- char *end = NULL;
- long n = strtol(raw, &end, CBM_DECIMAL_BASE);
- if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
- cbm_log_set_level((CBMLogLevel)n);
+ /* Unrecognised value: leave the level unchanged (fail-open). */
+
+parse_format:;
+ const char *fmt = getenv("CBM_LOG_FORMAT");
+ if (fmt && fmt[0] != '\0') {
+ char lower_fmt[8];
+ size_t i = 0;
+ for (; i < sizeof(lower_fmt) - 1 && fmt[i] != '\0'; i++) {
+ lower_fmt[i] = (char)tolower((unsigned char)fmt[i]);
+ }
+ lower_fmt[i] = '\0';
+ if (fmt[i] == '\0' && strcmp(lower_fmt, "json") == 0) {
+ cbm_log_set_format(CBM_LOG_FORMAT_JSON);
+ } else if (fmt[i] == '\0' && strcmp(lower_fmt, "text") == 0) {
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ }
return;
}
- /* Unrecognised value: leave the level unchanged (fail-open). */
+ const char *env = getenv("ENVIRONMENT");
+ const char *cloud_run = getenv("K_SERVICE");
+ if ((env && strcmp(env, "production") == 0) || (cloud_run && cloud_run[0] != '\0')) {
+ cbm_log_set_format(CBM_LOG_FORMAT_JSON);
+ }
}
void cbm_log_set_sink(cbm_log_sink_fn fn) {
+ cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
+}
+
+void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode) {
g_log_sink = fn;
+ g_log_sink_mode = mode;
}
void cbm_log_set_level(CBMLogLevel level) {
@@ -63,6 +90,14 @@ CBMLogLevel cbm_log_get_level(void) {
return g_log_level;
}
+void cbm_log_set_format(CBMLogFormat format) {
+ g_log_format = format;
+}
+
+CBMLogFormat cbm_log_get_format(void) {
+ return g_log_format;
+}
+
static const char *level_str(CBMLogLevel level) {
switch (level) {
case CBM_LOG_DEBUG:
@@ -78,54 +113,226 @@ static const char *level_str(CBMLogLevel level) {
}
}
+static const char *severity_str(CBMLogLevel level) {
+ switch (level) {
+ case CBM_LOG_DEBUG:
+ return "DEBUG";
+ case CBM_LOG_INFO:
+ return "INFO";
+ case CBM_LOG_WARN:
+ return "WARNING";
+ case CBM_LOG_ERROR:
+ return "ERROR";
+ default:
+ return "DEFAULT";
+ }
+}
+
+static void append_char(char *buf, size_t bufsz, size_t *pos, char ch) {
+ if (*pos < bufsz - 1) {
+ buf[*pos] = ch;
+ }
+ (*pos)++;
+}
+
+static void append_raw(char *buf, size_t bufsz, size_t *pos, const char *s) {
+ if (!s) {
+ return;
+ }
+ while (*s) {
+ append_char(buf, bufsz, pos, *s++);
+ }
+}
+
+static void append_text_atom(char *buf, size_t bufsz, size_t *pos, const char *s) {
+ if (!s) {
+ return;
+ }
+ while (*s) {
+ unsigned char ch = (unsigned char)*s++;
+ if (ch <= ' ' || ch == 0x7f) {
+ append_char(buf, bufsz, pos, '_');
+ } else {
+ append_char(buf, bufsz, pos, (char)ch);
+ }
+ }
+}
+
+static void append_json_string(char *buf, size_t bufsz, size_t *pos, const char *s) {
+ append_char(buf, bufsz, pos, '"');
+ if (s) {
+ while (*s) {
+ unsigned char ch = (unsigned char)*s++;
+ switch (ch) {
+ case '"':
+ append_raw(buf, bufsz, pos, "\\\"");
+ break;
+ case '\\':
+ append_raw(buf, bufsz, pos, "\\\\");
+ break;
+ case '\b':
+ append_raw(buf, bufsz, pos, "\\b");
+ break;
+ case '\f':
+ append_raw(buf, bufsz, pos, "\\f");
+ break;
+ case '\n':
+ append_raw(buf, bufsz, pos, "\\n");
+ break;
+ case '\r':
+ append_raw(buf, bufsz, pos, "\\r");
+ break;
+ case '\t':
+ append_raw(buf, bufsz, pos, "\\t");
+ break;
+ default:
+ if (ch < 0x20) {
+ static const char hex[] = "0123456789abcdef";
+ append_raw(buf, bufsz, pos, "\\u00");
+ append_char(buf, bufsz, pos, hex[ch >> 4]);
+ append_char(buf, bufsz, pos, hex[ch & 0xf]);
+ } else {
+ append_char(buf, bufsz, pos, (char)ch);
+ }
+ break;
+ }
+ }
+ }
+ append_char(buf, bufsz, pos, '"');
+}
+
+static void finish_line(char *buf, size_t bufsz, size_t pos) {
+ if (bufsz == 0) {
+ return;
+ }
+ if (pos >= bufsz) {
+ buf[bufsz - 1] = '\0';
+ } else {
+ buf[pos] = '\0';
+ }
+}
+
+static void emit_line(const char *line) {
+ if (g_log_sink) {
+ g_log_sink(line);
+ if (g_log_sink_mode == CBM_LOG_SINK_REPLACE) {
+ return;
+ }
+ }
+ (void)fprintf(stderr, "%s\n", line);
+}
+
void cbm_log(CBMLogLevel level, const char *msg, ...) {
if (level < g_log_level) {
return;
}
- /* Build the log line into a buffer ONCE — no double va_list iteration */
- char line_buf[CBM_SZ_512];
- int pos =
- snprintf(line_buf, sizeof(line_buf), "level=%s msg=%s", level_str(level), msg ? msg : "");
-
+ char line_buf[CBM_SZ_4K];
+ size_t pos = 0;
va_list args;
va_start(args, msg);
- for (;;) {
- const char *key = va_arg(args, const char *);
- if (!key) {
- break;
- }
- const char *val = va_arg(args, const char *);
- if (!val) {
- val = "";
+
+ if (g_log_format == CBM_LOG_FORMAT_JSON) {
+ append_raw(line_buf, sizeof(line_buf), &pos, "{\"severity\":");
+ append_json_string(line_buf, sizeof(line_buf), &pos, severity_str(level));
+ append_raw(line_buf, sizeof(line_buf), &pos, ",\"message\":");
+ append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
+ append_raw(line_buf, sizeof(line_buf), &pos, ",\"event\":");
+ append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
+ for (;;) {
+ const char *key = va_arg(args, const char *);
+ if (!key) {
+ break;
+ }
+ const char *val = va_arg(args, const char *);
+ append_char(line_buf, sizeof(line_buf), &pos, ',');
+ append_json_string(line_buf, sizeof(line_buf), &pos, key);
+ append_char(line_buf, sizeof(line_buf), &pos, ':');
+ append_json_string(line_buf, sizeof(line_buf), &pos, val ? val : "");
}
- if ((size_t)pos < sizeof(line_buf) - SKIP_ONE) {
- pos += snprintf(line_buf + pos, sizeof(line_buf) - (size_t)pos, " %s=%s", key, val);
+ append_char(line_buf, sizeof(line_buf), &pos, '}');
+ } else {
+ append_raw(line_buf, sizeof(line_buf), &pos, "level=");
+ append_text_atom(line_buf, sizeof(line_buf), &pos, level_str(level));
+ append_raw(line_buf, sizeof(line_buf), &pos, " msg=");
+ append_text_atom(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
+ for (;;) {
+ const char *key = va_arg(args, const char *);
+ if (!key) {
+ break;
+ }
+ const char *val = va_arg(args, const char *);
+ append_char(line_buf, sizeof(line_buf), &pos, ' ');
+ append_text_atom(line_buf, sizeof(line_buf), &pos, key);
+ append_char(line_buf, sizeof(line_buf), &pos, '=');
+ append_text_atom(line_buf, sizeof(line_buf), &pos, val ? val : "");
}
}
va_end(args);
- /* When a sink is registered it takes over all output (exclusive).
- * Otherwise write structured log to stderr. */
- if (g_log_sink) {
- g_log_sink(line_buf);
- } else {
- (void)fprintf(stderr, "%s\n", line_buf);
- }
+ finish_line(line_buf, sizeof(line_buf), pos);
+ emit_line(line_buf);
}
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value) {
- if (level < g_log_level) {
+ char value_buf[CBM_SZ_32];
+ snprintf(value_buf, sizeof(value_buf), "%" PRId64, value);
+ cbm_log(level, msg, key ? key : "?", value_buf, NULL);
+}
+
+static void copy_path_without_query(const char *path, char *out, size_t outsz) {
+ if (!out || outsz == 0) {
return;
}
+ out[0] = '\0';
+ if (!path) {
+ return;
+ }
+ size_t n = 0;
+ while (path[n] && path[n] != '?' && path[n] != '#' && n < outsz - 1) {
+ out[n] = path[n];
+ n++;
+ }
+ out[n] = '\0';
+}
- char line_buf[CBM_SZ_256];
- snprintf(line_buf, sizeof(line_buf), "level=%s msg=%s %s=%" PRId64, level_str(level),
- msg ? msg : "", key ? key : "?", value);
-
- if (g_log_sink) {
- g_log_sink(line_buf);
+void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
+ int64_t duration_us) {
+ char duration_ms[CBM_SZ_32];
+ snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000);
+ if (tool_name && tool_name[0] != '\0') {
+ cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "rpc.system", "jsonrpc",
+ "rpc.method", method ? method : "", "mcp.tool.name", tool_name, "status",
+ is_error ? "error" : "ok", "duration_ms", duration_ms, NULL);
} else {
- (void)fprintf(stderr, "%s\n", line_buf);
+ cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "rpc.system", "jsonrpc",
+ "rpc.method", method ? method : "", "status", is_error ? "error" : "ok",
+ "duration_ms", duration_ms, NULL);
}
}
+
+void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
+ int64_t duration_ms, size_t request_bytes, size_t response_bytes) {
+ char safe_path[CBM_SZ_1K];
+ char status_buf[CBM_SZ_16];
+ char duration_buf[CBM_SZ_32];
+ char request_buf[CBM_SZ_32];
+ char response_buf[CBM_SZ_32];
+ copy_path_without_query(path, safe_path, sizeof(safe_path));
+ snprintf(status_buf, sizeof(status_buf), "%d", status);
+ snprintf(duration_buf, sizeof(duration_buf), "%" PRId64, duration_ms);
+ snprintf(request_buf, sizeof(request_buf), "%zu", request_bytes);
+ snprintf(response_buf, sizeof(response_buf), "%zu", response_bytes);
+
+ CBMLogLevel level = CBM_LOG_INFO;
+ if (status >= 500) {
+ level = CBM_LOG_ERROR;
+ } else if (status >= 400) {
+ level = CBM_LOG_WARN;
+ }
+
+ cbm_log(level, "http.request", "component", component ? component : "", "http.request.method",
+ method ? method : "", "url.path", safe_path, "http.response.status_code", status_buf,
+ "duration_ms", duration_buf, "http.request.body.size", request_buf,
+ "http.response.body.size", response_buf, NULL);
+}
diff --git a/src/foundation/log.h b/src/foundation/log.h
index beb60d5be..74c68d45a 100644
--- a/src/foundation/log.h
+++ b/src/foundation/log.h
@@ -3,7 +3,8 @@
*
* Design:
* - All output goes to stderr (stdout is reserved for MCP JSON-RPC)
- * - Structured format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
+ * - Structured text format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
+ * - Optional JSON format for production collectors / Cloud Logging
* - Levels: DEBUG, INFO, WARN, ERROR
* - Level filtering at runtime via cbm_log_set_level() or the
* CBM_LOG_LEVEL env var (see cbm_log_init_from_env)
@@ -12,7 +13,9 @@
#ifndef CBM_LOG_H
#define CBM_LOG_H
+#include
#include
+#include
typedef enum {
CBM_LOG_DEBUG = 0,
@@ -22,11 +25,24 @@ typedef enum {
CBM_LOG_NONE = 4 /* disable all logging */
} CBMLogLevel;
+typedef enum {
+ CBM_LOG_FORMAT_TEXT = 0,
+ CBM_LOG_FORMAT_JSON = 1,
+} CBMLogFormat;
+
+typedef enum {
+ CBM_LOG_SINK_REPLACE = 0,
+ CBM_LOG_SINK_TEE = 1,
+} CBMLogSinkMode;
+
/* Apply the CBM_LOG_LEVEL environment variable to the runtime log level.
* Accepts (case-insensitive) "debug", "info", "warn", "error", "none", or
* the numeric equivalents 0..4 matching CBMLogLevel. Unknown, empty, or
- * unset values leave the level unchanged (fail-open). Call once at startup,
- * before any threads or log statements. Distilled from #414 (closes #413). */
+ * unset values leave the level unchanged (fail-open).
+ *
+ * Also applies CBM_LOG_FORMAT=text|json. If unset, JSON is auto-enabled for
+ * production / Cloud Run style environments (ENVIRONMENT=production or
+ * K_SERVICE present). Call once at startup before any threads or log lines. */
void cbm_log_init_from_env(void);
/* Set minimum log level (default: INFO). */
@@ -35,6 +51,10 @@ void cbm_log_set_level(CBMLogLevel level);
/* Get current log level. */
CBMLogLevel cbm_log_get_level(void);
+/* Set/get output format. Default is text. */
+void cbm_log_set_format(CBMLogFormat format);
+CBMLogFormat cbm_log_get_format(void);
+
/* Core logging function. msg is a short semantic tag.
* Variadic args are key-value pairs: (const char *key, const char *value)...
* Terminated by NULL key.
@@ -57,8 +77,16 @@ void cbm_log(CBMLogLevel level, const char *msg, ...);
/* Log with integer value (avoids sprintf for common case). */
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value);
+/* Operational event helpers. They deliberately avoid request bodies, headers,
+ * arguments, and query strings. */
+void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
+ int64_t duration_us);
+void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
+ int64_t duration_ms, size_t request_bytes, size_t response_bytes);
+
/* Optional log sink callback — called with the formatted log line. */
typedef void (*cbm_log_sink_fn)(const char *line);
void cbm_log_set_sink(cbm_log_sink_fn fn);
+void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode);
#endif /* CBM_LOG_H */
diff --git a/src/main.c b/src/main.c
index f2b72cba8..3fec11630 100644
--- a/src/main.c
+++ b/src/main.c
@@ -429,7 +429,7 @@ int main(int argc, char **argv) {
* No more untracked heap blind spots. */
/* Store binary path for subprocess spawning + hook log sink */
cbm_http_server_set_binary_path(argv[0]);
- cbm_log_set_sink(cbm_ui_log_append);
+ cbm_log_set_sink_ex(cbm_ui_log_append, CBM_LOG_SINK_TEE);
cbm_log_info("server.start", "version", CBM_VERSION);
cbm_diag_start(); /* starts if CBM_DIAGNOSTICS=1 */
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 65c8233da..19a326c1a 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -4943,7 +4943,10 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
return NULL;
}
+ struct timespec req_t0;
+ cbm_clock_gettime(CLOCK_MONOTONIC, &req_t0);
char *result_json = NULL;
+ bool request_logged = false;
if (strcmp(req.method, "initialize") == 0) {
result_json = cbm_mcp_initialize_response(req.params_raw);
@@ -4974,6 +4977,10 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
((long long)(t1.tv_nsec - t0.tv_nsec) / MCP_MS_TO_US);
bool is_err = (result_json != NULL) && (strstr(result_json, "\"isError\":true") != NULL);
cbm_diag_record_query(dur_us, is_err);
+ long long request_dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
+ ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
+ cbm_log_mcp_request(req.method, tool_name, is_err, request_dur_us);
+ request_logged = true;
result_json = inject_update_notice(srv, result_json);
free(tool_name);
@@ -4989,10 +4996,23 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
.error_json = err_obj,
};
char *err = cbm_jsonrpc_format_response(&err_resp);
+ struct timespec t1;
+ cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
+ long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
+ ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
+ cbm_log_mcp_request(req.method, NULL, true, dur_us);
cbm_jsonrpc_request_free(&req);
return err;
}
+ if (!request_logged) {
+ struct timespec t1;
+ cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
+ long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
+ ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
+ cbm_log_mcp_request(req.method, NULL, false, dur_us);
+ }
+
cbm_jsonrpc_response_t resp = {
.id = req.id,
.id_str = req.id_str,
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index 568b47cc0..1f61ef771 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -1386,15 +1386,22 @@ void cbm_http_server_run(cbm_http_server_t *srv) {
if (!conn)
continue; /* timeout — re-check stop flag */
+ uint64_t request_start_ms = cbm_now_ms();
cbm_http_req_t req;
int rc = cbm_httpd_read_request(conn, &req);
if (rc == 0) {
dispatch_request(srv, conn, &req);
+ cbm_log_http_request("graph_ui", req.method, req.path, cbm_http_conn_status(conn),
+ (int64_t)(cbm_now_ms() - request_start_ms), req.body_len,
+ cbm_http_conn_response_bytes(conn));
cbm_http_req_free(&req);
} else if (rc > 0) {
/* Parse/transport error with a known HTTP status (400/408/411/413/431).
* No CORS reflection here — the request was never parsed. */
cbm_http_replyf(conn, rc, "", "bad request");
+ cbm_log_http_request("graph_ui", "", "", cbm_http_conn_status(conn),
+ (int64_t)(cbm_now_ms() - request_start_ms), 0,
+ cbm_http_conn_response_bytes(conn));
}
cbm_httpd_conn_close(conn);
}
diff --git a/src/ui/httpd.c b/src/ui/httpd.c
index 5d1b01e09..fc0bf6431 100644
--- a/src/ui/httpd.c
+++ b/src/ui/httpd.c
@@ -52,6 +52,8 @@ struct cbm_httpd {
struct cbm_http_conn {
cbm_sock_t fd;
int recv_deadline_ms;
+ int response_status;
+ size_t response_bytes;
};
/* ── Small platform helpers ───────────────────────────────────── */
@@ -215,6 +217,14 @@ void cbm_httpd_conn_close(cbm_http_conn_t *c) {
free(c);
}
+int cbm_http_conn_status(const cbm_http_conn_t *c) {
+ return c ? c->response_status : 0;
+}
+
+size_t cbm_http_conn_response_bytes(const cbm_http_conn_t *c) {
+ return c ? c->response_bytes : 0;
+}
+
/* ── Head parsing ─────────────────────────────────────────────── */
static bool header_name_is(const char *line, size_t name_len, const char *name) {
@@ -506,6 +516,8 @@ void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_header
size_t len) {
if (!c)
return;
+ c->response_status = status;
+ c->response_bytes = len;
char head[1024];
int hn = snprintf(head, sizeof(head),
"HTTP/1.1 %d %s\r\n"
diff --git a/src/ui/httpd.h b/src/ui/httpd.h
index 75835fee5..d29dce64c 100644
--- a/src/ui/httpd.h
+++ b/src/ui/httpd.h
@@ -95,6 +95,8 @@ void cbm_http_replyf(cbm_http_conn_t *c, int status, const char *extra_headers,
void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_headers, const void *data,
size_t len);
+int cbm_http_conn_status(const cbm_http_conn_t *c);
+size_t cbm_http_conn_response_bytes(const cbm_http_conn_t *c);
void cbm_httpd_conn_close(cbm_http_conn_t *c);
/* ── Pure helpers (unit-tested without sockets) ───────────────── */
diff --git a/tests/test_httpd.c b/tests/test_httpd.c
index ca4e66a75..8aea6ccdf 100644
--- a/tests/test_httpd.c
+++ b/tests/test_httpd.c
@@ -12,6 +12,7 @@
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_thread.h"
+#include "../src/foundation/log.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "ui/httpd.h"
@@ -37,6 +38,18 @@ typedef int th_sock_t;
#define TH_SOCK_BAD (-1)
#endif
+static char httpd_log_buf[8192];
+
+static void httpd_capture_log(const char *line) {
+ size_t used = strlen(httpd_log_buf);
+ size_t avail = sizeof(httpd_log_buf) - used;
+ if (avail <= 1)
+ return;
+ int n = snprintf(httpd_log_buf + used, avail, "%s\n", line ? line : "");
+ if (n < 0 || (size_t)n >= avail)
+ httpd_log_buf[sizeof(httpd_log_buf) - 1] = '\0';
+}
+
/* ── Raw-socket test client ───────────────────────────────────── */
static th_sock_t th_connect(int port) {
@@ -535,6 +548,35 @@ TEST(ui_server_slow_request_hits_deadline) {
PASS();
}
+TEST(ui_server_access_log_redacts_query) {
+ httpd_log_buf[0] = '\0';
+ CBMLogLevel prev_level = cbm_log_get_level();
+ cbm_log_set_level(CBM_LOG_DEBUG);
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ cbm_log_set_sink_ex(httpd_capture_log, CBM_LOG_SINK_REPLACE);
+
+ th_server_t ts;
+ ASSERT_EQ(th_server_start(&ts), 0);
+ char resp[4096];
+ int n = th_http(cbm_http_server_port(ts.srv),
+ "GET /definitely/not/here?token=secret HTTP/1.1\r\n\r\n", resp, sizeof(resp));
+ ASSERT_GT(n, 0);
+ ASSERT_EQ(th_status(resp), 404);
+ th_server_stop(&ts);
+
+ cbm_log_set_sink(NULL);
+ cbm_log_set_level(prev_level);
+
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "msg=http.request"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "component=graph_ui"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "http.request.method=GET"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "url.path=/definitely/not/here"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "http.response.status_code=404"));
+ ASSERT_NULL(strstr(httpd_log_buf, "token"));
+ ASSERT_NULL(strstr(httpd_log_buf, "secret"));
+ PASS();
+}
+
TEST(ui_server_stop_joins_cleanly) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
@@ -578,5 +620,6 @@ SUITE(httpd) {
RUN_TEST(ui_server_nul_in_target_rejected);
RUN_TEST(ui_server_browse_traversal_probe);
RUN_TEST(ui_server_slow_request_hits_deadline);
+ RUN_TEST(ui_server_access_log_redacts_query);
RUN_TEST(ui_server_stop_joins_cleanly);
}
diff --git a/tests/test_log.c b/tests/test_log.c
index 9b87efcc8..8165d8967 100644
--- a/tests/test_log.c
+++ b/tests/test_log.c
@@ -22,9 +22,14 @@ static inline bool cbm_str_contains_raw(const char *s, const char *sub) {
}
static char log_buf[4096];
+static char sink_buf[4096];
static int saved_stderr;
static int pipe_fds[2];
+static void test_log_sink(const char *line) {
+ snprintf(sink_buf, sizeof(sink_buf), "%s", line ? line : "");
+}
+
static void capture_start(void) {
fflush(stderr);
saved_stderr = dup(STDERR_FILENO);
@@ -112,6 +117,86 @@ TEST(log_int_helper) {
PASS();
}
+TEST(log_json_output) {
+ cbm_log_set_level(CBM_LOG_DEBUG);
+ cbm_log_set_format(CBM_LOG_FORMAT_JSON);
+ capture_start();
+ cbm_log_info("test.msg", "key1", "val1", "key2", "line\nbreak");
+ const char *output = capture_end();
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ cbm_log_set_level(CBM_LOG_INFO);
+
+ ASSERT(cbm_str_contains_raw(output, "\"severity\":\"INFO\""));
+ ASSERT(cbm_str_contains_raw(output, "\"message\":\"test.msg\""));
+ ASSERT(cbm_str_contains_raw(output, "\"key1\":\"val1\""));
+ ASSERT(cbm_str_contains_raw(output, "\"key2\":\"line\\nbreak\""));
+ PASS();
+}
+
+TEST(log_text_sanitizes_control_chars) {
+ cbm_log_set_level(CBM_LOG_DEBUG);
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ capture_start();
+ cbm_log_info("test\nmsg", "key", "line\r\nbreak\tvalue");
+ const char *output = capture_end();
+ cbm_log_set_level(CBM_LOG_INFO);
+
+ ASSERT(cbm_str_contains_raw(output, "msg=test_msg"));
+ ASSERT(cbm_str_contains_raw(output, "key=line__break_value"));
+ ASSERT_EQ(output[strlen(output) - 1], '\n');
+ ASSERT_NULL(strchr(output, '\r'));
+ PASS();
+}
+
+TEST(log_sink_tee_keeps_stderr) {
+ sink_buf[0] = '\0';
+ cbm_log_set_level(CBM_LOG_DEBUG);
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ cbm_log_set_sink_ex(test_log_sink, CBM_LOG_SINK_TEE);
+ capture_start();
+ cbm_log_info("tee.msg", "key", "val");
+ const char *output = capture_end();
+ cbm_log_set_sink(NULL);
+ cbm_log_set_level(CBM_LOG_INFO);
+
+ ASSERT(cbm_str_contains_raw(output, "msg=tee.msg"));
+ ASSERT(cbm_str_contains_raw(sink_buf, "msg=tee.msg"));
+ PASS();
+}
+
+TEST(log_operational_helpers) {
+ cbm_log_set_level(CBM_LOG_DEBUG);
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ capture_start();
+ cbm_log_mcp_request("tools/call", "search_graph", false, 1250);
+ cbm_log_http_request("graph_ui", "GET", "/api/layout", 200, 7, 0, 42);
+ const char *output = capture_end();
+ cbm_log_set_level(CBM_LOG_INFO);
+
+ ASSERT(cbm_str_contains_raw(output, "msg=mcp.request"));
+ ASSERT(cbm_str_contains_raw(output, "rpc.method=tools/call"));
+ ASSERT(cbm_str_contains_raw(output, "mcp.tool.name=search_graph"));
+ ASSERT(cbm_str_contains_raw(output, "msg=http.request"));
+ ASSERT(cbm_str_contains_raw(output, "http.request.method=GET"));
+ ASSERT(cbm_str_contains_raw(output, "url.path=/api/layout"));
+ ASSERT(cbm_str_contains_raw(output, "http.response.status_code=200"));
+ PASS();
+}
+
+TEST(log_format_from_env) {
+ cbm_setenv("CBM_LOG_FORMAT", "json", 1);
+ cbm_log_init_from_env();
+ ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_JSON);
+
+ cbm_setenv("CBM_LOG_FORMAT", "text", 1);
+ cbm_log_init_from_env();
+ ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
+
+ cbm_unsetenv("CBM_LOG_FORMAT");
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ PASS();
+}
+
/* CBM_LOG_LEVEL parsing — distilled from #414 (closes #413). */
TEST(log_level_from_env_textual) {
cbm_setenv("CBM_LOG_LEVEL", "error", 1);
@@ -191,6 +276,11 @@ SUITE(log) {
RUN_TEST(log_filtered_by_level);
RUN_TEST(log_error_output);
RUN_TEST(log_int_helper);
+ RUN_TEST(log_json_output);
+ RUN_TEST(log_text_sanitizes_control_chars);
+ RUN_TEST(log_sink_tee_keeps_stderr);
+ RUN_TEST(log_operational_helpers);
+ RUN_TEST(log_format_from_env);
RUN_TEST(log_level_from_env_textual);
RUN_TEST(log_level_from_env_numeric);
RUN_TEST(log_level_from_env_invalid_ignored);
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index cf9992731..554c444d7 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -5,6 +5,7 @@
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */
+#include "../src/foundation/log.h"
#include "test_framework.h"
#include
#include
@@ -14,6 +15,12 @@
#include
#include
+static char mcp_log_buf[4096];
+
+static void mcp_capture_log(const char *line) {
+ snprintf(mcp_log_buf, sizeof(mcp_log_buf), "%s", line ? line : "");
+}
+
/* ══════════════════════════════════════════════════════════════════
* JSON-RPC PARSING
* ══════════════════════════════════════════════════════════════════ */
@@ -406,6 +413,34 @@ TEST(server_handle_tools_list_paginates) {
PASS();
}
+TEST(server_handle_logs_request_without_params) {
+ mcp_log_buf[0] = '\0';
+ CBMLogLevel prev_level = cbm_log_get_level();
+ cbm_log_set_level(CBM_LOG_DEBUG);
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE);
+
+ cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
+ char *resp =
+ cbm_mcp_server_handle(srv,
+ "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"tools/list\","
+ "\"params\":{\"token\":\"secret\"}}");
+ ASSERT_NOT_NULL(resp);
+ free(resp);
+ cbm_mcp_server_free(srv);
+
+ cbm_log_set_sink(NULL);
+ cbm_log_set_level(prev_level);
+
+ ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request"));
+ ASSERT_NOT_NULL(strstr(mcp_log_buf, "rpc.system=jsonrpc"));
+ ASSERT_NOT_NULL(strstr(mcp_log_buf, "rpc.method=tools/list"));
+ ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok"));
+ ASSERT_NULL(strstr(mcp_log_buf, "token"));
+ ASSERT_NULL(strstr(mcp_log_buf, "secret"));
+ PASS();
+}
+
TEST(server_handle_unknown_method) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -2416,6 +2451,7 @@ SUITE(mcp) {
RUN_TEST(server_handle_initialized_notification);
RUN_TEST(server_handle_tools_list);
RUN_TEST(server_handle_tools_list_paginates);
+ RUN_TEST(server_handle_logs_request_without_params);
RUN_TEST(server_handle_unknown_method);
/* Server handle — edge cases */
From f05f0df8116ca76fa5481d3f32f3b9b65e85f0b0 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 09:42:03 +0200
Subject: [PATCH 4/8] fix(logging): keep format selection local
Signed-off-by: Martin Vogel
---
src/foundation/log.c | 8 +++-----
src/foundation/log.h | 7 +++----
tests/test_log.c | 14 ++++++++++++++
3 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/src/foundation/log.c b/src/foundation/log.c
index 3e5f90c43..fd916524a 100644
--- a/src/foundation/log.c
+++ b/src/foundation/log.c
@@ -66,11 +66,9 @@ parse_format:;
return;
}
- const char *env = getenv("ENVIRONMENT");
- const char *cloud_run = getenv("K_SERVICE");
- if ((env && strcmp(env, "production") == 0) || (cloud_run && cloud_run[0] != '\0')) {
- cbm_log_set_format(CBM_LOG_FORMAT_JSON);
- }
+ /* Format is intentionally explicit-only. Logs stay local to stderr and the
+ * optional in-process sink; deployment environment variables must not
+ * silently change the operator-selected output shape. */
}
void cbm_log_set_sink(cbm_log_sink_fn fn) {
diff --git a/src/foundation/log.h b/src/foundation/log.h
index 74c68d45a..7f247f086 100644
--- a/src/foundation/log.h
+++ b/src/foundation/log.h
@@ -4,7 +4,7 @@
* Design:
* - All output goes to stderr (stdout is reserved for MCP JSON-RPC)
* - Structured text format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
- * - Optional JSON format for production collectors / Cloud Logging
+ * - Optional JSON format for local structured parsing
* - Levels: DEBUG, INFO, WARN, ERROR
* - Level filtering at runtime via cbm_log_set_level() or the
* CBM_LOG_LEVEL env var (see cbm_log_init_from_env)
@@ -40,9 +40,8 @@ typedef enum {
* the numeric equivalents 0..4 matching CBMLogLevel. Unknown, empty, or
* unset values leave the level unchanged (fail-open).
*
- * Also applies CBM_LOG_FORMAT=text|json. If unset, JSON is auto-enabled for
- * production / Cloud Run style environments (ENVIRONMENT=production or
- * K_SERVICE present). Call once at startup before any threads or log lines. */
+ * Also applies CBM_LOG_FORMAT=text|json. If unset, the current format is left
+ * unchanged. Call once at startup before any threads or log lines. */
void cbm_log_init_from_env(void);
/* Set minimum log level (default: INFO). */
diff --git a/tests/test_log.c b/tests/test_log.c
index 8165d8967..5a1fdb7aa 100644
--- a/tests/test_log.c
+++ b/tests/test_log.c
@@ -197,6 +197,19 @@ TEST(log_format_from_env) {
PASS();
}
+TEST(log_format_is_explicit_only) {
+ cbm_unsetenv("CBM_LOG_FORMAT");
+ cbm_setenv("ENVIRONMENT", "production", 1);
+ cbm_setenv("K_SERVICE", "cbm", 1);
+ cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
+ cbm_log_init_from_env();
+ ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
+
+ cbm_unsetenv("ENVIRONMENT");
+ cbm_unsetenv("K_SERVICE");
+ PASS();
+}
+
/* CBM_LOG_LEVEL parsing — distilled from #414 (closes #413). */
TEST(log_level_from_env_textual) {
cbm_setenv("CBM_LOG_LEVEL", "error", 1);
@@ -281,6 +294,7 @@ SUITE(log) {
RUN_TEST(log_sink_tee_keeps_stderr);
RUN_TEST(log_operational_helpers);
RUN_TEST(log_format_from_env);
+ RUN_TEST(log_format_is_explicit_only);
RUN_TEST(log_level_from_env_textual);
RUN_TEST(log_level_from_env_numeric);
RUN_TEST(log_level_from_env_invalid_ignored);
From 62a28fd36e260d4966246934db8d24908858a3a5 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 13:05:05 +0200
Subject: [PATCH 5/8] fix(logging): use local log schema
Signed-off-by: Martin Vogel
---
src/foundation/log.c | 38 ++++++++++----------------------------
tests/test_httpd.c | 6 +++---
tests/test_log.c | 27 ++++++++++++++-------------
tests/test_mcp.c | 9 ++++-----
4 files changed, 31 insertions(+), 49 deletions(-)
diff --git a/src/foundation/log.c b/src/foundation/log.c
index fd916524a..fc4bb6c9e 100644
--- a/src/foundation/log.c
+++ b/src/foundation/log.c
@@ -111,21 +111,6 @@ static const char *level_str(CBMLogLevel level) {
}
}
-static const char *severity_str(CBMLogLevel level) {
- switch (level) {
- case CBM_LOG_DEBUG:
- return "DEBUG";
- case CBM_LOG_INFO:
- return "INFO";
- case CBM_LOG_WARN:
- return "WARNING";
- case CBM_LOG_ERROR:
- return "ERROR";
- default:
- return "DEFAULT";
- }
-}
-
static void append_char(char *buf, size_t bufsz, size_t *pos, char ch) {
if (*pos < bufsz - 1) {
buf[*pos] = ch;
@@ -231,10 +216,8 @@ void cbm_log(CBMLogLevel level, const char *msg, ...) {
va_start(args, msg);
if (g_log_format == CBM_LOG_FORMAT_JSON) {
- append_raw(line_buf, sizeof(line_buf), &pos, "{\"severity\":");
- append_json_string(line_buf, sizeof(line_buf), &pos, severity_str(level));
- append_raw(line_buf, sizeof(line_buf), &pos, ",\"message\":");
- append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
+ append_raw(line_buf, sizeof(line_buf), &pos, "{\"level\":");
+ append_json_string(line_buf, sizeof(line_buf), &pos, level_str(level));
append_raw(line_buf, sizeof(line_buf), &pos, ",\"event\":");
append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
for (;;) {
@@ -299,13 +282,13 @@ void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_erro
char duration_ms[CBM_SZ_32];
snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000);
if (tool_name && tool_name[0] != '\0') {
- cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "rpc.system", "jsonrpc",
- "rpc.method", method ? method : "", "mcp.tool.name", tool_name, "status",
+ cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
+ "method", method ? method : "", "tool", tool_name, "status",
is_error ? "error" : "ok", "duration_ms", duration_ms, NULL);
} else {
- cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "rpc.system", "jsonrpc",
- "rpc.method", method ? method : "", "status", is_error ? "error" : "ok",
- "duration_ms", duration_ms, NULL);
+ cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
+ "method", method ? method : "", "status", is_error ? "error" : "ok", "duration_ms",
+ duration_ms, NULL);
}
}
@@ -329,8 +312,7 @@ void cbm_log_http_request(const char *component, const char *method, const char
level = CBM_LOG_WARN;
}
- cbm_log(level, "http.request", "component", component ? component : "", "http.request.method",
- method ? method : "", "url.path", safe_path, "http.response.status_code", status_buf,
- "duration_ms", duration_buf, "http.request.body.size", request_buf,
- "http.response.body.size", response_buf, NULL);
+ cbm_log(level, "http.request", "component", component ? component : "", "method",
+ method ? method : "", "path", safe_path, "status", status_buf, "duration_ms",
+ duration_buf, "request_bytes", request_buf, "response_bytes", response_buf, NULL);
}
diff --git a/tests/test_httpd.c b/tests/test_httpd.c
index 8aea6ccdf..d5609786f 100644
--- a/tests/test_httpd.c
+++ b/tests/test_httpd.c
@@ -569,9 +569,9 @@ TEST(ui_server_access_log_redacts_query) {
ASSERT_NOT_NULL(strstr(httpd_log_buf, "msg=http.request"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "component=graph_ui"));
- ASSERT_NOT_NULL(strstr(httpd_log_buf, "http.request.method=GET"));
- ASSERT_NOT_NULL(strstr(httpd_log_buf, "url.path=/definitely/not/here"));
- ASSERT_NOT_NULL(strstr(httpd_log_buf, "http.response.status_code=404"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "method=GET"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "path=/definitely/not/here"));
+ ASSERT_NOT_NULL(strstr(httpd_log_buf, "status=404"));
ASSERT_NULL(strstr(httpd_log_buf, "token"));
ASSERT_NULL(strstr(httpd_log_buf, "secret"));
PASS();
diff --git a/tests/test_log.c b/tests/test_log.c
index 5a1fdb7aa..562768567 100644
--- a/tests/test_log.c
+++ b/tests/test_log.c
@@ -126,8 +126,8 @@ TEST(log_json_output) {
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_level(CBM_LOG_INFO);
- ASSERT(cbm_str_contains_raw(output, "\"severity\":\"INFO\""));
- ASSERT(cbm_str_contains_raw(output, "\"message\":\"test.msg\""));
+ ASSERT(cbm_str_contains_raw(output, "\"level\":\"info\""));
+ ASSERT(cbm_str_contains_raw(output, "\"event\":\"test.msg\""));
ASSERT(cbm_str_contains_raw(output, "\"key1\":\"val1\""));
ASSERT(cbm_str_contains_raw(output, "\"key2\":\"line\\nbreak\""));
PASS();
@@ -174,12 +174,13 @@ TEST(log_operational_helpers) {
cbm_log_set_level(CBM_LOG_INFO);
ASSERT(cbm_str_contains_raw(output, "msg=mcp.request"));
- ASSERT(cbm_str_contains_raw(output, "rpc.method=tools/call"));
- ASSERT(cbm_str_contains_raw(output, "mcp.tool.name=search_graph"));
+ ASSERT(cbm_str_contains_raw(output, "protocol=jsonrpc"));
+ ASSERT(cbm_str_contains_raw(output, "method=tools/call"));
+ ASSERT(cbm_str_contains_raw(output, "tool=search_graph"));
ASSERT(cbm_str_contains_raw(output, "msg=http.request"));
- ASSERT(cbm_str_contains_raw(output, "http.request.method=GET"));
- ASSERT(cbm_str_contains_raw(output, "url.path=/api/layout"));
- ASSERT(cbm_str_contains_raw(output, "http.response.status_code=200"));
+ ASSERT(cbm_str_contains_raw(output, "method=GET"));
+ ASSERT(cbm_str_contains_raw(output, "path=/api/layout"));
+ ASSERT(cbm_str_contains_raw(output, "status=200"));
PASS();
}
@@ -197,16 +198,16 @@ TEST(log_format_from_env) {
PASS();
}
-TEST(log_format_is_explicit_only) {
+TEST(log_format_unset_keeps_current) {
cbm_unsetenv("CBM_LOG_FORMAT");
- cbm_setenv("ENVIRONMENT", "production", 1);
- cbm_setenv("K_SERVICE", "cbm", 1);
+ cbm_log_set_format(CBM_LOG_FORMAT_JSON);
+ cbm_log_init_from_env();
+ ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_JSON);
+
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
- cbm_unsetenv("ENVIRONMENT");
- cbm_unsetenv("K_SERVICE");
PASS();
}
@@ -294,7 +295,7 @@ SUITE(log) {
RUN_TEST(log_sink_tee_keeps_stderr);
RUN_TEST(log_operational_helpers);
RUN_TEST(log_format_from_env);
- RUN_TEST(log_format_is_explicit_only);
+ RUN_TEST(log_format_unset_keeps_current);
RUN_TEST(log_level_from_env_textual);
RUN_TEST(log_level_from_env_numeric);
RUN_TEST(log_level_from_env_invalid_ignored);
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index 554c444d7..ab728eb86 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -422,9 +422,8 @@ TEST(server_handle_logs_request_without_params) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
- cbm_mcp_server_handle(srv,
- "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"tools/list\","
- "\"params\":{\"token\":\"secret\"}}");
+ cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"tools/list\","
+ "\"params\":{\"token\":\"secret\"}}");
ASSERT_NOT_NULL(resp);
free(resp);
cbm_mcp_server_free(srv);
@@ -433,8 +432,8 @@ TEST(server_handle_logs_request_without_params) {
cbm_log_set_level(prev_level);
ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request"));
- ASSERT_NOT_NULL(strstr(mcp_log_buf, "rpc.system=jsonrpc"));
- ASSERT_NOT_NULL(strstr(mcp_log_buf, "rpc.method=tools/list"));
+ ASSERT_NOT_NULL(strstr(mcp_log_buf, "protocol=jsonrpc"));
+ ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=tools/list"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok"));
ASSERT_NULL(strstr(mcp_log_buf, "token"));
ASSERT_NULL(strstr(mcp_log_buf, "secret"));
From d9d70656b4c2e7256a15ab5a7f796a74cb86d762 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 20:57:14 +0200
Subject: [PATCH 6/8] fix(ui): cap graph rendering and index spawn
Signed-off-by: Martin Vogel
---
graph-ui/src/components/GraphScene.test.ts | 9 +
graph-ui/src/components/GraphScene.tsx | 12 +-
graph-ui/src/components/GraphTab.test.ts | 36 ++++
graph-ui/src/components/GraphTab.tsx | 9 +
graph-ui/src/hooks/useGraphData.test.ts | 26 +++
graph-ui/src/hooks/useGraphData.ts | 10 +-
graph-ui/tsconfig.tsbuildinfo | 2 +-
src/ui/http_server.c | 113 +++++++++--
src/ui/http_server.h | 4 +
src/ui/layout3d.c | 32 +++-
tests/test_httpd.c | 41 ++++
tests/test_main.c | 206 ++++++++++++---------
tests/test_ui.c | 41 ++++
13 files changed, 429 insertions(+), 112 deletions(-)
create mode 100644 graph-ui/src/components/GraphScene.test.ts
create mode 100644 graph-ui/src/components/GraphTab.test.ts
create mode 100644 graph-ui/src/hooks/useGraphData.test.ts
diff --git a/graph-ui/src/components/GraphScene.test.ts b/graph-ui/src/components/GraphScene.test.ts
new file mode 100644
index 000000000..75fcb2154
--- /dev/null
+++ b/graph-ui/src/components/GraphScene.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from "vitest";
+import { GRAPH_CANVAS_DPR } from "./GraphScene";
+
+describe("GraphScene render limits", () => {
+ it("caps the high-DPI WebGL backing store below the MSAA failure range", () => {
+ expect(GRAPH_CANVAS_DPR[0]).toBe(1);
+ expect(GRAPH_CANVAS_DPR[1]).toBeLessThanOrEqual(1.5);
+ });
+});
diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx
index fa150be36..6aeceb6b2 100644
--- a/graph-ui/src/components/GraphScene.tsx
+++ b/graph-ui/src/components/GraphScene.tsx
@@ -45,6 +45,8 @@ function CameraAnimator({ target }: { target: CameraTarget | null }) {
/* ── Idle auto-rotation ──────────────────────────────────── */
const IDLE_TIMEOUT_MS = 60_000;
+export const GRAPH_CANVAS_DPR: [number, number] = [1, 1.5];
+export const GRAPH_COMPOSER_MULTISAMPLING = 0;
function IdleAutoRotate({
controlsRef,
@@ -108,8 +110,12 @@ export function GraphScene({
)}
+ {limitNotice && (
+ {limitNotice}
+ )}
{highlightedIds && highlightedIds.size > 0 && (
{highlightedIds.size} selected
diff --git a/graph-ui/src/hooks/useGraphData.test.ts b/graph-ui/src/hooks/useGraphData.test.ts
new file mode 100644
index 000000000..7edb76248
--- /dev/null
+++ b/graph-ui/src/hooks/useGraphData.test.ts
@@ -0,0 +1,26 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchLayout, GRAPH_RENDER_NODE_LIMIT } from "./useGraphData";
+
+describe("fetchLayout", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("uses the safe graph render cap by default", async () => {
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ nodes: [], edges: [], total_nodes: 0 }),
+ }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await fetchLayout("large-project");
+
+ expect(GRAPH_RENDER_NODE_LIMIT).toBe(2000);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const calls = fetchMock.mock.calls as unknown as Array<[string]>;
+ const [url] = calls[0];
+ expect(url).toBe(
+ "/api/layout?project=large-project&max_nodes=2000",
+ );
+ });
+});
diff --git a/graph-ui/src/hooks/useGraphData.ts b/graph-ui/src/hooks/useGraphData.ts
index 36048bcac..5705555fa 100644
--- a/graph-ui/src/hooks/useGraphData.ts
+++ b/graph-ui/src/hooks/useGraphData.ts
@@ -9,9 +9,11 @@ interface UseGraphDataResult {
fetchDetail: (project: string, centerNode: string) => void;
}
-async function fetchLayout(
+export const GRAPH_RENDER_NODE_LIMIT = 2000;
+
+export async function fetchLayout(
project: string,
- maxNodes = 50000,
+ maxNodes = GRAPH_RENDER_NODE_LIMIT,
): Promise {
const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
const res = await fetch(`/api/layout?${params}`);
@@ -33,7 +35,7 @@ export function useGraphData(): UseGraphDataResult {
setLoading(true);
setError(null);
try {
- const result = await fetchLayout(project, 50000);
+ const result = await fetchLayout(project);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
@@ -48,7 +50,7 @@ export function useGraphData(): UseGraphDataResult {
setError(null);
try {
/* TODO: detail level with center_node filtering */
- const result = await fetchLayout(project, 50000);
+ const result = await fetchLayout(project);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo
index e1401cf36..90f6612f2 100644
--- a/graph-ui/tsconfig.tsbuildinfo
+++ b/graph-ui/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.tsx","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index 1f61ef771..5ec072bbc 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -23,6 +23,7 @@
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
+#include "foundation/compat_fs.h"
#include "foundation/str_util.h"
#include "foundation/compat_thread.h"
@@ -39,6 +40,7 @@
#include
#include /* GetProcessMemoryInfo */
#else
+#include
#include
#include
#endif
@@ -582,9 +584,107 @@ static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) {
static char g_binary_path[1024] = {0};
+static bool copy_path(char *out, size_t outsz, const char *path) {
+ if (!out || outsz == 0 || !path || !path[0]) {
+ return false;
+ }
+ int n = snprintf(out, outsz, "%s", path);
+ return n > 0 && (size_t)n < outsz;
+}
+
+#ifndef _WIN32
+static bool is_executable_file(const char *path) {
+ struct stat st;
+ return path && stat(path, &st) == 0 && S_ISREG(st.st_mode) && access(path, X_OK) == 0;
+}
+
+static bool resolve_from_path(const char *name, char *out, size_t outsz) {
+ const char *path = getenv("PATH");
+ if (!name || !name[0] || strchr(name, '/') || !path || !path[0]) {
+ return false;
+ }
+
+ const char *cur = path;
+ while (*cur) {
+ const char *colon = strchr(cur, ':');
+ size_t dir_len = colon ? (size_t)(colon - cur) : strlen(cur);
+ if (dir_len > 0 && dir_len < 900) {
+ char candidate[1024];
+ int n = snprintf(candidate, sizeof(candidate), "%.*s/%s", (int)dir_len, cur, name);
+ if (n > 0 && (size_t)n < sizeof(candidate) && is_executable_file(candidate)) {
+ return copy_path(out, outsz, candidate);
+ }
+ }
+ if (!colon) {
+ break;
+ }
+ cur = colon + 1;
+ }
+ return false;
+}
+
+static bool resolve_self_executable(char *out, size_t outsz) {
+#if defined(__APPLE__)
+ char buf[1024];
+ uint32_t sz = sizeof(buf);
+ if (_NSGetExecutablePath(buf, &sz) == 0 && buf[0]) {
+ return copy_path(out, outsz, buf);
+ }
+ return false;
+#else
+ char buf[1024];
+ ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
+ if (len > 0) {
+ buf[len] = '\0';
+ return copy_path(out, outsz, buf);
+ }
+ return false;
+#endif
+}
+#else
+static bool resolve_self_executable(char *out, size_t outsz) {
+ char buf[1024];
+ DWORD n = GetModuleFileNameA(NULL, buf, (DWORD)sizeof(buf));
+ if (n > 0 && n < sizeof(buf)) {
+ return copy_path(out, outsz, buf);
+ }
+ return false;
+}
+#endif
+
+bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz) {
+ if (!out || outsz == 0) {
+ return false;
+ }
+ out[0] = '\0';
+
+#ifndef _WIN32
+ if (argv0 && strchr(argv0, '/') && is_executable_file(argv0)) {
+ return copy_path(out, outsz, argv0);
+ }
+ if (resolve_from_path(argv0, out, outsz)) {
+ return true;
+ }
+#else
+ if (argv0 && argv0[0]) {
+ DWORD attrs = GetFileAttributesA(argv0);
+ if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) {
+ return copy_path(out, outsz, argv0);
+ }
+ }
+#endif
+
+ if (resolve_self_executable(out, outsz)) {
+ return true;
+ }
+ return copy_path(out, outsz, argv0);
+}
+
void cbm_http_server_set_binary_path(const char *path) {
if (path) {
- snprintf(g_binary_path, sizeof(g_binary_path), "%s", path);
+ if (!cbm_http_server_resolve_binary_path(path, g_binary_path, sizeof(g_binary_path))) {
+ g_binary_path[0] = '\0';
+ }
}
}
@@ -597,16 +697,7 @@ static void *index_thread_fn(void *arg) {
const char *bin = g_binary_path;
char self_path[1024] = {0};
if (!bin[0]) {
-#ifdef _WIN32
- GetModuleFileNameA(NULL, self_path, sizeof(self_path));
-#elif defined(__APPLE__)
- uint32_t sz = sizeof(self_path);
- _NSGetExecutablePath(self_path, &sz);
-#else
- ssize_t len = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
- if (len > 0)
- self_path[len] = '\0';
-#endif
+ cbm_http_server_resolve_binary_path(NULL, self_path, sizeof(self_path));
bin = self_path[0] ? self_path : "codebase-memory-mcp";
}
diff --git a/src/ui/http_server.h b/src/ui/http_server.h
index 741a9f990..69d89b5f8 100644
--- a/src/ui/http_server.h
+++ b/src/ui/http_server.h
@@ -11,6 +11,7 @@
#define CBM_UI_HTTP_SERVER_H
#include
+#include
typedef struct cbm_http_server cbm_http_server_t;
@@ -47,4 +48,7 @@ void cbm_ui_log_append(const char *line);
/* Set the binary path for subprocess spawning (call from main). */
void cbm_http_server_set_binary_path(const char *path);
+/* Resolve argv[0] into an executable path suitable for subprocess spawning. */
+bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz);
+
#endif /* CBM_UI_HTTP_SERVER_H */
diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c
index 5758a3334..e7e69f0dd 100644
--- a/src/ui/layout3d.c
+++ b/src/ui/layout3d.c
@@ -17,13 +17,15 @@
#include
#include
+#include
#include
#include
#include
/* ── Constants ────────────────────────────────────────────────── */
-#define DEFAULT_MAX_NODES 50000
+#define DEFAULT_MAX_NODES 2000
+#define HARD_MAX_NODES 10000
#define BH_THETA 1.2f
/* Local optimization: gentle, preserves structure */
@@ -110,6 +112,31 @@ static float rand_float(uint32_t *seed) {
return (float)((*seed >> 16) & 0x7FFF) / 32768.0f - 0.5f;
}
+static int render_node_limit(void) {
+ const char *raw = getenv("CBM_UI_MAX_RENDER_NODES");
+ if (!raw || !raw[0]) {
+ return DEFAULT_MAX_NODES;
+ }
+ errno = 0;
+ char *end = NULL;
+ long v = strtol(raw, &end, 10);
+ if (errno != 0 || end == raw || *end != '\0' || v <= 0) {
+ return DEFAULT_MAX_NODES;
+ }
+ if (v > HARD_MAX_NODES) {
+ return HARD_MAX_NODES;
+ }
+ return (int)v;
+}
+
+static int clamp_max_nodes(int requested) {
+ int cap = render_node_limit();
+ if (requested <= 0 || requested > cap) {
+ return cap;
+ }
+ return requested;
+}
+
/* ── Barnes-Hut Octree ────────────────────────────────────────── */
typedef struct octree_node {
@@ -387,8 +414,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
int radius, int max_nodes) {
if (!store || !project)
return NULL;
- if (max_nodes <= 0)
- max_nodes = DEFAULT_MAX_NODES;
+ max_nodes = clamp_max_nodes(max_nodes);
(void)center_node;
(void)radius;
(void)level;
diff --git a/tests/test_httpd.c b/tests/test_httpd.c
index d5609786f..c8d42bed3 100644
--- a/tests/test_httpd.c
+++ b/tests/test_httpd.c
@@ -11,8 +11,10 @@
* RPC dispatch, transport limits, receive deadline, clean shutdown.
*/
#include "../src/foundation/compat.h"
+#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/log.h"
+#include "../src/ui/http_server.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "ui/httpd.h"
@@ -21,6 +23,9 @@
#include
#include
#include
+#ifndef _WIN32
+#include
+#endif
#ifdef _WIN32
#include
@@ -325,6 +330,41 @@ TEST(httpd_path_match_matrix) {
PASS();
}
+TEST(httpd_resolves_bare_binary_path_from_path) {
+#ifdef _WIN32
+ PASS();
+#else
+ char tmpdir[256];
+ snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_bin_XXXXXX");
+ char *td = cbm_mkdtemp(tmpdir);
+ ASSERT_NOT_NULL(td);
+
+ char exe[512];
+ snprintf(exe, sizeof(exe), "%s/codebase-memory-mcp", td);
+ FILE *f = fopen(exe, "w");
+ ASSERT_NOT_NULL(f);
+ fputs("#!/bin/sh\nexit 0\n", f);
+ fclose(f);
+ ASSERT_EQ(chmod(exe, 0755), 0);
+
+ char *old_path = getenv("PATH") ? strdup(getenv("PATH")) : NULL;
+ cbm_setenv("PATH", td, 1);
+
+ char resolved[1024];
+ ASSERT_TRUE(cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved,
+ sizeof(resolved)));
+ ASSERT_STR_EQ(resolved, exe);
+
+ if (old_path) {
+ cbm_setenv("PATH", old_path, 1);
+ free(old_path);
+ } else {
+ cbm_unsetenv("PATH");
+ }
+ PASS();
+#endif
+}
+
/* ── Transport integration (listener only) ────────────────────── */
TEST(httpd_listen_ephemeral_port) {
@@ -604,6 +644,7 @@ SUITE(httpd) {
RUN_TEST(httpd_query_param_decode);
RUN_TEST(httpd_query_param_edge_cases);
RUN_TEST(httpd_path_match_matrix);
+ RUN_TEST(httpd_resolves_bare_binary_path_from_path);
/* Transport */
RUN_TEST(httpd_listen_ephemeral_port);
diff --git a/tests/test_main.c b/tests/test_main.c
index ee2d8a9d7..227e838dd 100644
--- a/tests/test_main.c
+++ b/tests/test_main.c
@@ -10,6 +10,30 @@ int tf_skip_count = 0;
#include "test_framework.h"
#include
+#include
+#include
+
+static int g_suite_argc = 0;
+static char **g_suite_argv = NULL;
+
+static bool suite_requested(const char *name) {
+ if (g_suite_argc <= 1) {
+ return true;
+ }
+ for (int i = 1; i < g_suite_argc; i++) {
+ if (strcmp(g_suite_argv[i], name) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+#define RUN_SELECTED_SUITE(name) \
+ do { \
+ if (suite_requested(#name)) { \
+ RUN_SUITE(name); \
+ } \
+ } while (0)
/* Forward declarations of suite functions */
extern void suite_arena(void);
@@ -106,155 +130,157 @@ extern void suite_dump_verify_io(void);
* caches at thread teardown (pass_parallel.c). */
extern void cbm_kind_in_set_free_cache(void);
-int main(void) {
+int main(int argc, char **argv) {
+ g_suite_argc = argc;
+ g_suite_argv = argv;
printf("\n codebase-memory-mcp C test suite\n");
/* Foundation */
- RUN_SUITE(arena);
- RUN_SUITE(hash_table);
- RUN_SUITE(dyn_array);
- RUN_SUITE(str_intern);
- RUN_SUITE(log);
- RUN_SUITE(str_util);
- RUN_SUITE(platform);
- RUN_SUITE(dump_verify);
+ RUN_SELECTED_SUITE(arena);
+ RUN_SELECTED_SUITE(hash_table);
+ RUN_SELECTED_SUITE(dyn_array);
+ RUN_SELECTED_SUITE(str_intern);
+ RUN_SELECTED_SUITE(log);
+ RUN_SELECTED_SUITE(str_util);
+ RUN_SELECTED_SUITE(platform);
+ RUN_SELECTED_SUITE(dump_verify);
/* Existing C code regression tests */
- RUN_SUITE(ac);
- RUN_SUITE(extraction);
- RUN_SUITE(extraction_inheritance);
- RUN_SUITE(extraction_imports);
- RUN_SUITE(grammar_regression);
- RUN_SUITE(grammar_labels);
- RUN_SUITE(grammar_imports);
+ RUN_SELECTED_SUITE(ac);
+ RUN_SELECTED_SUITE(extraction);
+ RUN_SELECTED_SUITE(extraction_inheritance);
+ RUN_SELECTED_SUITE(extraction_imports);
+ RUN_SELECTED_SUITE(grammar_regression);
+ RUN_SELECTED_SUITE(grammar_labels);
+ RUN_SELECTED_SUITE(grammar_imports);
/* Store (M5) */
- RUN_SUITE(store_nodes);
- RUN_SUITE(store_edges);
- RUN_SUITE(store_search);
- RUN_SUITE(store_bulk);
- RUN_SUITE(store_pragmas);
- RUN_SUITE(store_checkpoint);
- RUN_SUITE(dump_verify_io);
+ RUN_SELECTED_SUITE(store_nodes);
+ RUN_SELECTED_SUITE(store_edges);
+ RUN_SELECTED_SUITE(store_search);
+ RUN_SELECTED_SUITE(store_bulk);
+ RUN_SELECTED_SUITE(store_pragmas);
+ RUN_SELECTED_SUITE(store_checkpoint);
+ RUN_SELECTED_SUITE(dump_verify_io);
/* Cypher (M6) */
- RUN_SUITE(cypher);
+ RUN_SELECTED_SUITE(cypher);
/* MCP Server (M9) */
- RUN_SUITE(mcp);
+ RUN_SELECTED_SUITE(mcp);
/* Discover (M2) */
- RUN_SUITE(language);
- RUN_SUITE(userconfig);
- RUN_SUITE(gitignore);
- RUN_SUITE(discover);
+ RUN_SELECTED_SUITE(language);
+ RUN_SELECTED_SUITE(userconfig);
+ RUN_SELECTED_SUITE(gitignore);
+ RUN_SELECTED_SUITE(discover);
/* Graph Buffer (M7) */
- RUN_SUITE(graph_buffer);
+ RUN_SELECTED_SUITE(graph_buffer);
/* Pipeline (M8) */
- RUN_SUITE(registry);
- RUN_SUITE(pipeline);
- RUN_SUITE(fqn);
- RUN_SUITE(route_canon);
- RUN_SUITE(path_alias);
+ RUN_SELECTED_SUITE(registry);
+ RUN_SELECTED_SUITE(pipeline);
+ RUN_SELECTED_SUITE(fqn);
+ RUN_SELECTED_SUITE(route_canon);
+ RUN_SELECTED_SUITE(path_alias);
/* Watcher (M10) */
- RUN_SUITE(watcher);
+ RUN_SELECTED_SUITE(watcher);
/* LZ4 + zstd + SQLite writer */
- RUN_SUITE(lz4);
- RUN_SUITE(zstd);
- RUN_SUITE(sqlite_writer);
+ RUN_SELECTED_SUITE(lz4);
+ RUN_SELECTED_SUITE(zstd);
+ RUN_SELECTED_SUITE(sqlite_writer);
/* Persistent artifact export/import */
- RUN_SUITE(artifact);
+ RUN_SELECTED_SUITE(artifact);
/* LSP resolvers */
- RUN_SUITE(scope);
- RUN_SUITE(type_rep);
- RUN_SUITE(go_lsp);
- RUN_SUITE(c_lsp);
- RUN_SUITE(php_lsp);
- RUN_SUITE(cs_lsp);
- RUN_SUITE(cs_lsp_bench);
- RUN_SUITE(py_lsp);
- RUN_SUITE(kotlin_lsp);
- RUN_SUITE(rust_lsp);
- RUN_SUITE(py_lsp_bench);
- RUN_SUITE(py_lsp_stress);
- RUN_SUITE(py_lsp_scale);
- RUN_SUITE(ts_lsp);
- RUN_SUITE(java_lsp);
- RUN_SUITE(java_lsp_coverage);
+ RUN_SELECTED_SUITE(scope);
+ RUN_SELECTED_SUITE(type_rep);
+ RUN_SELECTED_SUITE(go_lsp);
+ RUN_SELECTED_SUITE(c_lsp);
+ RUN_SELECTED_SUITE(php_lsp);
+ RUN_SELECTED_SUITE(cs_lsp);
+ RUN_SELECTED_SUITE(cs_lsp_bench);
+ RUN_SELECTED_SUITE(py_lsp);
+ RUN_SELECTED_SUITE(kotlin_lsp);
+ RUN_SELECTED_SUITE(rust_lsp);
+ RUN_SELECTED_SUITE(py_lsp_bench);
+ RUN_SELECTED_SUITE(py_lsp_stress);
+ RUN_SELECTED_SUITE(py_lsp_scale);
+ RUN_SELECTED_SUITE(ts_lsp);
+ RUN_SELECTED_SUITE(java_lsp);
+ RUN_SELECTED_SUITE(java_lsp_coverage);
/* Architecture + ADR + Louvain */
- RUN_SUITE(store_arch);
+ RUN_SELECTED_SUITE(store_arch);
/* HTTP link */
/* Traces helpers */
- RUN_SUITE(traces);
+ RUN_SELECTED_SUITE(traces);
/* Config link */
- RUN_SUITE(configlink);
+ RUN_SELECTED_SUITE(configlink);
/* Infrastructure scanning */
- RUN_SUITE(infrascan);
+ RUN_SELECTED_SUITE(infrascan);
/* CLI (install, update, config) */
- RUN_SUITE(cli);
+ RUN_SELECTED_SUITE(cli);
/* System info + worker pool (parallelism) */
- RUN_SUITE(system_info);
- RUN_SUITE(worker_pool);
+ RUN_SELECTED_SUITE(system_info);
+ RUN_SELECTED_SUITE(worker_pool);
/* Parallel pipeline */
- RUN_SUITE(parallel);
+ RUN_SELECTED_SUITE(parallel);
/* mem + arena + slab integration */
- RUN_SUITE(mem);
+ RUN_SELECTED_SUITE(mem);
/* UI (config, embedded assets, layout) */
- RUN_SUITE(ui);
+ RUN_SELECTED_SUITE(ui);
/* UI HTTP server (transport + routing) */
- RUN_SUITE(httpd);
+ RUN_SELECTED_SUITE(httpd);
/* Security defenses */
- RUN_SUITE(security);
+ RUN_SELECTED_SUITE(security);
/* YAML parser */
- RUN_SUITE(yaml);
+ RUN_SELECTED_SUITE(yaml);
/* SimHash / SIMILAR_TO */
- RUN_SUITE(simhash);
+ RUN_SELECTED_SUITE(simhash);
/* Stack overflow regression (GitHub #199) */
- RUN_SUITE(stack_overflow);
+ RUN_SELECTED_SUITE(stack_overflow);
/* Integration (end-to-end) */
- RUN_SUITE(integration);
+ RUN_SELECTED_SUITE(integration);
/* Per-language graph contracts (node/edge types, attribution, no-crash) */
- RUN_SUITE(lang_contract);
- RUN_SUITE(edge_imports);
- RUN_SUITE(edge_structural);
- RUN_SUITE(lsp_resolution_probe);
- RUN_SUITE(node_creation_probe);
- RUN_SUITE(edge_types_probe);
- RUN_SUITE(convergence_probe);
- RUN_SUITE(matrix_known_classes);
- RUN_SUITE(matrix_new_constructs);
- RUN_SUITE(grammar_probe_a);
- RUN_SUITE(grammar_probe_b);
- RUN_SUITE(grammar_probe_c);
- RUN_SUITE(grammar_probe_d);
- RUN_SUITE(grammar_probe_e);
- RUN_SUITE(grammar_probe_f);
- RUN_SUITE(grammar_probe_g);
-
- RUN_SUITE(incremental);
+ RUN_SELECTED_SUITE(lang_contract);
+ RUN_SELECTED_SUITE(edge_imports);
+ RUN_SELECTED_SUITE(edge_structural);
+ RUN_SELECTED_SUITE(lsp_resolution_probe);
+ RUN_SELECTED_SUITE(node_creation_probe);
+ RUN_SELECTED_SUITE(edge_types_probe);
+ RUN_SELECTED_SUITE(convergence_probe);
+ RUN_SELECTED_SUITE(matrix_known_classes);
+ RUN_SELECTED_SUITE(matrix_new_constructs);
+ RUN_SELECTED_SUITE(grammar_probe_a);
+ RUN_SELECTED_SUITE(grammar_probe_b);
+ RUN_SELECTED_SUITE(grammar_probe_c);
+ RUN_SELECTED_SUITE(grammar_probe_d);
+ RUN_SELECTED_SUITE(grammar_probe_e);
+ RUN_SELECTED_SUITE(grammar_probe_f);
+ RUN_SELECTED_SUITE(grammar_probe_g);
+
+ RUN_SELECTED_SUITE(incremental);
/* Release process-lifetime caches so LeakSanitizer reports no leaks. */
cbm_kind_in_set_free_cache();
diff --git a/tests/test_ui.c b/tests/test_ui.c
index 002e8d39a..9cb8efd45 100644
--- a/tests/test_ui.c
+++ b/tests/test_ui.c
@@ -317,6 +317,46 @@ TEST(layout_respects_max_nodes) {
PASS();
}
+TEST(layout_clamps_render_cap_from_env) {
+ cbm_store_t *store = cbm_store_open_memory();
+ ASSERT_NOT_NULL(store);
+
+ const char *old_raw = getenv("CBM_UI_MAX_RENDER_NODES");
+ char *old_cap = old_raw ? strdup(old_raw) : NULL;
+ cbm_setenv("CBM_UI_MAX_RENDER_NODES", "25", 1);
+
+ cbm_store_upsert_project(store, "test", "/tmp/test");
+
+ for (int i = 0; i < 40; i++) {
+ char name[32], qn[64];
+ snprintf(name, sizeof(name), "fn%d", i);
+ snprintf(qn, sizeof(qn), "test::fn%d", i);
+ cbm_node_t n = {.project = "test",
+ .label = "Function",
+ .name = name,
+ .qualified_name = qn,
+ .file_path = "a.c",
+ .start_line = i,
+ .end_line = i + 1};
+ cbm_store_upsert_node(store, &n);
+ }
+
+ cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 50000);
+ ASSERT_NOT_NULL(r);
+ ASSERT_LTE(r->node_count, 25);
+ ASSERT_EQ(r->total_nodes, 40);
+
+ cbm_layout_free(r);
+ cbm_store_close(store);
+ if (old_cap) {
+ cbm_setenv("CBM_UI_MAX_RENDER_NODES", old_cap, 1);
+ free(old_cap);
+ } else {
+ cbm_unsetenv("CBM_UI_MAX_RENDER_NODES");
+ }
+ PASS();
+}
+
TEST(layout_deterministic) {
cbm_store_t *store = cbm_store_open_memory();
ASSERT_NOT_NULL(store);
@@ -433,6 +473,7 @@ SUITE(ui) {
RUN_TEST(layout_single_node);
RUN_TEST(layout_two_connected);
RUN_TEST(layout_respects_max_nodes);
+ RUN_TEST(layout_clamps_render_cap_from_env);
RUN_TEST(layout_deterministic);
RUN_TEST(layout_to_json);
RUN_TEST(layout_null_inputs);
From d5414111b34034910053772a18cc633ce580a2df Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 21:21:32 +0200
Subject: [PATCH 7/8] feat(ui): add i18n and index picker UX
Signed-off-by: Martin Vogel
---
graph-ui/src/App.tsx | 27 +--
graph-ui/src/components/ControlTab.tsx | 34 ++--
graph-ui/src/components/Sidebar.tsx | 10 +-
graph-ui/src/components/StatsTab.test.tsx | 95 +++++++++++
graph-ui/src/components/StatsTab.tsx | 195 ++++++++++++++++-----
graph-ui/src/lib/i18n.test.ts | 15 ++
graph-ui/src/lib/i18n.ts | 197 ++++++++++++++++++++++
graph-ui/tsconfig.tsbuildinfo | 2 +-
src/cli/cli.c | 4 +
src/cli/cli.h | 1 +
src/foundation/str_util.c | 11 +-
src/mcp/mcp.c | 13 ++
src/pipeline/fqn.c | 4 +-
src/pipeline/pipeline.c | 22 +++
src/pipeline/pipeline.h | 3 +
src/ui/http_server.c | 87 ++++++++--
src/ui/httpd.c | 3 +
src/ui/httpd.h | 5 +-
tests/test_fqn.c | 16 ++
tests/test_httpd.c | 59 +++++++
tests/test_mcp.c | 11 ++
21 files changed, 717 insertions(+), 97 deletions(-)
create mode 100644 graph-ui/src/components/StatsTab.test.tsx
create mode 100644 graph-ui/src/lib/i18n.test.ts
create mode 100644 graph-ui/src/lib/i18n.ts
diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx
index be88d23aa..732a9d18a 100644
--- a/graph-ui/src/App.tsx
+++ b/graph-ui/src/App.tsx
@@ -3,16 +3,17 @@ import { GraphTab } from "./components/GraphTab";
import { StatsTab } from "./components/StatsTab";
import { ControlTab } from "./components/ControlTab";
import type { TabId } from "./lib/types";
-
-const TABS: { id: TabId; label: string }[] = [
- { id: "graph", label: "Graph" },
- { id: "stats", label: "Projects" },
- { id: "control", label: "Control" },
-];
+import { useUiMessages } from "./lib/i18n";
export function App() {
+ const t = useUiMessages();
const [activeTab, setActiveTab] = useState("stats");
const [selectedProject, setSelectedProject] = useState(null);
+ const tabs: { id: TabId; label: string }[] = [
+ { id: "graph", label: t.tabs.graph },
+ { id: "stats", label: t.tabs.projects },
+ { id: "control", label: t.tabs.control },
+ ];
return (
@@ -28,17 +29,17 @@ export function App() {
{/* Tabs inline in header */}
@@ -46,7 +47,9 @@ export function App() {
{selectedProject && (
- Graph
+
+ {t.graph.selectedLabel}
+
{selectedProject}
diff --git a/graph-ui/src/components/ControlTab.tsx b/graph-ui/src/components/ControlTab.tsx
index f3febc79a..2d2ce103b 100644
--- a/graph-ui/src/components/ControlTab.tsx
+++ b/graph-ui/src/components/ControlTab.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ProcessInfo } from "../lib/types";
+import { useUiMessages } from "../lib/i18n";
/* ── Gauge component ────────────────────────────────────── */
@@ -30,6 +31,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
proc: ProcessInfo; selected: boolean;
onSelect: () => void; onKill: () => void;
}) {
+ const t = useUiMessages();
return (
{!proc.is_self && (
@@ -54,7 +56,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
onClick={(e) => { e.stopPropagation(); onKill(); }}
className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all"
>
- Kill
+ {t.control.kill}
)}
@@ -69,7 +71,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
{proc.rss_mb.toFixed(0)} MB
-
Uptime
+
{t.control.uptime}
{proc.elapsed}
@@ -82,6 +84,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
/* ── Log viewer ─────────────────────────────────────────── */
function LogViewer() {
+ const t = useUiMessages();
const [lines, setLines] = useState([]);
useEffect(() => {
@@ -100,13 +103,13 @@ function LogViewer() {
return (
- Process Logs
+ {t.control.processLogs}
{lines.length} lines
{lines.length === 0 ? (
-
No logs yet
+
{t.control.noLogs}
) : (
lines.map((line, i) => {
const isErr = line.includes("level=error");
@@ -132,6 +135,7 @@ function LogViewer() {
/* ── Main Control Tab ───────────────────────────────────── */
export function ControlTab() {
+ const t = useUiMessages();
const [processes, setProcesses] = useState
([]);
const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 });
const [selectedPid, setSelectedPid] = useState(null);
@@ -156,7 +160,7 @@ export function ControlTab() {
}, [fetchProcesses]);
const killProcess = useCallback(async (pid: number) => {
- if (!confirm(`Kill process ${pid}?`)) return;
+ if (!confirm(t.control.killConfirm(pid))) return;
try {
await fetch("/api/process-kill", {
method: "POST",
@@ -165,7 +169,7 @@ export function ControlTab() {
});
setTimeout(fetchProcesses, 1000);
} catch { /* ignore */ }
- }, [fetchProcesses]);
+ }, [fetchProcesses, t.control]);
/* Aggregates */
const totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
@@ -174,32 +178,32 @@ export function ControlTab() {
return (
-
Control Panel
+
{t.control.panel}
{/* Aggregate gauges */}
-
-
-
-
+
+
+
+
{/* Process grid */}
- Active Processes
+ {t.control.activeProcesses}
{processes.length === 0 ? (
-
No processes found
+
{t.control.noProcesses}
) : (
{processes.map((p) => (
diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx
index 161dffcb3..73c3f898f 100644
--- a/graph-ui/src/components/Sidebar.tsx
+++ b/graph-ui/src/components/Sidebar.tsx
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { GraphNode } from "../lib/types";
+import { useUiMessages } from "../lib/i18n";
interface SidebarProps {
nodes: GraphNode[];
@@ -105,6 +106,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
}
export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
+ const t = useUiMessages();
const [search, setSearch] = useState("");
const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]);
@@ -122,7 +124,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
setSearch(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all"
@@ -134,7 +136,9 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
{filtered ? (
filtered.length === 0 ? (
-
No matches
+
+ {t.common.noMatches}
+
) : (
filtered.map((n) => (
)}
diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx
new file mode 100644
index 000000000..1a3df5b6f
--- /dev/null
+++ b/graph-ui/src/components/StatsTab.test.tsx
@@ -0,0 +1,95 @@
+/* @vitest-environment jsdom */
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { StatsTab } from "./StatsTab";
+
+function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const overridden = extra?.(url, init);
+ if (overridden) return overridden;
+ if (url === "/rpc") {
+ return new Response(JSON.stringify({
+ result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
+ }), { status: 200, headers: { "Content-Type": "application/json" } });
+ }
+ if (url.startsWith("/api/ui-config")) {
+ return new Response(JSON.stringify({ lang: "en" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (url.startsWith("/api/browse")) {
+ return new Response(JSON.stringify({
+ path: "/home/dev",
+ parent: "/home",
+ dirs: ["alpha", "beta"],
+ roots: ["/", "D:/"],
+ }), { status: 200, headers: { "Content-Type": "application/json" } });
+ }
+ if (url === "/api/index") {
+ return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
+ status: 202,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response("{}", { status: 200 });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+}
+
+describe("StatsTab index modal", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("submits a custom path and project name", async () => {
+ let submitted: unknown = null;
+ mockProjectsFetch((url, init) => {
+ if (url === "/api/index") {
+ submitted = JSON.parse(String(init?.body));
+ return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
+ status: 202,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return undefined;
+ });
+
+ render(
{}} />);
+ fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
+
+ fireEvent.change(await screen.findByLabelText("Repository path"), {
+ target: { value: "D:\\work\\信租风控通后端" },
+ });
+ fireEvent.change(screen.getByLabelText("Project name"), {
+ target: { value: "信租风控通后端" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Index This Folder" }));
+
+ await waitFor(() => {
+ expect(submitted).toEqual({
+ root_path: "D:\\work\\信租风控通后端",
+ project_name: "信租风控通后端",
+ });
+ });
+ });
+
+ it("filters picker rows and exposes quick row indexing", async () => {
+ mockProjectsFetch();
+
+ render( {}} />);
+ fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
+
+ fireEvent.change(await screen.findByPlaceholderText("Filter folders"), {
+ target: { value: "bet" },
+ });
+
+ expect(screen.queryByText("alpha")).not.toBeInTheDocument();
+ expect(screen.getByText("beta")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
+ });
+});
diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx
index 988902714..3c439dce4 100644
--- a/graph-ui/src/components/StatsTab.tsx
+++ b/graph-ui/src/components/StatsTab.tsx
@@ -1,7 +1,8 @@
-import { useMemo, useState, useCallback, useEffect } from "react";
+import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useProjects } from "../hooks/useProjects";
import { colorForLabel } from "../lib/colors";
+import { useUiMessages } from "../lib/i18n";
interface StatsTabProps {
onSelectProject: (project: string) => void;
@@ -10,6 +11,7 @@ interface StatsTabProps {
/* ── Glowy health dot ───────────────────────────────────── */
function HealthDot({ name }: { name: string }) {
+ const t = useUiMessages();
const [status, setStatus] = useState<"loading" | "healthy" | "corrupt" | "missing">("loading");
const [info, setInfo] = useState("");
@@ -34,9 +36,9 @@ function HealthDot({ name }: { name: string }) {
status === "corrupt" ? "#f87171" : "#555";
const label =
- status === "healthy" ? "Database healthy" :
- status === "missing" ? "Database missing" :
- status === "corrupt" ? "Database unhealthy" : "Checking...";
+ status === "healthy" ? t.projects.healthHealthy :
+ status === "missing" ? t.projects.healthMissing :
+ status === "corrupt" ? t.projects.healthCorrupt : t.projects.healthChecking;
return (
@@ -64,6 +66,7 @@ function HealthDot({ name }: { name: string }) {
/* ── ADR button + modal ─────────────────────────────────── */
function AdrButton({ project }: { project: string }) {
+ const t = useUiMessages();
const [hasAdr, setHasAdr] = useState
(null);
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
@@ -117,13 +120,13 @@ function AdrButton({ project }: { project: string }) {
e.stopPropagation()}>
-
Architecture Decision Record
+
{t.adr.title}
{project}
{updatedAt && (
-
Last updated: {updatedAt}
+
{t.adr.lastUpdated}: {updatedAt}
)}
@@ -156,16 +159,30 @@ function AdrButton({ project }: { project: string }) {
/* ── Create Index Modal ─────────────────────────────────── */
+function joinPath(base: string, dir: string): string {
+ if (!base || base === "/") return `/${dir}`;
+ if (/^[A-Za-z]:[\\/]?$/.test(base)) return `${base[0]}:/${dir}`;
+ const slash = base.includes("\\") && !base.includes("/") ? "\\" : "/";
+ return `${base.replace(/[\\/]+$/, "")}${slash}${dir}`;
+}
+
function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
+ const t = useUiMessages();
const [currentPath, setCurrentPath] = useState("");
const [dirs, setDirs] = useState([]);
+ const [roots, setRoots] = useState(["/"]);
const [parentPath, setParentPath] = useState("");
+ const [projectName, setProjectName] = useState("");
+ const [filter, setFilter] = useState("");
+ const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
+ const filterRef = useRef(null);
const browse = useCallback(async (path?: string) => {
setLoading(true);
+ setError(null);
try {
const q = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/browse${q}`);
@@ -173,18 +190,30 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
if (data.error) throw new Error(data.error);
setCurrentPath(data.path ?? "");
setDirs((data.dirs ?? []).sort());
+ setRoots(data.roots ?? ["/"]);
setParentPath(data.parent ?? "/");
} catch (e) { setError(e instanceof Error ? e.message : "Browse failed"); }
finally { setLoading(false); }
}, []);
useEffect(() => { browse(); }, [browse]);
+ useEffect(() => { filterRef.current?.focus(); }, []);
+
+ const filteredDirs = useMemo(() => {
+ const q = filter.trim().toLowerCase();
+ if (!q) return dirs;
+ return dirs.filter((d) => d.toLowerCase().includes(q));
+ }, [dirs, filter]);
- const submit = async () => {
- if (!currentPath) return;
+ useEffect(() => { setActiveIndex(0); }, [filter, currentPath]);
+
+ const submit = async (path = currentPath) => {
+ if (!path) return;
setSubmitting(true); setError(null);
try {
- const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root_path: currentPath }) });
+ const body: { root_path: string; project_name?: string } = { root_path: path };
+ if (projectName.trim()) body.project_name = projectName.trim();
+ const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
onCreated(); onClose();
@@ -192,17 +221,78 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
finally { setSubmitting(false); }
};
+ const onFilterKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIndex((i) => Math.min(i + 1, Math.max(filteredDirs.length - 1, 0)));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter" && filteredDirs.length > 0) {
+ e.preventDefault();
+ const dir = filteredDirs.length === 1 ? filteredDirs[0] : filteredDirs[activeIndex];
+ if (filteredDirs.length === 1) void submit(joinPath(currentPath, dir));
+ else void browse(joinPath(currentPath, dir));
+ }
+ };
+
/* Breadcrumb segments */
- const segments = currentPath.split("/").filter(Boolean);
+ const displayPath = currentPath.replace(/\\/g, "/");
+ const segments = displayPath.split("/").filter(Boolean);
return (
-
e.stopPropagation()}>
+
e.stopPropagation()}>
{/* Header */}
-
Select Repository Folder
-
Navigate to the project root and click "Index This Folder".
+
{t.index.selectRepositoryFolder}
+
{t.index.instructions}
+
+
+
+
+
+
+
+
+
setFilter(e.target.value)}
+ onKeyDown={onFilterKeyDown}
+ className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
+ />
+
+ {roots.map((root) => (
+
+ ))}
+
{/* Breadcrumb */}
@@ -235,19 +325,34 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
)}
{loading ? (
-
Loading...
- ) : dirs.length === 0 ? (
-
No subdirectories
+
{t.common.loading}
+ ) : filteredDirs.length === 0 ? (
+
{t.index.noSubdirectories}
) : (
- dirs.map((d) => (
-
@@ -259,9 +364,9 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
{currentPath}
- Cancel
-
- {submitting ? "Starting..." : "Index This Folder"}
+ {t.common.cancel}
+ submit()} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
+ {submitting ? t.index.starting : t.index.indexThisFolder}
@@ -274,6 +379,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
/* ── Index Progress ─────────────────────────────────────── */
function IndexProgress({ onDone }: { onDone: () => void }) {
+ const t = useUiMessages();
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]);
useEffect(() => {
const poll = setInterval(async () => {
@@ -293,7 +399,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
-
Indexing in progress
+
{t.projects.indexingInProgress}
{j.path}
@@ -305,6 +411,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
/* ── Main Stats Tab ─────────────────────────────────────── */
export function StatsTab({ onSelectProject }: StatsTabProps) {
+ const t = useUiMessages();
const { projects, loading, error, refresh } = useProjects();
const [showModal, setShowModal] = useState(false);
const [indexing, setIndexing] = useState(false);
@@ -319,9 +426,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
}, [projects]);
const deleteProject = useCallback(async (name: string) => {
- if (!confirm(`Delete index for "${name}"?`)) return;
+ if (!confirm(t.projects.deleteConfirm(name))) return;
try { await fetch(`/api/project?name=${encodeURIComponent(name)}`, { method: "DELETE" }); refresh(); } catch { /* */ }
- }, [refresh]);
+ }, [refresh, t.projects]);
return (
@@ -329,9 +436,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{projects.length > 0 && (
{[
- { label: "Projects", value: aggregate.projects, color: "text-primary" },
- { label: "Nodes", value: aggregate.nodes, color: "text-foreground/80" },
- { label: "Edges", value: aggregate.edges, color: "text-foreground/80" },
+ { label: t.tabs.projects, value: aggregate.projects, color: "text-primary" },
+ { label: t.projects.nodes, value: aggregate.nodes, color: "text-foreground/80" },
+ { label: t.projects.edges, value: aggregate.edges, color: "text-foreground/80" },
].map((s) => (
{s.label}
@@ -344,10 +451,10 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{indexing &&
{ setIndexing(false); refresh(); }} />}
-
Indexed Projects
+
{t.projects.indexedProjects}
- setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ New Index
- {loading ? "..." : "Refresh"}
+ setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ {t.index.newIndex}
+ {loading ? "..." : t.common.refresh}
@@ -355,8 +462,8 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{!loading && projects.length === 0 && !error && (
-
No indexed projects
-
setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">Index your first repository
+
{t.projects.noIndexedProjects}
+
setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.indexFirstRepository}
)}
@@ -376,15 +483,15 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
-
onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">View Graph
-
deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title="Delete index">✕
+
onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.viewGraph}
+
deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title={t.projects.deleteTitle}>✕
{p.schema && (
<>
- {totalNodes.toLocaleString()} nodes
- {totalEdges.toLocaleString()} edges
+ {totalNodes.toLocaleString()} {t.projects.nodes}
+ {totalEdges.toLocaleString()} {t.projects.edges}
{p.schema.node_labels?.map((l) => (
diff --git a/graph-ui/src/lib/i18n.test.ts b/graph-ui/src/lib/i18n.test.ts
new file mode 100644
index 000000000..cc7b10141
--- /dev/null
+++ b/graph-ui/src/lib/i18n.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { detectLanguage, messages } from "./i18n";
+
+describe("i18n", () => {
+ it("detects Chinese from Accept-Language and falls back to English", () => {
+ expect(detectLanguage("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh");
+ expect(detectLanguage("de-DE,de;q=0.9")).toBe("en");
+ });
+
+ it("keeps UI chrome messages in the catalog", () => {
+ expect(messages.zh.tabs.projects).toBe("项目");
+ expect(messages.zh.index.newIndex).toBe("新建索引");
+ expect(messages.en.index.repositoryPath).toBe("Repository path");
+ });
+});
diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts
new file mode 100644
index 000000000..10c68c748
--- /dev/null
+++ b/graph-ui/src/lib/i18n.ts
@@ -0,0 +1,197 @@
+import { useEffect, useState } from "react";
+
+export type UiLanguage = "en" | "zh";
+
+export const messages = {
+ en: {
+ tabs: {
+ graph: "Graph",
+ projects: "Projects",
+ control: "Control",
+ },
+ common: {
+ cancel: "Cancel",
+ refresh: "Refresh",
+ loading: "Loading...",
+ save: "Save",
+ saving: "Saving...",
+ delete: "Delete",
+ noMatches: "No matches",
+ },
+ graph: {
+ selectedLabel: "Graph",
+ search: "Search...",
+ clearSelection: "Clear selection",
+ },
+ projects: {
+ indexedProjects: "Indexed Projects",
+ noIndexedProjects: "No indexed projects",
+ indexFirstRepository: "Index your first repository",
+ viewGraph: "View Graph",
+ nodes: "nodes",
+ edges: "edges",
+ deleteTitle: "Delete index",
+ deleteConfirm: (name: string) => `Delete index for "${name}"?`,
+ healthHealthy: "Database healthy",
+ healthMissing: "Database missing",
+ healthCorrupt: "Database unhealthy",
+ healthChecking: "Checking...",
+ indexingInProgress: "Indexing in progress",
+ },
+ index: {
+ newIndex: "New Index",
+ selectRepositoryFolder: "Select Repository Folder",
+ instructions: "Navigate to the project root and click \"Index This Folder\".",
+ repositoryPath: "Repository path",
+ projectName: "Project name",
+ projectNamePlaceholder: "Optional display name",
+ filterFolders: "Filter folders",
+ noSubdirectories: "No subdirectories",
+ indexThisFolder: "Index This Folder",
+ starting: "Starting...",
+ browseRoot: (path: string) => `Browse ${path}`,
+ indexDirectory: (name: string) => `Index ${name}`,
+ },
+ adr: {
+ title: "Architecture Decision Record",
+ lastUpdated: "Last updated",
+ },
+ control: {
+ panel: "Control Panel",
+ totalCpu: "Total CPU",
+ totalRam: "Total RAM",
+ processes: "Processes",
+ selfRam: "Self RAM",
+ activeProcesses: "Active Processes",
+ processLogs: "Process Logs",
+ noProcesses: "No processes found",
+ noLogs: "No logs yet",
+ kill: "Kill",
+ thisProcess: "THIS",
+ uptime: "Uptime",
+ killConfirm: (pid: number) => `Kill process ${pid}?`,
+ },
+ },
+ zh: {
+ tabs: {
+ graph: "图谱",
+ projects: "项目",
+ control: "控制",
+ },
+ common: {
+ cancel: "取消",
+ refresh: "刷新",
+ loading: "加载中...",
+ save: "保存",
+ saving: "保存中...",
+ delete: "删除",
+ noMatches: "无匹配结果",
+ },
+ graph: {
+ selectedLabel: "图谱",
+ search: "搜索...",
+ clearSelection: "清除选择",
+ },
+ projects: {
+ indexedProjects: "已索引项目",
+ noIndexedProjects: "暂无已索引项目",
+ indexFirstRepository: "索引第一个仓库",
+ viewGraph: "查看图谱",
+ nodes: "节点",
+ edges: "边",
+ deleteTitle: "删除索引",
+ deleteConfirm: (name: string) => `删除 "${name}" 的索引?`,
+ healthHealthy: "数据库正常",
+ healthMissing: "数据库缺失",
+ healthCorrupt: "数据库异常",
+ healthChecking: "检查中...",
+ indexingInProgress: "正在索引",
+ },
+ index: {
+ newIndex: "新建索引",
+ selectRepositoryFolder: "选择仓库目录",
+ instructions: "导航到项目根目录,然后点击“索引此目录”。",
+ repositoryPath: "仓库路径",
+ projectName: "项目名称",
+ projectNamePlaceholder: "可选显示名称",
+ filterFolders: "筛选目录",
+ noSubdirectories: "没有子目录",
+ indexThisFolder: "索引此目录",
+ starting: "启动中...",
+ browseRoot: (path: string) => `浏览 ${path}`,
+ indexDirectory: (name: string) => `索引 ${name}`,
+ },
+ adr: {
+ title: "架构决策记录",
+ lastUpdated: "最后更新",
+ },
+ control: {
+ panel: "控制面板",
+ totalCpu: "总 CPU",
+ totalRam: "总内存",
+ processes: "进程",
+ selfRam: "自身内存",
+ activeProcesses: "活动进程",
+ processLogs: "进程日志",
+ noProcesses: "未找到进程",
+ noLogs: "暂无日志",
+ kill: "结束",
+ thisProcess: "本进程",
+ uptime: "运行时间",
+ killConfirm: (pid: number) => `结束进程 ${pid}?`,
+ },
+ },
+} as const;
+
+export type UiMessages = (typeof messages)[UiLanguage];
+
+export function detectLanguage(acceptLanguage?: string | null, override?: string | null): UiLanguage {
+ if (override === "zh" || override === "en") return override;
+ if (!acceptLanguage) return "en";
+ const normalized = acceptLanguage.toLowerCase();
+ return normalized.includes("zh-cn") || normalized.includes("zh") ? "zh" : "en";
+}
+
+let cachedLanguage: UiLanguage = "en";
+let languageLoaded = false;
+let languageRequest: Promise | null = null;
+const languageListeners = new Set<(lang: UiLanguage) => void>();
+
+function loadUiLanguage(): Promise {
+ if (languageLoaded) return Promise.resolve(cachedLanguage);
+ if (languageRequest) return languageRequest;
+
+ languageRequest = fetch("/api/ui-config")
+ .then((r) => r.json())
+ .then((data) => detectLanguage(null, data?.lang))
+ .catch(() => detectLanguage(navigator.language))
+ .then((lang) => {
+ cachedLanguage = lang;
+ languageLoaded = true;
+ for (const listener of languageListeners) listener(lang);
+ return lang;
+ })
+ .finally(() => {
+ languageRequest = null;
+ });
+
+ return languageRequest;
+}
+
+export function useUiMessages(): UiMessages {
+ const [lang, setLang] = useState(cachedLanguage);
+
+ useEffect(() => {
+ let cancelled = false;
+ languageListeners.add(setLang);
+ void loadUiLanguage().then((nextLang) => {
+ if (!cancelled) setLang(nextLang);
+ });
+ return () => {
+ cancelled = true;
+ languageListeners.delete(setLang);
+ };
+ }, []);
+
+ return messages[lang];
+}
diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo
index 90f6612f2..0da71675b 100644
--- a/graph-ui/tsconfig.tsbuildinfo
+++ b/graph-ui/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/src/cli/cli.c b/src/cli/cli.c
index f159f5914..719e3d1a0 100644
--- a/src/cli/cli.c
+++ b/src/cli/cli.c
@@ -2625,6 +2625,8 @@ int cbm_cmd_config(int argc, char **argv) {
"Enable auto-indexing on MCP session start");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_INDEX_LIMIT, "50000",
"Max files for auto-indexing new projects");
+ printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
+ "Pin graph UI language: en, zh, or auto");
return 0;
}
@@ -2650,6 +2652,8 @@ int cbm_cmd_config(int argc, char **argv) {
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX, "false"));
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX_LIMIT,
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
+ printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
+ cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
} else if (strcmp(argv[0], "get") == 0) {
if (argc < MIN_ARGC_GET) {
(void)fprintf(stderr, "Usage: config get \n");
diff --git a/src/cli/cli.h b/src/cli/cli.h
index 9efe67896..1de6f5697 100644
--- a/src/cli/cli.h
+++ b/src/cli/cli.h
@@ -264,6 +264,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
/* Well-known config keys */
#define CBM_CONFIG_AUTO_INDEX "auto_index"
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
+#define CBM_CONFIG_UI_LANG "ui-lang"
/* ── Subcommands (wired from main.c) ─────────────────────────── */
diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c
index 26542e927..127828532 100644
--- a/src/foundation/str_util.c
+++ b/src/foundation/str_util.c
@@ -283,10 +283,15 @@ bool cbm_validate_project_name(const char *name) {
/* Reject leading dot (hidden files / relative refs) */
if (name[0] == '.')
return false;
- /* Allow only alphanumeric, dash, underscore, dot */
+ /* Allow alphanumeric, dash, underscore, dot, and UTF-8 bytes. Reject
+ * ASCII controls and punctuation that can affect paths or shells. */
for (const char *p = name; *p; p++) {
- if (!(((*p >= 'a') && (*p <= 'z')) || ((*p >= 'A') && (*p <= 'Z')) ||
- ((*p >= '0') && (*p <= '9')) || *p == '-' || *p == '_' || *p == '.')) {
+ unsigned char c = (unsigned char)*p;
+ if (c >= 0x80) {
+ continue;
+ }
+ if (!(((c >= 'a') && (c <= 'z')) || ((c >= 'A') && (c <= 'Z')) ||
+ ((c >= '0') && (c <= '9')) || c == '-' || c == '_' || c == '.')) {
return false;
}
}
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 19a326c1a..5e034dee4 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -325,6 +325,8 @@ static const tool_def_t TOOLS[] = {
"\"target_projects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},"
"\"description\":\"Projects to search for cross-repo links (cross-repo-intelligence mode). "
"Use [\\\"*\\\"] for all indexed projects. Run list_projects to see available projects.\"},"
+ "\"name\":{\"type\":\"string\",\"description\":"
+ "\"Override the derived project name. Unicode is preserved and unsafe path characters are normalized.\"},"
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
@@ -2919,15 +2921,18 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
+ char *name_override = cbm_mcp_get_string_arg(args, "name");
cbm_normalize_path_sep(repo_path);
if (!repo_path) {
free(mode_str);
+ free(name_override);
return cbm_mcp_text_result("repo_path is required", true);
}
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
free(mode_str);
+ free(name_override);
char *result = handle_cross_repo_mode(repo_path, args);
free(repo_path);
return result;
@@ -2945,9 +2950,17 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
cbm_pipeline_t *p = cbm_pipeline_new(repo_path, NULL, mode);
if (!p) {
+ free(name_override);
free(repo_path);
return cbm_mcp_text_result("failed to create pipeline", true);
}
+ if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) {
+ cbm_pipeline_free(p);
+ free(name_override);
+ free(repo_path);
+ return cbm_mcp_text_result("invalid project name", true);
+ }
+ free(name_override);
cbm_pipeline_set_persistence(p, persistence);
char *project_name = heap_strdup(cbm_pipeline_project_name(p));
diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c
index 0da3e7370..2fa7b8cfd 100644
--- a/src/pipeline/fqn.c
+++ b/src/pipeline/fqn.c
@@ -340,8 +340,8 @@ char *cbm_project_name_from_path(const char *abs_path) {
* the space and reports project-not-found (#349). */
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)path[i];
- bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
- c == '.' || c == '_' || c == '-';
+ bool safe = (c >= 0x80) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
if (!safe) {
path[i] = '-';
}
diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c
index 9d99a925b..1b37cb697 100644
--- a/src/pipeline/pipeline.c
+++ b/src/pipeline/pipeline.c
@@ -27,6 +27,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P
#include "foundation/platform.h"
#include "foundation/compat_fs.h"
#include "foundation/log.h"
+#include "foundation/str_util.h"
#include "foundation/hash_table.h"
#include "foundation/compat.h"
#include "foundation/compat_thread.h"
@@ -175,6 +176,27 @@ void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled) {
}
}
+bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) {
+ if (!p || !name || !name[0]) {
+ return false;
+ }
+
+ char *normalized = cbm_project_name_from_path(name);
+ if (!normalized) {
+ return false;
+ }
+ if (!cbm_validate_project_name(normalized)) {
+ free(normalized);
+ return false;
+ }
+
+ free(p->project_name);
+ p->project_name = normalized;
+ free(p->branch_qn);
+ p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx);
+ return true;
+}
+
void cbm_pipeline_free(cbm_pipeline_t *p) {
if (!p) {
return;
diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h
index 7586fa134..99934ed0a 100644
--- a/src/pipeline/pipeline.h
+++ b/src/pipeline/pipeline.h
@@ -63,6 +63,9 @@ void cbm_pipeline_cancel(cbm_pipeline_t *p);
* owned by the pipeline. Valid until cbm_pipeline_free(). */
const char *cbm_pipeline_project_name(const cbm_pipeline_t *p);
+/* Override the derived project name with a sanitized user-provided label. */
+bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name);
+
/* Get the index mode (CBM_MODE_FULL, CBM_MODE_MODERATE, CBM_MODE_FAST). */
int cbm_pipeline_get_mode(const cbm_pipeline_t *p);
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index 5ec072bbc..96e071539 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -19,6 +19,7 @@
#include "ui/layout3d.h"
#include "mcp/mcp.h"
#include "store/store.h"
+#include "cli/cli.h"
/* pipeline.h no longer needed — indexing runs as subprocess */
#include "foundation/log.h"
#include "foundation/platform.h"
@@ -81,6 +82,33 @@ static void update_cors(const cbm_http_req_t *req) {
snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors);
}
+static const char *detect_ui_lang(const char *accept_language) {
+ if (accept_language && (strstr(accept_language, "zh-CN") || strstr(accept_language, "zh"))) {
+ return "zh";
+ }
+ return "en";
+}
+
+static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
+ const char *lang = NULL;
+ char cache_dir[1024];
+ snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
+ cbm_config_t *cfg = cbm_config_open(cache_dir);
+ if (cfg) {
+ const char *pinned = cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto");
+ if (strcmp(pinned, "zh") == 0 || strcmp(pinned, "en") == 0) {
+ lang = pinned;
+ }
+ }
+
+ char lang_buf[8];
+ snprintf(lang_buf, sizeof(lang_buf), "%s", lang ? lang : detect_ui_lang(req->accept_language));
+ if (cfg) {
+ cbm_config_close(cfg);
+ }
+ cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\"}", lang_buf);
+}
+
/* ── Server state ─────────────────────────────────────────────── */
struct cbm_http_server {
@@ -398,6 +426,26 @@ static void handle_process_kill(cbm_http_conn_t *c, const cbm_http_req_t *req) {
#include
+static void append_roots_json(char *buf, size_t bufsz, int *pos) {
+ *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, ",\"roots\":[");
+#ifdef _WIN32
+ DWORD drives = GetLogicalDrives();
+ int count = 0;
+ for (int i = 0; i < 26; i++) {
+ if (!(drives & (1u << i))) {
+ continue;
+ }
+ if (count++ > 0) {
+ buf[(*pos)++] = ',';
+ }
+ *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"%c:/\"", 'A' + i);
+ }
+#else
+ *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"/\"");
+#endif
+ *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "]");
+}
+
/* GET /api/browse?path=/some/dir — list subdirectories for file picker */
static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char path[1024] = {0};
@@ -469,7 +517,9 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
{
char esc_parent[2048];
cbm_json_escape(esc_parent, (int)sizeof(esc_parent), parent);
- pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", esc_parent);
+ pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"", esc_parent);
+ append_roots_json(buf, sizeof(buf), &pos);
+ pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "}");
}
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
@@ -703,21 +753,18 @@ static void *index_thread_fn(void *arg) {
char log_file[256];
- /* JSON-escape root_path to prevent injection via double-quotes or backslashes */
+ /* JSON-escape root_path and optional project name. */
char escaped_path[2048];
- {
- const char *s = job->root_path;
- size_t j = 0;
- for (; *s && j < sizeof(escaped_path) - 2; s++) {
- if (*s == '"' || *s == '\\') {
- escaped_path[j++] = '\\';
- }
- escaped_path[j++] = *s;
- }
- escaped_path[j] = '\0';
- }
+ cbm_json_escape(escaped_path, (int)sizeof(escaped_path), job->root_path);
+ char escaped_name[512];
+ cbm_json_escape(escaped_name, (int)sizeof(escaped_name), job->project_name);
char json_arg[4096];
- snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
+ if (job->project_name[0]) {
+ snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\",\"name\":\"%s\"}", escaped_path,
+ escaped_name);
+ } else {
+ snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
+ }
#ifdef _WIN32
snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log",
@@ -843,7 +890,7 @@ static void *index_thread_fn(void *arg) {
return NULL;
}
-/* POST /api/index — body: {"root_path": "/abs/path"} → starts background indexing */
+/* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */
static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
if (req->body_len == 0 || req->body_len > 4096) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
@@ -863,6 +910,9 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
return;
}
const char *rpath = yyjson_get_str(v_path);
+ yyjson_val *v_project_name = yyjson_obj_get(root, "project_name");
+ const char *project_name =
+ yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
/* Check path exists */
if (!cbm_is_dir(rpath)) {
@@ -888,6 +938,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
index_job_t *job = &g_index_jobs[slot];
snprintf(job->root_path, sizeof(job->root_path), "%s", rpath);
+ snprintf(job->project_name, sizeof(job->project_name), "%s", project_name);
job->error_msg[0] = '\0';
atomic_store(&job->status, 1);
yyjson_doc_free(doc);
@@ -1343,6 +1394,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
return;
}
+ /* GET /api/ui-config → language and local UI preferences */
+ if (is_get && cbm_http_path_match(req->path, "/api/ui-config")) {
+ handle_ui_config(c, req);
+ return;
+ }
+
/* DELETE /api/project → delete a project's .db file */
if (is_delete && cbm_http_path_match(req->path, "/api/project*")) {
handle_delete_project(c, req);
diff --git a/src/ui/httpd.c b/src/ui/httpd.c
index fc0bf6431..c268b0900 100644
--- a/src/ui/httpd.c
+++ b/src/ui/httpd.c
@@ -342,6 +342,9 @@ int cbm_http_parse_head(const char *data, size_t len, cbm_http_req_t *req, size_
if (header_name_is(p, nlen, "origin")) {
copy_header_value(colon + 1, eol, req->origin, sizeof(req->origin));
+ } else if (header_name_is(p, nlen, "accept-language")) {
+ copy_header_value(colon + 1, eol, req->accept_language,
+ sizeof(req->accept_language));
} else if (header_name_is(p, nlen, "transfer-encoding")) {
/* Chunked (or any transfer coding) is not supported. */
return 411;
diff --git a/src/ui/httpd.h b/src/ui/httpd.h
index d29dce64c..b6fd48110 100644
--- a/src/ui/httpd.h
+++ b/src/ui/httpd.h
@@ -42,13 +42,14 @@ typedef struct cbm_httpd cbm_httpd_t; /* listener */
typedef struct cbm_http_conn cbm_http_conn_t; /* accepted connection */
/* A parsed request. `path` and `query` are raw (NOT percent-decoded).
- * `origin` is the Origin header value ("" when absent) — the only header
- * the routing layer consumes. `body` is heap-allocated, NUL-terminated. */
+ * `origin` and `accept_language` are the header values consumed by the
+ * routing layer ("" when absent). `body` is heap-allocated, NUL-terminated. */
typedef struct {
char method[16];
char path[2048];
char query[2048];
char origin[256];
+ char accept_language[256];
char *body;
size_t body_len;
} cbm_http_req_t;
diff --git a/tests/test_fqn.c b/tests/test_fqn.c
index 9ff999e60..6b098bb50 100644
--- a/tests/test_fqn.c
+++ b/tests/test_fqn.c
@@ -478,6 +478,21 @@ TEST(project_name_always_validator_safe_issue349) {
PASS();
}
+TEST(project_name_preserves_unicode_segments_issue571) {
+ char *got = cbm_project_name_from_path(
+ "/Users/yunxin/Desktop/\xe5\xbc\x80\xe5\x8f\x91/"
+ "\xe5\x90\x8e\xe7\xab\xaf/"
+ "\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
+ ASSERT_NOT_NULL(got);
+ ASSERT_STR_EQ(got,
+ "Users-yunxin-Desktop-\xe5\xbc\x80\xe5\x8f\x91-"
+ "\xe5\x90\x8e\xe7\xab\xaf-"
+ "\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
+ ASSERT_TRUE(cbm_validate_project_name(got));
+ free(got);
+ PASS();
+}
+
/* ================================================================
* Suite
* ================================================================ */
@@ -579,6 +594,7 @@ SUITE(fqn) {
RUN_TEST(project_name_already_dashed);
RUN_TEST(project_name_deep_path);
RUN_TEST(project_name_always_validator_safe_issue349);
+ RUN_TEST(project_name_preserves_unicode_segments_issue571);
RUN_TEST(project_name_colon_only);
RUN_TEST(project_name_backslash_only);
RUN_TEST(project_name_consecutive_colons);
diff --git a/tests/test_httpd.c b/tests/test_httpd.c
index c8d42bed3..8fb5acf46 100644
--- a/tests/test_httpd.c
+++ b/tests/test_httpd.c
@@ -14,6 +14,8 @@
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/log.h"
+#include "../src/foundation/platform.h"
+#include "../src/cli/cli.h"
#include "../src/ui/http_server.h"
#include "test_framework.h"
#include "test_helpers.h"
@@ -560,6 +562,61 @@ TEST(ui_server_browse_traversal_probe) {
PASS();
}
+TEST(ui_server_ui_config_detects_zh_accept_language) {
+ th_server_t ts;
+ ASSERT_EQ(th_server_start(&ts), 0);
+
+ char resp[4096];
+ int n = th_http(cbm_http_server_port(ts.srv),
+ "GET /api/ui-config HTTP/1.1\r\n"
+ "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n"
+ "\r\n",
+ resp, sizeof(resp));
+ ASSERT_TRUE(n > 0);
+ ASSERT_EQ(th_status(resp), 200);
+ ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
+
+ th_server_stop(&ts);
+ PASS();
+}
+
+TEST(ui_server_ui_config_prefers_config_lang) {
+ char tmpdir[256];
+ snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX");
+ char *td = cbm_mkdtemp(tmpdir);
+ ASSERT_NOT_NULL(td);
+
+ char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL;
+ cbm_setenv("HOME", td, 1);
+
+ char cache_dir[1024];
+ snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
+ cbm_config_t *cfg = cbm_config_open(cache_dir);
+ ASSERT_NOT_NULL(cfg);
+ ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_UI_LANG, "zh"), 0);
+ cbm_config_close(cfg);
+
+ th_server_t ts;
+ ASSERT_EQ(th_server_start(&ts), 0);
+
+ char resp[4096];
+ int n = th_http(cbm_http_server_port(ts.srv),
+ "GET /api/ui-config HTTP/1.1\r\n"
+ "Accept-Language: en-US,en;q=0.9\r\n"
+ "\r\n",
+ resp, sizeof(resp));
+ ASSERT_TRUE(n > 0);
+ ASSERT_EQ(th_status(resp), 200);
+ ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
+
+ th_server_stop(&ts);
+ if (old_home) {
+ cbm_setenv("HOME", old_home, 1);
+ free(old_home);
+ }
+ PASS();
+}
+
TEST(ui_server_slow_request_hits_deadline) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
@@ -660,6 +717,8 @@ SUITE(httpd) {
RUN_TEST(ui_server_encoded_slash_not_routed);
RUN_TEST(ui_server_nul_in_target_rejected);
RUN_TEST(ui_server_browse_traversal_probe);
+ RUN_TEST(ui_server_ui_config_detects_zh_accept_language);
+ RUN_TEST(ui_server_ui_config_prefers_config_lang);
RUN_TEST(ui_server_slow_request_hits_deadline);
RUN_TEST(ui_server_access_log_redacts_query);
RUN_TEST(ui_server_stop_joins_cleanly);
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index ab728eb86..25cedfc62 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -207,6 +207,16 @@ TEST(mcp_tools_list_latest_metadata) {
PASS();
}
+TEST(mcp_index_repository_declares_name_override_issue571) {
+ char *json = cbm_mcp_tools_list();
+ ASSERT_NOT_NULL(json);
+ ASSERT_NOT_NULL(strstr(json, "\"index_repository\""));
+ ASSERT_NOT_NULL(strstr(json, "\"name\":{\"type\":\"string\""));
+ ASSERT_NOT_NULL(strstr(json, "Override the derived project name"));
+ free(json);
+ PASS();
+}
+
TEST(mcp_tools_array_schemas_have_items) {
/* VS Code 1.112+ rejects array schemas without "items" (see
* https://github.com/microsoft/vscode/issues/248810).
@@ -2417,6 +2427,7 @@ SUITE(mcp) {
RUN_TEST(mcp_initialize_response);
RUN_TEST(mcp_tools_list);
RUN_TEST(mcp_tools_list_latest_metadata);
+ RUN_TEST(mcp_index_repository_declares_name_override_issue571);
RUN_TEST(mcp_tools_array_schemas_have_items);
RUN_TEST(mcp_text_result);
RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text);
From b7f1a71a6b94fb89c7f680d93de39726dda4f303 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Sun, 28 Jun 2026 21:49:17 +0200
Subject: [PATCH 8/8] style: apply clang-format to new mcp/ui/logging lines
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The CI lint gate (scripts/lint.sh --ci, clang-format-20) flagged 7 files whose
new lines were not run through the project formatter. Reformat only those lines
(line wrapping / continuation alignment) — no behavior change.
Signed-off-by: Martin Vogel
---
src/mcp/mcp.c | 3 ++-
src/ui/http_server.c | 3 +--
src/ui/httpd.c | 3 +--
tests/test_fqn.c | 5 ++---
tests/test_httpd.c | 4 ++--
tests/test_main.c | 10 +++++-----
tests/test_mcp.c | 20 ++++++++++----------
7 files changed, 23 insertions(+), 25 deletions(-)
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index a6bebb779..5235aaab5 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -326,7 +326,8 @@ static const tool_def_t TOOLS[] = {
"\"description\":\"Projects to search for cross-repo links (cross-repo-intelligence mode). "
"Use [\\\"*\\\"] for all indexed projects. Run list_projects to see available projects.\"},"
"\"name\":{\"type\":\"string\",\"description\":"
- "\"Override the derived project name. Non-ASCII bytes are encoded and unsafe path characters are normalized.\"},"
+ "\"Override the derived project name. Non-ASCII bytes are encoded and unsafe path characters "
+ "are normalized.\"},"
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index fc6c6c710..c12685ef9 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -925,8 +925,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
}
const char *rpath = yyjson_get_str(v_path);
yyjson_val *v_project_name = yyjson_obj_get(root, "project_name");
- const char *project_name =
- yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
+ const char *project_name = yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
/* Check path exists */
if (!cbm_is_dir(rpath)) {
diff --git a/src/ui/httpd.c b/src/ui/httpd.c
index c268b0900..da5f0146f 100644
--- a/src/ui/httpd.c
+++ b/src/ui/httpd.c
@@ -343,8 +343,7 @@ int cbm_http_parse_head(const char *data, size_t len, cbm_http_req_t *req, size_
if (header_name_is(p, nlen, "origin")) {
copy_header_value(colon + 1, eol, req->origin, sizeof(req->origin));
} else if (header_name_is(p, nlen, "accept-language")) {
- copy_header_value(colon + 1, eol, req->accept_language,
- sizeof(req->accept_language));
+ copy_header_value(colon + 1, eol, req->accept_language, sizeof(req->accept_language));
} else if (header_name_is(p, nlen, "transfer-encoding")) {
/* Chunked (or any transfer coding) is not supported. */
return 411;
diff --git a/tests/test_fqn.c b/tests/test_fqn.c
index 69b87d053..798974f13 100644
--- a/tests/test_fqn.c
+++ b/tests/test_fqn.c
@@ -484,9 +484,8 @@ TEST(project_name_encodes_unicode_segments_issue571) {
"\xe5\x90\x8e\xe7\xab\xaf/"
"\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
ASSERT_NOT_NULL(got);
- ASSERT_STR_EQ(got,
- "Users-yunxin-Desktop-e5bc80e58f91-e5908ee7abaf-"
- "e4bfa1e7a79fe9a38ee68ea7e9809ae5908ee7abaf");
+ ASSERT_STR_EQ(got, "Users-yunxin-Desktop-e5bc80e58f91-e5908ee7abaf-"
+ "e4bfa1e7a79fe9a38ee68ea7e9809ae5908ee7abaf");
ASSERT_TRUE(cbm_validate_project_name(got));
free(got);
PASS();
diff --git a/tests/test_httpd.c b/tests/test_httpd.c
index 8fb5acf46..ddbca5568 100644
--- a/tests/test_httpd.c
+++ b/tests/test_httpd.c
@@ -353,8 +353,8 @@ TEST(httpd_resolves_bare_binary_path_from_path) {
cbm_setenv("PATH", td, 1);
char resolved[1024];
- ASSERT_TRUE(cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved,
- sizeof(resolved)));
+ ASSERT_TRUE(
+ cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved, sizeof(resolved)));
ASSERT_STR_EQ(resolved, exe);
if (old_path) {
diff --git a/tests/test_main.c b/tests/test_main.c
index 227e838dd..824b91fe8 100644
--- a/tests/test_main.c
+++ b/tests/test_main.c
@@ -28,11 +28,11 @@ static bool suite_requested(const char *name) {
return false;
}
-#define RUN_SELECTED_SUITE(name) \
- do { \
- if (suite_requested(#name)) { \
- RUN_SUITE(name); \
- } \
+#define RUN_SELECTED_SUITE(name) \
+ do { \
+ if (suite_requested(#name)) { \
+ RUN_SUITE(name); \
+ } \
} while (0)
/* Forward declarations of suite functions */
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index 1e3461231..3bfb45c8f 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -608,11 +608,11 @@ TEST(tool_search_graph_query_honors_file_pattern_issue552) {
"FROM nodes;"),
CBM_STORE_OK);
- char *resp = cbm_mcp_server_handle(
- srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
- "\"params\":{\"name\":\"search_graph\","
- "\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
- "\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
+ char *resp =
+ cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
+ "\"params\":{\"name\":\"search_graph\","
+ "\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
+ "\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -943,11 +943,11 @@ TEST(tool_get_architecture_path_scoping) {
ASSERT_NOT_NULL(inner_root);
ASSERT_NOT_NULL(strstr(inner_root, "Django"));
- char *resp_scoped = cbm_mcp_server_handle(
- srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
- "\"params\":{\"name\":\"get_architecture\","
- "\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
- "\"aspects\":[\"packages\"]}}}");
+ char *resp_scoped =
+ cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
+ "\"params\":{\"name\":\"get_architecture\","
+ "\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
+ "\"aspects\":[\"packages\"]}}}");
ASSERT_NOT_NULL(resp_scoped);
char *inner_scoped = extract_text_content(resp_scoped);
ASSERT_NOT_NULL(inner_scoped);