mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-11 09:25:35 +08:00
feat(mcp): add structured tool metadata
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
+70
-22
@@ -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) +
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user