@@ -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
809826static 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+
93069548static 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