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({ @@ -176,7 +182,7 @@ export function GraphScene({ - + { + it("reports when the graph response is truncated for render safety", () => { + const data = { + nodes: Array.from({ length: 2000 }, (_, id) => ({ + id, + x: 0, + y: 0, + z: 0, + label: "Function", + name: `fn${id}`, + size: 1, + color: "#ffffff", + })), + edges: [], + total_nodes: 43729, + } satisfies GraphData; + + expect(formatGraphLimitNotice(data)).toBe( + "Showing 2,000 of 43,729 nodes. Use filters to narrow.", + ); + }); + + it("stays quiet when the full graph is rendered", () => { + const data = { + nodes: [], + edges: [], + total_nodes: 0, + } satisfies GraphData; + + expect(formatGraphLimitNotice(data)).toBeNull(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index ea9b7bb5b..0e6c8fb08 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -29,6 +29,11 @@ interface GraphTabProps { project: string | null; } +export function formatGraphLimitNotice(data: GraphData | null): string | null { + if (!data || data.total_nodes <= data.nodes.length) return null; + return `Showing ${data.nodes.length.toLocaleString()} of ${data.total_nodes.toLocaleString()} nodes. Use filters to narrow.`; +} + export function GraphTab({ project }: GraphTabProps) { const { data, loading, error, fetchOverview } = useGraphData(); const [highlightedIds, setHighlightedIds] = useState | null>(null); @@ -38,6 +43,7 @@ export function GraphTab({ project }: GraphTabProps) { const [showLabels, setShowLabels] = useState(true); const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260)); const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280)); + const limitNotice = formatGraphLimitNotice(data); /* Filter state — all enabled by default */ const [enabledLabels, setEnabledLabels] = useState>(new Set()); @@ -282,6 +288,9 @@ export function GraphTab({ project }: GraphTabProps) { filtered from {data.nodes.length.toLocaleString()}

)} + {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}

)}