fix(mcp): trace_path cross_service follows CROSS_* edges (#522) (#538)

trace_path mode=cross_service resolved its default edge-type list to
{HTTP_CALLS, ASYNC_CALLS, DATA_FLOWS, CALLS} — none of the CROSS_* edge
types the cross-repo pass actually writes. Since cbm_store_bfs filters
edges with an exact `WHERE e.type IN (...)` clause, cross-service traces
silently skipped every cross hop even when the edge was present and
visible via query_graph.

Add the six CROSS_* types (CROSS_HTTP_CALLS, CROSS_ASYNC_CALLS,
CROSS_CHANNEL, CROSS_GRPC_CALLS, CROSS_GRAPHQL_CALLS, CROSS_TRPC_CALLS)
to the cross_service default list — exactly the set pass_cross_repo.c
emits and cross_repo_delete_edges() cleans up. The count is now computed
from the array size (sizeof) so it can't drift; the dead MCP_N_DEFAULTS_4
constant is removed. Tool description updated to match.

Adds integ_mcp_trace_path_cross_service: seeds a CROSS_HTTP_CALLS edge
between two indexed functions with no CALLS relationship and asserts
cross_service surfaces the hop while calls mode does not.

Signed-off-by: kirilklein <kikl@di.ku.dk>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kiril Klein, PhDandClaude Opus 4.8 authored and GitHub committed 2026-06-22 22:05:18 +02:00
1 parent 8ba286324c
commit df8bc9254d
2 files changed
+65 -4

No files matched your search

+8 -4
View File
@@ -29,7 +29,6 @@ enum {
MCP_DEFAULT_LIMIT = 10,
MCP_BFS_LIMIT = 100,
MCP_N_DEFAULTS_2 = 2,
MCP_N_DEFAULTS_4 = 4,
MCP_URI_PREFIX = 7, /* strlen("file://") */
MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */
MCP_RETURN_2 = 2,
@@ -362,7 +361,9 @@ static const tool_def_t TOOLS[] = {
"\"type\":\"string\",\"enum\":[\"calls\",\"data_flow\",\"cross_service\"],\"default\":"
"\"calls\",\"description\":\"calls: follow CALLS edges. data_flow: follow CALLS+DATA_FLOWS "
"with arg expressions. cross_service: follow HTTP_CALLS+ASYNC_CALLS+DATA_FLOWS through "
"Routes.\"},\"parameter_name\":{\"type\":\"string\",\"description\":\"For data_flow mode: "
"Routes, plus CROSS_* cross-repo edges (CROSS_HTTP_CALLS/ASYNC_CALLS/CHANNEL/GRPC_CALLS/"
"GRAPHQL_CALLS/TRPC_CALLS) to hop into other services.\"},\"parameter_name\":{\"type\":"
"\"string\",\"description\":\"For data_flow mode: "
"scope trace to a specific parameter name\"},\"edge_types\":{\"type\":\"array\",\"items\":{"
"\"type\":\"string\"}},\"risk_labels\":{\"type\":\"boolean\",\"default\":false,"
"\"description\":\"Add risk classification (CRITICAL/HIGH/MEDIUM/LOW) based on hop distance"
@@ -2164,7 +2165,10 @@ static yyjson_doc *resolve_trace_edge_types(const char *args, const char *mode,
const char **out_types, int *out_count) {
static const char *mode_calls[] = {"CALLS"};
static const char *mode_data_flow[] = {"CALLS", "DATA_FLOWS"};
static const char *mode_cross_svc[] = {"HTTP_CALLS", "ASYNC_CALLS", "DATA_FLOWS", "CALLS"};
static const char *mode_cross_svc[] = {
"HTTP_CALLS", "ASYNC_CALLS", "DATA_FLOWS", "CALLS",
"CROSS_HTTP_CALLS", "CROSS_ASYNC_CALLS", "CROSS_CHANNEL", "CROSS_GRPC_CALLS",
"CROSS_GRAPHQL_CALLS", "CROSS_TRPC_CALLS"};
*out_count = 0;
@@ -2196,7 +2200,7 @@ static yyjson_doc *resolve_trace_edge_types(const char *args, const char *mode,
n_defaults = MCP_N_DEFAULTS_2;
} else if (mode && strcmp(mode, "cross_service") == 0) {
defaults = mode_cross_svc;
n_defaults = MCP_N_DEFAULTS_4;
n_defaults = (int)(sizeof(mode_cross_svc) / sizeof(mode_cross_svc[0]));
}
for (int i = 0; i < n_defaults; i++) {
out_types[i] = defaults[i];
+57
View File
@@ -383,6 +383,62 @@ TEST(integ_mcp_trace_path) {
PASS();
}
/* #522: trace_path mode=cross_service must follow CROSS_* cross-repo edges.
* Seed a CROSS_HTTP_CALLS edge between two indexed functions that have no CALLS
* relationship, then confirm cross_service surfaces the hop while the default
* calls mode does not (proving the cross edge specifically is what's followed).
*
* The trace goes through a fresh server so it opens the db after the edge is
* committed — exactly what a new MCP session sees after a cross-repo pass writes
* CROSS_* edges (g_srv's cached connection predates this write). */
TEST(integ_mcp_trace_path_cross_service) {
cbm_store_t *store = cbm_store_open_path(g_dbpath);
ASSERT_NOT_NULL(store);
cbm_node_t *src = NULL;
cbm_node_t *dst = NULL;
int src_count = 0;
int dst_count = 0;
cbm_store_find_nodes_by_name(store, g_project, "greet", &src, &src_count);
cbm_store_find_nodes_by_name(store, g_project, "farewell", &dst, &dst_count);
ASSERT_TRUE(src_count > 0 && dst_count > 0);
cbm_edge_t edge = {.project = g_project,
.source_id = src[0].id,
.target_id = dst[0].id,
.type = "CROSS_HTTP_CALLS"};
ASSERT_TRUE(cbm_store_insert_edge(store, &edge) > 0);
cbm_store_free_nodes(src, src_count);
cbm_store_free_nodes(dst, dst_count);
cbm_store_close(store);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[256];
snprintf(args, sizeof(args),
"{\"function_name\":\"greet\",\"project\":\"%s\","
"\"direction\":\"outbound\",\"mode\":\"cross_service\"}",
g_project);
char *resp = cbm_mcp_handle_tool(srv, "trace_path", args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "farewell"));
free(resp);
snprintf(args, sizeof(args),
"{\"function_name\":\"greet\",\"project\":\"%s\","
"\"direction\":\"outbound\",\"mode\":\"calls\"}",
g_project);
resp = cbm_mcp_handle_tool(srv, "trace_path", args);
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "farewell") == NULL);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(integ_mcp_index_status) {
char args[128];
snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project);
@@ -604,6 +660,7 @@ SUITE(integration) {
RUN_TEST(integ_mcp_get_graph_schema);
RUN_TEST(integ_mcp_get_architecture);
RUN_TEST(integ_mcp_trace_path);
RUN_TEST(integ_mcp_trace_path_cross_service);
RUN_TEST(integ_mcp_index_status);
/* Store query validation */