Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 72 additions & 11 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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\"]}"},
Expand Down Expand Up @@ -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];
Expand All @@ -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);
}
Expand All @@ -2365,35 +2371,53 @@ 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) {
yyjson_mut_obj_add_strcpy(doc, p, "branch", gctx.branch);
}
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);
}
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));
Expand All @@ -2415,24 +2439,61 @@ 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;
size_t len = strlen(name);
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) {
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 23 additions & 1 deletion tests/test_mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"));

Expand Down Expand Up @@ -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\","
Expand Down
Loading