@@ -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. */
24992505static 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. */
25592571static 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 ) {
0 commit comments