Skip to content

Commit 3dab881

Browse files
authored
Merge pull request DeusData#1181 from tmonestudio/codex/fix-list-projects-coverage-scope
fix(mcp): restore scalable project discovery
2 parents ce1f273 + a62c678 commit 3dab881

2 files changed

Lines changed: 118 additions & 10 deletions

File tree

‎src/mcp/mcp.c‎

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -605,8 +605,14 @@ static const tool_def_t TOOLS[] = {
605605
"offset parameter — raise limit or narrow with file_pattern / path_filter to see more."
606606
"\",\"default\":10,\"minimum\":1}},\"required\":[\"pattern\",\"project\"]}"},
607607

608-
{"list_projects", "List projects", "List all indexed projects",
609-
"{\"type\":\"object\",\"properties\":{}}"},
608+
{"list_projects", "List projects", "List indexed projects with deterministic pagination",
609+
"{\"type\":\"object\",\"properties\":{\"offset\":{\"type\":\"integer\","
610+
"\"minimum\":0,\"default\":0},"
611+
"\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":100,\"default\":50},"
612+
"\"include_details\":{\"type\":\"boolean\",\"default\":false,"
613+
"\"description\":\"Include branch, node/edge counts and database size. Slower.\"},"
614+
"\"metadata_only\":{\"type\":\"boolean\",\"description\":\"Deprecated compatibility "
615+
"alias for include_details=false.\"}}}"},
610616
{"delete_project", "Delete project", "Delete a project from the index",
611617
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
612618
"\"project\"]}"},
@@ -2500,7 +2506,7 @@ static cbm_store_t *resolve_store_fallback_scan(const char *project) {
25002506
* then append a JSON entry to arr. */
25012507
static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, const char *dir_path,
25022508
const char *name, size_t name_len, int64_t size_bytes,
2503-
bool metadata_only) {
2509+
bool include_details) {
25042510
(void)name_len;
25052511

25062512
char full_path[CBM_SZ_2K];
@@ -2519,7 +2525,7 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c
25192525

25202526
int nodes = 0;
25212527
int edges = 0;
2522-
if (!metadata_only) {
2528+
if (include_details) {
25232529
nodes = cbm_store_count_nodes(pstore, project_name);
25242530
edges = cbm_store_count_edges(pstore, project_name);
25252531
}
@@ -2540,35 +2546,53 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c
25402546
* disambiguates same-repo projects). The 12-field git block — mostly
25412547
* null for non-git roots — cost ~10KB across a full cache and is one
25422548
* index_status call away for the project you actually care about. */
2543-
if (!metadata_only && root_path_buf[0]) {
2549+
if (include_details && root_path_buf[0]) {
25442550
cbm_git_context_t gctx = {0};
25452551
(void)cbm_git_context_resolve(root_path_buf, &gctx);
25462552
if (gctx.is_git && gctx.branch) {
25472553
yyjson_mut_obj_add_strcpy(doc, p, "branch", gctx.branch);
25482554
}
25492555
cbm_git_context_free(&gctx);
25502556
}
2551-
if (!metadata_only) {
2557+
if (include_details) {
25522558
yyjson_mut_obj_add_int(doc, p, "nodes", nodes);
25532559
yyjson_mut_obj_add_int(doc, p, "edges", edges);
25542560
yyjson_mut_obj_add_int(doc, p, "size_bytes", size_bytes);
25552561
}
25562562
yyjson_mut_arr_add_val(arr, p);
25572563
}
25582564

2565+
static int project_db_name_cmp(const void *a, const void *b) {
2566+
const char *const *sa = (const char *const *)a;
2567+
const char *const *sb = (const char *const *)b;
2568+
return strcmp(*sa, *sb);
2569+
}
2570+
25592571
/* list_projects: scan cache directory for .db files.
25602572
* Each project is a single .db file — no central registry needed. */
25612573
static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) {
25622574
(void)srv;
2563-
bool metadata_only = false;
2575+
int offset = cbm_mcp_get_int_arg(args, "offset", 0);
2576+
int limit = cbm_mcp_get_int_arg(args, "limit", 50);
2577+
bool include_details = cbm_mcp_get_bool_arg(args, "include_details");
25642578
yyjson_doc *args_doc = args ? yyjson_read(args, strlen(args), 0) : NULL;
25652579
yyjson_val *args_root = args_doc ? yyjson_doc_get_root(args_doc) : NULL;
25662580
yyjson_val *metadata_value =
25672581
args_root && yyjson_is_obj(args_root) ? yyjson_obj_get(args_root, "metadata_only") : NULL;
2568-
metadata_only = metadata_value && yyjson_is_true(metadata_value);
2582+
if (metadata_value && yyjson_is_true(metadata_value)) {
2583+
include_details = false;
2584+
}
25692585
if (args_doc) {
25702586
yyjson_doc_free(args_doc);
25712587
}
2588+
if (offset < 0) {
2589+
offset = 0;
2590+
}
2591+
if (limit < 1) {
2592+
limit = 1;
2593+
} else if (limit > 100) {
2594+
limit = 100;
2595+
}
25722596

25732597
char dir_path[CBM_SZ_1K];
25742598
cache_dir(dir_path, sizeof(dir_path));
@@ -2590,24 +2614,76 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) {
25902614
return cbm_mcp_text_result(msg, true);
25912615
}
25922616

2617+
char **db_names = NULL;
2618+
size_t db_count = 0;
2619+
size_t db_cap = 0;
2620+
bool name_collection_failed = false;
25932621
cbm_dirent_t *entry;
25942622
while ((entry = cbm_readdir(d)) != NULL) {
25952623
const char *name = entry->name;
25962624
size_t len = strlen(name);
25972625
if (!is_project_db_file(name, len)) {
25982626
continue;
25992627
}
2628+
if (db_count == db_cap) {
2629+
size_t next_cap = db_cap ? db_cap * 2 : 64;
2630+
char **grown = realloc(db_names, next_cap * sizeof(*db_names));
2631+
if (!grown) {
2632+
name_collection_failed = true;
2633+
break;
2634+
}
2635+
db_names = grown;
2636+
db_cap = next_cap;
2637+
}
2638+
db_names[db_count] = heap_strdup(name);
2639+
if (!db_names[db_count]) {
2640+
name_collection_failed = true;
2641+
break;
2642+
}
2643+
db_count++;
2644+
}
2645+
cbm_closedir(d);
2646+
2647+
if (name_collection_failed) {
2648+
for (size_t i = 0; i < db_count; i++) {
2649+
free(db_names[i]);
2650+
}
2651+
free(db_names);
2652+
yyjson_mut_doc_free(doc);
2653+
return cbm_mcp_text_result(
2654+
"{\"error\":\"out of memory while collecting indexed projects\"}", true);
2655+
}
2656+
2657+
if (db_count > 1) {
2658+
qsort(db_names, db_count, sizeof(*db_names), project_db_name_cmp);
2659+
}
2660+
size_t start = (size_t)offset < db_count ? (size_t)offset : db_count;
2661+
size_t end = start + (size_t)limit;
2662+
if (end > db_count) {
2663+
end = db_count;
2664+
}
2665+
for (size_t i = start; i < end; i++) {
2666+
const char *name = db_names[i];
2667+
size_t len = strlen(name);
26002668
char full_path[CBM_SZ_2K];
26012669
snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name);
26022670
int64_t size_bytes = cbm_file_size(full_path);
26032671
if (size_bytes < 0) {
26042672
continue;
26052673
}
2606-
build_project_json_entry(doc, arr, dir_path, name, len, size_bytes, metadata_only);
2674+
build_project_json_entry(doc, arr, dir_path, name, len, size_bytes, include_details);
26072675
}
2608-
cbm_closedir(d);
2676+
for (size_t i = 0; i < db_count; i++) {
2677+
free(db_names[i]);
2678+
}
2679+
free(db_names);
26092680

26102681
yyjson_mut_obj_add_val(doc, root, "projects", arr);
2682+
yyjson_mut_obj_add_uint(doc, root, "total", db_count);
2683+
yyjson_mut_obj_add_int(doc, root, "offset", offset);
2684+
yyjson_mut_obj_add_int(doc, root, "limit", limit);
2685+
yyjson_mut_obj_add_uint(doc, root, "returned", yyjson_mut_arr_size(arr));
2686+
yyjson_mut_obj_add_bool(doc, root, "has_more", end < db_count);
26112687

26122688
/* Guide user when no projects are indexed */
26132689
if (yyjson_mut_arr_size(arr) == 0) {

‎tests/test_mcp.c‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8525,6 +8525,38 @@ TEST(tool_resolve_store_by_internal_name_issue704) {
85258525
ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */
85268526
free(list);
85278527

8528+
char *page = cbm_mcp_server_handle(
8529+
srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
8530+
"\"params\":{\"name\":\"list_projects\","
8531+
"\"arguments\":{\"offset\":0,\"limit\":1}}}");
8532+
ASSERT_NOT_NULL(page);
8533+
ASSERT_NOT_NULL(strstr(page, "\\\"total\\\":3"));
8534+
ASSERT_NOT_NULL(strstr(page, "\\\"limit\\\":1"));
8535+
ASSERT_NOT_NULL(strstr(page, "\\\"returned\\\":1"));
8536+
ASSERT_NOT_NULL(strstr(page, "\\\"has_more\\\":true"));
8537+
ASSERT_NOT_NULL(strstr(page, "alpha704"));
8538+
ASSERT_NULL(strstr(page, "beta704"));
8539+
ASSERT_NULL(strstr(page, "\\\"nodes\\\"")); /* details are opt-in */
8540+
free(page);
8541+
8542+
char *next_page = cbm_mcp_server_handle(
8543+
srv, "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\","
8544+
"\"params\":{\"name\":\"list_projects\","
8545+
"\"arguments\":{\"offset\":1,\"limit\":1,\"metadata_only\":true}}}");
8546+
ASSERT_NOT_NULL(next_page);
8547+
ASSERT_NOT_NULL(strstr(next_page, "beta704"));
8548+
ASSERT_NULL(strstr(next_page, "alpha704"));
8549+
ASSERT_NULL(strstr(next_page, "\\\"nodes\\\"")); /* compatibility alias stays lean */
8550+
free(next_page);
8551+
8552+
char *details = cbm_mcp_server_handle(
8553+
srv, "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/call\","
8554+
"\"params\":{\"name\":\"list_projects\","
8555+
"\"arguments\":{\"offset\":0,\"limit\":1,\"include_details\":true}}}");
8556+
ASSERT_NOT_NULL(details);
8557+
ASSERT_NOT_NULL(strstr(details, "\\\"nodes\\\""));
8558+
free(details);
8559+
85288560
/* ── B: the drifted project resolves by its INTERNAL name ──────── */
85298561
char *q_beta = cbm_mcp_server_handle(
85308562
srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","

0 commit comments

Comments
 (0)