Skip to content

Commit a62c678

Browse files
committed
fix(mcp): restore scalable project discovery
Add deterministic pagination and opt-in project details while preserving the metadata_only compatibility alias. Fail list_projects atomically on name allocation errors and skip qsort for empty project caches. Signed-off-by: tmonestudio <tmonestudio@users.noreply.github.com> Signed-off-by: Leonardo trindade miranda <tmonestudio@gmail.com>
1 parent 7f23a66 commit a62c678

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
@@ -603,8 +603,14 @@ static const tool_def_t TOOLS[] = {
603603
"offset parameter — raise limit or narrow with file_pattern / path_filter to see more."
604604
"\",\"default\":10}},\"required\":[\"pattern\",\"project\"]}"},
605605

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

25042510
char full_path[CBM_SZ_2K];
@@ -2517,7 +2523,7 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c
25172523

25182524
int nodes = 0;
25192525
int edges = 0;
2520-
if (!metadata_only) {
2526+
if (include_details) {
25212527
nodes = cbm_store_count_nodes(pstore, project_name);
25222528
edges = cbm_store_count_edges(pstore, project_name);
25232529
}
@@ -2538,35 +2544,53 @@ static void build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c
25382544
* disambiguates same-repo projects). The 12-field git block — mostly
25392545
* null for non-git roots — cost ~10KB across a full cache and is one
25402546
* index_status call away for the project you actually care about. */
2541-
if (!metadata_only && root_path_buf[0]) {
2547+
if (include_details && root_path_buf[0]) {
25422548
cbm_git_context_t gctx = {0};
25432549
(void)cbm_git_context_resolve(root_path_buf, &gctx);
25442550
if (gctx.is_git && gctx.branch) {
25452551
yyjson_mut_obj_add_strcpy(doc, p, "branch", gctx.branch);
25462552
}
25472553
cbm_git_context_free(&gctx);
25482554
}
2549-
if (!metadata_only) {
2555+
if (include_details) {
25502556
yyjson_mut_obj_add_int(doc, p, "nodes", nodes);
25512557
yyjson_mut_obj_add_int(doc, p, "edges", edges);
25522558
yyjson_mut_obj_add_int(doc, p, "size_bytes", size_bytes);
25532559
}
25542560
yyjson_mut_arr_add_val(arr, p);
25552561
}
25562562

2563+
static int project_db_name_cmp(const void *a, const void *b) {
2564+
const char *const *sa = (const char *const *)a;
2565+
const char *const *sb = (const char *const *)b;
2566+
return strcmp(*sa, *sb);
2567+
}
2568+
25572569
/* list_projects: scan cache directory for .db files.
25582570
* Each project is a single .db file — no central registry needed. */
25592571
static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) {
25602572
(void)srv;
2561-
bool metadata_only = false;
2573+
int offset = cbm_mcp_get_int_arg(args, "offset", 0);
2574+
int limit = cbm_mcp_get_int_arg(args, "limit", 50);
2575+
bool include_details = cbm_mcp_get_bool_arg(args, "include_details");
25622576
yyjson_doc *args_doc = args ? yyjson_read(args, strlen(args), 0) : NULL;
25632577
yyjson_val *args_root = args_doc ? yyjson_doc_get_root(args_doc) : NULL;
25642578
yyjson_val *metadata_value =
25652579
args_root && yyjson_is_obj(args_root) ? yyjson_obj_get(args_root, "metadata_only") : NULL;
2566-
metadata_only = metadata_value && yyjson_is_true(metadata_value);
2580+
if (metadata_value && yyjson_is_true(metadata_value)) {
2581+
include_details = false;
2582+
}
25672583
if (args_doc) {
25682584
yyjson_doc_free(args_doc);
25692585
}
2586+
if (offset < 0) {
2587+
offset = 0;
2588+
}
2589+
if (limit < 1) {
2590+
limit = 1;
2591+
} else if (limit > 100) {
2592+
limit = 100;
2593+
}
25702594

25712595
char dir_path[CBM_SZ_1K];
25722596
cache_dir(dir_path, sizeof(dir_path));
@@ -2588,24 +2612,76 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) {
25882612
return cbm_mcp_text_result(msg, true);
25892613
}
25902614

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

26082679
yyjson_mut_obj_add_val(doc, root, "projects", arr);
2680+
yyjson_mut_obj_add_uint(doc, root, "total", db_count);
2681+
yyjson_mut_obj_add_int(doc, root, "offset", offset);
2682+
yyjson_mut_obj_add_int(doc, root, "limit", limit);
2683+
yyjson_mut_obj_add_uint(doc, root, "returned", yyjson_mut_arr_size(arr));
2684+
yyjson_mut_obj_add_bool(doc, root, "has_more", end < db_count);
26092685

26102686
/* Guide user when no projects are indexed */
26112687
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
@@ -8168,6 +8168,38 @@ TEST(tool_resolve_store_by_internal_name_issue704) {
81688168
ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */
81698169
free(list);
81708170

8171+
char *page = cbm_mcp_server_handle(
8172+
srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
8173+
"\"params\":{\"name\":\"list_projects\","
8174+
"\"arguments\":{\"offset\":0,\"limit\":1}}}");
8175+
ASSERT_NOT_NULL(page);
8176+
ASSERT_NOT_NULL(strstr(page, "\\\"total\\\":3"));
8177+
ASSERT_NOT_NULL(strstr(page, "\\\"limit\\\":1"));
8178+
ASSERT_NOT_NULL(strstr(page, "\\\"returned\\\":1"));
8179+
ASSERT_NOT_NULL(strstr(page, "\\\"has_more\\\":true"));
8180+
ASSERT_NOT_NULL(strstr(page, "alpha704"));
8181+
ASSERT_NULL(strstr(page, "beta704"));
8182+
ASSERT_NULL(strstr(page, "\\\"nodes\\\"")); /* details are opt-in */
8183+
free(page);
8184+
8185+
char *next_page = cbm_mcp_server_handle(
8186+
srv, "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\","
8187+
"\"params\":{\"name\":\"list_projects\","
8188+
"\"arguments\":{\"offset\":1,\"limit\":1,\"metadata_only\":true}}}");
8189+
ASSERT_NOT_NULL(next_page);
8190+
ASSERT_NOT_NULL(strstr(next_page, "beta704"));
8191+
ASSERT_NULL(strstr(next_page, "alpha704"));
8192+
ASSERT_NULL(strstr(next_page, "\\\"nodes\\\"")); /* compatibility alias stays lean */
8193+
free(next_page);
8194+
8195+
char *details = cbm_mcp_server_handle(
8196+
srv, "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/call\","
8197+
"\"params\":{\"name\":\"list_projects\","
8198+
"\"arguments\":{\"offset\":0,\"limit\":1,\"include_details\":true}}}");
8199+
ASSERT_NOT_NULL(details);
8200+
ASSERT_NOT_NULL(strstr(details, "\\\"nodes\\\""));
8201+
free(details);
8202+
81718203
/* ── B: the drifted project resolves by its INTERNAL name ──────── */
81728204
char *q_beta = cbm_mcp_server_handle(
81738205
srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","

0 commit comments

Comments
 (0)