diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index fb5a750ec..146d42bbc 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -570,8 +570,14 @@ 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 projects", "List all indexed projects", - "{\"type\":\"object\",\"properties\":{}}"}, + {"list_projects", "List projects", "List indexed projects with deterministic pagination", + "{\"type\":\"object\",\"properties\":{\"offset\":{\"type\":\"integer\"," + "\"minimum\":0,\"default\":0}," + "\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":100,\"default\":50}," + "\"include_details\":{\"type\":\"boolean\",\"default\":false," + "\"description\":\"Include branch, node/edge counts and database size. Slower.\"}," + "\"metadata_only\":{\"type\":\"boolean\",\"description\":\"Deprecated compatibility " + "alias for include_details=false.\"}}}"}, {"delete_project", "Delete project", "Delete a project from the index", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, @@ -2325,7 +2331,7 @@ static cbm_store_t *resolve_store_fallback_scan(const char *project) { * then append a JSON entry to arr. */ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, const char *dir_path, const char *name, size_t name_len, int64_t size_bytes, - bool metadata_only) { + bool include_details) { (void)name_len; char full_path[CBM_SZ_2K]; @@ -2344,7 +2350,7 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c int nodes = 0; int edges = 0; - if (!metadata_only) { + if (include_details) { nodes = cbm_store_count_nodes(pstore, project_name); edges = cbm_store_count_edges(pstore, project_name); } @@ -2365,7 +2371,7 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c * disambiguates same-repo projects). The 12-field git block — mostly * null for non-git roots — cost ~10KB across a full cache and is one * index_status call away for the project you actually care about. */ - if (!metadata_only && root_path_buf[0]) { + if (include_details && root_path_buf[0]) { cbm_git_context_t gctx = {0}; (void)cbm_git_context_resolve(root_path_buf, &gctx); if (gctx.is_git && gctx.branch) { @@ -2373,7 +2379,7 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c } cbm_git_context_free(&gctx); } - if (!metadata_only) { + if (include_details) { yyjson_mut_obj_add_int(doc, p, "nodes", nodes); yyjson_mut_obj_add_int(doc, p, "edges", edges); yyjson_mut_obj_add_int(doc, p, "size_bytes", size_bytes); @@ -2381,19 +2387,37 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c yyjson_mut_arr_add_val(arr, p); } +static int project_db_name_cmp(const void *a, const void *b) { + const char *const *sa = (const char *const *)a; + const char *const *sb = (const char *const *)b; + return strcmp(*sa, *sb); +} + /* list_projects: scan cache directory for .db files. * Each project is a single .db file — no central registry needed. */ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { (void)srv; - bool metadata_only = false; + int offset = cbm_mcp_get_int_arg(args, "offset", 0); + int limit = cbm_mcp_get_int_arg(args, "limit", 50); + bool include_details = cbm_mcp_get_bool_arg(args, "include_details"); yyjson_doc *args_doc = args ? yyjson_read(args, strlen(args), 0) : NULL; yyjson_val *args_root = args_doc ? yyjson_doc_get_root(args_doc) : NULL; yyjson_val *metadata_value = args_root && yyjson_is_obj(args_root) ? yyjson_obj_get(args_root, "metadata_only") : NULL; - metadata_only = metadata_value && yyjson_is_true(metadata_value); + if (metadata_value && yyjson_is_true(metadata_value)) { + include_details = false; + } if (args_doc) { yyjson_doc_free(args_doc); } + if (offset < 0) { + offset = 0; + } + if (limit < 1) { + limit = 1; + } else if (limit > 100) { + limit = 100; + } char dir_path[CBM_SZ_1K]; cache_dir(dir_path, sizeof(dir_path)); @@ -2415,6 +2439,9 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(msg, true); } + char **db_names = NULL; + size_t db_count = 0; + size_t db_cap = 0; cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *name = entry->name; @@ -2422,17 +2449,51 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { if (!is_project_db_file(name, len)) { continue; } + if (db_count == db_cap) { + size_t next_cap = db_cap ? db_cap * 2 : 64; + char **grown = realloc(db_names, next_cap * sizeof(*db_names)); + if (!grown) { + break; + } + db_names = grown; + db_cap = next_cap; + } + db_names[db_count] = heap_strdup(name); + if (!db_names[db_count]) { + break; + } + db_count++; + } + cbm_closedir(d); + + qsort(db_names, db_count, sizeof(*db_names), project_db_name_cmp); + size_t start = (size_t)offset < db_count ? (size_t)offset : db_count; + size_t end = start + (size_t)limit; + if (end > db_count) { + end = db_count; + } + for (size_t i = start; i < end; i++) { + const char *name = db_names[i]; + size_t len = strlen(name); char full_path[CBM_SZ_2K]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); int64_t size_bytes = cbm_file_size(full_path); if (size_bytes < 0) { continue; } - build_project_json_entry(doc, arr, dir_path, name, len, size_bytes, metadata_only); + build_project_json_entry(doc, arr, dir_path, name, len, size_bytes, include_details); } - cbm_closedir(d); + for (size_t i = 0; i < db_count; i++) { + free(db_names[i]); + } + free(db_names); yyjson_mut_obj_add_val(doc, root, "projects", arr); + yyjson_mut_obj_add_uint(doc, root, "total", db_count); + yyjson_mut_obj_add_int(doc, root, "offset", offset); + yyjson_mut_obj_add_int(doc, root, "limit", limit); + yyjson_mut_obj_add_uint(doc, root, "returned", yyjson_mut_arr_size(arr)); + yyjson_mut_obj_add_bool(doc, root, "has_more", end < db_count); /* Guide user when no projects are indexed */ if (yyjson_mut_arr_size(arr) == 0) { @@ -4223,7 +4284,7 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args yyjson_mut_arr_add_val(scope_results, item); continue; } - yyjson_mut_obj_add_str(doc, item, "scope", scope[0] ? scope : "."); + yyjson_mut_obj_add_strcpy(doc, item, "scope", scope[0] ? scope : "."); cbm_coverage_row_t *rows = NULL; int row_count = 0; int cov_rc = cbm_store_coverage_get_scope(store, project, scope, &rows, &row_count); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index f1809dccd..df0a34c02 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2159,7 +2159,7 @@ TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges) { cbm_mcp_handle_tool(srv, "check_index_coverage", "{\"project\":\"test-project\"," "\"paths\":[\"main.go\",\"generated/pkg/a.c\",\"../escape.c\"]," - "\"scopes\":[\".\"]}"); + "\"scopes\":[\".\",\"src\"]}"); ASSERT_NOT_NULL(coverage); char *inner = extract_text_content(coverage); ASSERT_NOT_NULL(inner); @@ -2172,6 +2172,8 @@ TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges) { ASSERT_NOT_NULL(strstr(inner, "not_indexed_dir")); ASSERT_NOT_NULL(strstr(inner, "outside_project")); ASSERT_NOT_NULL(strstr(inner, "src/skip.c")); + ASSERT_NOT_NULL(strstr(inner, "\"requested_scope\":\"src\"")); + ASSERT_NOT_NULL(strstr(inner, "\"scope\":\"src\"")); ASSERT_NOT_NULL(strstr(inner, "file exceeds cap")); ASSERT_NOT_NULL(strstr(inner, "best_effort")); @@ -7045,6 +7047,26 @@ TEST(tool_resolve_store_by_internal_name_issue704) { ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */ free(list); + char *page = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\"," + "\"arguments\":{\"offset\":0,\"limit\":1}}}"); + ASSERT_NOT_NULL(page); + ASSERT_NOT_NULL(strstr(page, "\\\"total\\\":3")); + ASSERT_NOT_NULL(strstr(page, "\\\"limit\\\":1")); + ASSERT_NOT_NULL(strstr(page, "\\\"returned\\\":1")); + ASSERT_NOT_NULL(strstr(page, "\\\"has_more\\\":true")); + ASSERT_NULL(strstr(page, "\\\"nodes\\\"")); /* details are opt-in */ + free(page); + + char *details = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\"," + "\"arguments\":{\"offset\":0,\"limit\":1,\"include_details\":true}}}"); + ASSERT_NOT_NULL(details); + ASSERT_NOT_NULL(strstr(details, "\\\"nodes\\\"")); + free(details); + /* ── B: the drifted project resolves by its INTERNAL name ──────── */ char *q_beta = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","