feat(mcp): add structured tool metadata

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-06-28 01:37:41 +02:00
parent b075f0506c
commit 800476f284
3 changed files with 110 additions and 24 deletions
+70 -22
View File
@@ -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) +
+4
View File
@@ -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);
+36 -2
View File
@@ -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 */