/* * mcp.c — MCP server: JSON-RPC 2.0 over stdio with graph tools. * * Uses yyjson for fast JSON parsing/building. * Single-threaded event loop: read line → parse → dispatch → respond. */ // operations #include "foundation/constants.h" enum { MCP_FIELD_SIZE = 1040, MCP_TIMEOUT_MS = 1000, MCP_HALF_SEC_US = 500000, MCP_COL_2 = 2, MCP_COL_3 = 3, MCP_COL_4 = 4, MCP_COL_7 = 7, MCP_COL_10 = 10, MCP_COL_16 = 16, MCP_DB_EXT = 3, /* strlen(".db") */ MCP_SEPARATOR = 2, /* space for separator chars */ MCP_DEFAULT_DEPTH = 3, MCP_DEFAULT_BFS_DEPTH = 2, MCP_DEFAULT_LIMIT = 10, MCP_BFS_LIMIT = 100, /* default per-direction trace budget (limit param raises) */ MCP_BFS_LIMIT_MAX = 5000, /* hard ceiling for the limit param (context-bomb guard) */ MCP_DEFAULT_IMPACT_LIMIT = 200, /* detect_changes per-symbol rows; rollup stays complete */ MCP_SNIPPET_MAX_LINES = 500, /* get_code_snippet line cap (whole-file Module guard) */ MCP_N_DEFAULTS_2 = 2, MCP_URI_PREFIX = 7, /* strlen("file://") */ MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */ MCP_RETURN_2 = 2, MCP_TOOLS_PAGE_SIZE = 8, MCP_HELP_TOOLS_WRAP_COL = 74, /* --help tool list stays readable on 80-col terminals */ MCP_MAX_CROSS_REPO_TARGETS = 4096, MCP_COMPARE_DEFAULT_LIMIT = 200, MCP_COMPARE_MAX_LIMIT = 1000, MCP_COMPARE_DEFAULT_SCAN_LIMIT = 2000000, MCP_COMPARE_MAX_SCAN_LIMIT = 10000000, MCP_COMPARE_SET_BYTE_BUDGET = 512 * 1024, MCP_QUERY_MAX_VISIBLE_ROWS = 99998, /* search_code `context`: lines of surrounding source per hit, the same * ceiling as `source_max_lines`. Clamped at the handler and again where * the window is computed, so the arithmetic never overflows. */ MCP_SEARCH_CONTEXT_MAX_LINES = 200, /* max_output_tokens is model-neutral sizing guidance, not a tokenizer * promise. The actual cross-platform contract is this deterministic UTF-8 * byte ceiling, applied only at whole semantic-unit boundaries. */ MCP_OUTPUT_BYTES_PER_TOKEN_ESTIMATE = 4, }; #define MCP_MS_TO_US 1000LL #define MCP_S_TO_US 1000000LL #define SLEN(s) (sizeof(s) - 1) #include "mcp/mcp.h" #include "mcp/mcp_internal.h" #include "mcp/architecture_jobs.h" #include "mcp/test_impact.h" #include "mcp/test_impact_engine.h" #include "store/graph_mem.h" #include "store/store.h" #include "store/store_impact.h" #include #include "cypher/cypher.h" #include "discover/discover.h" #include "pipeline/pipeline.h" #include "callable_sig.h" /* cbm_qn_callable_base_len */ #include "pipeline/doc_links.h" /* cbm_doclink_reason_name: the reasons the layer writes */ #include "pipeline/pass_cross_repo.h" #include "git/git_context.h" #include "cli/cli.h" #include "watcher/watcher.h" #include "foundation/mem.h" #include "foundation/mem_core.h" #include "foundation/hash_table.h" #include "foundation/json_writer.h" #include "foundation/arena.h" #include "foundation/diagnostics.h" #include "foundation/platform.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/compat_thread.h" #include "foundation/log.h" #include "foundation/limits.h" #include "foundation/hash_table.h" #include "foundation/subprocess.h" #include "foundation/sha256.h" #include "mcp/index_supervisor.h" #include "mcp/compact_out.h" #include "foundation/str_util.h" #include "foundation/workspace.h" #include "foundation/dump_verify.h" #include "foundation/compat_regex.h" #include "pipeline/artifact.h" #ifdef _WIN32 #include "foundation/win_utf8.h" #include #include #include #include #define getpid _getpid /* Write through the descriptor cbm_mkstemp returned rather than reopening its * path — see search_scratch_open. Mirrors config_toml_edit.c's toml_fdopen. */ #define mcp_fdopen _fdopen #define mcp_close _close #else #include #include #include #include #include #define mcp_fdopen fdopen #define mcp_close close #endif #include #include #include #include // va_list, for the bounded help-list appender #include // int64_t #include #include #include #include #include #include #include /* ── Constants ────────────────────────────────────────────────── */ /* Default snippet fallback line count */ #define SNIPPET_DEFAULT_LINES 50 /* Idle store eviction: close cached project store after this many seconds * of inactivity to free SQLite memory during idle periods. */ #define STORE_IDLE_TIMEOUT_S 60 /* Directory permissions: rwxr-xr-x */ #define ADR_DIR_PERMS 0755 /* JSON-RPC 2.0 standard error codes */ #define JSONRPC_PARSE_ERROR (-32700) #define JSONRPC_METHOD_NOT_FOUND (-32601) #define JSONRPC_INVALID_PARAMS (-32602) #define JSONRPC_INTERNAL_ERROR (-32603) /* MCP stdio framing limits. The body limit is also the upper bound used by the * daemon IPC transport; headers stay deliberately small to prevent a peer from * growing getline buffers without bound through ignored extension headers. */ #define MCP_MAX_MESSAGE_SIZE ((size_t)10U * 1024U * 1024U) #define MCP_MAX_HEADER_SIZE ((size_t)8U * 1024U) #define MCP_SEARCH_OUTPUT_MAX ((size_t)64U * 1024U * 1024U) #define MCP_SEARCH_SCAN_TIMEOUT_MS ((uint64_t)30000U) #define MCP_FILE_OUTLINE_OUTPUT_MAX ((size_t)2U * 1024U * 1024U) /* ── Helpers ────────────────────────────────────────────────────── */ static char *heap_strdup(const char *s) { if (!s) { return NULL; } size_t len = strlen(s); char *d = malloc(len + SKIP_ONE); if (d) { memcpy(d, s, len + SKIP_ONE); } return d; } /* Replace non-UTF-8 strings inside a mutable document before standard JSON * serialization. Payload documents also escape literal reserved prefixes; * final JSON-RPC envelopes preserve already-normalized payload strings so * structuredContent does not acquire a second @utf8: layer. */ static bool yy_mut_normalize_output_text(yyjson_mut_doc *doc, yyjson_mut_val *value, bool escape_reserved) { if (yyjson_mut_is_str(value)) { char *encoded = NULL; size_t encoded_len = 0; if (!cbm_output_encode_text(yyjson_mut_get_str(value), yyjson_mut_get_len(value), &encoded, &encoded_len)) { return false; } if (!encoded) { return true; } if (!escape_reserved && strncmp(encoded, "@utf8:", 6) == 0) { free(encoded); return true; } yyjson_mut_val *owned = yyjson_mut_strncpy(doc, encoded, encoded_len); free(encoded); return owned && yyjson_mut_set_strn(value, yyjson_mut_get_str(owned), encoded_len); } if (yyjson_mut_is_arr(value)) { size_t index; size_t maximum; yyjson_mut_val *item; yyjson_mut_arr_foreach(value, index, maximum, item) { if (!yy_mut_normalize_output_text(doc, item, escape_reserved)) { return false; } } } else if (yyjson_mut_is_obj(value)) { size_t index; size_t maximum; yyjson_mut_val *key; yyjson_mut_val *item; yyjson_mut_obj_foreach(value, index, maximum, key, item) { if (!yy_mut_normalize_output_text(doc, key, escape_reserved) || !yy_mut_normalize_output_text(doc, item, escape_reserved)) { return false; } } } return true; } static char *yy_doc_write_output(yyjson_mut_doc *doc, bool escape_reserved) { yyjson_mut_val *root = doc ? yyjson_mut_doc_get_root(doc) : NULL; if (!root || !yy_mut_normalize_output_text(doc, root, escape_reserved)) { return NULL; } return yyjson_mut_write(doc, 0, NULL); } /* Tool payloads are the first encoding boundary. */ static char *yy_doc_to_str(yyjson_mut_doc *doc) { return yy_doc_write_output(doc, true); } /* Payloads embedded in a JSON-RPC envelope have already crossed that boundary. */ static char *yy_final_doc_to_str(yyjson_mut_doc *doc) { return yy_doc_write_output(doc, false); } /* ══════════════════════════════════════════════════════════════════ * JSON-RPC PARSING * ══════════════════════════════════════════════════════════════════ */ int cbm_jsonrpc_parse(const char *line, cbm_jsonrpc_request_t *out) { memset(out, 0, sizeof(*out)); out->id = CBM_NOT_FOUND; yyjson_doc *doc = yyjson_read(line, strlen(line), 0); if (!doc) { return CBM_NOT_FOUND; } yyjson_val *root = yyjson_doc_get_root(doc); if (!yyjson_is_obj(root)) { yyjson_doc_free(doc); return CBM_NOT_FOUND; } yyjson_val *v_jsonrpc = yyjson_obj_get(root, "jsonrpc"); yyjson_val *v_method = yyjson_obj_get(root, "method"); yyjson_val *v_id = yyjson_obj_get(root, "id"); yyjson_val *v_params = yyjson_obj_get(root, "params"); if (!v_method || !yyjson_is_str(v_method)) { yyjson_doc_free(doc); return CBM_NOT_FOUND; } out->jsonrpc = heap_strdup(v_jsonrpc && yyjson_is_str(v_jsonrpc) ? yyjson_get_str(v_jsonrpc) : "2.0"); out->method = heap_strdup(yyjson_get_str(v_method)); if (v_id) { out->has_id = true; if (yyjson_is_int(v_id)) { out->id = yyjson_get_int(v_id); } else if (yyjson_is_str(v_id)) { /* JSON-RPC 2.0 §4 permits string ids (Claude Desktop uses them). * Preserve verbatim instead of coercing via strtol (issue #253). */ out->id_str = heap_strdup(yyjson_get_str(v_id)); } } if (v_params) { out->params_raw = yyjson_val_write(v_params, 0, NULL); } yyjson_doc_free(doc); return 0; } void cbm_jsonrpc_request_free(cbm_jsonrpc_request_t *r) { if (!r) { return; } safe_str_free(&r->jsonrpc); safe_str_free(&r->method); safe_str_free(&r->id_str); safe_str_free(&r->params_raw); memset(r, 0, sizeof(*r)); } /* ══════════════════════════════════════════════════════════════════ * JSON-RPC FORMATTING * ══════════════════════════════════════════════════════════════════ */ char *cbm_jsonrpc_format_response(const cbm_jsonrpc_response_t *resp) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_str(doc, root, "jsonrpc", "2.0"); if (resp->id_str) { yyjson_mut_obj_add_str(doc, root, "id", resp->id_str); } else { yyjson_mut_obj_add_int(doc, root, "id", resp->id); } if (resp->error_json) { /* Parse the error JSON and embed */ yyjson_doc *err_doc = yyjson_read(resp->error_json, strlen(resp->error_json), 0); if (err_doc) { yyjson_mut_val *err_val = yyjson_val_mut_copy(doc, yyjson_doc_get_root(err_doc)); yyjson_mut_obj_add_val(doc, root, "error", err_val); yyjson_doc_free(err_doc); } } else if (resp->result_json) { /* Parse the result JSON and embed */ yyjson_doc *res_doc = yyjson_read(resp->result_json, strlen(resp->result_json), 0); if (res_doc) { yyjson_mut_val *res_val = yyjson_val_mut_copy(doc, yyjson_doc_get_root(res_doc)); yyjson_mut_obj_add_val(doc, root, "result", res_val); yyjson_doc_free(res_doc); } } else { /* JSON-RPC 2.0 spec: response MUST contain "result" or "error" */ yyjson_mut_obj_add_null(doc, root, "result"); } char *out = yy_final_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_str(doc, root, "jsonrpc", "2.0"); yyjson_mut_obj_add_int(doc, root, "id", id); char *encoded_message = NULL; size_t encoded_message_len = 0; const char *raw_message = message ? message : ""; if (!cbm_output_encode_text(raw_message, strlen(raw_message), &encoded_message, &encoded_message_len)) { yyjson_mut_doc_free(doc); return NULL; } yyjson_mut_val *err = yyjson_mut_obj(doc); yyjson_mut_obj_add_int(doc, err, "code", code); yyjson_mut_obj_add_str(doc, err, "message", encoded_message ? encoded_message : raw_message); yyjson_mut_obj_add_val(doc, root, "error", err); char *out = yy_final_doc_to_str(doc); yyjson_mut_doc_free(doc); free(encoded_message); return out; } /* ══════════════════════════════════════════════════════════════════ * MCP PROTOCOL HELPERS * ══════════════════════════════════════════════════════════════════ */ char *cbm_mcp_text_result(const char *text, bool is_error) { yyjson_doc *structured_doc = text ? yyjson_read(text, strlen(text), 0) : NULL; yyjson_val *structured_root = structured_doc ? yyjson_doc_get_root(structured_doc) : NULL; bool structured_object = structured_root && yyjson_is_obj(structured_root); char *encoded_text = NULL; size_t encoded_text_len = 0; const char *raw_text = text ? text : ""; if (!structured_object && !cbm_output_encode_text(raw_text, strlen(raw_text), &encoded_text, &encoded_text_len)) { yyjson_doc_free(structured_doc); return NULL; } const char *wire_text = encoded_text ? encoded_text : raw_text; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); 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", wire_text); yyjson_mut_arr_add_val(content, item); yyjson_mut_obj_add_val(doc, root, "content", content); bool has_structured_content = false; if (structured_object) { yyjson_mut_val *structured = yyjson_val_mut_copy(doc, structured_root); if (structured) { yyjson_mut_obj_add_val(doc, root, "structuredContent", structured); has_structured_content = true; } } if (!has_structured_content && is_error) { /* structuredContent has now been wrong in both directions, so the rule * is spelled out here in full: * * - JSON-object payload -> structuredContent = the PARSED object * (the branch above; the spec's structured+serialized pattern). * - error -> structuredContent = {"error": } — * bounded, small, and the only machine-readable failure form. * - anything else -> NO structuredContent key at all. * * Pre-#1488 the "anything else" case duplicated the payload verbatim * ({"text": } beside an identical content[0].text — 2.05x the * bytes on a 20k-node query_graph, #1375). #1488 replaced that with an * EMPTY object on the theory that it "still satisfies outputSchema" — * but clients that honor a declared outputSchema treat structuredContent * as THE authoritative result, so every tree-format reply rendered as * literally "{}" in Claude Code and friends (#1522). An empty object * beside a non-empty payload is not conservative; it is a wrong answer. * * The key is therefore OMITTED for text-shaped payloads, and no tool * declares an outputSchema anymore (see mcp_add_tool_def): output is * format-parameter-polymorphic, so a static schema was never truthful. * tests/test_mcp.c binds all three branches; scripts/smoke-test.sh * asserts the same contract on the shipped binary. */ yyjson_mut_val *structured = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, structured, "error", wire_text); yyjson_mut_obj_add_val(doc, root, "structuredContent", structured); } yyjson_mut_obj_add_bool(doc, root, "isError", is_error); char *out = yy_final_doc_to_str(doc); yyjson_mut_doc_free(doc); yyjson_doc_free(structured_doc); free(encoded_text); return out; } /* Rebuild the payload text with the notice attached; NULL when the payload * cannot be rewritten. The result is a memory-core block (class other) on * both branches, so the caller has one release path. */ static char *tool_result_payload_with_notice(const char *payload, const char *notice) { yyjson_doc *payload_doc = yyjson_read(payload, strlen(payload), 0); yyjson_val *payload_root = payload_doc ? yyjson_doc_get_root(payload_doc) : NULL; char *rewritten = NULL; if (payload_root && yyjson_is_obj(payload_root)) { yyjson_mut_doc *copy = yyjson_doc_mut_copy(payload_doc, NULL); yyjson_mut_val *copy_root = copy ? yyjson_mut_doc_get_root(copy) : NULL; char *written = copy_root && yyjson_mut_obj_add_strcpy(copy, copy_root, "notice", notice) ? yy_doc_to_str(copy) : NULL; rewritten = written ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, written) : NULL; safe_free(written); /* yyjson's writer block, not a core block */ yyjson_mut_doc_free(copy); } else { size_t payload_len = strlen(payload); size_t notice_len = strlen(notice); rewritten = cbm_alloc(CBM_MEM_CLASS_OTHER, payload_len + notice_len + 3U); if (rewritten) { memcpy(rewritten, payload, payload_len); memcpy(rewritten + payload_len, "\n\n", 2U); memcpy(rewritten + payload_len + 2U, notice, notice_len + 1U); } } yyjson_doc_free(payload_doc); return rewritten; } char *cbm_mcp_tool_result_add_notice(char *result, const char *notice) { if (!result || !notice || !notice[0]) { return result; } yyjson_doc *doc = yyjson_read(result, strlen(result), 0); yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; yyjson_val *content = root && yyjson_is_obj(root) ? yyjson_obj_get(root, "content") : NULL; yyjson_val *first = content && yyjson_is_arr(content) ? yyjson_arr_get_first(content) : NULL; yyjson_val *text = first && yyjson_is_obj(first) ? yyjson_obj_get(first, "text") : NULL; yyjson_val *is_error = root && yyjson_is_obj(root) ? yyjson_obj_get(root, "isError") : NULL; char *payload = text && yyjson_is_str(text) ? tool_result_payload_with_notice(yyjson_get_str(text), notice) : NULL; char *rebuilt = payload ? cbm_mcp_text_result(payload, is_error && yyjson_get_bool(is_error)) : NULL; cbm_free(CBM_MEM_CLASS_OTHER, payload); yyjson_doc_free(doc); if (!rebuilt) { return result; } safe_free(result); /* the caller's tool result, not a core block */ return rebuilt; } 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 *description; const char *input_schema; /* JSON string */ } tool_def_t; static const tool_def_t TOOLS[] = { {"index_repository", "Index a repository. full/moderate add semantics; fast omits them; cross-repo-intelligence " "links services. Reports coverage gaps. Waits for the whole index by default; a large repo " "can exceed a client's per-call deadline. async:true starts (or joins) the index in the " "daemon and returns at once; poll with status:true (same repo_path) until state is " "succeeded, failed or cancelled. async needs a daemon that outlives the call: an MCP session " "or `daemon start`.", "{\"type\":\"object\",\"properties\":{\"repo_path\":{\"type\":\"string\",\"description\":" "\"Repository path\"}," "\"mode\":{\"type\":\"string\"," "\"enum\":[\"full\",\"moderate\",\"fast\",\"cross-repo-intelligence\"]," "\"default\":\"full\",\"description\":\"full: all+semantic; moderate: " "filtered+semantic; fast: filtered only; cross-repo-intelligence: link services.\"}," "\"target_projects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," "\"description\":\"Cross-repo targets; [\\\"*\\\"] means all.\"}," "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Write .codebase-memory/graph.db.zst.\"}," "\"doc_candidates\":{\"type\":\"boolean\",\"description\":" "\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for " "moderate/fast.\"}," "\"async\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Start the index in the background and return immediately; it keeps running if this " "call is cancelled or times out. Not with status or cross-repo-intelligence. Refused for a " "one-shot cli call that is the only client of a temporary daemon (run daemon start).\"}," "\"status\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Report the running or last index job (queued/running/succeeded/failed/cancelled) " "instead of indexing. Pass the same repo_path (and name, if any) as the index call.\"}" "},\"required\":[\"repo_path\"]}"}, {"search_graph", "Find symbols via BM25 query, regex name/qn filters, or semantic_query. Rows keep " "qn/file/lines and in/out over CALLS/USAGE/CALL_REFERENCE/INHERITS/IMPLEMENTS.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," "\"query\":{\"type\":\"string\"}," "\"label\":{\"type\":\"string\"},\"name_pattern\":{\"type\":\"string\"},\"qn_pattern\":{" "\"type\":\"string\"},\"file_pattern\":{\"type\":\"string\"}," "\"kind\":{\"type\":\"string\",\"description\":\"Resource kind, e.g. Deployment.\"}," "\"namespace\":{\"type\":\"string\",\"description\":\"Resource namespace.\"}," "\"relationship\":{\"type\":\"string\"},\"min_degree\":{\"type\":\"integer\"}," "\"max_degree\":{\"type\":\"integer\"},\"exclude_entry_points\":{\"type\":\"boolean\"}," "\"include_connected\":{\"type\":\"boolean\"},\"semantic_query\":{" "\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Not with query.\"}," "\"semantic_limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":0," "\"maximum\":500}," "\"semantic_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0," "\"maximum\":99998}," "\"limit\":{\"type\":" "\"integer\",\"default\":50,\"minimum\":1,\"maximum\":500}," "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"max_output_tokens\":{\"type\":\"integer\",\"default\":3200,\"minimum\":128," "\"maximum\":1000000}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}," "\"fields\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}," "\"detail\":{\"type\":\"string\",\"enum\":[\"ids\",\"default\"],\"default\":\"default\"}}," "\"required\":[\"project\"]}"}, {"query_graph", "Read-only Cypher for multi-hop, aggregation, complexity, or cross-service analysis. " "Default: 200 visible rows with " "exact/lower-bound totals and truncation; continue safely with next_cursor. " "graph=missed is a file tree of flagged coverage gaps; absence is not proof of completeness. " "Use get_graph_schema(diagnostics=full) for properties.", "{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"Cypher " "query\"},\"project\":{\"type\":\"string\"}," "\"graph\":{\"type\":\"string\",\"enum\":[\"code\",\"missed\"],\"default\":\"code\"," "\"description\":\"code graph (default) or missed coverage-gap file tree.\"}," "\"max_rows\":{\"type\":\"integer\"," "\"description\":" "\"Visible rows (default 200; max 99998); 0 uses the legacy maximum. Evaluation is " "unchanged.\",\"minimum\":0,\"default\":200}," "\"offset\":{\"type\":\"integer\",\"minimum\":0,\"default\":0," "\"description\":\"Live compatibility paging; cannot be combined with cursor.\"}," "\"cursor\":{\"type\":\"string\",\"description\":\"Snapshot continuation; keep " "query/project/graph. Format, budget, and max_rows may change.\"}," "\"max_output_tokens\":{\"type\":\"integer\",\"minimum\":128,\"maximum\":1000000," "\"description\":\"Sizing hint; hard ceiling is 4 UTF-8 bytes/token. Rows stay whole.\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"," "\"description\":\"tree may use local @N+suffix prefix refs; json uses direct strings.\"}}," "\"required\":[\"query\",\"project\"]}"}, {"trace_path", "Trace callers/callees, data flow, or cross-service paths. Defaults exclude tests and " "resolver evidence. Rows keep qn/hop with explicit totals, relations, and continuations.", "{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"project\":{" "\"type\":\"string\"},\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\"," "\"both\"],\"default\":\"both\"},\"depth\":{\"type\":\"integer\",\"default\":3," "\"minimum\":1,\"maximum\":15}," "\"limit\":{\"type\":\"integer\",\"default\":100,\"minimum\":1,\"maximum\":5000," "\"description\":\"Rows/page; gte flags the 5000-node engine ceiling.\"}," "\"max_output_tokens\":{\"type\":\"integer\",\"default\":3200,\"minimum\":128," "\"maximum\":1000000,\"description\":\"Sizing hint; hard ceiling is 4 UTF-8 bytes/token. " "Evidence/args yield before nearest graph rows.\"}," "\"cursor\":{\"type\":\"string\",\"description\":\"Pass next/next_cursor with traversal " "args unchanged; budget may increase.\"}," "\"mode\":{" "\"type\":\"string\",\"enum\":[\"calls\",\"data_flow\",\"cross_service\",\"topology\"," "\"deploy\",\"run\"]," "\"default\":\"calls\",\"description\":\"calls; argument-aware data_flow; service edges; " "topology: infra objects, routes, controllers (from a Resource/Route); deploy: what CI " "jobs and scripts run and deploy; run: what a Service runs (image, Dockerfile, program, " "env).\"}," "\"parameter_name\":{\"type\":\"string\",\"description\":\"data_flow parameter filter.\"}," "\"edge_types\":{\"type\":\"array\",\"items\":{" "\"type\":\"string\"}},\"risk_labels\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Add hop-risk labels.\"},\"include_tests\":{\"type\":\"boolean\"," "\"default\":false}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"," "\"description\":\"tree chooses smaller complete direct/grouped output; json uses stable " "grouped tables.\"}," "\"include_evidence\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Add resolver class and confidence.\"}}," "\"required\":[\"function_name\",\"project\"]}"}, {"get_code_snippet", "Read a search_graph symbol. auto bounds source and outlines large containers; full " "restores up to 500 lines. Source/outline pages continue; coverage_note marks gaps.", "{\"type\":\"object\",\"properties\":{\"qualified_name\":{\"type\":\"string\",\"description\":" "\"search_graph qn, or short name.\"},\"project\":{" "\"type\":\"string\"},\"include_neighbors\":{" "\"type\":\"boolean\",\"default\":false}," "\"source_mode\":{\"type\":\"string\",\"enum\":[\"auto\",\"full\",\"outline\"]," "\"default\":\"auto\",\"description\":\"auto outlines 200+ line containers; full returns " "source; " "outline lists members.\"}," "\"member_limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":1,\"maximum\":500}," "\"member_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"start_line\":{\"type\":\"integer\",\"minimum\":1}," "\"max_lines\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":500}," "\"max_output_tokens\":{\"type\":\"integer\",\"minimum\":128," "\"maximum\":1000000,\"description\":\"auto/outline sizing hint (default 2500), hard " "ceiling 4 UTF-8 bytes/token. Lines stay whole; explicit full is uncapped unless set.\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":[\"qualified_name\",\"project\"]}"}, {"get_file_outline", "Declaration outline of one exact repository-relative file: optional exact label filter, " "source order, exact total/offset/limit paging; file/folder/container nodes excluded.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," "\"file_path\":{\"type\":\"string\",\"description\":\"Exact repository-relative " "file path\"},\"labels\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," "\"maxItems\":16,\"description\":\"Optional exact node-label filter\"}," "\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":200,\"default\":100}," "\"offset\":{\"type\":\"integer\",\"minimum\":0,\"default\":0}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"]," "\"default\":\"tree\"}},\"additionalProperties\":false," "\"required\":[\"project\",\"file_path\"]}"}, {"get_graph_schema", "Get node-label and edge-type counts. diagnostics=full also lists queryable properties.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}," "\"diagnostics\":{\"type\":\"string\",\"enum\":[\"none\",\"full\"],\"default\":\"none\"}," "\"limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":1,\"maximum\":500}," "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}},\"required\":[" "\"project\"]}"}, {"compare_graphs", "Compare two indexed snapshots: deterministic target-only additions and base-only " "removals of stable node/edge identities; each set capped by limit and a 512 KiB budget " "with exact totals and truncation reasons.", "{\"type\":\"object\",\"properties\":{" "\"base_project\":{\"type\":\"string\",\"minLength\":1}," "\"target_project\":{\"type\":\"string\",\"minLength\":1}," "\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":1000,\"default\":200}," "\"scan_limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":10000000," "\"default\":2000000}},\"required\":[\"base_project\",\"target_project\"]," "\"additionalProperties\":false}"}, {"get_architecture", "Bounded static architecture; poll pending jobs. include_behavior_evidence=true adds " "indexed call arguments, parameter declarations and return types; not full dataflow or " "runtime traces.", /* The aspects enum mirrors VALID_ASPECTS (see aspect_is_valid) — update both together. */ "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"path\":{\"type\":" "\"string\",\"description\":\"Directory prefix.\"}," "\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"all\"," "\"overview\",\"structure\",\"dependencies\",\"routes\",\"languages\",\"packages\"," "\"entry_points\",\"hotspots\",\"boundaries\",\"layers\",\"file_tree\",\"clusters\"," "\"cycles\",\"system_structure\"]}," "\"description\":\"Default: languages/packages/entry_points. overview omits file_tree. " "cycles/system_structure opt-in; system_structure alone, no path.\"}," "\"entry_node_id\":{\"type\":\"integer\",\"minimum\":1," "\"description\":\"Start symbol.\"}," "\"target_node_id\":{\"type\":\"integer\",\"minimum\":1," "\"description\":\"Requires entry_node_id.\"}," "\"expected_generation\":{\"type\":\"string\",\"minLength\":1," "\"description\":\"Required generation.\"}," "\"include_behavior_evidence\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"system_structure only.\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":[\"project\"]}"}, {"search_code", "Graph-ranked text search: compact symbols, full bounded source, or file paths.", "{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":" "\"string\"},\"file_pattern\":{\"type\":\"string\"},\"path_filter\":{\"type\":\"string\"}," "\"mode\":{\"type\":\"string\"," "\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\"}," "\"context\":{\"type\":\"integer\",\"default\":0,\"minimum\":0,\"maximum\":200}," "\"regex\":{\"type\":\"boolean\",\"default\":false}," "\"debug\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Add scope_ms/scan_ms/enrich_ms phase timings.\"}," "\"limit\":{\"type\":\"integer\"," "\"default\":10,\"minimum\":1,\"maximum\":500}," "\"result_limit\":{\"type\":\"integer\",\"default\":10,\"minimum\":1,\"maximum\":500}," "\"result_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"raw_limit\":{\"type\":\"integer\",\"default\":5,\"minimum\":0,\"maximum\":100}," "\"raw_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"raw_content_offset\":{\"type\":\"integer\",\"minimum\":0," "\"description\":\"Raw-line byte offset; omit for match-centered preview.\"}," "\"directory_limit\":{\"type\":\"integer\",\"default\":20,\"minimum\":0," "\"maximum\":64}," "\"directory_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"match_limit\":{\"type\":\"integer\",\"default\":8,\"minimum\":1," "\"maximum\":500}," "\"source_max_lines\":{\"type\":\"integer\",\"default\":20,\"minimum\":1," "\"maximum\":200}," "\"max_output_tokens\":{\"type\":\"integer\",\"minimum\":128,\"maximum\":1000000}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":[\"pattern\",\"project\"]}"}, {"list_projects", "List projects with stable paging. Identity is lean; stats adds graph sizes.", "{\"type\":\"object\",\"properties\":{" "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}," "\"detail\":{\"type\":\"string\",\"enum\":[\"identity\",\"stats\"]," "\"default\":\"identity\",\"description\":\"stats adds node/edge/database-size counts.\"}," "\"include_details\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Alias for detail=stats: include branch, node/edge counts and database " "size. Slower.\"}," "\"limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":1,\"maximum\":500}," "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"metadata_only\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"}, {"delete_project", "Delete a project from the index", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, {"index_status", "Project readiness, counts, root, watch state, coverage gaps, and doc_links (doc-comment " "references: MENTIONS edges, unresolved by reason). diagnostics adds coverage rows; verbose " "adds Git paths. Best-effort only; verify cited paths with check_index_coverage.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," "\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Add worktree/" "shadow Git paths for index-location debugging.\"}," "\"diagnostics\":{\"type\":\"string\",\"enum\":[\"none\",\"summary\",\"full\"]," "\"default\":\"none\",\"description\":\"Coverage rows: counts, five samples, or up to " "500.\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":[" "\"project\"]}"}, {"check_index_coverage", "Best-effort exact-path/scope coverage and freshness, paged separately. full diagnostics " "adds raw detail. Clean is not proof of completeness.", "{\"type\":\"object\",\"properties\":{" "\"project\":{\"type\":\"string\"}," "\"paths\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"maxItems\":128}," "\"path_limit\":{\"type\":\"integer\",\"default\":20,\"minimum\":1,\"maximum\":128}," "\"path_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"scopes\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"maxItems\":32}," "\"scope_limit\":{\"type\":\"integer\",\"default\":20,\"minimum\":1,\"maximum\":1000}," "\"scope_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"diagnostics\":{\"type\":\"string\",\"enum\":[\"none\",\"full\"],\"default\":\"none\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":[\"project\"]" "}"}, {"detect_changes", "Map a Git diff to files and impact. Page with snapshot cursors.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":" "\"string\",\"enum\":[\"files\",\"impact\",\"tests\"],\"default\":\"impact\"}," "\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\",\"both\"],\"default\":" "\"inbound\",\"description\":\"inbound=callers; outbound=dependencies; both=union.\"}," "\"depth\":{\"type\":\"integer\",\"default\":2}," "\"edge_types\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," "\"description\":\"Inbound walk; default CALLS.\"}," "\"fixpoint\":{\"type\":\"boolean\",\"description\":\"Walk to closure; no depth.\"}," "\"via\":{\"type\":\"boolean\",\"description\":\"Add via_qn, via_edge.\"}," "\"seeds\":{\"type\":\"string\",\"enum\":[\"none\",\"list\"],\"default\":\"none\"," "\"description\":\"List changed-symbol seeds with independent pagination.\"}," "\"seed_filter\":{\"type\":\"string\",\"enum\":[\"all\",\"product\"],\"default\":\"all\"," "\"description\":\"Product excludes indexed test metadata and configured test globs.\"}," "\"config_path\":{\"type\":\"string\",\"description\":\"Project config override; absolute or " "project-relative.\"}," "\"seed_limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":0,\"maximum\":1000}," "\"seed_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"seed_cursor\":{\"type\":\"string\",\"description\":\"Continue seeds:list; excludes nonzero " "seed_offset.\"}," "\"limit\":{\"type\":\"integer\",\"default\":200,\"maximum\":5000}," "\"impact_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"impact_cursor\":{\"type\":\"string\"}," "\"changed_limit\":{\"type\":\"integer\",\"default\":20,\"minimum\":0,\"maximum\":5000}," "\"changed_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"changed_cursor\":{\"type\":\"string\"}," "\"module_limit\":{\"type\":\"integer\",\"default\":20,\"minimum\":0,\"maximum\":256}," "\"module_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"module_cursor\":{\"type\":\"string\"}," "\"max_output_tokens\":{\"type\":\"integer\",\"default\":3200,\"minimum\":128," "\"maximum\":1000000,\"description\":\"Sizing hint; hard ceiling is 4 UTF-8 bytes/token.\"}," "\"base_branch\":{\"type\":\"string\",\"description\":\"Default: origin/HEAD, else " "main, else master, else the upstream.\"},\"since\":{\"type\":\"string\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":" "[\"project\"]}"}, {"manage_adr", "Outline an ADR by default; get reads it; update replaces it", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":" "\"string\",\"enum\":[\"outline\",\"get\",\"update\",\"set_sections\",\"sections\"]," "\"default\":\"outline\",\"description\":\"outline pages headings; get reads; update replaces " "the whole document; set_sections rewrites only the named sections and leaves every other " "byte untouched (an identical repeated write is byte-identical, so retrying is safe); " "sections is legacy.\"}," "\"content\":{\"type\":\"string\",\"description\":\"Whole document for update\"}," "\"section_updates\":{\"type\":\"object\",\"description\":\"set_sections: section name -> " "new body. Any heading name works; names match exactly, including case.\"," "\"additionalProperties\":{\"type\":\"string\"}}," "\"section_limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":1,\"maximum\":500}," "\"section_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"additionalProperties\":false," "\"required\":[\"project\"]}"}, {"ingest_traces", "Record observed runtime calls so the graph can distinguish observed from merely possible " "paths. Each trace is a call path (qualified names, adjacent = caller\xe2\x86\x92" "callee) or a bare caller/callee pair. Stored per run label, keyed by qualified name " "(reindex-proof). Only pairs whose both endpoints are indexed symbols are stored; the " "response reports unresolved names (up to 10) so the producer can fix its naming.", "{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":" "\"object\",\"properties\":{\"path\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," "\"description\":\"Call path of qualified names, caller before callee; 2..256 entries\"}," "\"caller\":{\"type\":\"string\"},\"callee\":{\"type\":\"string\"}," "\"count\":{\"type\":\"integer\",\"description\":\"How often this trace was observed " "(default 1)\"}},\"additionalProperties\":false}},\"label\":{\"type\":\"string\"," "\"description\":\"Run identity, e.g. 'pytest 2026-08-27' (default 'unlabeled'); counts " "accumulate per label and whole runs age out together\"},\"project\":{\"type\":" "\"string\"}},\"required\":[\"traces\",\"project\"]}"}, }; static const int TOOL_COUNT = sizeof(TOOLS) / sizeof(TOOLS[0]); typedef struct { const char *name; bool read_only; bool destructive; bool idempotent; bool open_world; bool load_upfront; } tool_annotation_def_t; /* Tool annotations are deliberately explicit. All tools operate on the local * repository/index domain, so none cross an open-world trust boundary. * * The ten pure query tools resolve their store through resolve_store(), whose * query-only path is strictly non-mutating: a corrupt database is reported and * left in place, never quarantined or rebuilt — quarantine/rebuild is reserved * for write-side opens (index_repository, manage_adr writes). That is what * makes readOnlyHint=true honest for them and lets plan-mode clients expose * them (the "read-only analysis tools" surface described in #1100). * get_file_outline reads through the same resolve_store() path (#2118). * Only handlers that write user-visible state -- index_repository, manage_adr, * delete_project, ingest_traces -- may clear readOnlyHint. Trace ingestion * accumulates observation counts on retries and can evict old runs, so it is * neither read-only nor idempotent and must advertise destructive effects. * * load_upfront marks the structural discovery path (search_graph -> * trace_path -> get_code_snippet). Claude Code defers every MCP tool behind * ToolSearch by default, so an agent deciding how to answer sees Grep and Read * but not the graph; `_meta["anthropic/alwaysLoad"]` asks it to load these * three schemas upfront (about 4.6K characters) while the rest stay deferred. * Clients that do not know the key ignore it, as MCP reserves `_meta`. */ static const tool_annotation_def_t TOOL_ANNOTATIONS[] = { {"index_repository", false, false, true, false, false}, {"search_graph", true, false, true, false, true}, {"query_graph", true, false, true, false, false}, {"trace_path", true, false, true, false, true}, {"get_code_snippet", true, false, true, false, true}, {"get_file_outline", true, false, true, false, false}, {"get_graph_schema", true, false, true, false, false}, {"compare_graphs", true, false, true, false, false}, {"get_architecture", true, false, true, false, false}, {"search_code", true, false, true, false, false}, {"list_projects", true, false, true, false, false}, {"delete_project", false, true, true, false, false}, {"index_status", true, false, true, false, false}, {"check_index_coverage", true, false, true, false, false}, {"detect_changes", true, false, true, false, false}, {"manage_adr", false, true, false, false, false}, {"ingest_traces", false, true, false, false, false}, }; static const tool_annotation_def_t *mcp_tool_annotations(const char *name) { size_t count = sizeof(TOOL_ANNOTATIONS) / sizeof(TOOL_ANNOTATIONS[0]); for (size_t i = 0; i < count; i++) { if (strcmp(TOOL_ANNOTATIONS[i].name, name) == 0) { return &TOOL_ANNOTATIONS[i]; } } return NULL; } static void mcp_add_json_schema(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *key, const char *schema_json) { yyjson_doc *schema_doc = yyjson_read(schema_json, strlen(schema_json), 0); if (schema_doc) { yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc)); if (schema) { yyjson_mut_obj_add_val(doc, obj, key, schema); } yyjson_doc_free(schema_doc); } } static void mcp_add_tool_def(yyjson_mut_doc *doc, yyjson_mut_val *tools, int i) { yyjson_mut_val *tool = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name); /* MCP title is optional and clients fall back to name. These titles only * repeated the snake_case name with spaces, so omit their recurring * discovery cost and retain the behavioral description instead. */ yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description); mcp_add_json_schema(doc, tool, "inputSchema", TOOLS[i].input_schema); /* Deliberately NO outputSchema. Tool output is format-parameter-polymorphic * (tree text by default, a JSON object under format:"json"), so no static * schema is truthful — and a declared schema makes spec-honoring clients * read structuredContent as the authoritative result, which is exactly how * the empty-object regression rendered every tree reply as "{}" (#1522). * The blanket {"type":"object","additionalProperties":true} it replaced * validated anything and informed nobody. */ const tool_annotation_def_t *def = mcp_tool_annotations(TOOLS[i].name); yyjson_mut_val *annotations = yyjson_mut_obj(doc); yyjson_mut_obj_add_bool(doc, annotations, "readOnlyHint", def ? def->read_only : false); yyjson_mut_obj_add_bool(doc, annotations, "destructiveHint", def ? def->destructive : true); yyjson_mut_obj_add_bool(doc, annotations, "idempotentHint", def ? def->idempotent : false); yyjson_mut_obj_add_bool(doc, annotations, "openWorldHint", def ? def->open_world : true); yyjson_mut_obj_add_val(doc, tool, "annotations", annotations); if (def && def->load_upfront) { yyjson_mut_val *meta = yyjson_mut_obj(doc); yyjson_mut_obj_add_bool(doc, meta, "anthropic/alwaysLoad", true); yyjson_mut_obj_add_val(doc, tool, "_meta", meta); } yyjson_mut_arr_add_val(tools, tool); } static bool mcp_tool_allowed(cbm_mcp_tool_profile_t profile, const char *name) { static const char *const analysis_tools[] = { "search_graph", "query_graph", "trace_path", "get_code_snippet", "get_file_outline", "get_graph_schema", "compare_graphs", "get_architecture", "search_code", "list_projects", "index_status", "check_index_coverage", "detect_changes", }; static const char *const scout_tools[] = { "search_graph", "trace_path", "get_code_snippet", "get_file_outline", "get_architecture", "list_projects", "index_status", "check_index_coverage", }; if (!name) { return false; } if (profile == CBM_MCP_TOOL_PROFILE_ALL) { return true; } const char *const *allowed = NULL; size_t allowed_count = 0U; if (profile == CBM_MCP_TOOL_PROFILE_ANALYSIS) { allowed = analysis_tools; allowed_count = sizeof(analysis_tools) / sizeof(analysis_tools[0]); } else if (profile == CBM_MCP_TOOL_PROFILE_SCOUT) { allowed = scout_tools; allowed_count = sizeof(scout_tools) / sizeof(scout_tools[0]); } for (size_t i = 0U; i < allowed_count; i++) { if (strcmp(name, allowed[i]) == 0) { return true; } } return false; } static const char *mcp_tool_profile_name(cbm_mcp_tool_profile_t profile) { return profile == CBM_MCP_TOOL_PROFILE_SCOUT ? "scout" : "analysis"; } int cbm_mcp_parse_tool_profile_args(int argc, const char *const argv[const], cbm_mcp_tool_profile_t *profile_out) { if (argc < 0 || !argv || !profile_out) { return -1; } *profile_out = CBM_MCP_TOOL_PROFILE_ALL; for (int i = 1; i < argc; i++) { const char *arg = argv[i]; if (!arg) { return -1; } if (strcmp(arg, "--tool-profile=analysis") == 0) { *profile_out = CBM_MCP_TOOL_PROFILE_ANALYSIS; continue; } if (strcmp(arg, "--tool-profile=scout") == 0) { *profile_out = CBM_MCP_TOOL_PROFILE_SCOUT; continue; } if (strcmp(arg, "--tool-profile") == 0) { if (i + 1 >= argc || !argv[i + 1]) { return -1; } if (strcmp(argv[i + 1], "analysis") == 0) { *profile_out = CBM_MCP_TOOL_PROFILE_ANALYSIS; } else if (strcmp(argv[i + 1], "scout") == 0) { *profile_out = CBM_MCP_TOOL_PROFILE_SCOUT; } else { return -1; } i++; continue; } if (strncmp(arg, "--tool-profile=", strlen("--tool-profile=")) == 0) { return -1; } } return 0; } bool cbm_mcp_tool_profile_allows_http(cbm_mcp_tool_profile_t profile) { return profile == CBM_MCP_TOOL_PROFILE_ALL; } static int mcp_allowed_tool_count(cbm_mcp_tool_profile_t profile) { int count = 0; for (int i = 0; i < TOOL_COUNT; i++) { if (mcp_tool_allowed(profile, TOOLS[i].name)) { count++; } } return count; } static char *cbm_mcp_tools_list_range(cbm_mcp_tool_profile_t profile, int offset, int limit, bool include_next_cursor) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_val *tools = yyjson_mut_arr(doc); if (offset < 0) { offset = 0; } int allowed_count = mcp_allowed_tool_count(profile); if (offset > allowed_count) { offset = allowed_count; } if (limit < 0 || limit > allowed_count) { limit = allowed_count; } int end = offset + limit; if (end > allowed_count) { end = allowed_count; } int visible = 0; for (int i = 0; i < TOOL_COUNT && visible < end; i++) { if (!mcp_tool_allowed(profile, TOOLS[i].name)) { continue; } if (visible >= offset) { mcp_add_tool_def(doc, tools, i); } visible++; } yyjson_mut_obj_add_val(doc, root, "tools", tools); if (include_next_cursor && end < allowed_count) { char cursor[32]; snprintf(cursor, sizeof(cursor), "%d", end); yyjson_mut_obj_add_strcpy(doc, root, "nextCursor", cursor); } char *out = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } char *cbm_mcp_tools_list(void) { return cbm_mcp_tools_list_range(CBM_MCP_TOOL_PROFILE_ALL, 0, TOOL_COUNT, false); } /* Return the JSON input_schema string for a tool by name, or NULL if unknown. * Used by the CLI to build --flag arguments and per-tool --help from the same * source of truth the MCP tools/list advertises. Static lifetime; do not free. */ const char *cbm_mcp_tool_input_schema(const char *tool_name) { if (!tool_name) { return NULL; } for (int i = 0; i < TOOL_COUNT; i++) { if (strcmp(TOOLS[i].name, tool_name) == 0) { return TOOLS[i].input_schema; } } return NULL; } int cbm_mcp_tool_count(void) { return TOOL_COUNT; } const char *cbm_mcp_tool_name(int index) { if (index < 0 || index >= TOOL_COUNT) { return NULL; } return TOOLS[index].name; } const char *cbm_mcp_tool_description(const char *tool_name) { if (!tool_name) { return NULL; } for (int i = 0; i < TOOL_COUNT; i++) { if (strcmp(TOOLS[i].name, tool_name) == 0) { return TOOLS[i].description; } } return NULL; } /* Render the top-level --help "Tools:" block from the registry tools/list * serves. The list used to be hand-maintained in the help text and drifted * when check_index_coverage was added (#1361); deriving it here makes that * divergence impossible. Heap-allocated; caller frees. */ /* Append at out[len] and return the bytes ACTUALLY written. * * snprintf returns the length it WOULD have written, so accumulating that value * lets len run past cap; the next `cap - len` then underflows to a huge size_t * and the following write lands outside the buffer. CodeQL flagged exactly that * shape here, and it is the same class #1173 just fixed in the Cypher list * builder. The capacity computed below does happen to be sufficient today — * which makes this the more dangerous version, not the safer one: the code is * correct only by an argument made ten lines away, so renaming a tool or * changing the wrap rule would turn it into an overflow with nothing to notice. * Clamping makes `len <= cap - 1` a local invariant no later edit can void. */ static size_t help_append(char *out, size_t cap, size_t len, const char *fmt, ...) { if (len + 1 >= cap) { return 0; } va_list args; va_start(args, fmt); int written = vsnprintf(out + len, cap - len, fmt, args); va_end(args); if (written <= 0) { return 0; } size_t room = cap - len - 1; return (size_t)written > room ? room : (size_t)written; } char *cbm_mcp_tools_help_list(void) { size_t cap = SLEN("Tools:") + 2; /* trailing newline + NUL */ for (int i = 0; i < TOOL_COUNT; i++) { cap += strlen(TOOLS[i].name) + SLEN(" ,\n "); /* per-tool worst case incl. a wrap */ } char *out = malloc(cap); if (!out) { return NULL; } size_t len = help_append(out, cap, 0, "Tools:"); size_t col = len; for (int i = 0; i < TOOL_COUNT; i++) { const char *sep = (i + 1 < TOOL_COUNT) ? "," : ""; size_t item = SLEN(" ") + strlen(TOOLS[i].name) + strlen(sep); if (i > 0 && col + item > MCP_HELP_TOOLS_WRAP_COL) { len += help_append(out, cap, len, "\n "); col = 1; } size_t wrote = help_append(out, cap, len, " %s%s", TOOLS[i].name, sep); len += wrote; col += wrote; } len += help_append(out, cap, len, "\n"); (void)len; /* final length is not needed; the buffer is NUL-terminated */ return out; } static int mcp_tools_cursor_offset(const char *params_json, bool *has_cursor_out) { if (has_cursor_out) { *has_cursor_out = false; } if (!params_json) { return 0; } yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0); if (!doc) { return 0; } int offset = 0; yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *cursor = root ? yyjson_obj_get(root, "cursor") : NULL; if (cursor) { if (has_cursor_out) { *has_cursor_out = true; } offset = TOOL_COUNT; if (yyjson_is_str(cursor)) { const char *cursor_str = yyjson_get_str(cursor); if (cursor_str && *cursor_str != '\0') { char *endptr = NULL; errno = 0; long parsed = strtol(cursor_str, &endptr, 10); if (endptr && *endptr == '\0' && errno == 0 && parsed >= 0) { offset = parsed > TOOL_COUNT ? TOOL_COUNT : (int)parsed; } } } } yyjson_doc_free(doc); return offset; } static char *cbm_mcp_tools_list_page(cbm_mcp_tool_profile_t profile, const char *params_json) { bool has_cursor = false; int offset = mcp_tools_cursor_offset(params_json, &has_cursor); if (!has_cursor) { return cbm_mcp_tools_list_range(profile, 0, TOOL_COUNT, false); } return cbm_mcp_tools_list_range(profile, offset, MCP_TOOLS_PAGE_SIZE, true); } /* ── Prompt definitions ───────────────────────────────────────── */ static void mcp_add_prompt_argument(yyjson_mut_doc *doc, yyjson_mut_val *arguments, const char *name, const char *title, const char *description, bool required) { yyjson_mut_val *argument = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, argument, "name", name); yyjson_mut_obj_add_str(doc, argument, "title", title); yyjson_mut_obj_add_str(doc, argument, "description", description); yyjson_mut_obj_add_bool(doc, argument, "required", required); yyjson_mut_arr_add_val(arguments, argument); } static char *cbm_mcp_prompts_list(void) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_val *prompts = yyjson_mut_arr(doc); yyjson_mut_val *explore = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, explore, "name", "explore_codebase"); yyjson_mut_obj_add_str(doc, explore, "title", "Explore codebase"); yyjson_mut_obj_add_str(doc, explore, "description", "Explore a codebase with graph-first structural discovery."); yyjson_mut_val *explore_arguments = yyjson_mut_arr(doc); mcp_add_prompt_argument(doc, explore_arguments, "project", "Project", "Indexed project name from list_projects.", true); mcp_add_prompt_argument(doc, explore_arguments, "question", "Question", "Architecture or implementation question to investigate.", true); yyjson_mut_obj_add_val(doc, explore, "arguments", explore_arguments); yyjson_mut_arr_add_val(prompts, explore); yyjson_mut_val *review = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, review, "name", "review_change_impact"); yyjson_mut_obj_add_str(doc, review, "title", "Review change impact"); yyjson_mut_obj_add_str(doc, review, "description", "Review affected callers, tests, boundaries, and risks."); yyjson_mut_val *review_arguments = yyjson_mut_arr(doc); mcp_add_prompt_argument(doc, review_arguments, "project", "Project", "Indexed project name from list_projects.", true); mcp_add_prompt_argument(doc, review_arguments, "change", "Change", "Change, symbol, or area whose impact should be reviewed.", true); mcp_add_prompt_argument(doc, review_arguments, "base_branch", "Base branch", "Git branch or ref for detect_changes; defaults to main.", false); yyjson_mut_obj_add_val(doc, review, "arguments", review_arguments); yyjson_mut_arr_add_val(prompts, review); yyjson_mut_obj_add_val(doc, root, "prompts", prompts); char *out = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } static const char *mcp_prompt_string_argument(yyjson_val *arguments, const char *name) { if (!arguments || !yyjson_is_obj(arguments)) { return NULL; } yyjson_val *value = yyjson_obj_get(arguments, name); if (!value || !yyjson_is_str(value)) { return NULL; } const char *text = yyjson_get_str(value); return text && text[0] ? text : NULL; } static char *mcp_prompt_result(const char *description, const char *text) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_str(doc, root, "description", description); yyjson_mut_val *messages = yyjson_mut_arr(doc); yyjson_mut_val *message = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, message, "role", "user"); yyjson_mut_val *content = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, content, "type", "text"); yyjson_mut_obj_add_str(doc, content, "text", text); yyjson_mut_obj_add_val(doc, message, "content", content); yyjson_mut_arr_add_val(messages, message); yyjson_mut_obj_add_val(doc, root, "messages", messages); char *out = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } static char *mcp_prompt_error_json(int code, const char *message) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_int(doc, root, "code", code); yyjson_mut_obj_add_str(doc, root, "message", message); char *out = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } static char *cbm_mcp_prompt_get(const char *params_json, char **error_json) { *error_json = NULL; yyjson_doc *params_doc = params_json ? yyjson_read(params_json, strlen(params_json), 0) : NULL; yyjson_val *params = params_doc ? yyjson_doc_get_root(params_doc) : NULL; yyjson_val *name_value = params && yyjson_is_obj(params) ? yyjson_obj_get(params, "name") : NULL; if (!name_value || !yyjson_is_str(name_value)) { *error_json = mcp_prompt_error_json(JSONRPC_INVALID_PARAMS, "Invalid prompt name"); if (params_doc) { yyjson_doc_free(params_doc); } return NULL; } const char *name = yyjson_get_str(name_value); bool is_explore = strcmp(name, "explore_codebase") == 0; bool is_review = strcmp(name, "review_change_impact") == 0; if (!is_explore && !is_review) { *error_json = mcp_prompt_error_json(JSONRPC_INVALID_PARAMS, "Invalid prompt name"); yyjson_doc_free(params_doc); return NULL; } yyjson_val *arguments = yyjson_obj_get(params, "arguments"); const char *project = mcp_prompt_string_argument(arguments, "project"); const char *request = mcp_prompt_string_argument(arguments, is_explore ? "question" : "change"); if (!project || !request) { *error_json = mcp_prompt_error_json(JSONRPC_INVALID_PARAMS, "Missing required prompt arguments"); yyjson_doc_free(params_doc); return NULL; } const char *base_branch = "main"; yyjson_val *base_branch_value = is_review && arguments && yyjson_is_obj(arguments) ? yyjson_obj_get(arguments, "base_branch") : NULL; if (base_branch_value) { if (!yyjson_is_str(base_branch_value) || !yyjson_get_str(base_branch_value)[0]) { *error_json = mcp_prompt_error_json(JSONRPC_INVALID_PARAMS, "Invalid prompt arguments"); yyjson_doc_free(params_doc); return NULL; } base_branch = yyjson_get_str(base_branch_value); } static const char EXPLORE_TEMPLATE[] = "Explore project \"%s\" to answer: %s\n\n" "Use graph tools first: search_graph to find relevant symbols, get_code_snippet for " "exact source, and trace_path(direction=\"both\") for callers and callees. Use " "get_architecture for broad orientation and query_graph only for multi-hop patterns. " "Check has_more and paginate. Fall back to search_code or grep only for literal or " "non-code text, or where graph coverage is incomplete."; static const char REVIEW_TEMPLATE[] = "Review change impact in project \"%s\" for: %s\n\n" "Use detect_changes with base_branch \"%s\", then trace_path(direction=\"both\", " "include_tests=true) for affected callers, callees, and tests. Read exact definitions " "with get_code_snippet and use query_graph for cross-boundary patterns. Report affected " "callers, tests, boundaries, and risks; do not modify files."; size_t text_size = strlen(project) + strlen(request) + strlen(base_branch) + (is_explore ? sizeof(EXPLORE_TEMPLATE) : sizeof(REVIEW_TEMPLATE)); char *text = malloc(text_size); if (!text) { *error_json = mcp_prompt_error_json(JSONRPC_INTERNAL_ERROR, "Internal error"); yyjson_doc_free(params_doc); return NULL; } if (is_explore) { snprintf(text, text_size, EXPLORE_TEMPLATE, project, request); } else { snprintf(text, text_size, REVIEW_TEMPLATE, project, request, base_branch); } char *result = mcp_prompt_result( is_explore ? "Graph-first codebase exploration" : "Graph-first change-impact review", text); free(text); yyjson_doc_free(params_doc); return result; } /* Supported protocol versions, newest first. The server picks the newest * version that it shares with the client (per MCP spec version negotiation). */ static const char *SUPPORTED_PROTOCOL_VERSIONS[] = { "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", }; static const int SUPPORTED_VERSION_COUNT = (int)(sizeof(SUPPORTED_PROTOCOL_VERSIONS) / sizeof(SUPPORTED_PROTOCOL_VERSIONS[0])); /* With tool search (Claude Code's default, Codex's too) the model sees only tool * names plus these instructions until it searches, and Codex shows them as the * server's namespace description. So they state, keywords first, which * questions the graph answers; facts, not commands. Claude Code cuts them at * 4,096 characters. */ static const char MCP_SERVER_INSTRUCTIONS[] = "codebase-memory-mcp is a code knowledge graph of the indexed repository: functions, " "classes, methods, routes, calls, imports, inheritance and cross-service links. It answers " "structural code questions: where a symbol is defined, who calls a function, what a function " "calls, call chains and data flow, which code a change affects, which handler serves a route, " "and how modules fit together. search_graph finds symbols by name or pattern; trace_path " "follows callers, callees and data flow; get_code_snippet returns a symbol's exact source; " "query_graph runs multi-hop Cypher queries; get_architecture summarizes structure; " "detect_changes maps the current diff to affected symbols. Grep and file reads fit literal " "text, config values, comments and non-code files better; search_code combines text search " "with graph context. With auto_watch enabled, an index registered by an active MCP session " "refreshes automatically; sessions register their root and successful explicit " "index_repository targets. CLI calls do not add watches. check_index_coverage tells whether " "a cited file was fully indexed. " "Results are paginated."; static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] = "analysis tool profile (read-only): a code knowledge graph of the indexed repository for " "structural questions: where a symbol is defined, who calls a function, what it calls, call " "chains and data flow, which code a change affects, how modules fit together. search_graph " "finds symbols; trace_path follows callers, callees and data flow; get_code_snippet returns " "exact source; query_graph runs multi-hop Cypher; get_architecture summarizes structure; " "search_code combines text search with graph context; detect_changes maps the current diff " "to affected symbols. check_index_coverage tells whether a cited file was fully indexed. " "Results are paginated."; static const char MCP_SCOUT_SERVER_INSTRUCTIONS[] = "scout tool profile (fast, provisional): quick structural lookups in the indexed " "repository: where a symbol is defined, who calls a function, what it calls. search_graph " "finds symbols; trace_path follows callers and callees; get_code_snippet returns exact " "source; get_architecture summarizes structure; narrow limits keep answers fast. " "check_index_coverage tells whether a cited file was fully indexed. Findings are " "provisional."; static char *cbm_mcp_initialize_response_for_profile(const char *params_json, cbm_mcp_tool_profile_t profile) { /* Determine protocol version: if client requests a version we support, * echo it back; otherwise respond with our latest. */ const char *version = SUPPORTED_PROTOCOL_VERSIONS[0]; /* default: latest */ if (params_json) { yyjson_doc *pdoc = yyjson_read(params_json, strlen(params_json), 0); if (pdoc) { yyjson_val *pv = yyjson_obj_get(yyjson_doc_get_root(pdoc), "protocolVersion"); if (pv && yyjson_is_str(pv)) { const char *requested = yyjson_get_str(pv); for (int i = 0; i < SUPPORTED_VERSION_COUNT; i++) { if (strcmp(requested, SUPPORTED_PROTOCOL_VERSIONS[i]) == 0) { version = SUPPORTED_PROTOCOL_VERSIONS[i]; break; } } } yyjson_doc_free(pdoc); } } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_str(doc, root, "protocolVersion", version); yyjson_mut_val *impl = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, impl, "name", "codebase-memory-mcp"); yyjson_mut_obj_add_str(doc, impl, "version", cbm_cli_get_version()); yyjson_mut_obj_add_val(doc, root, "serverInfo", impl); 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_val *prompts_cap = yyjson_mut_obj(doc); yyjson_mut_obj_add_bool(doc, prompts_cap, "listChanged", false); yyjson_mut_obj_add_val(doc, caps, "prompts", prompts_cap); yyjson_mut_obj_add_val(doc, root, "capabilities", caps); const char *instructions = MCP_SERVER_INSTRUCTIONS; if (profile == CBM_MCP_TOOL_PROFILE_ANALYSIS) { instructions = MCP_ANALYSIS_SERVER_INSTRUCTIONS; } else if (profile == CBM_MCP_TOOL_PROFILE_SCOUT) { instructions = MCP_SCOUT_SERVER_INSTRUCTIONS; } yyjson_mut_obj_add_str(doc, root, "instructions", instructions); char *out = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); return out; } char *cbm_mcp_initialize_response(const char *params_json) { return cbm_mcp_initialize_response_for_profile(params_json, CBM_MCP_TOOL_PROFILE_ALL); } /* ══════════════════════════════════════════════════════════════════ * ARGUMENT EXTRACTION * ══════════════════════════════════════════════════════════════════ */ char *cbm_mcp_get_tool_name(const char *params_json) { yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0); if (!doc) { return NULL; } yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *name = yyjson_obj_get(root, "name"); char *result = NULL; if (name && yyjson_is_str(name)) { result = heap_strdup(yyjson_get_str(name)); } yyjson_doc_free(doc); return result; } char *cbm_mcp_get_arguments(const char *params_json) { yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0); if (!doc) { return NULL; } yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *args = yyjson_obj_get(root, "arguments"); char *result = NULL; if (args) { result = yyjson_val_write(args, 0, NULL); } yyjson_doc_free(doc); return result ? result : heap_strdup("{}"); } char *cbm_mcp_get_string_arg(const char *args_json, const char *key) { yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0); if (!doc) { return NULL; } yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *val = yyjson_obj_get(root, key); char *result = NULL; if (val && yyjson_is_str(val)) { result = heap_strdup(yyjson_get_str(val)); } yyjson_doc_free(doc); return result; } /* String argument `key` copied into `buf` (truncated to fit): buf, or NULL * when absent or empty. Nothing to free. */ static const char *mcp_string_arg_buf(const char *args_json, const char *key, char *buf, size_t buf_sz) { buf[0] = '\0'; yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0); if (!doc) { return NULL; } yyjson_val *val = yyjson_obj_get(yyjson_doc_get_root(doc), key); if (val && yyjson_is_str(val)) { snprintf(buf, buf_sz, "%s", yyjson_get_str(val)); } yyjson_doc_free(doc); return buf[0] ? buf : NULL; } static char *canonicalize_repo_path_if_exists(char *repo_path) { if (!repo_path) { return NULL; } bool root_syntax = true; for (const char *p = repo_path; *p; p++) { if (*p != '/' && *p != '\\' && *p != ':') { root_syntax = false; break; } } if (root_syntax) { return repo_path; } char real[CBM_SZ_4K]; /* Wide-path canonicalization: the old _access/_fullpath pair decoded the * UTF-8 repo_path through the ANSI codepage and corrupted CJK paths on * CJK-locale systems (#973). */ if (cbm_canonical_path(repo_path, real, sizeof(real))) { cbm_normalize_path_sep(real); char *canonical = heap_strdup(real); if (canonical) { free(repo_path); return canonical; } } return repo_path; } static bool repo_path_is_absolute(const char *path) { if (!path || path[0] == '\0') { return false; } #ifdef _WIN32 /* Path separators are normalized before this helper is called. A drive- * relative path such as "C:repo" is deliberately not considered absolute. */ return (path[0] == '/' && path[1] == '/') || (isalpha((unsigned char)path[0]) && path[1] == ':' && path[2] == '/'); #else return path[0] == '/'; #endif } bool cbm_validate_project_name(const char *project); static bool has_non_ascii_byte(const char *s) { for (; *s; s++) { if ((unsigned char)*s >= 0x80) { return true; } } return false; } /* #1827: a bare non-ASCII selector, typically the real folder name of a repo * under a CJK path, is encoded exactly like the path segment it came from * (non-ASCII bytes -> hex, #571), so the exact/tail lookup matches the stored * identity. Stored names never change; ASCII selectors keep their existing * validation; a selector that collapses to the "root" fallback is left alone * so it never selects the root project. */ static bool bare_project_encoding_usable(const char *encoded) { return strcmp(encoded, "root") != 0 && cbm_validate_project_name(encoded); } static char *resolve_path_owner(const char *path, char *derived); /* #1690, defined below */ static char *normalize_project_arg(char *project) { if (!project) { return project; } char *normalized = NULL; bool bare = !strchr(project, '/') && !strchr(project, '\\'); if (bare) { if (has_non_ascii_byte(project)) { normalized = cbm_project_name_sanitize(project); } } else { project = canonicalize_repo_path_if_exists(project); normalized = cbm_project_name_from_path(project); if (normalized) { normalized = resolve_path_owner(project, normalized); } } /* Keep exactly one of the two strings and release the other. */ bool use_normalized = normalized && (!bare || bare_project_encoding_usable(normalized)); free(use_normalized ? project : normalized); return use_normalized ? normalized : project; } /* Forward decls — defined below alongside store resolution. */ static const char *cache_dir(char *buf, size_t bufsz); /* #1025: agents naturally pass the repo FOLDER name ("codebase-memory-mcp"), * but indexed project names derive from the full path * (E:\project\graph\x -> "E-project-graph-x"), so the exact lookup fails * while list_projects clearly shows the project. When no .db exists, * scan cache-dir FILENAMES for a segment-aligned tail match ("-.db"): * exactly one match adopts the full name; zero or several keep the original so * the existing not-found error (which lists all candidates) fires. Filename- * level only — internal-name drift stays #704's fallback in resolve_store. */ static char *resolve_project_tail(char *project) { if (!project || !cbm_validate_project_name(project)) { return project; } char dir[CBM_SZ_1K]; cache_dir(dir, sizeof(dir)); char exact[CBM_SZ_2K]; snprintf(exact, sizeof(exact), "%s/%s.db", dir, project); if (cbm_file_exists(exact)) { return project; /* exact name — untouched fast path */ } size_t plen = strlen(project); char match[CBM_SZ_1K] = ""; int matches = 0; cbm_dir_t *d = cbm_opendir(dir); if (!d) { return project; } cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *n = entry->name; size_t len = strlen(n); if (!cbm_is_project_index_db(n)) { continue; } size_t stem_len = len - MCP_DB_EXT; /* strip ".db" */ if (stem_len <= plen + 1 || stem_len >= sizeof(match)) { continue; } if (n[stem_len - plen - 1] != '-' || strncmp(n + stem_len - plen, project, plen) != 0) { continue; } matches++; if (matches > 1) { break; /* ambiguous — keep the original name */ } memcpy(match, n, stem_len); match[stem_len] = '\0'; } cbm_closedir(d); if (matches == 1) { cbm_log_info("mcp.project_tail_resolved", "passed", project, "resolved", match); free(project); return heap_strdup(match); } return project; } /* Resolve the project argument, accepting the canonical "project" key plus the * aliases a caller naturally reaches for (#640): list_projects surfaces the * field as "name" and the not-found hint says "pass the project name", so * "project_name" is the usual guess; "project_id" / "projectName" are accepted * too. NOT bare "name" — index_repository uses "name" for an explicit * project-name override. Caller must free() the result. */ static char *get_project_arg(const char *args_json) { char *p = cbm_mcp_get_string_arg(args_json, "project"); if (!p) { p = cbm_mcp_get_string_arg(args_json, "project_name"); } if (!p) { p = cbm_mcp_get_string_arg(args_json, "project_id"); } if (!p) { p = cbm_mcp_get_string_arg(args_json, "projectName"); } return resolve_project_tail(normalize_project_arg(p)); } int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val) { yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0); if (!doc) { return default_val; } yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *val = yyjson_obj_get(root, key); int result = default_val; /* yyjson_get_int truncates 64-bit integers to int (2^32 + 1 became 1); * read the full width and treat anything outside int range like a * non-integer, i.e. fall back to the caller's default. */ if (val && yyjson_is_sint(val)) { int64_t parsed = yyjson_get_sint(val); if (parsed >= INT_MIN && parsed <= INT_MAX) { result = (int)parsed; } } else if (val && yyjson_is_uint(val)) { uint64_t parsed = yyjson_get_uint(val); if (parsed <= (uint64_t)INT_MAX) { result = (int)parsed; } } yyjson_doc_free(doc); return result; } bool cbm_mcp_get_bool_arg(const char *args_json, const char *key) { yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0); if (!doc) { return false; } yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *val = yyjson_obj_get(root, key); bool result = false; if (val && yyjson_is_bool(val)) { result = yyjson_get_bool(val); } yyjson_doc_free(doc); return result; } /* Read tools use a compact tree for the model-facing default. Machine callers * can request format:"json", which preserves the structuredContent fallback * in cbm_mcp_text_result. Keeping this conversion at the final transport edge * avoids maintaining a second semantic response model in each handler. */ static bool mcp_wants_json(const char *args_json) { char *format = cbm_mcp_get_string_arg(args_json ? args_json : "{}", "format"); bool wants_json = format && strcmp(format, "json") == 0; free(format); return wants_json; } static char *mcp_result_from_json(const char *args_json, const char *json) { if (!json) { return cbm_mcp_text_result("out of memory", true); } if (mcp_wants_json(args_json)) { return cbm_mcp_text_result(json, false); } char *tree = cbm_json_to_tree(json); if (!tree) { return cbm_mcp_text_result("failed to render compact output", true); } char *result = cbm_mcp_text_result(tree, false); free(tree); return result; } /* ══════════════════════════════════════════════════════════════════ * MCP SERVER * ══════════════════════════════════════════════════════════════════ */ struct cbm_mcp_server { cbm_store_t *store; /* currently open project store (or NULL) */ bool owns_store; /* true if we opened the store */ char *current_project; /* which project store is open for (heap) */ time_t store_last_used; /* last time resolve_store was called for a named project */ /* Set by a query-only resolve that hit a confirmed-corrupt database and * left it in place (no quarantine, no rebuild). Error builders read this * so the tool reply names the corruption instead of a misleading * "project not found". */ bool readonly_resolve_hit_corrupt; char readonly_corrupt_project[CBM_SZ_1K]; /* Session + auto-index state */ char session_root[CBM_SZ_1K]; /* detected project root path */ char session_project[CBM_SZ_256]; /* derived project name */ bool session_detected; /* true after first detection attempt */ char *allowed_root; /* explicit per-session boundary (heap, nullable) */ bool allowed_root_policy_set; /* true even when explicit policy is unrestricted */ bool background_tasks; /* per-server update/auto-index work enabled */ struct cbm_watcher *watcher; /* external watcher ref (not owned) */ struct cbm_config *config; /* external config ref (not owned) */ /* index_status watch visibility (#2167) */ cbm_mcp_watch_status_fn watch_status_fn; void *watch_status_context; cbm_mcp_index_executor_fn index_executor; void *index_executor_context; cbm_mcp_index_status_fn index_status_provider; /* #2144; NULL outside the daemon */ void *index_status_context; cbm_proc_log_cb index_log_callback; void *index_log_context; cbm_mcp_project_mutation_begin_fn mutation_begin; cbm_mcp_project_mutation_try_begin_fn mutation_try_begin; cbm_mcp_project_mutation_end_fn mutation_end; void *mutation_context; cbm_mcp_quarantine_test_hook_fn quarantine_test_hook; void *quarantine_test_context; cbm_mcp_command_test_hook_fn command_test_hook; void *command_test_context; #ifdef CBM_ENABLE_TEST_SEAMS cbm_mcp_auto_index_count_test_hook_fn auto_index_count_test_hook; void *auto_index_count_test_context; cbm_mcp_snapshot_read_test_hook_fn snapshot_read_test_hook; void *snapshot_read_test_context; cbm_mcp_snippet_capture_test_hook_fn snippet_capture_test_hook; void *snippet_capture_test_context; #endif bool read_batch_tool; /* this request's store reads run in a read batch */ size_t search_output_limit_override; const char *search_scan_command_override; const char *search_scratch_dir_override; /* NULL = cbm_tmpdir() */ uint64_t search_scan_timeout_override_ms; bool search_scan_timeout_override_set; cbm_thread_t autoindex_tid; bool autoindex_active; /* true if auto-index thread was started */ /* Request-scoped cancellation. The flag is shared by every cancellable * operation reached during one tool dispatch; active_pipeline remains a * diagnostic pointer for index_repository only. */ cbm_mutex_t request_scope_mutex; unsigned int request_scope_depth; atomic_int pipeline_cancel_requested; 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_tool_profile_t tool_profile; cbm_architecture_jobs_t *architecture_jobs; }; cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { cbm_mcp_server_t *srv = calloc(CBM_ALLOC_ONE, sizeof(*srv)); if (!srv) { return NULL; } cbm_mutex_init(&srv->request_scope_mutex); atomic_init(&srv->pipeline_cancel_requested, 0); /* If a store_path is given, open that project directly. * Otherwise, create an in-memory store for test/embedded use. */ if (store_path) { srv->store = cbm_store_open(store_path); srv->current_project = heap_strdup(store_path); } else { srv->store = cbm_store_open_memory(); } srv->owns_store = true; srv->tool_profile = CBM_MCP_TOOL_PROFILE_ALL; srv->background_tasks = true; srv->architecture_jobs = cbm_architecture_jobs_new(); return srv; } void cbm_mcp_server_set_tool_profile(cbm_mcp_server_t *srv, cbm_mcp_tool_profile_t profile) { if (srv) { srv->tool_profile = profile; } } cbm_store_t *cbm_mcp_server_store(cbm_mcp_server_t *srv) { return srv ? srv->store : NULL; } void cbm_mcp_server_set_project(cbm_mcp_server_t *srv, const char *project) { if (!srv) { return; } free(srv->current_project); srv->current_project = project ? heap_strdup(project) : NULL; } void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w) { if (srv) { srv->watcher = w; } } void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn, void *context) { if (srv) { srv->watch_status_fn = fn; srv->watch_status_context = context; } } void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { if (srv) { srv->config = cfg; } } #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, void *context) { if (srv) { srv->auto_index_count_test_hook = hook; srv->auto_index_count_test_context = context; } } #endif bool cbm_mcp_server_set_session_context(cbm_mcp_server_t *srv, const char *session_root, const char *allowed_root) { if (!srv || !session_root || session_root[0] == '\0' || strlen(session_root) >= sizeof(srv->session_root)) { return false; } char *project = cbm_project_name_from_path(session_root); if (!project || project[0] == '\0' || strlen(project) >= sizeof(srv->session_project)) { free(project); return false; } char *allowed_copy = allowed_root ? heap_strdup(allowed_root) : NULL; if (allowed_root && !allowed_copy) { free(project); return false; } snprintf(srv->session_root, sizeof(srv->session_root), "%s", session_root); snprintf(srv->session_project, sizeof(srv->session_project), "%s", project); free(project); free(srv->allowed_root); srv->allowed_root = allowed_copy; srv->allowed_root_policy_set = true; srv->session_detected = true; return true; } const char *cbm_mcp_server_session_root(const cbm_mcp_server_t *srv) { return srv ? srv->session_root : NULL; } const char *cbm_mcp_server_session_project(const cbm_mcp_server_t *srv) { return srv ? srv->session_project : NULL; } const char *cbm_mcp_server_allowed_root(const cbm_mcp_server_t *srv) { return srv ? srv->allowed_root : NULL; } void cbm_mcp_server_set_background_tasks(cbm_mcp_server_t *srv, bool enabled) { if (srv) { srv->background_tasks = enabled; } } void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, cbm_mcp_index_executor_fn executor, void *context) { if (srv) { srv->index_executor = executor; srv->index_executor_context = context; } } void cbm_mcp_server_set_index_status_provider(cbm_mcp_server_t *srv, cbm_mcp_index_status_fn provider, void *context) { if (srv) { srv->index_status_provider = provider; srv->index_status_context = context; } } void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, cbm_proc_log_cb callback, void *context) { if (srv) { srv->index_log_callback = callback; srv->index_log_context = callback ? context : NULL; } } void cbm_mcp_server_set_project_mutation_guard(cbm_mcp_server_t *srv, cbm_mcp_project_mutation_begin_fn begin, cbm_mcp_project_mutation_end_fn end, void *context) { if (!srv) { return; } /* A half-configured guard could acquire without releasing (or mutate * without acquiring), so accept only complete callback pairs. */ if ((begin == NULL) != (end == NULL)) { return; } srv->mutation_begin = begin; srv->mutation_try_begin = NULL; srv->mutation_end = end; srv->mutation_context = begin ? context : NULL; } void cbm_mcp_server_set_project_mutation_try_guard( cbm_mcp_server_t *srv, cbm_mcp_project_mutation_try_begin_fn try_begin) { if (srv && srv->mutation_begin) { srv->mutation_try_begin = try_begin; } } static bool mcp_project_mutation_begin(cbm_mcp_server_t *srv, const char *project) { return !srv->mutation_begin || srv->mutation_begin(srv->mutation_context, project); } static bool mcp_project_mutation_try_begin(cbm_mcp_server_t *srv, const char *project) { return !srv->mutation_begin || (srv->mutation_try_begin && srv->mutation_try_begin(srv->mutation_context, project)); } static void mcp_project_mutation_end(cbm_mcp_server_t *srv, const char *project) { if (srv->mutation_end) { srv->mutation_end(srv->mutation_context, project); } } void cbm_mcp_server_free(cbm_mcp_server_t *srv) { if (!srv) { return; } cbm_architecture_jobs_free(srv->architecture_jobs); if (srv->autoindex_active) { cbm_thread_join(&srv->autoindex_tid); } if (srv->owns_store && srv->store) { cbm_store_close(srv->store); } free(srv->current_project); free(srv->allowed_root); free(srv->active_request_id_str); cbm_mutex_destroy(&srv->request_scope_mutex); free(srv); } /* ── Idle store eviction ──────────────────────────────────────── */ void cbm_mcp_server_evict_idle(cbm_mcp_server_t *srv, int timeout_s) { if (!srv || !srv->store) { return; } /* Protect initial in-memory stores that were never accessed via a named project. * store_last_used stays 0 until resolve_store is called with a non-NULL project. */ if (srv->store_last_used == 0) { return; } time_t now = time(NULL); if ((now - srv->store_last_used) < timeout_s) { return; } if (srv->owns_store) { cbm_store_close(srv->store); } srv->store = NULL; free(srv->current_project); srv->current_project = NULL; srv->store_last_used = 0; } bool cbm_mcp_server_has_cached_store(cbm_mcp_server_t *srv) { return (srv && srv->store != NULL) != 0; } bool cbm_mcp_server_release_pristine_memory_store(cbm_mcp_server_t *srv) { const char *db_path = srv && srv->store ? cbm_store_db_path(srv->store) : NULL; if (!srv || !srv->owns_store || !srv->store || srv->current_project || srv->store_last_used != 0 || db_path != NULL) { return false; } cbm_store_close(srv->store); srv->store = NULL; return true; } cbm_pipeline_t *cbm_mcp_server_active_pipeline(cbm_mcp_server_t *srv) { return srv ? srv->active_pipeline : NULL; } bool cbm_mcp_server_cancel_active(cbm_mcp_server_t *srv) { if (!srv) { return false; } cbm_mutex_lock(&srv->request_scope_mutex); bool active = srv->request_scope_depth != 0; if (active) { atomic_store_explicit(&srv->pipeline_cancel_requested, 1, memory_order_release); } cbm_mutex_unlock(&srv->request_scope_mutex); return active; } bool cbm_mcp_server_request_scope_begin(cbm_mcp_server_t *srv) { if (!srv) { return false; } cbm_mutex_lock(&srv->request_scope_mutex); bool available = srv->request_scope_depth < UINT_MAX; if (available) { if (srv->request_scope_depth == 0) { atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); } srv->request_scope_depth++; } cbm_mutex_unlock(&srv->request_scope_mutex); return available; } void cbm_mcp_server_request_scope_end(cbm_mcp_server_t *srv) { if (!srv) { return; } cbm_mutex_lock(&srv->request_scope_mutex); if (srv->request_scope_depth > 0) { srv->request_scope_depth--; if (srv->request_scope_depth == 0) { atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); } } cbm_mutex_unlock(&srv->request_scope_mutex); } static bool mcp_request_cancelled(const cbm_mcp_server_t *srv) { return srv && atomic_load_explicit(&srv->pipeline_cancel_requested, memory_order_acquire) != 0; } void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv, cbm_mcp_quarantine_test_hook_fn hook, void *context) { if (!srv) { return; } srv->quarantine_test_hook = hook; srv->quarantine_test_context = context; } void cbm_mcp_server_set_command_test_hook(cbm_mcp_server_t *srv, cbm_mcp_command_test_hook_fn hook, void *context) { if (!srv) { return; } srv->command_test_hook = hook; srv->command_test_context = context; } void cbm_mcp_server_set_search_output_limit_for_test(cbm_mcp_server_t *srv, size_t limit) { if (srv) { srv->search_output_limit_override = limit; } } void cbm_mcp_server_set_search_scan_command_for_test(cbm_mcp_server_t *srv, const char *command) { if (srv) { srv->search_scan_command_override = command; } } void cbm_mcp_server_set_search_scan_timeout_for_test(cbm_mcp_server_t *srv, uint64_t timeout_ms, bool override_set) { if (srv) { srv->search_scan_timeout_override_ms = timeout_ms; srv->search_scan_timeout_override_set = override_set; } } void cbm_mcp_server_set_search_scratch_dir_for_test(cbm_mcp_server_t *srv, const char *dir) { if (srv) { srv->search_scratch_dir_override = dir; } } #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_snapshot_read_test_hook(cbm_mcp_server_t *srv, cbm_mcp_snapshot_read_test_hook_fn hook, void *context) { if (!srv) { return; } srv->snapshot_read_test_hook = hook; srv->snapshot_read_test_context = context; } void cbm_mcp_server_set_snippet_capture_test_hook(cbm_mcp_server_t *srv, cbm_mcp_snippet_capture_test_hook_fn hook, void *context) { if (!srv) { return; } srv->snippet_capture_test_hook = hook; srv->snippet_capture_test_context = context; } #endif /* ── Cache dir + project DB path helpers ───────────────────────── */ /* Returns the cache directory. Writes to buf, returns buf for convenience. */ static const char *cache_dir(char *buf, size_t bufsz) { const char *dir = cbm_resolve_cache_dir(); if (!dir) { dir = cbm_tmpdir(); } snprintf(buf, bufsz, "%s", dir); return buf; } /* Returns full .db path for a project: /.db */ static const char *project_db_path(const char *project, char *buf, size_t bufsz) { if (!cbm_validate_project_name(project)) { buf[0] = '\0'; return buf; } char dir[CBM_SZ_1K]; cache_dir(dir, sizeof(dir)); snprintf(buf, bufsz, "%s/%s.db", dir, project); return buf; } /* ── Store resolution ──────────────────────────────────────────── */ /* Read the sole INTERNAL project name from a .db file at full_path. * Opens the file query-mode (no create) and succeeds ONLY when the db holds * exactly one project row with a non-empty name — this filters ghost/empty * /corrupt dbs (0-byte file, missing `projects` table, or >1 row). On success * the internal name is copied into name_out; if out_store is non-NULL the open * handle is transferred to the caller (who must cbm_store_close it). On failure * the store is always closed. Defined below. */ static bool db_internal_project_name(const char *full_path, char *name_out, size_t name_sz, cbm_store_t **out_store); /* #704 fallback: scan the cache dir for the db whose sole internal project name * equals `project`, returning an open store handle (caller owns it) or NULL. * Used only when .db is absent or its internal name differs from the * passed name (drifted filename). Defined below. */ static cbm_store_t *resolve_store_fallback_scan(const char *project); static bool reserve_unique_corrupt_pending(const char *path, char *pending, size_t pending_size, char *backup, size_t backup_size) { static atomic_uint_fast64_t sequence = 0; for (unsigned int attempt = 0; attempt < 128; attempt++) { uint64_t token = cbm_now_ns() ^ ((uint64_t)(unsigned int)getpid() << 32) ^ atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed); int backup_written = snprintf(backup, backup_size, "%s.corrupt.%016llx", path, (unsigned long long)token); int pending_written = snprintf(pending, pending_size, "%s.corrupt.pending.%016llx", path, (unsigned long long)token); if (backup_written <= 0 || (size_t)backup_written >= backup_size || pending_written <= 0 || (size_t)pending_written >= pending_size) { return false; } if (cbm_file_exists(backup)) { continue; } #ifdef _WIN32 wchar_t *wide = cbm_path_to_wide(pending); HANDLE file = wide ? CreateFileW(wide, GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL) : INVALID_HANDLE_VALUE; DWORD create_error = file == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; free(wide); if (file != INVALID_HANDLE_VALUE) { CloseHandle(file); return true; } if (create_error != ERROR_FILE_EXISTS && create_error != ERROR_ALREADY_EXISTS) { return false; } #else int fd = open(pending, O_WRONLY | O_CREAT | O_EXCL, 0600); if (fd >= 0) { (void)close(fd); return true; } if (errno != EEXIST) { return false; } #endif } return false; } static void discard_corrupt_pending(const char *pending) { if (!pending) { return; } (void)cbm_remove_db_sidecars(pending); (void)cbm_unlink(pending); } #ifndef _WIN32 static bool sync_parent_directory(const char *path) { char directory[CBM_SZ_2K]; int written = snprintf(directory, sizeof(directory), "%s", path ? path : ""); if (written <= 0 || (size_t)written >= sizeof(directory)) { return false; } char *slash = strrchr(directory, '/'); if (!slash) { snprintf(directory, sizeof(directory), "."); } else if (slash == directory) { slash[1] = '\0'; } else { *slash = '\0'; } int fd = open(directory, O_RDONLY | O_DIRECTORY); if (fd < 0) { return false; } int rc; do { rc = fsync(fd); } while (rc != 0 && errno == EINTR); (void)close(fd); return rc == 0; } #endif /* Publish only a fully closed SQLite snapshot, without ever replacing a prior * recovery file. POSIX link() and Windows MoveFileExW without REPLACE are * atomic no-clobber operations within the cache directory. */ static bool publish_corrupt_backup(const char *pending, const char *backup) { #ifdef _WIN32 wchar_t *wide_pending = cbm_path_to_wide(pending); wchar_t *wide_backup = cbm_path_to_wide(backup); bool published = wide_pending && wide_backup && MoveFileExW(wide_pending, wide_backup, MOVEFILE_WRITE_THROUGH) != 0; free(wide_pending); free(wide_backup); return published; #else if (link(pending, backup) != 0) { return false; } if (!sync_parent_directory(backup)) { (void)cbm_unlink(backup); return false; } /* A crash before this cleanup merely leaves a second link to the same * complete snapshot; the published recovery generation is already safe. */ (void)cbm_unlink(pending); (void)sync_parent_directory(backup); return true; #endif } static bool quarantine_step_allowed(cbm_mcp_server_t *srv, const char *step) { return !srv || !srv->quarantine_test_hook || srv->quarantine_test_hook(srv->quarantine_test_context, step); } /* Create one transactionally consistent, self-contained recovery snapshot * (SQLite backup incorporates committed WAL frames), publish it atomically, * and only then remove the corrupt live generation. A crash can therefore * leave the live DB, the completed backup, or both, but never destroys the * only recoverable generation. */ static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project, const char *path, char *backup_out, size_t backup_out_size) { char backup[CBM_SZ_2K]; char pending[CBM_SZ_2K]; /* #1425 belt-and-braces: an empty store path would render the backup as a * bare relative ".corrupt." in the process cwd. There is nothing at * such a path worth quarantining. */ if (!path || !path[0]) { cbm_log_error("store.auto_clean_failed", "project", project, "path", "", "reason", "empty store path"); return false; } if (!reserve_unique_corrupt_pending(path, pending, sizeof(pending), backup, sizeof(backup))) { cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", "cannot reserve unique backup"); return false; } if (cbm_store_backup_path(path, pending) != CBM_STORE_OK || cbm_store_prepare_path_for_replace(pending) != CBM_STORE_OK) { discard_corrupt_pending(pending); cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", "cannot create self-contained recovery snapshot"); return false; } cbm_store_t *snapshot = cbm_store_open_path_query(pending); if (!snapshot) { discard_corrupt_pending(pending); cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", "recovery snapshot cannot be reopened"); return false; } cbm_store_close(snapshot); if (!quarantine_step_allowed(srv, "before_snapshot_publish") || !publish_corrupt_backup(pending, backup)) { discard_corrupt_pending(pending); cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", "cannot atomically publish recovery snapshot"); return false; } discard_corrupt_pending(pending); if (!quarantine_step_allowed(srv, "after_snapshot_publish")) { cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", "backup complete; live generation retained", "backup", backup); return false; } if (cbm_unlink(path) != 0 && errno != ENOENT) { cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", "backup complete; live database removal failed", "backup", backup); return false; } if (cbm_remove_db_sidecars(path) != 0) { cbm_log_error("store.auto_clean_sidecars", "project", project, "path", path, "reason", "backup complete; stale sidecar cleanup deferred"); } if (backup_out && backup_out_size > 0) { snprintf(backup_out, backup_out_size, "%s", backup); } return true; } /* Open the right project's .db file for query tools. * Caches the connection — reopens only when project changes. * Tracks last-access time so the event loop can evict idle stores. */ typedef enum { STORE_RECOVERY_NONE, STORE_RECOVERY_BUSY, STORE_RECOVERY_TRY_GUARD_UNAVAILABLE, /* Query-only resolve: a confirmed-corrupt database was reported and left * in place. Not an error state of the recovery machinery — the caller is * expected to answer with the corruption, not retry. */ STORE_RECOVERY_CORRUPT, } store_recovery_status_t; static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *project, bool mutation_already_held, bool nonblocking_recovery, store_recovery_status_t *recovery_status, bool allow_autorecovery) { if (recovery_status) { *recovery_status = STORE_RECOVERY_NONE; } srv->readonly_resolve_hit_corrupt = false; srv->readonly_corrupt_project[0] = '\0'; if (!project) { return NULL; /* project is required — no implicit fallback */ } srv->store_last_used = time(NULL); /* Already open for this project? */ if (srv->current_project && strcmp(srv->current_project, project) == 0 && srv->store) { return srv->store; } /* Close old store */ if (srv->owns_store && srv->store) { cbm_store_close(srv->store); srv->store = NULL; } /* Open project's .db file — query-only open (no SQLITE_OPEN_CREATE) to * prevent ghost .db file creation for unknown/unindexed projects. * #1425: an invalid project name yields an empty path. SQLite opens "" * as an anonymous temp db, which then fails the integrity check and * quarantines a db that never existed — as a RELATIVE .corrupt. * file in the daemon's cwd. Skip the direct open entirely; the fallback * scan below still resolves legacy dbs whose internal name predates * validation. */ char path[CBM_SZ_1K]; project_db_path(project, path, sizeof(path)); srv->store = path[0] ? cbm_store_open_path_query(path) : NULL; if (srv->store) { /* Query-only resolve: classify a failed integrity check without any * mutation — no lease, no quarantine. A corrupt database is reported * and left in place for a write-side open (index_repository) to * quarantine and rebuild; that is what keeps the ten read-only * tools' readOnlyHint=true honest. A transient failure (lock, IO, or * the shallow check's own prepare-error-as-corrupt blind spot) is * answered as busy and retried by the next resolve. */ if (!allow_autorecovery && !cbm_store_check_integrity(srv->store)) { cbm_store_close(srv->store); srv->store = NULL; srv->store = cbm_store_open_path_query(path); cbm_integrity_verdict_t verdict = srv->store ? cbm_store_check_integrity_verdict(srv->store) : CBM_INTEGRITY_TRANSIENT; if (srv->store) { cbm_store_close(srv->store); srv->store = NULL; } if (verdict == CBM_INTEGRITY_CORRUPT) { srv->readonly_resolve_hit_corrupt = true; snprintf(srv->readonly_corrupt_project, sizeof(srv->readonly_corrupt_project), "%s", project); cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, "action", "left in place for a write-side rebuild"); if (recovery_status) { *recovery_status = STORE_RECOVERY_CORRUPT; } } else if (recovery_status) { *recovery_status = STORE_RECOVERY_BUSY; } return NULL; } /* Check DB integrity — back up (never silently delete) a corrupt DB */ if (!cbm_store_check_integrity(srv->store)) { cbm_store_close(srv->store); srv->store = NULL; bool mutation_acquired = mutation_already_held; if (!mutation_acquired) { mutation_acquired = nonblocking_recovery ? mcp_project_mutation_try_begin(srv, project) : mcp_project_mutation_begin(srv, project); } if (!mutation_acquired) { if (nonblocking_recovery && recovery_status) { *recovery_status = srv->mutation_try_begin ? STORE_RECOVERY_BUSY : STORE_RECOVERY_TRY_GUARD_UNAVAILABLE; } return NULL; } /* The lease may have waited behind a publisher. Re-open and trust * only the current generation, never the stale pre-wait verdict. * Use the verdict API here — this is the point that decides whether * a healthy DB gets quarantined. The plain bool check cannot tell * corruption from a transient SQLITE_BUSY race (#1206: concurrent * instances quarantining each other's DBs) and does not run * quick_check, so page-torn DBs with an intact projects table sail * through (#1037). Only a confirmed CORRUPT verdict is quarantined; * TRANSIENT (lock/IO) falls through and retries on next access. */ srv->store = cbm_store_open_path_query(path); cbm_integrity_verdict_t verdict = srv->store ? cbm_store_check_integrity_verdict(srv->store) : CBM_INTEGRITY_TRANSIENT; bool current_valid = (verdict == CBM_INTEGRITY_OK); if (verdict == CBM_INTEGRITY_TRANSIENT) { /* The DB could not be conclusively evaluated (lock contention, * busy writer, IO hiccup). Do NOT quarantine — close and let * the next resolve retry. A spurious quarantine here is exactly * what destroys healthy DBs under concurrent access. */ cbm_store_close(srv->store); srv->store = NULL; if (recovery_status) { *recovery_status = STORE_RECOVERY_BUSY; } if (!mutation_already_held) { mcp_project_mutation_end(srv, project); } return NULL; } if (!current_valid) { cbm_store_close(srv->store); srv->store = NULL; char backup[CBM_SZ_2K] = {0}; bool quarantined = quarantine_corrupt_store(srv, project, path, backup, sizeof(backup)); cbm_log_error("store.auto_clean", "project", project, "path", path, "action", quarantined ? "corrupt generation quarantined" : "corrupt generation preserved", "backup", quarantined ? backup : "none"); } if (!mutation_already_held) { mcp_project_mutation_end(srv, project); } if (!srv->store) { return NULL; } } /* Verify the project actually exists in this database. * A .db file may exist but be empty (e.g., after delete_project on * Linux where unlink defers actual removal). Opening an empty/deleted * store without closing it leaks the SQLite connection. */ cbm_project_t proj_verify = {0}; if (cbm_store_get_project(srv->store, project, &proj_verify) == CBM_STORE_OK) { cbm_project_free_fields(&proj_verify); srv->owns_store = true; free(srv->current_project); srv->current_project = heap_strdup(project); return srv->store; /* fast path: filename == internal name */ } /* #704: .db exists but its INTERNAL project name differs from * the passed name (a copied/renamed db, or a legacy '.'-vs-'-' username * twin). Close it and fall through to the cache-dir scan below. */ cbm_store_close(srv->store); srv->store = NULL; } /* #704 fallback: either .db is absent or its internal name drifted * from its filename. Node rows are keyed on the INTERNAL name (== the passed * name, since list_projects now advertises internal names), so scan the * cache dir for the db whose sole internal project name equals `project` and * adopt it. Runs ONLY on the fallback — the common fast path is unchanged. * No match → NULL (a genuine typo stays not-found). */ cbm_store_t *scanned = resolve_store_fallback_scan(project); if (scanned) { srv->store = scanned; srv->owns_store = true; free(srv->current_project); srv->current_project = heap_strdup(project); } return srv->store; } static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { /* Query-only callers (every read tool): strictly non-mutating resolve. */ cbm_store_t *store = resolve_store_internal(srv, project, false, false, NULL, false); if (store && srv->read_batch_tool) { (void)cbm_store_read_batch_begin(store); } return store; } /* Forward decl — definition lives below in handle_trace_call_path's helpers. */ static void free_node_contents(cbm_node_t *n); /* Scan cache dir for .db files, writing comma-separated quoted names into out. * Returns the number of projects found. */ static int collect_db_project_names(const char *dir_path, char *out, size_t out_sz) { int count = 0; int offset = 0; cbm_dir_t *d = cbm_opendir(dir_path); if (!d) { return 0; } cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *n = entry->name; if (!cbm_is_project_index_db(n)) { continue; } /* #704: advertise the db's INTERNAL project name, not its filename, and * skip ghost/empty/corrupt dbs — so the hint lists names the user can * actually pass to resolve a store. */ char full_path[CBM_SZ_2K]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, n); char iname[CBM_SZ_1K]; if (!db_internal_project_name(full_path, iname, sizeof(iname), NULL)) { continue; } /* Element-boundary write: only emit this name if the WHOLE element — * optional leading comma + "iname" — plus the NUL fits in what remains. * Never truncate mid-token; a partial name would corrupt the JSON array * (issue #235). Stop cleanly at the last name that fits: the array then * always holds complete names and `count` == its length. */ size_t off = (size_t)offset; size_t need = strlen(iname) + 2 /* quotes */ + (count > 0 ? 1u : 0u) /* comma */; if (off + need + 1 > out_sz) { break; /* would not fit entirely — stop at this element boundary */ } if (count > 0) { out[offset++] = ','; } int wrote = snprintf(out + offset, out_sz - (size_t)offset, "\"%s\"", iname); if (wrote > 0) { offset += wrote; /* guaranteed to fit (checked above) — no truncation */ } count++; } cbm_closedir(d); return count; } static void add_git_context_string(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *key, const char *value) { if (value) { yyjson_mut_obj_add_strcpy(doc, obj, key, value); } else { yyjson_mut_obj_add_null(doc, obj, key); } } static void add_git_context_json(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *root_path) { cbm_git_context_t ctx = {0}; (void)cbm_git_context_resolve(root_path, &ctx); yyjson_mut_val *git = yyjson_mut_obj(doc); yyjson_mut_obj_add_bool(doc, git, "is_git", ctx.is_git); yyjson_mut_obj_add_bool(doc, git, "is_worktree", ctx.is_worktree); yyjson_mut_obj_add_bool(doc, git, "is_detached", ctx.is_detached); yyjson_mut_obj_add_bool(doc, git, "root_exists", ctx.root_exists); add_git_context_string(doc, git, "worktree_root", ctx.worktree_root); add_git_context_string(doc, git, "git_dir", ctx.git_dir); add_git_context_string(doc, git, "git_common_dir", ctx.git_common_dir); add_git_context_string(doc, git, "canonical_root", ctx.canonical_root); add_git_context_string(doc, git, "branch", ctx.branch); add_git_context_string(doc, git, "branch_slug", ctx.branch_slug); add_git_context_string(doc, git, "head_sha", ctx.head_sha); add_git_context_string(doc, git, "base_sha", ctx.base_sha); yyjson_mut_obj_add_val(doc, obj, "git", git); cbm_git_context_free(&ctx); } /* Build a helpful error listing available projects. Caller must free() result. */ static char *build_project_list_error(const char *reason) { char dir_path[CBM_SZ_1K]; cache_dir(dir_path, sizeof(dir_path)); char projects[CBM_SZ_4K] = ""; int count = collect_db_project_names(dir_path, projects, sizeof(projects)); enum { ERR_BUF_SZ = 5120 }; char buf[ERR_BUF_SZ]; if (count > 0) { /* #1690: say how to pick the project, so an agent that guessed a repo * or folder name can recover instead of dropping the graph. */ snprintf(buf, sizeof(buf), "{\"error\":\"%s\",\"hint\":\"Use list_projects and pass the name whose " "root_path contains your working directory (or that absolute directory) as " "the \\\"project\\\" argument; never a repo or folder " "name.\",\"available_projects\":[%s],\"count\":%d}", reason, projects, count); } else { snprintf(buf, sizeof(buf), "{\"error\":\"%s\",\"hint\":\"No projects indexed yet. " "Call index_repository first.\"}", reason); } return heap_strdup(buf); } /* Distinct from "unknown project": the caller omitted the project argument * entirely (no recognized key). Name the literal "project" key so the fix is * obvious (#640). Caller must free() result. */ static char *build_missing_project_error(void) { return heap_strdup("{\"error\":\"missing required argument: project\",\"hint\":\"Pass " "the project as the \\\"project\\\" argument, e.g. " "{\\\"project\\\":\\\"\\\"}. Run " "list_projects to see indexed projects.\"}"); } /* Pick the right no-store error: a NULL project means the argument was missing * (clearer message); a non-NULL project that didn't resolve means it's * unknown/unindexed (list the available ones). */ static char *build_no_store_error(const char *project) { return project ? build_project_list_error("project not found or not indexed") : build_missing_project_error(); } /* Same contract as build_no_store_error, but when the query-only resolve * confirmed a corrupt database and left it in place, name that instead of a * misleading "project not found". */ static char *build_no_store_error_checked(cbm_mcp_server_t *srv, const char *project) { if (srv->readonly_resolve_hit_corrupt && project && strcmp(project, srv->readonly_corrupt_project) == 0) { return build_project_list_error( "project store is corrupt (left untouched); run index_repository to rebuild it"); } return build_no_store_error(project); } /* Bail with the right error when no store is available. */ #define REQUIRE_STORE(store, project) \ do { \ if (!(store)) { \ char *_err = build_no_store_error_checked(srv, project); \ char *_res = cbm_mcp_text_result(_err, true); \ free(_err); \ free(project); \ return _res; \ } \ } while (0) static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) { if (store && project) { cbm_adr_t adr; memset(&adr, 0, sizeof(adr)); if (cbm_store_adr_get(store, project, &adr) == CBM_STORE_OK) { cbm_store_adr_free(&adr); return true; } } if (!root_path) { return false; } char adr_path[CBM_SZ_4K]; snprintf(adr_path, sizeof(adr_path), "%s/.codebase-memory/adr.md", root_path); struct stat adr_st; return stat(adr_path, &adr_st) == 0; } /* ── Tool handler implementations ─────────────────────────────── */ /* db_internal_project_name — see forward declaration above resolve_store. */ static bool db_internal_project_name(const char *full_path, char *name_out, size_t name_sz, cbm_store_t **out_store) { if (out_store) { *out_store = NULL; } cbm_store_t *st = cbm_store_open_path_query(full_path); if (!st) { return false; /* nonexistent / unreadable */ } cbm_project_t *projs = NULL; int n = 0; bool ok = false; if (cbm_store_list_projects(st, &projs, &n) == CBM_STORE_OK) { /* Ignore internal shadow projects ("::missed" miss-graph rows): * they share the db with the primary project and must not make it * unresolvable — requiring n == 1 over ALL rows made every project * with a miss graph vanish from list_projects and the UI (#1044). */ int primary = -1; int primary_count = 0; for (int i = 0; i < n; i++) { if (projs[i].name && projs[i].name[0] && !strstr(projs[i].name, "::")) { primary = i; primary_count++; } } if (primary_count == 1) { snprintf(name_out, name_sz, "%s", projs[primary].name); ok = true; } } cbm_store_free_projects(projs, n); if (ok && out_store) { *out_store = st; /* transfer ownership to caller */ } else { cbm_store_close(st); } return ok; } /* resolve_store_fallback_scan — see forward declaration above resolve_store. */ static cbm_store_t *resolve_store_fallback_scan(const char *project) { char dir_path[CBM_SZ_1K]; cache_dir(dir_path, sizeof(dir_path)); cbm_dir_t *d = cbm_opendir(dir_path); if (!d) { return NULL; } cbm_store_t *found = NULL; cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *n = entry->name; if (!cbm_is_project_index_db(n)) { continue; } char full_path[CBM_SZ_2K]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, n); char iname[CBM_SZ_1K]; cbm_store_t *st = NULL; if (db_internal_project_name(full_path, iname, sizeof(iname), &st)) { if (strcmp(iname, project) == 0) { found = st; /* adopt — caller takes ownership */ break; } cbm_store_close(st); } } cbm_closedir(d); return found; } typedef struct { char *name; char *root_path; char *db_file; char *branch; int nodes; int edges; int64_t size_bytes; bool populated; } mcp_project_record_t; static void project_record_clear(mcp_project_record_t *record) { if (!record) { return; } free(record->name); free(record->root_path); free(record->db_file); free(record->branch); memset(record, 0, sizeof(*record)); } /* strcmp that orders NULL like "". */ static int nullable_strcmp(const char *a, const char *b) { return strcmp(a ? a : "", b ? b : ""); } static int project_record_compare(const void *left, const void *right) { const mcp_project_record_t *a = left; const mcp_project_record_t *b = right; int by_name = nullable_strcmp(a->name, b->name); if (by_name != 0) { return by_name; } int by_root = nullable_strcmp(a->root_path, b->root_path); if (by_root != 0) { return by_root; } return nullable_strcmp(a->db_file, b->db_file); } typedef enum { PROJECT_RECORD_SKIP = 0, PROJECT_RECORD_OK = 1, PROJECT_RECORD_OOM = -1, } project_record_status_t; /* Open a .db file briefly and collect sortable identity only. Counts and Git * context are populated after paging so a 50-row response does not repeat * heavyweight work for every database in the cache. */ static project_record_status_t read_project_record_identity(const char *dir_path, const char *name, int64_t size_bytes, mcp_project_record_t *record) { memset(record, 0, sizeof(*record)); char full_path[CBM_SZ_2K]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); /* #704: key on the db's INTERNAL project name, not its filename. Node/edge * rows are tagged with the internal name, so a drifted filename (copied or * renamed db, legacy '.'-vs-'-' username twin) would otherwise report 0 * nodes/edges and be unresolvable. Skip ghost/empty/corrupt dbs entirely so * they don't appear as resolvable projects. */ char project_name[CBM_SZ_1K]; cbm_store_t *pstore = NULL; if (!db_internal_project_name(full_path, project_name, sizeof(project_name), &pstore)) { return PROJECT_RECORD_SKIP; /* ghost / unreadable — not resolvable */ } record->name = heap_strdup(project_name); record->db_file = heap_strdup(name); record->size_bytes = size_bytes; cbm_project_t proj = {0}; if (cbm_store_get_project(pstore, project_name, &proj) == CBM_STORE_OK) { /* root_path is NOT NULL in the store schema, so a missing copy here is * allocation failure rather than an empty identity. */ if (!proj.root_path) { cbm_project_free_fields(&proj); cbm_store_close(pstore); project_record_clear(record); return PROJECT_RECORD_OOM; } /* Store project fields are heap-owned by the caller. Transfer the root * identity intact: it is the value used for sorting, output, and branch * lookup, so routing it through a fixed buffer would silently change * all three for long roots. */ record->root_path = (char *)proj.root_path; proj.root_path = NULL; cbm_project_free_fields(&proj); } cbm_store_close(pstore); if (!record->root_path) { record->root_path = heap_strdup(""); } if (!record->name || !record->root_path || !record->db_file) { project_record_clear(record); return PROJECT_RECORD_OOM; } return PROJECT_RECORD_OK; } static bool populate_project_record(const char *dir_path, bool include_stats, mcp_project_record_t *record) { if (record->populated) { record->populated = true; return true; } if (include_stats) { char full_path[CBM_SZ_2K]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, record->db_file); cbm_store_t *pstore = cbm_store_open_path_query(full_path); if (!pstore) { return false; } record->nodes = cbm_store_count_nodes(pstore, record->name); record->edges = cbm_store_count_edges(pstore, record->name); cbm_store_close(pstore); } /* Listing stays lean: only the branch (the one git fact that * disambiguates same-repo projects). The 12-field git block — mostly * null for non-git roots — cost ~10KB across a full cache and is one * index_status call away for the project you actually care about. */ if (record->root_path[0]) { bool is_git = false; char branch[CBM_GIT_BRANCH_MAX]; (void)cbm_git_branch_resolve(record->root_path, &is_git, branch, sizeof(branch)); if (is_git && branch[0]) { record->branch = heap_strdup(branch); if (!record->branch) { return false; } } } record->populated = true; return true; } static void add_project_record_json(yyjson_mut_doc *doc, yyjson_mut_val *arr, const mcp_project_record_t *record, bool include_stats) { yyjson_mut_val *p = yyjson_mut_obj(doc); yyjson_mut_obj_add_strcpy(doc, p, "name", record->name); yyjson_mut_obj_add_strcpy(doc, p, "root_path", record->root_path); if (record->branch) { yyjson_mut_obj_add_strcpy(doc, p, "branch", record->branch); } if (include_stats) { yyjson_mut_obj_add_int(doc, p, "nodes", record->nodes); yyjson_mut_obj_add_int(doc, p, "edges", record->edges); yyjson_mut_obj_add_int(doc, p, "size_bytes", record->size_bytes); } yyjson_mut_arr_add_val(arr, p); } /* list_projects: scan cache directory for .db files. * Each project is a single .db file — no central registry needed. */ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { (void)srv; bool metadata_only = false; yyjson_doc *args_doc = args ? yyjson_read(args, strlen(args), 0) : NULL; yyjson_val *args_root = args_doc ? yyjson_doc_get_root(args_doc) : NULL; yyjson_val *metadata_value = args_root && yyjson_is_obj(args_root) ? yyjson_obj_get(args_root, "metadata_only") : NULL; metadata_only = metadata_value && yyjson_is_true(metadata_value); char *detail = cbm_mcp_get_string_arg(args, "detail"); /* `include_details` is the boolean spelling main shipped for the same * request; both select the stats projection. */ bool include_stats = !metadata_only && ((detail && strcmp(detail, "stats") == 0) || cbm_mcp_get_bool_arg(args, "include_details")); free(detail); if (args_doc) { yyjson_doc_free(args_doc); } char dir_path[CBM_SZ_1K]; cache_dir(dir_path, sizeof(dir_path)); cbm_dir_t *d = cbm_opendir(dir_path); if (!d) { char msg[CBM_SZ_1K]; snprintf(msg, sizeof(msg), "{\"error\":\"cannot read cache directory: %s\",\"hint\":" "\"Check directory permissions or run index_repository first.\"}", dir_path); return cbm_mcp_text_result(msg, true); } mcp_project_record_t *records = NULL; int record_count = 0; int record_cap = 0; bool oom = false; cbm_dirent_t *entry; while ((entry = cbm_readdir(d)) != NULL) { const char *name = entry->name; if (!cbm_is_project_index_db(name)) { continue; } char full_path[CBM_SZ_2K]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, name); int64_t size_bytes = cbm_file_size(full_path); if (size_bytes < 0) { continue; } mcp_project_record_t record = {0}; project_record_status_t read_status = read_project_record_identity(dir_path, name, size_bytes, &record); if (read_status == PROJECT_RECORD_OOM) { oom = true; break; } if (read_status != PROJECT_RECORD_OK) { continue; } if (record_count == record_cap) { int new_cap = record_cap ? record_cap * 2 : 16; void *grown = realloc(records, (size_t)new_cap * sizeof(*records)); if (!grown) { project_record_clear(&record); oom = true; break; } records = grown; record_cap = new_cap; } records[record_count++] = record; } cbm_closedir(d); if (oom) { for (int i = 0; i < record_count; i++) { project_record_clear(&records[i]); } free(records); return cbm_mcp_text_result("out of memory while listing projects", true); } /* An empty cache never allocates the record array, and qsort's base is * declared nonnull (glibc): skip the sort when there is nothing to order. */ if (record_count > 1) { qsort(records, (size_t)record_count, sizeof(*records), project_record_compare); } int limit = cbm_mcp_get_int_arg(args, "limit", 50); int offset = cbm_mcp_get_int_arg(args, "offset", 0); if (limit < 1) { limit = 1; } else if (limit > 500) { limit = 500; } if (offset < 0) { offset = 0; } int start = offset < record_count ? offset : record_count; int end = start + limit < record_count ? start + limit : record_count; if (!metadata_only) { for (int i = start; i < end; i++) { if (!populate_project_record(dir_path, include_stats, &records[i])) { oom = true; break; } } } if (oom) { for (int i = 0; i < record_count; i++) { project_record_clear(&records[i]); } free(records); return cbm_mcp_text_result("failed to populate project page", true); } bool wants_json = mcp_wants_json(args); char *payload = NULL; if (wants_json) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_val *arr = yyjson_mut_arr(doc); for (int i = start; i < end; i++) { add_project_record_json(doc, arr, &records[i], include_stats); } yyjson_mut_obj_add_val(doc, root, "projects", arr); yyjson_mut_obj_add_int(doc, root, "total", record_count); yyjson_mut_obj_add_int(doc, root, "offset", offset); yyjson_mut_obj_add_int(doc, root, "limit", limit); yyjson_mut_obj_add_int(doc, root, "returned", end - start); yyjson_mut_obj_add_bool(doc, root, "has_more", end < record_count); if (end < record_count) { yyjson_mut_obj_add_int(doc, root, "next_offset", end); } if (record_count == 0) { yyjson_mut_obj_add_str( doc, root, "hint", "No projects indexed. Call index_repository(repo_path=...) first."); } payload = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); } else { /* Stable union schema: branch is an explicit empty marker instead of * changing each object's shape and forcing the generic tree emitter * to repeat every key. Values and row order remain direct/copyable. */ cbm_sb_t sb; cbm_sb_init(&sb); const char *metadata_cols[] = {"name", "root_path"}; const char *identity_cols[] = {"name", "root_path", "branch"}; const char *stats_cols[] = {"name", "root_path", "branch", "nodes", "edges", "size_bytes"}; const char *const *cols = metadata_only ? metadata_cols : include_stats ? stats_cols : identity_cols; int column_count = metadata_only ? 2 : include_stats ? 6 : 3; int page_count = end - start; size_t cell_count = (size_t)page_count * (size_t)column_count; const char **cells = cell_count > 0 && cell_count <= SIZE_MAX / sizeof(*cells) ? malloc(cell_count * sizeof(*cells)) : NULL; char (*numbers)[32] = include_stats && page_count > 0 ? malloc((size_t)page_count * 3U * sizeof(*numbers)) : NULL; if (cells && (!include_stats || numbers)) { for (int row = 0; row < page_count; row++) { const mcp_project_record_t *record = &records[start + row]; size_t base = (size_t)row * (size_t)column_count; cells[base] = record->name; cells[base + 1U] = record->root_path; if (!metadata_only) { cells[base + 2U] = record->branch ? record->branch : ""; } if (include_stats) { size_t number_base = (size_t)row * 3U; snprintf(numbers[number_base], sizeof(numbers[number_base]), "%d", record->nodes); snprintf(numbers[number_base + 1U], sizeof(numbers[number_base + 1U]), "%d", record->edges); snprintf(numbers[number_base + 2U], sizeof(numbers[number_base + 2U]), "%lld", (long long)record->size_bytes); cells[base + 3U] = numbers[number_base]; cells[base + 4U] = numbers[number_base + 1U]; cells[base + 5U] = numbers[number_base + 2U]; } } const bool string_cols[] = {true, true, true, false, false, false}; const bool prefix_cols[] = {false, true, false, false, false, false}; cbm_tree_table_rows_profiled(&sb, "projects", page_count, cols, column_count, cells, string_cols, prefix_cols); } else { cbm_tree_table_header(&sb, "projects", page_count, cols, column_count); for (int i = start; i < end; i++) { cbm_tree_row_begin(&sb); cbm_tree_cell_str(&sb, records[i].name, true); cbm_tree_cell_str(&sb, records[i].root_path, false); if (!metadata_only) { cbm_tree_cell_str(&sb, records[i].branch ? records[i].branch : "", false); } if (include_stats) { cbm_tree_cell_int(&sb, records[i].nodes, false); cbm_tree_cell_int(&sb, records[i].edges, false); cbm_tree_cell_int(&sb, (long long)records[i].size_bytes, false); } cbm_tree_row_end(&sb); } } free(numbers); free(cells); cbm_tree_scalar_int(&sb, "total", record_count); cbm_tree_scalar_int(&sb, "returned", end - start); cbm_tree_scalar_bool(&sb, "has_more", end < record_count); if (end < record_count) { cbm_tree_scalar_int(&sb, "next_offset", end); } if (record_count == 0) { cbm_tree_scalar_str(&sb, "hint", "No projects indexed. Call index_repository(repo_path=...) first."); } payload = cbm_sb_finish(&sb); } for (int i = 0; i < record_count; i++) { project_record_clear(&records[i]); } free(records); char *result = cbm_mcp_text_result(payload ? payload : "out of memory", payload == NULL); free(payload); return result; } /* verify_project_indexed — returns a heap-allocated error JSON string when the * named project has not been indexed yet, or NULL when the project exists. * resolve_store uses cbm_store_open_path_query (no SQLITE_OPEN_CREATE), so * store is NULL for missing .db files (REQUIRE_STORE fires first). This * function catches the remaining case: a .db file exists but has no indexed * nodes (e.g., an empty or half-initialised project). * Callers that receive a non-NULL return value must free(project) themselves * before returning the error string. */ static char *verify_project_indexed(cbm_store_t *store, const char *project) { cbm_project_t proj_check = {0}; if (cbm_store_get_project(store, project, &proj_check) != CBM_STORE_OK) { char *err = build_project_list_error("project not indexed — run index_repository first"); char *res = cbm_mcp_text_result(err, true); free(err); return res; } cbm_project_free_fields(&proj_check); return NULL; } /* compare_graphs deliberately bypasses resolve_store(): it needs two * independently-owned request-scoped read handles, while resolve_store caches * one handle on the server. Direct-name lookup stays the fast path; the * existing internal-name fallback preserves legacy renamed databases. */ static cbm_store_t *compare_open_project_store(const char *project) { char path[CBM_SZ_1K]; project_db_path(project, path, sizeof(path)); cbm_store_t *store = path[0] ? cbm_store_open_path_query(path) : NULL; if (store) { cbm_project_t row = {0}; if (cbm_store_get_project(store, project, &row) == CBM_STORE_OK) { cbm_project_free_fields(&row); return store; } cbm_store_close(store); } return resolve_store_fallback_scan(project); } typedef struct { yyjson_mut_val *items; size_t returned; size_t encoded_bytes; bool budget_exhausted; } compare_result_set_t; typedef struct { cbm_mcp_server_t *server; yyjson_mut_doc *doc; size_t limit; compare_result_set_t nodes_added; compare_result_set_t nodes_removed; compare_result_set_t edges_added; compare_result_set_t edges_removed; } compare_response_t; static char *compare_graphs_error(const char *code, const char *message) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL; if (!doc || !root) { yyjson_mut_doc_free(doc); return cbm_mcp_text_result("compare_graphs failed: out of memory", true); } yyjson_mut_doc_set_root(doc, root); if (!yyjson_mut_obj_add_strcpy(doc, root, "error", message) || !yyjson_mut_obj_add_strcpy(doc, root, "code", code)) { yyjson_mut_doc_free(doc); return cbm_mcp_text_result("compare_graphs failed: out of memory", true); } char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); if (!json) { return cbm_mcp_text_result("compare_graphs failed: out of memory", true); } char *result = cbm_mcp_text_result(json, true); free(json); return result; } static bool compare_arg_name_allowed(const char *name) { return strcmp(name, "base_project") == 0 || strcmp(name, "target_project") == 0 || strcmp(name, "limit") == 0 || strcmp(name, "scan_limit") == 0; } static bool compare_parse_bounded_integer(yyjson_val *root, const char *key, int64_t default_value, int64_t maximum, uint64_t *out, const char **error_message) { yyjson_val *value = yyjson_obj_get(root, key); int64_t parsed = default_value; if (value) { if (!yyjson_is_int(value)) { *error_message = "limit values must be integers"; return false; } parsed = yyjson_get_int(value); } if (parsed < 1 || parsed > maximum) { *error_message = strcmp(key, "limit") == 0 ? "limit must be between 1 and 1000" : "scan_limit must be between 1 and 10000000"; return false; } *out = (uint64_t)parsed; return true; } static bool compare_parse_arguments(const char *args, char **base_project, char **target_project, uint64_t *limit, uint64_t *scan_limit, const char **error_message) { *base_project = NULL; *target_project = NULL; yyjson_doc *doc = yyjson_read(args ? args : "{}", args ? strlen(args) : SLEN("{}"), 0); if (!doc) { *error_message = "arguments must be valid JSON"; return false; } yyjson_val *root = yyjson_doc_get_root(doc); if (!yyjson_is_obj(root)) { *error_message = "arguments must be an object"; yyjson_doc_free(doc); return false; } yyjson_obj_iter iterator = yyjson_obj_iter_with(root); yyjson_val *key = NULL; while ((key = yyjson_obj_iter_next(&iterator)) != NULL) { const char *name = yyjson_get_str(key); if (!name || !compare_arg_name_allowed(name)) { *error_message = "unknown argument"; yyjson_doc_free(doc); return false; } } yyjson_val *base = yyjson_obj_get(root, "base_project"); yyjson_val *target = yyjson_obj_get(root, "target_project"); if (!base || !yyjson_is_str(base) || yyjson_get_len(base) == 0 || !target || !yyjson_is_str(target) || yyjson_get_len(target) == 0) { *error_message = "base_project and target_project are required non-empty strings"; yyjson_doc_free(doc); return false; } if (strcmp(yyjson_get_str(base), yyjson_get_str(target)) == 0) { *error_message = "base_project and target_project must be distinct"; yyjson_doc_free(doc); return false; } if (!compare_parse_bounded_integer(root, "limit", MCP_COMPARE_DEFAULT_LIMIT, MCP_COMPARE_MAX_LIMIT, limit, error_message) || !compare_parse_bounded_integer(root, "scan_limit", MCP_COMPARE_DEFAULT_SCAN_LIMIT, MCP_COMPARE_MAX_SCAN_LIMIT, scan_limit, error_message)) { yyjson_doc_free(doc); return false; } *base_project = heap_strdup(yyjson_get_str(base)); *target_project = heap_strdup(yyjson_get_str(target)); yyjson_doc_free(doc); if (!*base_project || !*target_project) { free(*base_project); free(*target_project); *base_project = NULL; *target_project = NULL; *error_message = "out of memory while validating arguments"; return false; } return true; } static char *sanitize_utf8_lossy(const char *s); static bool compare_add_identity_string(yyjson_mut_doc *doc, yyjson_mut_val *object, const char *key, const char *value) { char *sanitized = sanitize_utf8_lossy(value); if (!sanitized) { return false; } bool ok = yyjson_mut_obj_add_strcpy(doc, object, key, sanitized); free(sanitized); return ok; } static yyjson_mut_val *compare_node_json(yyjson_mut_doc *doc, const cbm_graph_node_identity_t *node) { yyjson_mut_val *object = yyjson_mut_obj(doc); if (!object || !compare_add_identity_string(doc, object, "qualified_name", node->qualified_name) || !compare_add_identity_string(doc, object, "label", node->label) || !compare_add_identity_string(doc, object, "file_path", node->file_path)) { return NULL; } return object; } static bool compare_append_item(compare_response_t *response, compare_result_set_t *set, yyjson_mut_doc *item_doc, yyjson_mut_val *item) { if (!item_doc || !item) { yyjson_mut_doc_free(item_doc); return false; } char *encoded = yy_doc_to_str(item_doc); if (!encoded) { yyjson_mut_doc_free(item_doc); return false; } size_t encoded_len = strlen(encoded); size_t separator = set->returned > 0 ? 1U : 0U; free(encoded); if (set->encoded_bytes > MCP_COMPARE_SET_BYTE_BUDGET || separator > MCP_COMPARE_SET_BYTE_BUDGET - set->encoded_bytes || encoded_len > MCP_COMPARE_SET_BYTE_BUDGET - set->encoded_bytes - separator) { set->budget_exhausted = true; yyjson_mut_doc_free(item_doc); return true; } yyjson_mut_val *copy = yyjson_mut_val_mut_copy(response->doc, item); bool ok = copy && yyjson_mut_arr_add_val(set->items, copy); yyjson_mut_doc_free(item_doc); if (!ok) { return false; } set->encoded_bytes += separator + encoded_len; set->returned++; return true; } static bool compare_node_callback(void *context, bool added, const cbm_graph_node_identity_t *node) { compare_response_t *response = (compare_response_t *)context; compare_result_set_t *set = added ? &response->nodes_added : &response->nodes_removed; if (set->returned >= response->limit || set->budget_exhausted) { return true; } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *item = doc ? compare_node_json(doc, node) : NULL; if (doc && item) { yyjson_mut_doc_set_root(doc, item); } return compare_append_item(response, set, doc, item); } static bool compare_edge_callback(void *context, bool added, const cbm_graph_edge_identity_t *edge) { compare_response_t *response = (compare_response_t *)context; compare_result_set_t *set = added ? &response->edges_added : &response->edges_removed; if (set->returned >= response->limit || set->budget_exhausted) { return true; } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *item = doc ? yyjson_mut_obj(doc) : NULL; yyjson_mut_val *source = doc ? compare_node_json(doc, &edge->source) : NULL; yyjson_mut_val *target = doc ? compare_node_json(doc, &edge->target) : NULL; bool ok = item && source && target && yyjson_mut_obj_add_val(doc, item, "source", source) && yyjson_mut_obj_add_val(doc, item, "target", target) && compare_add_identity_string(doc, item, "type", edge->type) && compare_add_identity_string(doc, item, "local_name_gen", edge->local_name_gen); if (ok) { yyjson_mut_doc_set_root(doc, item); } return compare_append_item(response, set, doc, ok ? item : NULL); } static bool compare_cancel_callback(void *context) { compare_response_t *response = (compare_response_t *)context; return mcp_request_cancelled(response->server); } static yyjson_mut_val *compare_project_json(yyjson_mut_doc *doc, const char *project, const cbm_graph_compare_project_t *metadata) { yyjson_mut_val *object = yyjson_mut_obj(doc); if (!object || !yyjson_mut_obj_add_strcpy(doc, object, "project", project) || !compare_add_identity_string(doc, object, "generation", metadata->generation) || !compare_add_identity_string(doc, object, "index_mode", metadata->index_mode) || !yyjson_mut_obj_add_sint(doc, object, "node_count", metadata->node_count) || !yyjson_mut_obj_add_sint(doc, object, "edge_count", metadata->edge_count)) { return NULL; } return object; } static yyjson_mut_val *compare_set_json(yyjson_mut_doc *doc, compare_result_set_t *set, uint64_t total, size_t limit) { yyjson_mut_val *object = yyjson_mut_obj(doc); yyjson_mut_val *reasons = yyjson_mut_arr(doc); bool truncated = total > (uint64_t)set->returned; if (!object || !reasons || !yyjson_mut_obj_add_val(doc, object, "items", set->items) || !yyjson_mut_obj_add_uint(doc, object, "returned", set->returned) || !yyjson_mut_obj_add_uint(doc, object, "total", total) || !yyjson_mut_obj_add_bool(doc, object, "truncated", truncated)) { return NULL; } if (truncated && set->returned >= limit && !yyjson_mut_arr_add_strcpy(doc, reasons, "limit")) { return NULL; } if (truncated && set->budget_exhausted && !yyjson_mut_arr_add_strcpy(doc, reasons, "encoded_byte_budget")) { return NULL; } if (!yyjson_mut_obj_add_val(doc, object, "truncation_reasons", reasons)) { return NULL; } return object; } static char *handle_compare_graphs(cbm_mcp_server_t *server, const char *args) { char *base_project = NULL; char *target_project = NULL; uint64_t limit = 0; uint64_t scan_limit = 0; const char *argument_error = NULL; if (!compare_parse_arguments(args, &base_project, &target_project, &limit, &scan_limit, &argument_error)) { return compare_graphs_error("invalid_arguments", argument_error); } if (mcp_request_cancelled(server)) { free(base_project); free(target_project); return compare_graphs_error("cancelled", "compare_graphs cancelled for this request"); } cbm_store_t *base_store = compare_open_project_store(base_project); if (!base_store) { free(base_project); free(target_project); return compare_graphs_error("project_not_indexed", "base project is not indexed"); } cbm_store_t *target_store = compare_open_project_store(target_project); if (!target_store) { cbm_store_close(base_store); free(base_project); free(target_project); return compare_graphs_error("project_not_indexed", "target project is not indexed"); } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL; compare_response_t response = { .server = server, .doc = doc, .limit = (size_t)limit, .nodes_added = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U}, .nodes_removed = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U}, .edges_added = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U}, .edges_removed = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U}, }; if (!doc || !root || !response.nodes_added.items || !response.nodes_removed.items || !response.edges_added.items || !response.edges_removed.items) { cbm_store_close(target_store); cbm_store_close(base_store); yyjson_mut_doc_free(doc); free(base_project); free(target_project); return compare_graphs_error("allocation_failed", "could not allocate comparison result"); } yyjson_mut_doc_set_root(doc, root); cbm_graph_compare_result_t comparison = {0}; int compare_rc = cbm_store_compare_graphs( base_store, base_project, target_store, target_project, scan_limit, compare_cancel_callback, compare_node_callback, compare_edge_callback, &response, &comparison); cbm_store_close(target_store); cbm_store_close(base_store); if (compare_rc != CBM_STORE_OK) { yyjson_mut_doc_free(doc); free(base_project); free(target_project); if (compare_rc == CBM_STORE_CANCELLED) { return compare_graphs_error("cancelled", "compare_graphs cancelled for this request"); } if (compare_rc == CBM_STORE_NOT_FOUND) { return compare_graphs_error("project_not_indexed", "project is not indexed"); } if (compare_rc == CBM_STORE_SCAN_LIMIT) { return compare_graphs_error("scan_limit_exceeded", "combined graph rows exceed scan_limit"); } if (compare_rc == CBM_STORE_CALLBACK_ERR) { return compare_graphs_error("allocation_failed", "could not allocate comparison result"); } return compare_graphs_error("query_failed", "graph comparison query failed"); } yyjson_mut_val *base = compare_project_json(doc, base_project, &comparison.base); yyjson_mut_val *target = compare_project_json(doc, target_project, &comparison.target); yyjson_mut_val *nodes = yyjson_mut_obj(doc); yyjson_mut_val *edges = yyjson_mut_obj(doc); yyjson_mut_val *limits = yyjson_mut_obj(doc); yyjson_mut_val *nodes_added = compare_set_json(doc, &response.nodes_added, comparison.nodes_added_total, response.limit); yyjson_mut_val *nodes_removed = compare_set_json( doc, &response.nodes_removed, comparison.nodes_removed_total, response.limit); yyjson_mut_val *edges_added = compare_set_json(doc, &response.edges_added, comparison.edges_added_total, response.limit); yyjson_mut_val *edges_removed = compare_set_json( doc, &response.edges_removed, comparison.edges_removed_total, response.limit); bool built = base && target && nodes && edges && limits && nodes_added && nodes_removed && edges_added && edges_removed && yyjson_mut_obj_add_int(doc, root, "schema_version", 1) && yyjson_mut_obj_add_val(doc, root, "base", base) && yyjson_mut_obj_add_val(doc, root, "target", target) && yyjson_mut_obj_add_val(doc, nodes, "added", nodes_added) && yyjson_mut_obj_add_val(doc, nodes, "removed", nodes_removed) && yyjson_mut_obj_add_val(doc, root, "nodes", nodes) && yyjson_mut_obj_add_val(doc, edges, "added", edges_added) && yyjson_mut_obj_add_val(doc, edges, "removed", edges_removed) && yyjson_mut_obj_add_val(doc, root, "edges", edges) && yyjson_mut_obj_add_uint(doc, limits, "limit", limit) && yyjson_mut_obj_add_uint(doc, limits, "scan_limit", scan_limit) && yyjson_mut_obj_add_uint(doc, limits, "encoded_byte_budget", MCP_COMPARE_SET_BYTE_BUDGET) && yyjson_mut_obj_add_val(doc, root, "limits", limits); free(base_project); free(target_project); if (!built) { yyjson_mut_doc_free(doc); return compare_graphs_error("allocation_failed", "could not allocate comparison result"); } if (mcp_request_cancelled(server)) { yyjson_mut_doc_free(doc); return compare_graphs_error("cancelled", "compare_graphs cancelled for this request"); } char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); if (!json) { return compare_graphs_error("allocation_failed", "could not serialize comparison result"); } char *result = cbm_mcp_text_result(json, false); free(json); return result; } static bool sg_field_blocked(const char *f); /* internal-only fields, defined with search_graph */ typedef struct { bool node; int index; int count; const char *name; } mcp_schema_record_t; static int schema_record_compare(const void *left, const void *right) { const mcp_schema_record_t *a = left; const mcp_schema_record_t *b = right; if (a->node != b->node) { return a->node ? -1 : 1; } if (a->count != b->count) { return a->count > b->count ? -1 : 1; } return strcmp(a->name ? a->name : "", b->name ? b->name : ""); } static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); char *not_indexed = verify_project_indexed(store, project); if (not_indexed) { free(project); return not_indexed; } char *diagnostics = cbm_mcp_get_string_arg(args, "diagnostics"); bool include_properties = diagnostics && strcmp(diagnostics, "full") == 0; free(diagnostics); cbm_schema_info_t schema = {0}; if (include_properties) { cbm_store_get_schema(store, project, &schema); } else { cbm_store_get_schema_counts(store, project, &schema); } int limit = cbm_mcp_get_int_arg(args, "limit", 50); int offset = cbm_mcp_get_int_arg(args, "offset", 0); if (limit < 1) { limit = 1; } else if (limit > 500) { limit = 500; } if (offset < 0) { offset = 0; } int total = schema.node_label_count + schema.edge_type_count; size_t record_capacity = total > 0 ? (size_t)total : 1U; mcp_schema_record_t *records = calloc(record_capacity, sizeof(*records)); if (!records) { cbm_store_schema_free(&schema); free(project); return cbm_mcp_text_result("out of memory while rendering schema", true); } int record_count = 0; for (int i = 0; i < schema.node_label_count; i++) { records[record_count++] = (mcp_schema_record_t){true, i, schema.node_labels[i].count, schema.node_labels[i].label}; } for (int i = 0; i < schema.edge_type_count; i++) { records[record_count++] = (mcp_schema_record_t){false, i, schema.edge_types[i].count, schema.edge_types[i].type}; } qsort(records, (size_t)record_count, sizeof(*records), schema_record_compare); int start = offset < total ? offset : total; int end = start + limit < total ? start + limit : total; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); yyjson_mut_val *labels = yyjson_mut_arr(doc); for (int page = start; page < end; page++) { if (!records[page].node) { continue; } int i = records[page].index; yyjson_mut_val *lbl = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, lbl, "label", schema.node_labels[i].label); yyjson_mut_obj_add_int(doc, lbl, "count", schema.node_labels[i].count); if (include_properties) { yyjson_mut_val *props = yyjson_mut_arr(doc); for (int j = 0; j < schema.node_labels[i].property_count; j++) { /* Internal similarity intermediates are never public. */ if (sg_field_blocked(schema.node_labels[i].properties[j])) { continue; } yyjson_mut_arr_add_str(doc, props, schema.node_labels[i].properties[j]); } yyjson_mut_obj_add_val(doc, lbl, "properties", props); } yyjson_mut_arr_add_val(labels, lbl); } yyjson_mut_obj_add_val(doc, root, "node_labels", labels); yyjson_mut_val *types = yyjson_mut_arr(doc); for (int page = start; page < end; page++) { if (records[page].node) { continue; } int i = records[page].index; yyjson_mut_val *typ = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, typ, "type", schema.edge_types[i].type); yyjson_mut_obj_add_int(doc, typ, "count", schema.edge_types[i].count); if (include_properties) { yyjson_mut_val *eprops = yyjson_mut_arr(doc); for (int j = 0; j < schema.edge_types[i].property_count; j++) { yyjson_mut_arr_add_str(doc, eprops, schema.edge_types[i].properties[j]); } yyjson_mut_obj_add_val(doc, typ, "properties", eprops); } yyjson_mut_arr_add_val(types, typ); } yyjson_mut_obj_add_val(doc, root, "edge_types", types); yyjson_mut_obj_add_int(doc, root, "total", total); yyjson_mut_obj_add_int(doc, root, "returned", end - start); yyjson_mut_obj_add_bool(doc, root, "has_more", end < total); if (end < total) { yyjson_mut_obj_add_int(doc, root, "next_offset", end); } /* Check ADR presence */ cbm_project_t proj_info = {0}; if (cbm_store_get_project(store, project, &proj_info) == 0 && proj_info.root_path) { bool adr_exists = project_has_adr(store, project, proj_info.root_path); yyjson_mut_obj_add_bool(doc, root, "adr_present", adr_exists); if (!adr_exists && include_properties) { yyjson_mut_obj_add_str( doc, root, "adr_hint", "No ADR found. Use manage_adr(mode='update') to persist architectural " "decisions across sessions. Run get_architecture(aspects=['all']) first."); } cbm_project_free_fields(&proj_info); } char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); free(records); cbm_store_schema_free(&schema); free(project); char *result = mcp_result_from_json(args, json); free(json); return result; } /* Validate edge type: uppercase letters + underscore only, max 64 chars. */ static bool validate_edge_type(const char *s) { if (!s || strlen(s) > CBM_SZ_64) { return false; } for (const char *c = s; *c; c++) { if (!(*c >= 'A' && *c <= 'Z') && *c != '_') { return false; } } return true; } /* Enrich search result with 1-hop connected node names. */ /* Add BFS results to a yyjson array (deduped by name). */ static void enrich_add_bfs(yyjson_mut_doc *doc, yyjson_mut_val *arr, cbm_traverse_result_t *tr) { for (int j = 0; j < tr->visited_count; j++) { if (tr->visited[j].node.name) { yyjson_mut_arr_add_strcpy(doc, arr, tr->visited[j].node.name); } } } /* Enrich search result with 1-hop connected node names (inbound + outbound). */ /* Build the connected-names array (1-hop callers + callees) for a node. * Returns a (possibly empty) yyjson array owned by doc. */ static yyjson_mut_val *enrich_connected(yyjson_mut_doc *doc, cbm_store_t *store, int64_t node_id, const char *relationship) { const char *et[] = {relationship ? relationship : "CALLS"}; yyjson_mut_val *conn = yyjson_mut_arr(doc); /* BFS doesn't support "both" — run inbound + outbound separately. */ cbm_traverse_result_t tr_in = {0}; cbm_store_bfs(store, node_id, "inbound", et, SKIP_ONE, SKIP_ONE, MCP_DEFAULT_LIMIT, &tr_in); enrich_add_bfs(doc, conn, &tr_in); cbm_store_traverse_free(&tr_in); cbm_traverse_result_t tr_out = {0}; cbm_store_bfs(store, node_id, "outbound", et, SKIP_ONE, SKIP_ONE, MCP_DEFAULT_LIMIT, &tr_out); enrich_add_bfs(doc, conn, &tr_out); cbm_store_traverse_free(&tr_out); return conn; } /* Text-tree variant: the same 1-hop neighbor names joined without a fixed * staging buffer. Identifiers are semantic atoms: emit each in full or omit * the entire connected column at the response-budget layer. */ static char *enrich_connected_joined(cbm_store_t *store, int64_t node_id, const char *relationship) { const char *et[] = {relationship ? relationship : "CALLS"}; cbm_sb_t sb; cbm_sb_init(&sb); bool any = false; const char *dirs[2] = {"inbound", "outbound"}; for (int d = 0; d < 2; d++) { cbm_traverse_result_t tr = {0}; cbm_store_bfs(store, node_id, dirs[d], et, SKIP_ONE, SKIP_ONE, MCP_DEFAULT_LIMIT, &tr); for (int j = 0; j < tr.visited_count; j++) { if (!tr.visited[j].node.name) { continue; } if (any) { cbm_sb_append(&sb, ";"); } cbm_sb_append(&sb, tr.visited[j].node.name); any = true; } cbm_store_traverse_free(&tr); } return cbm_sb_finish(&sb); } /* Build an FTS5 MATCH expression from a free-form query string by splitting * on whitespace and joining the terms with OR. Each token is also sanitized: * anything that isn't alnum or underscore is dropped, so the caller can't * inject FTS5 operators or double-quoted phrases. Returns the number of * tokens emitted (0 if the query contained no usable terms). */ enum { BM25_MIN_BUF = 2, /* minimum buffer size: at least NUL + one char */ BM25_SEP_RESERVE = 1, BM25_QUERY_BUF = 1024, BM25_DEFAULT_LIMIT = 50, BM25_COL_ID = 0, BM25_COL_LABEL = 1, BM25_COL_NAME = 2, BM25_COL_QN = 3, BM25_COL_FILE = 4, BM25_COL_START = 5, BM25_COL_END = 6, BM25_COL_RANK = 7, BM25_BIND_QUERY = 1, BM25_BIND_PROJECT = 2, BM25_BIND_LIMIT = 3, BM25_BIND_OFFSET = 4, BM25_BIND_INNER = 5, BM25_BIND_FILE = 6, BM25_BIND_LABEL = 7, BM25_BIND_EXACT = 8, BM25_BIND_KIND = 9, BM25_BIND_NS = 10, BM25_BIND_LABEL_WEIGHT = 11, BM25_SQL_AUTO_LEN = -1, /* Inner FTS5 candidate cap. SQLite can early-terminate a plain FTS5 query * (no JOIN/WHERE on outer table) of the form: * SELECT rowid, bm25() FROM nodes_fts WHERE MATCH ? ORDER BY bm25() LIMIT N * By fetching only the top BM25_INNER_LIMIT candidates from the FTS5 index * and then joining/filtering/re-ranking those, we bound all work to O(N) where * N = BM25_INNER_LIMIT rather than the full match set size. */ BM25_INNER_LIMIT = 2000, /* Longest label spliced into the MATCH pre-filter; real labels are short * identifiers, so anything longer simply skips the pre-filter. */ BM25_LABEL_MAX = 64, /* Room for "() AND label : \"