diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 368d73f3e..ccb81f5fe 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -249,29 +249,66 @@ char *cbm_mcp_text_result(const char *text, bool is_error) { yyjson_mut_val *content = yyjson_mut_arr(doc); yyjson_mut_val *item = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, item, "type", "text"); - yyjson_mut_obj_add_str(doc, item, "text", text); + yyjson_mut_obj_add_str(doc, item, "text", text ? text : ""); yyjson_mut_arr_add_val(content, item); yyjson_mut_obj_add_val(doc, root, "content", content); - if (is_error) { - yyjson_mut_obj_add_bool(doc, root, "isError", true); + if (!is_error && text) { + yyjson_doc *structured_doc = yyjson_read(text, strlen(text), 0); + if (structured_doc) { + yyjson_val *structured_root = yyjson_doc_get_root(structured_doc); + if (yyjson_is_obj(structured_root)) { + yyjson_mut_val *structured = yyjson_val_mut_copy(doc, structured_root); + yyjson_mut_obj_add_val(doc, root, "structuredContent", structured); + } + yyjson_doc_free(structured_doc); + } } + yyjson_mut_obj_add_bool(doc, root, "isError", is_error); char *out = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } +bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id, + const char *active_id_str) { + if (!params_json) { + return false; + } + + yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0); + if (!doc) { + return false; + } + + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *request_id = yyjson_obj_get(root, "requestId"); + bool matches = false; + if (request_id) { + if (active_id_str) { + matches = + yyjson_is_str(request_id) && strcmp(yyjson_get_str(request_id), active_id_str) == 0; + } else { + matches = yyjson_is_int(request_id) && yyjson_get_int(request_id) == active_id; + } + } + + yyjson_doc_free(doc); + return matches; +} + /* ── Tool definitions ─────────────────────────────────────────── */ typedef struct { const char *name; + const char *title; const char *description; const char *input_schema; /* JSON string */ } tool_def_t; static const tool_def_t TOOLS[] = { - {"index_repository", + {"index_repository", "Index repository", "Index a repository into the knowledge graph. " "Special mode 'cross-repo-intelligence': skip extraction, only match Routes/Channels " "across projects to create CROSS_HTTP_CALLS/CROSS_ASYNC_CALLS/CROSS_CHANNEL edges. " @@ -292,7 +329,7 @@ static const tool_def_t TOOLS[] = { "Teammates can bootstrap from the artifact instead of full re-indexing.\"}" "},\"required\":[\"repo_path\"]}"}, - {"search_graph", + {"search_graph", "Search graph", "Search the code knowledge graph for functions, classes, routes, and variables. Use INSTEAD " "OF grep/glob when finding code definitions, implementations, or relationships. Three search " "modes: (1) query='update settings' for BM25 ranked full-text search with camelCase " @@ -329,7 +366,7 @@ static const tool_def_t TOOLS[] = { "increment offset by limit and re-call while has_more is true.\"}}," "\"required\":[\"project\"]}"}, - {"query_graph", + {"query_graph", "Query graph", "Execute a Cypher query against the knowledge graph for complex multi-hop patterns, " "aggregations, and cross-service analysis. The response includes 'total' (returned " "row count). There is a hard 100k row ceiling — for broad queries add LIMIT in the " @@ -352,7 +389,7 @@ static const tool_def_t TOOLS[] = { "ceiling. No offset support — use search_graph for paginated browsing.\"}}," "\"required\":[\"query\",\"project\"]}"}, - {"trace_path", + {"trace_path", "Trace path", "Trace paths through the code graph. Modes: calls (callers/callees), data_flow (value " "propagation with args at each hop), cross_service (through HTTP/async Route nodes). " "Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing.", @@ -373,7 +410,7 @@ static const tool_def_t TOOLS[] = { "filtered out. When true, test nodes are included with is_test=true marker." "\"}},\"required\":[\"function_name\",\"project\"]}"}, - {"get_code_snippet", + {"get_code_snippet", "Get code snippet", "Read source code for a function/class/symbol. IMPORTANT: First call search_graph to find the " "exact qualified_name, then pass it here. This is a read tool, not a search tool. Accepts " "full qualified_name (exact match) or short function name (returns suggestions if ambiguous).", @@ -382,11 +419,12 @@ static const tool_def_t TOOLS[] = { "\"type\":\"string\"},\"include_neighbors\":{" "\"type\":\"boolean\",\"default\":false}},\"required\":[\"qualified_name\",\"project\"]}"}, - {"get_graph_schema", "Get the schema of the knowledge graph (node labels, edge types)", + {"get_graph_schema", "Get graph schema", + "Get the schema of the knowledge graph (node labels, edge types)", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, - {"get_architecture", + {"get_architecture", "Get architecture", "Get high-level architecture overview — packages, services, dependencies, and project " "structure at a glance. Includes 'clusters': Leiden community detection over the call/import " "graph, surfacing the de-facto modules (each with a label, member count, cohesion score, " @@ -399,7 +437,7 @@ static const tool_def_t TOOLS[] = { "\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]" "}"}, - {"search_code", + {"search_code", "Search code", "Graph-augmented code search. Finds text patterns via grep, then enriches results with " "the knowledge graph: deduplicates matches into containing functions, ranks by structural " "importance (definitions first, popular functions next, tests last). " @@ -423,17 +461,17 @@ 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 all indexed projects", "{\"type\":\"object\",\"properties\":{}}"}, - - {"delete_project", "Delete a project from the index", + {"list_projects", "List projects", "List all indexed projects", + "{\"type\":\"object\",\"properties\":{}}"}, + {"delete_project", "Delete project", "Delete a project from the index", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, - {"index_status", "Get the indexing status of a project", + {"index_status", "Index status", "Get the indexing status of a project", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, - {"detect_changes", "Detect code changes and their impact", + {"detect_changes", "Detect changes", "Detect code changes and their impact", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":" "\"string\"},\"depth\":{\"type\":\"integer\",\"default\":2},\"base_branch\":{\"type\":" "\"string\",\"default\":\"main\"},\"since\":{\"type\":\"string\",\"description\":" @@ -441,13 +479,13 @@ static const tool_def_t TOOLS[] = { "\"required\":" "[\"project\"]}"}, - {"manage_adr", "Create or update Architecture Decision Records", + {"manage_adr", "Manage ADR", "Create or update Architecture Decision Records", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":" "\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"content\":{\"type\":\"string\"}," "\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]" "}"}, - {"ingest_traces", "Ingest runtime traces to enhance the knowledge graph", + {"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph", "{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":" "\"object\"}},\"project\":{\"type\":" "\"string\"}},\"required\":[\"traces\",\"project\"]}"}, @@ -465,6 +503,7 @@ char *cbm_mcp_tools_list(void) { for (int i = 0; i < TOOL_COUNT; i++) { yyjson_mut_val *tool = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name); + yyjson_mut_obj_add_str(doc, tool, "title", TOOLS[i].title); yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description); /* Parse input schema JSON and embed */ @@ -531,6 +570,7 @@ char *cbm_mcp_initialize_response(const char *params_json) { yyjson_mut_val *caps = yyjson_mut_obj(doc); yyjson_mut_val *tools_cap = yyjson_mut_obj(doc); + yyjson_mut_obj_add_bool(doc, tools_cap, "listChanged", false); yyjson_mut_obj_add_val(doc, caps, "tools", tools_cap); yyjson_mut_obj_add_val(doc, root, "capabilities", caps); @@ -644,6 +684,7 @@ struct cbm_mcp_server { /* Active pipeline tracking for cancellation support */ cbm_pipeline_t *active_pipeline; /* non-NULL while index_repository runs */ int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */ + char *active_request_id_str; /* string JSON-RPC id of the in-progress tool call */ }; cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { @@ -703,6 +744,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) { cbm_store_close(srv->store); } free(srv->current_project); + free(srv->active_request_id_str); free(srv); } @@ -4816,11 +4858,11 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { /* Notifications (no id) → handle cancellation, then no response */ if (!req.has_id) { if (req.method && strcmp(req.method, "notifications/cancelled") == 0) { - /* MCP cancellation: cancel the active pipeline if request ID matches */ - if (srv->active_pipeline) { + if (srv->active_pipeline && + cbm_mcp_cancel_request_matches(req.params_raw, srv->active_request_id, + srv->active_request_id_str)) { cbm_pipeline_cancel(srv->active_pipeline); - cbm_log_info("mcp.cancelled", "request_id_active", - srv->active_request_id > 0 ? "yes" : "none"); + cbm_log_info("mcp.cancelled", "match", "true"); } } cbm_jsonrpc_request_free(&req); @@ -4842,10 +4884,16 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { char *tool_name = req.params_raw ? cbm_mcp_get_tool_name(req.params_raw) : NULL; char *tool_args = req.params_raw ? cbm_mcp_get_arguments(req.params_raw) : heap_strdup("{}"); + srv->active_request_id = req.id; + free(srv->active_request_id_str); + srv->active_request_id_str = req.id_str ? heap_strdup(req.id_str) : NULL; struct timespec t0; cbm_clock_gettime(CLOCK_MONOTONIC, &t0); result_json = cbm_mcp_handle_tool(srv, tool_name, tool_args); + srv->active_request_id = CBM_NOT_FOUND; + free(srv->active_request_id_str); + srv->active_request_id_str = NULL; struct timespec t1; cbm_clock_gettime(CLOCK_MONOTONIC, &t1); long long dur_us = ((long long)(t1.tv_sec - t0.tv_sec) * MCP_S_TO_US) + diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 121038cbd..e4cf4c5c9 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -54,6 +54,10 @@ char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message); /* Format an MCP tool result with text content. Returns heap-allocated JSON. */ char *cbm_mcp_text_result(const char *text, bool is_error); +/* Return true when notifications/cancelled params target the active request. */ +bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id, + const char *active_id_str); + /* Format the tools/list response. Returns heap-allocated JSON. */ char *cbm_mcp_tools_list(void); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 7dc7c1144..ac19b1d99 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -144,6 +144,7 @@ TEST(mcp_initialize_response) { ASSERT_NOT_NULL(strstr(json, "codebase-memory-mcp")); ASSERT_NOT_NULL(strstr(json, "capabilities")); ASSERT_NOT_NULL(strstr(json, "tools")); + ASSERT_NOT_NULL(strstr(json, "\"listChanged\":false")); ASSERT_NOT_NULL(strstr(json, "2025-11-25")); free(json); @@ -188,6 +189,15 @@ TEST(mcp_tools_list) { PASS(); } +TEST(mcp_tools_list_latest_metadata) { + char *json = cbm_mcp_tools_list(); + ASSERT_NOT_NULL(json); + ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\"")); + ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\"")); + free(json); + PASS(); +} + TEST(mcp_tools_array_schemas_have_items) { /* VS Code 1.112+ rejects array schemas without "items" (see * https://github.com/microsoft/vscode/issues/248810). @@ -223,11 +233,32 @@ TEST(mcp_text_result) { ASSERT_NOT_NULL(strstr(json, "\"type\":\"text\"")); /* The text value is JSON-escaped inside the "text" field */ ASSERT_NOT_NULL(strstr(json, "total")); + ASSERT_NOT_NULL(strstr(json, "\"structuredContent\":{\"total\":5}")); + ASSERT_NOT_NULL(strstr(json, "\"isError\":false")); ASSERT_NULL(strstr(json, "\"isError\":true")); free(json); PASS(); } +TEST(mcp_text_result_skips_structured_content_for_plain_text) { + char *json = cbm_mcp_text_result("plain text", false); + ASSERT_NOT_NULL(json); + ASSERT_NULL(strstr(json, "\"structuredContent\"")); + ASSERT_NOT_NULL(strstr(json, "\"isError\":false")); + free(json); + PASS(); +} + +TEST(mcp_cancel_matches_request_id) { + ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", 7, NULL)); + ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":8}", 7, NULL)); + ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-1\"}", -1, "call-1")); + ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-2\"}", -1, "call-1")); + ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", -1, "7")); + ASSERT_FALSE(cbm_mcp_cancel_request_matches("{}", 7, NULL)); + PASS(); +} + TEST(mcp_text_result_error) { char *json = cbm_mcp_text_result("something failed", true); ASSERT_NOT_NULL(json); @@ -1156,7 +1187,7 @@ TEST(tool_manage_adr_get_with_existing_adr) { /* ADR content must appear in response */ ASSERT_NOT_NULL(strstr(resp, "PURPOSE")); /* Must not be an error */ - ASSERT_NULL(strstr(resp, "isError")); + ASSERT_NULL(strstr(resp, "\"isError\":true")); free(resp); /* Clean up */ @@ -1202,7 +1233,7 @@ TEST(tool_manage_adr_unified_backend_issue256) { "\"mode\":\"get\"}}}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend.")); - ASSERT_NULL(strstr(resp, "isError")); + ASSERT_NULL(strstr(resp, "\"isError\":true")); free(resp); cbm_mcp_server_free(srv); @@ -2324,8 +2355,11 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); RUN_TEST(mcp_tools_list); + RUN_TEST(mcp_tools_list_latest_metadata); RUN_TEST(mcp_tools_array_schemas_have_items); RUN_TEST(mcp_text_result); + RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text); + RUN_TEST(mcp_cancel_matches_request_id); RUN_TEST(mcp_text_result_error); /* Argument extraction */