Skip to content

Commit 641c71f

Browse files
authored
Merge pull request #1868 from DeusData/feat/issue-469-file-outline
feat(mcp): add bounded file outline tool
2 parents 3627eff + 1794d9b commit 641c71f

5 files changed

Lines changed: 850 additions & 19 deletions

File tree

‎src/mcp/mcp.c‎

Lines changed: 250 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ enum {
130130
#define MCP_MAX_HEADER_SIZE ((size_t)8U * 1024U)
131131
#define MCP_SEARCH_OUTPUT_MAX ((size_t)64U * 1024U * 1024U)
132132
#define MCP_SEARCH_SCAN_TIMEOUT_MS ((uint64_t)30000U)
133+
#define MCP_FILE_OUTLINE_OUTPUT_MAX ((size_t)2U * 1024U * 1024U)
133134

134135
/* ── Helpers ────────────────────────────────────────────────────── */
135136

@@ -557,6 +558,21 @@ static const tool_def_t TOOLS[] = {
557558
"\"type\":\"string\"},\"include_neighbors\":{"
558559
"\"type\":\"boolean\",\"default\":false}},\"required\":[\"qualified_name\",\"project\"]}"},
559560

561+
{"get_file_outline", "Get file outline",
562+
"Return a compact declaration outline for one exact repository-relative file. Results are "
563+
"filtered by optional exact labels, ordered deterministically by source position, and "
564+
"bounded with exact total/offset/limit pagination metadata. File/folder/container nodes "
565+
"are excluded. The query observes request cancellation and fails without partial output.",
566+
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},"
567+
"\"file_path\":{\"type\":\"string\",\"description\":\"Exact repository-relative "
568+
"file path\"},\"labels\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},"
569+
"\"maxItems\":16,\"description\":\"Optional exact node-label filter\"},"
570+
"\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":200,\"default\":100},"
571+
"\"offset\":{\"type\":\"integer\",\"minimum\":0,\"default\":0},"
572+
"\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],"
573+
"\"default\":\"tree\"}},\"additionalProperties\":false,"
574+
"\"required\":[\"project\",\"file_path\"]}"},
575+
560576
{"get_graph_schema", "Get graph schema",
561577
"Get the schema of the knowledge graph (node labels, edge types)",
562578
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
@@ -745,6 +761,7 @@ static const tool_annotation_def_t TOOL_ANNOTATIONS[] = {
745761
{"query_graph", false, true, true, false},
746762
{"trace_path", false, true, true, false},
747763
{"get_code_snippet", false, true, true, false},
764+
{"get_file_outline", false, true, true, false},
748765
{"get_graph_schema", false, true, true, false},
749766
{"compare_graphs", true, false, true, false},
750767
{"get_architecture", false, true, true, false},
@@ -808,13 +825,14 @@ static void mcp_add_tool_def(yyjson_mut_doc *doc, yyjson_mut_val *tools, int i)
808825

809826
static bool mcp_tool_allowed(cbm_mcp_tool_profile_t profile, const char *name) {
810827
static const char *const analysis_tools[] = {
811-
"search_graph", "query_graph", "trace_path", "get_code_snippet",
812-
"get_graph_schema", "compare_graphs", "get_architecture", "search_code",
813-
"list_projects", "index_status", "check_index_coverage", "detect_changes",
828+
"search_graph", "query_graph", "trace_path", "get_code_snippet",
829+
"get_file_outline", "get_graph_schema", "compare_graphs", "get_architecture",
830+
"search_code", "list_projects", "index_status", "check_index_coverage",
831+
"detect_changes",
814832
};
815833
static const char *const scout_tools[] = {
816-
"search_graph", "trace_path", "get_code_snippet", "get_architecture",
817-
"list_projects", "index_status", "check_index_coverage",
834+
"search_graph", "trace_path", "get_code_snippet", "get_file_outline",
835+
"get_architecture", "list_projects", "index_status", "check_index_coverage",
818836
};
819837
if (!name) {
820838
return false;
@@ -9303,6 +9321,230 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node,
93039321
return result;
93049322
}
93059323

9324+
static bool file_outline_request_cancelled(void *context) {
9325+
return mcp_request_cancelled((const cbm_mcp_server_t *)context);
9326+
}
9327+
9328+
static char *handle_get_file_outline(cbm_mcp_server_t *srv, const char *args) {
9329+
const char *args_text = args ? args : "{}";
9330+
yyjson_doc *args_doc = yyjson_read(args_text, strlen(args_text), 0);
9331+
yyjson_val *root = args_doc ? yyjson_doc_get_root(args_doc) : NULL;
9332+
if (!root || !yyjson_is_obj(root)) {
9333+
yyjson_doc_free(args_doc);
9334+
return cbm_mcp_text_result("get_file_outline arguments must be a JSON object", true);
9335+
}
9336+
9337+
char *project = get_project_arg(args_text);
9338+
yyjson_val *file_value = yyjson_obj_get(root, "file_path");
9339+
const char *file_path =
9340+
file_value && yyjson_is_str(file_value) ? yyjson_get_str(file_value) : NULL;
9341+
if (!project) {
9342+
yyjson_doc_free(args_doc);
9343+
return cbm_mcp_text_result("project is required", true);
9344+
}
9345+
if (!file_path || !file_path[0]) {
9346+
free(project);
9347+
yyjson_doc_free(args_doc);
9348+
return cbm_mcp_text_result("file_path is required", true);
9349+
}
9350+
9351+
char normalized_path[CBM_SZ_4K];
9352+
if (coverage_normalize_rel(file_path, false, normalized_path, sizeof(normalized_path)) !=
9353+
COVERAGE_PATH_OK) {
9354+
free(project);
9355+
yyjson_doc_free(args_doc);
9356+
return cbm_mcp_text_result("file_path must be a repository-relative path without '..'",
9357+
true);
9358+
}
9359+
9360+
int limit = 100;
9361+
yyjson_val *limit_value = yyjson_obj_get(root, "limit");
9362+
if (limit_value) {
9363+
if (!yyjson_is_int(limit_value)) {
9364+
free(project);
9365+
yyjson_doc_free(args_doc);
9366+
return cbm_mcp_text_result("limit must be an integer", true);
9367+
}
9368+
int64_t parsed = yyjson_get_sint(limit_value);
9369+
if (parsed < 1 || parsed > CBM_STORE_FILE_OUTLINE_MAX_LIMIT) {
9370+
free(project);
9371+
yyjson_doc_free(args_doc);
9372+
return cbm_mcp_text_result("limit must be between 1 and 200", true);
9373+
}
9374+
limit = (int)parsed;
9375+
}
9376+
9377+
int offset = 0;
9378+
yyjson_val *offset_value = yyjson_obj_get(root, "offset");
9379+
if (offset_value) {
9380+
if (!yyjson_is_int(offset_value)) {
9381+
free(project);
9382+
yyjson_doc_free(args_doc);
9383+
return cbm_mcp_text_result("offset must be a non-negative integer", true);
9384+
}
9385+
int64_t parsed = yyjson_get_sint(offset_value);
9386+
if (parsed < 0 || parsed > INT_MAX) {
9387+
free(project);
9388+
yyjson_doc_free(args_doc);
9389+
return cbm_mcp_text_result("offset must be a non-negative integer", true);
9390+
}
9391+
offset = (int)parsed;
9392+
}
9393+
9394+
bool json_format = false;
9395+
yyjson_val *format_value = yyjson_obj_get(root, "format");
9396+
if (format_value) {
9397+
const char *format = yyjson_is_str(format_value) ? yyjson_get_str(format_value) : NULL;
9398+
if (!format || (strcmp(format, "tree") != 0 && strcmp(format, "json") != 0)) {
9399+
free(project);
9400+
yyjson_doc_free(args_doc);
9401+
return cbm_mcp_text_result("format must be either 'tree' or 'json'", true);
9402+
}
9403+
json_format = strcmp(format, "json") == 0;
9404+
}
9405+
9406+
const char *labels[CBM_STORE_FILE_OUTLINE_MAX_LABELS];
9407+
int label_count = 0;
9408+
yyjson_val *labels_value = yyjson_obj_get(root, "labels");
9409+
if (labels_value) {
9410+
if (!yyjson_is_arr(labels_value) ||
9411+
yyjson_arr_size(labels_value) > CBM_STORE_FILE_OUTLINE_MAX_LABELS) {
9412+
free(project);
9413+
yyjson_doc_free(args_doc);
9414+
return cbm_mcp_text_result("labels must be an array of at most 16 strings", true);
9415+
}
9416+
size_t index = 0;
9417+
size_t max = 0;
9418+
yyjson_val *item;
9419+
yyjson_arr_foreach(labels_value, index, max, item) {
9420+
const char *label = yyjson_is_str(item) ? yyjson_get_str(item) : NULL;
9421+
if (!label || !label[0] || strlen(label) >= CBM_SZ_128) {
9422+
free(project);
9423+
yyjson_doc_free(args_doc);
9424+
return cbm_mcp_text_result("labels must contain only non-empty bounded strings",
9425+
true);
9426+
}
9427+
labels[label_count++] = label;
9428+
}
9429+
}
9430+
9431+
cbm_store_t *store = resolve_store(srv, project);
9432+
if (!store) {
9433+
char *error = build_project_list_error("project not found or not indexed");
9434+
char *result = cbm_mcp_text_result(error, true);
9435+
free(error);
9436+
free(project);
9437+
yyjson_doc_free(args_doc);
9438+
return result;
9439+
}
9440+
char *not_indexed = verify_project_indexed(store, project);
9441+
if (not_indexed) {
9442+
free(project);
9443+
yyjson_doc_free(args_doc);
9444+
return not_indexed;
9445+
}
9446+
9447+
cbm_file_outline_row_t *rows = NULL;
9448+
int row_count = 0;
9449+
int total = 0;
9450+
int rc = cbm_store_get_file_outline(store, project, normalized_path, labels, label_count, limit,
9451+
offset, file_outline_request_cancelled, srv, &rows,
9452+
&row_count, &total);
9453+
if (rc != CBM_STORE_OK) {
9454+
free(project);
9455+
yyjson_doc_free(args_doc);
9456+
if (rc == CBM_STORE_CANCELLED) {
9457+
return cbm_mcp_text_result("get_file_outline cancelled for this request", true);
9458+
}
9459+
if (rc == CBM_STORE_SCAN_LIMIT) {
9460+
return cbm_mcp_text_result("get_file_outline exceeded its fixed output safety limit",
9461+
true);
9462+
}
9463+
return cbm_mcp_text_result("get_file_outline query failed", true);
9464+
}
9465+
9466+
char *payload = NULL;
9467+
if (!json_format) {
9468+
cbm_sb_t sb;
9469+
cbm_sb_init(&sb);
9470+
cbm_tree_scalar_str(&sb, "file_path", normalized_path);
9471+
static const char *const columns[] = {"name", "label", "lines", "qn"};
9472+
cbm_tree_table_header(&sb, "results", row_count, columns, 4);
9473+
for (int i = 0; i < row_count; i++) {
9474+
char lines[CBM_SZ_32];
9475+
if (rows[i].start_line > 0) {
9476+
snprintf(lines, sizeof(lines), "%d-%d", rows[i].start_line,
9477+
rows[i].end_line > rows[i].start_line ? rows[i].end_line
9478+
: rows[i].start_line);
9479+
} else {
9480+
lines[0] = '\0';
9481+
}
9482+
cbm_tree_row_begin(&sb);
9483+
cbm_tree_cell_str(&sb, rows[i].name, true);
9484+
cbm_tree_cell_str(&sb, rows[i].label, false);
9485+
cbm_tree_cell_str(&sb, lines, false);
9486+
cbm_tree_cell_str(&sb, rows[i].qualified_name, false);
9487+
cbm_tree_row_end(&sb);
9488+
}
9489+
cbm_tree_scalar_int(&sb, "total", total);
9490+
cbm_tree_scalar_int(&sb, "offset", offset);
9491+
cbm_tree_scalar_int(&sb, "limit", limit);
9492+
cbm_tree_scalar_int(&sb, "returned", row_count);
9493+
cbm_tree_scalar_bool(&sb, "has_more", (int64_t)offset + row_count < total);
9494+
payload = cbm_sb_finish(&sb);
9495+
} else {
9496+
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
9497+
yyjson_mut_val *object = yyjson_mut_obj(doc);
9498+
yyjson_mut_doc_set_root(doc, object);
9499+
yyjson_mut_obj_add_str(doc, object, "file_path", normalized_path);
9500+
yyjson_mut_val *columns = yyjson_mut_arr(doc);
9501+
static const char *const column_names[] = {"name", "label", "lines", "qn"};
9502+
for (size_t i = 0; i < sizeof(column_names) / sizeof(column_names[0]); i++) {
9503+
yyjson_mut_arr_add_str(doc, columns, column_names[i]);
9504+
}
9505+
yyjson_mut_obj_add_val(doc, object, "cols", columns);
9506+
yyjson_mut_val *json_rows = yyjson_mut_arr(doc);
9507+
for (int i = 0; i < row_count; i++) {
9508+
char lines[CBM_SZ_32];
9509+
if (rows[i].start_line > 0) {
9510+
snprintf(lines, sizeof(lines), "%d-%d", rows[i].start_line,
9511+
rows[i].end_line > rows[i].start_line ? rows[i].end_line
9512+
: rows[i].start_line);
9513+
} else {
9514+
lines[0] = '\0';
9515+
}
9516+
yyjson_mut_val *row = yyjson_mut_arr(doc);
9517+
yyjson_mut_arr_add_strcpy(doc, row, rows[i].name);
9518+
yyjson_mut_arr_add_strcpy(doc, row, rows[i].label);
9519+
yyjson_mut_arr_add_strcpy(doc, row, lines);
9520+
yyjson_mut_arr_add_strcpy(doc, row, rows[i].qualified_name);
9521+
yyjson_mut_arr_add_val(json_rows, row);
9522+
}
9523+
yyjson_mut_obj_add_val(doc, object, "rows", json_rows);
9524+
yyjson_mut_obj_add_int(doc, object, "total", total);
9525+
yyjson_mut_obj_add_int(doc, object, "offset", offset);
9526+
yyjson_mut_obj_add_int(doc, object, "limit", limit);
9527+
yyjson_mut_obj_add_int(doc, object, "returned", row_count);
9528+
yyjson_mut_obj_add_bool(doc, object, "has_more", (int64_t)offset + row_count < total);
9529+
payload = yy_doc_to_str(doc);
9530+
yyjson_mut_doc_free(doc);
9531+
}
9532+
9533+
cbm_store_free_file_outline(rows, row_count);
9534+
free(project);
9535+
yyjson_doc_free(args_doc);
9536+
if (!payload) {
9537+
return cbm_mcp_text_result("get_file_outline output allocation failed", true);
9538+
}
9539+
if (strlen(payload) > MCP_FILE_OUTLINE_OUTPUT_MAX) {
9540+
free(payload);
9541+
return cbm_mcp_text_result("get_file_outline exceeded its fixed output safety limit", true);
9542+
}
9543+
char *result = cbm_mcp_text_result(payload, false);
9544+
free(payload);
9545+
return result;
9546+
}
9547+
93069548
static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) {
93079549
char *qn = cbm_mcp_get_string_arg(args, "qualified_name");
93089550
char *project = get_project_arg(args);
@@ -12361,6 +12603,9 @@ static char *dispatch_tool(cbm_mcp_server_t *srv, const char *tool_name, const c
1236112603
if (strcmp(tool_name, "get_code_snippet") == 0) {
1236212604
return handle_get_code_snippet(srv, args_json);
1236312605
}
12606+
if (strcmp(tool_name, "get_file_outline") == 0) {
12607+
return handle_get_file_outline(srv, args_json);
12608+
}
1236412609
if (strcmp(tool_name, "search_code") == 0) {
1236512610
return handle_search_code(srv, args_json);
1236612611
}

0 commit comments

Comments
 (0)