mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 18:58:00 +00:00
522 lines
19 KiB
C
522 lines
19 KiB
C
/*
|
|
* client_adapter.c — Generate client extension modules from the MCP tool registry.
|
|
*
|
|
* See client_adapter.h for why these are generated rather than shipped.
|
|
*/
|
|
#include "cli/client_adapter.h"
|
|
|
|
#include "foundation/constants.h"
|
|
#include "mcp/mcp.h"
|
|
#include "yyjson/yyjson.h"
|
|
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* A generated module is small and bounded by the registry size; grow a plain
|
|
* buffer rather than pulling in a builder for two call sites. */
|
|
typedef struct {
|
|
char *buf;
|
|
size_t len;
|
|
size_t cap;
|
|
bool failed;
|
|
} adapter_sb_t;
|
|
|
|
static void sb_append(adapter_sb_t *sb, const char *s) {
|
|
if (sb->failed || !s) {
|
|
return;
|
|
}
|
|
size_t add = strlen(s);
|
|
if (sb->len + add + 1 > sb->cap) {
|
|
size_t want = sb->cap ? sb->cap : CBM_SZ_2K;
|
|
while (want < sb->len + add + 1) {
|
|
if (want > SIZE_MAX / PAIR_LEN) {
|
|
sb->failed = true;
|
|
return;
|
|
}
|
|
want *= PAIR_LEN;
|
|
}
|
|
char *grown = realloc(sb->buf, want);
|
|
if (!grown) {
|
|
sb->failed = true;
|
|
return;
|
|
}
|
|
sb->buf = grown;
|
|
sb->cap = want;
|
|
}
|
|
memcpy(sb->buf + sb->len, s, add);
|
|
sb->len += add;
|
|
sb->buf[sb->len] = '\0';
|
|
}
|
|
|
|
/* Append a single-quoted JavaScript string literal, escaping the characters
|
|
* that could terminate or corrupt it. Avoids the fixed buffer sizing that
|
|
* cbm_client_adapter_escape_js imposes, so long registry descriptions fit. */
|
|
static void sb_append_js_string(adapter_sb_t *sb, const char *s) {
|
|
if (!s) {
|
|
sb_append(sb, "''");
|
|
return;
|
|
}
|
|
sb_append(sb, "'");
|
|
for (const char *p = s; *p; p++) {
|
|
switch (*p) {
|
|
case '\\':
|
|
sb_append(sb, "\\\\");
|
|
break;
|
|
case '\'':
|
|
sb_append(sb, "\\'");
|
|
break;
|
|
case '\n':
|
|
sb_append(sb, "\\n");
|
|
break;
|
|
case '\r':
|
|
sb_append(sb, "\\r");
|
|
break;
|
|
default: {
|
|
char ch[2] = {*p, '\0'};
|
|
sb_append(sb, ch);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
sb_append(sb, "'");
|
|
}
|
|
|
|
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) {
|
|
if (!in || !out || out_sz == 0) {
|
|
return false;
|
|
}
|
|
size_t o = 0;
|
|
for (const char *p = in; *p; p++) {
|
|
/* Only these four can terminate or corrupt a single-quoted literal.
|
|
* Escaping more would be harmless but makes the emitted path harder to
|
|
* read when a user opens the generated file. */
|
|
const char *esc = NULL;
|
|
switch (*p) {
|
|
case '\\':
|
|
esc = "\\\\";
|
|
break;
|
|
case '\'':
|
|
esc = "\\'";
|
|
break;
|
|
case '\n':
|
|
esc = "\\n";
|
|
break;
|
|
case '\r':
|
|
esc = "\\r";
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
size_t need = esc ? strlen(esc) : 1U;
|
|
if (o + need + 1 > out_sz) {
|
|
out[0] = '\0';
|
|
return false; /* never emit a truncated literal */
|
|
}
|
|
if (esc) {
|
|
memcpy(out + o, esc, need);
|
|
} else {
|
|
out[o] = *p;
|
|
}
|
|
o += need;
|
|
}
|
|
out[o] = '\0';
|
|
return true;
|
|
}
|
|
|
|
/* Shared preamble: the ownership markers and a note telling whoever opens the
|
|
* file that edits inside the block are overwritten. */
|
|
static void emit_header(adapter_sb_t *sb, const char *client) {
|
|
/* No markers here: cbm_text_upsert_managed_block adds them and REJECTS
|
|
* content that already carries them, so the body must stay marker-free. */
|
|
sb_append(sb, "// Generated by codebase-memory-mcp for ");
|
|
sb_append(sb, client);
|
|
sb_append(sb,
|
|
".\n"
|
|
"// Regenerated on install/update from the MCP tool registry, so it cannot\n"
|
|
"// drift from the tools the server actually serves. Edits inside this block\n"
|
|
"// are overwritten; edit outside it, or remove the markers to take ownership.\n");
|
|
}
|
|
|
|
static void append_typebox_type(adapter_sb_t *sb, yyjson_val *val, const char *description,
|
|
bool include_description) {
|
|
yyjson_val *type_val = yyjson_obj_get(val, "type");
|
|
yyjson_val *enum_val = yyjson_obj_get(val, "enum");
|
|
yyjson_val *items_val = yyjson_obj_get(val, "items");
|
|
const char *type_str = yyjson_get_str(type_val);
|
|
|
|
if (type_str && strcmp(type_str, "string") == 0 && enum_val && yyjson_is_arr(enum_val) &&
|
|
yyjson_arr_size(enum_val) > 0) {
|
|
sb_append(sb, "Type.Union([");
|
|
size_t enum_idx = 0;
|
|
size_t enum_max = yyjson_arr_size(enum_val);
|
|
bool first_enum = true;
|
|
for (enum_idx = 0; enum_idx < enum_max; enum_idx++) {
|
|
yyjson_val *enum_item = yyjson_arr_get(enum_val, enum_idx);
|
|
const char *enum_str = yyjson_get_str(enum_item);
|
|
if (!enum_str) {
|
|
continue;
|
|
}
|
|
if (!first_enum) {
|
|
sb_append(sb, ", ");
|
|
}
|
|
first_enum = false;
|
|
sb_append(sb, "Type.Literal(");
|
|
sb_append_js_string(sb, enum_str);
|
|
sb_append(sb, ")");
|
|
}
|
|
if (first_enum) {
|
|
sb_append(sb, "Type.String()");
|
|
} else {
|
|
sb_append(sb, "]");
|
|
if (include_description && description) {
|
|
sb_append(sb, ", { description: ");
|
|
sb_append_js_string(sb, description);
|
|
sb_append(sb, " }");
|
|
}
|
|
sb_append(sb, ")");
|
|
return;
|
|
}
|
|
sb_append(sb, ")");
|
|
return;
|
|
}
|
|
|
|
const char *typebox_name = "Any";
|
|
if (type_str && strcmp(type_str, "string") == 0) {
|
|
typebox_name = "String";
|
|
} else if (type_str && strcmp(type_str, "integer") == 0) {
|
|
typebox_name = "Integer";
|
|
} else if (type_str && strcmp(type_str, "number") == 0) {
|
|
typebox_name = "Number";
|
|
} else if (type_str && strcmp(type_str, "boolean") == 0) {
|
|
typebox_name = "Boolean";
|
|
}
|
|
|
|
if (type_str && strcmp(type_str, "array") == 0) {
|
|
sb_append(sb, "Type.Array(");
|
|
if (items_val) {
|
|
append_typebox_type(sb, items_val, NULL, false);
|
|
} else {
|
|
sb_append(sb, "Type.Any()");
|
|
}
|
|
if (include_description && description) {
|
|
sb_append(sb, ", { description: ");
|
|
sb_append_js_string(sb, description);
|
|
sb_append(sb, " }");
|
|
}
|
|
sb_append(sb, ")");
|
|
return;
|
|
}
|
|
|
|
sb_append(sb, "Type.");
|
|
sb_append(sb, typebox_name);
|
|
sb_append(sb, "(");
|
|
if (include_description && description) {
|
|
sb_append(sb, "{ description: ");
|
|
sb_append_js_string(sb, description);
|
|
sb_append(sb, " }");
|
|
}
|
|
sb_append(sb, ")");
|
|
}
|
|
|
|
static bool typebox_property_is_required(yyjson_val *required, const char *prop_name) {
|
|
if (!required || !yyjson_is_arr(required)) {
|
|
return false;
|
|
}
|
|
size_t idx = 0;
|
|
size_t max = yyjson_arr_size(required);
|
|
for (idx = 0; idx < max; idx++) {
|
|
const char *required_name = yyjson_get_str(yyjson_arr_get(required, idx));
|
|
if (required_name && strcmp(required_name, prop_name) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static void convert_json_schema_to_typebox(adapter_sb_t *sb, const char *schema_str) {
|
|
if (!schema_str) {
|
|
sb_append(sb, "Type.Object({})");
|
|
return;
|
|
}
|
|
|
|
/* Parse the JSON schema to extract properties and convert to TypeBox */
|
|
yyjson_doc *doc = yyjson_read(schema_str, strlen(schema_str), 0);
|
|
if (!doc) {
|
|
sb_append(sb, "Type.Object({})");
|
|
return;
|
|
}
|
|
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
if (!root) {
|
|
sb_append(sb, "Type.Object({})");
|
|
yyjson_doc_free(doc);
|
|
return;
|
|
}
|
|
|
|
yyjson_val *properties = yyjson_obj_get(root, "properties");
|
|
yyjson_val *required = yyjson_obj_get(root, "required");
|
|
if (!properties) {
|
|
sb_append(sb, "Type.Object({})");
|
|
yyjson_doc_free(doc);
|
|
return;
|
|
}
|
|
|
|
sb_append(sb, "Type.Object({\n");
|
|
|
|
bool first_prop = true;
|
|
size_t idx = 0, max = 0;
|
|
yyjson_val *key = NULL, *val = NULL;
|
|
|
|
yyjson_obj_foreach(properties, idx, max, key, val) {
|
|
const char *prop_name = yyjson_get_str(key);
|
|
if (!prop_name) {
|
|
continue;
|
|
}
|
|
|
|
if (!first_prop) {
|
|
sb_append(sb, ",\n");
|
|
}
|
|
first_prop = false;
|
|
|
|
sb_append(sb, " ");
|
|
sb_append(sb, prop_name);
|
|
sb_append(sb, ": ");
|
|
|
|
yyjson_val *description_val = yyjson_obj_get(val, "description");
|
|
const char *description = yyjson_get_str(description_val);
|
|
bool is_required = typebox_property_is_required(required, prop_name);
|
|
if (!is_required) {
|
|
sb_append(sb, "Type.Optional(");
|
|
}
|
|
append_typebox_type(sb, val, description, true);
|
|
if (!is_required) {
|
|
sb_append(sb, ")");
|
|
}
|
|
}
|
|
|
|
sb_append(sb, "\n })");
|
|
yyjson_doc_free(doc);
|
|
}
|
|
|
|
char *cbm_client_adapter_pi(const char *binary_path) {
|
|
if (!binary_path || !binary_path[0]) {
|
|
return NULL;
|
|
}
|
|
char bin[CBM_SZ_1K];
|
|
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
|
|
return NULL;
|
|
}
|
|
int count = cbm_mcp_tool_count();
|
|
if (count <= 0) {
|
|
return NULL;
|
|
}
|
|
|
|
adapter_sb_t sb = {0};
|
|
emit_header(&sb, "pi");
|
|
/* Pin the coding-agent contract so a probe against @mariozechner/pi
|
|
* (pods CLI) or an old AgentTool arity cannot be mistaken for this file. */
|
|
sb_append(&sb, "// Target: @earendil-works/pi-coding-agent >= 0.74.0 (verified 0.84.2)\n"
|
|
"// ToolDefinition.execute(toolCallId, params, signal, onUpdate, ctx)\n");
|
|
sb_append(&sb, "import { spawn } from 'node:child_process';\n");
|
|
sb_append(&sb, "import { Type } from 'typebox';\n\n");
|
|
sb_append(&sb, "const BIN = '");
|
|
sb_append(&sb, bin);
|
|
sb_append(&sb, "';\n\n");
|
|
|
|
/* One shared invoker: the binary prints JSON on stdout, so take the last
|
|
* line that parses rather than trusting the first — a stray log line on
|
|
* stdout must not break the bridge. */
|
|
sb_append(
|
|
&sb, "async function call(tool, args, signal) {\n"
|
|
" return new Promise((resolve) => {\n"
|
|
" const child = spawn(BIN, ['cli', '--json', tool], {\n"
|
|
" stdio: ['pipe', 'pipe', 'pipe'],\n"
|
|
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
|
|
" });\n"
|
|
" let out = '';\n"
|
|
" let stdinError;\n"
|
|
" const onAbort = () => { if (!child.killed) child.kill(); };\n"
|
|
" signal?.addEventListener('abort', onAbort, { once: true });\n"
|
|
" child.stdout.on('data', (d) => (out += d.toString()));\n"
|
|
" child.stdin.on('error', (e) => { stdinError = e; });\n"
|
|
" child.on('error', (e) => {\n"
|
|
" signal?.removeEventListener('abort', onAbort);\n"
|
|
" resolve({ error: String(e && e.message ? e.message : e) });\n"
|
|
" });\n"
|
|
" child.on('close', () => {\n"
|
|
" signal?.removeEventListener('abort', onAbort);\n"
|
|
" const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n"
|
|
" for (let i = lines.length - 1; i >= 0; i--) {\n"
|
|
" try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n"
|
|
" }\n"
|
|
" if (stdinError) {\n"
|
|
" return resolve({ error: String(stdinError.message || stdinError) });\n"
|
|
" }\n"
|
|
" resolve({ error: 'no JSON response from codebase-memory-mcp' });\n"
|
|
" });\n"
|
|
" child.stdin.end(JSON.stringify(args ?? {}));\n"
|
|
" });\n"
|
|
"}\n\n");
|
|
|
|
/* Register every tool the registry advertises — never a hand-picked subset,
|
|
* which is the drift this generator exists to prevent.
|
|
*
|
|
* DEFAULT export, and it must stay that way. Pi loads an extension via
|
|
* jiti.import(path, { default: true }) and rejects anything that is not a
|
|
* function, so a named `export function register(pi)` hands the loader the
|
|
* module namespace object instead and every install fails with "Extension
|
|
* does not export a valid factory function". A Pi extension that fails to
|
|
* load takes EVERY pi command down with it — `pi doctor` included — not
|
|
* just cbm's tools (#1550). */
|
|
sb_append(&sb, "export default function (pi) {\n");
|
|
for (int i = 0; i < count; i++) {
|
|
const char *name = cbm_mcp_tool_name(i);
|
|
if (!name || !name[0]) {
|
|
continue;
|
|
}
|
|
const char *description = cbm_mcp_tool_description(name);
|
|
const char *schema = cbm_mcp_tool_input_schema(name);
|
|
sb_append(&sb, " pi.registerTool({\n");
|
|
sb_append(&sb, " name: ");
|
|
sb_append_js_string(&sb, name);
|
|
sb_append(&sb, ",\n label: ");
|
|
/* Tools carry no separate title: the name is the MCP display fallback. */
|
|
sb_append_js_string(&sb, name);
|
|
sb_append(&sb, ",\n description: ");
|
|
sb_append_js_string(&sb, description ? description : "");
|
|
sb_append(&sb, ",\n parameters: ");
|
|
/* Convert JSON schema to TypeBox format */
|
|
convert_json_schema_to_typebox(&sb, schema);
|
|
/* 0.84.2 calls execute(toolCallId, params, signal, onUpdate, ctx).
|
|
* The previous (args, ctx) shape bound the call id as the MCP args. */
|
|
sb_append(&sb, ",\n execute: async (toolCallId, params, signal, _onUpdate, ctx) => {\n");
|
|
sb_append(&sb, " const result = await call(");
|
|
sb_append_js_string(&sb, name);
|
|
sb_append(&sb,
|
|
", params, signal ?? ctx?.signal);\n"
|
|
" if (result && typeof result === 'object' && result.error) {\n"
|
|
" throw new Error(String(result.error));\n"
|
|
" }\n"
|
|
" const content = result && Array.isArray(result.content)\n"
|
|
" ? result.content\n"
|
|
" : [{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }];\n"
|
|
" return { content, details: result ?? {} };\n"
|
|
" },\n"
|
|
" });\n");
|
|
}
|
|
sb_append(&sb, "}\n");
|
|
|
|
if (sb.failed) {
|
|
free(sb.buf);
|
|
return NULL;
|
|
}
|
|
return sb.buf;
|
|
}
|
|
|
|
char *cbm_client_adapter_opencode(const char *binary_path) {
|
|
if (!binary_path || !binary_path[0]) {
|
|
return NULL;
|
|
}
|
|
char bin[CBM_SZ_1K];
|
|
if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) {
|
|
return NULL;
|
|
}
|
|
|
|
adapter_sb_t sb = {0};
|
|
emit_header(&sb, "OpenCode");
|
|
sb_append(&sb, "// OpenCode already reaches every tool over MCP; this module adds the\n"
|
|
"// context surfaces other clients get through hook configuration: graph\n"
|
|
"// lookup after grep/glob, index-coverage notes after read, session-start\n"
|
|
"// tier routing (carried on the first tool result of each session, since\n"
|
|
"// OpenCode documents no context-output lifecycle hook), and reinjection\n"
|
|
"// after compaction via the documented experimental surface.\n");
|
|
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
|
|
sb_append(&sb, "const BIN = '");
|
|
sb_append(&sb, bin);
|
|
sb_append(&sb, "';\n\n");
|
|
|
|
/* hook-augment requires hook_event_name; its default dialect accepts
|
|
* Grep/Glob under PreToolUse, Read under PostToolUse, and the
|
|
* SessionStart lifecycle event — and emits the Claude JSON envelope, so
|
|
* the plugin unwraps additionalContext instead of pasting raw JSON into
|
|
* the tool output. Every failure path resolves to '' (fail open). */
|
|
sb_append(&sb, "function augment(payload) {\n"
|
|
" return new Promise((resolve) => {\n"
|
|
" const child = spawn(BIN, ['hook-augment'], {\n"
|
|
" stdio: ['pipe', 'pipe', 'ignore'],\n"
|
|
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
|
|
" });\n"
|
|
" let out = '';\n"
|
|
" child.stdout.on('data', (d) => (out += d.toString()));\n"
|
|
" child.on('error', () => resolve(''));\n"
|
|
" child.on('close', () => {\n"
|
|
" try {\n"
|
|
" const ctx = JSON.parse(out)?.hookSpecificOutput?.additionalContext;\n"
|
|
" resolve(typeof ctx === 'string' ? ctx : '');\n"
|
|
" } catch { resolve(''); }\n"
|
|
" });\n"
|
|
" child.stdin.end(JSON.stringify(payload));\n"
|
|
" });\n"
|
|
"}\n\n");
|
|
|
|
sb_append(&sb,
|
|
"export const CodebaseMemory = async (ctx) => {\n"
|
|
" const dir = ctx?.directory;\n"
|
|
" const seen = new Set();\n"
|
|
" const lifecycle = () =>\n"
|
|
" augment({ hook_event_name: 'SessionStart', cwd: dir });\n"
|
|
" return {\n"
|
|
" 'tool.execute.after': async (input, output) => {\n"
|
|
" if (typeof output?.output !== 'string') return;\n"
|
|
" const pieces = [];\n"
|
|
" const sid = input?.sessionID;\n"
|
|
" if (typeof sid === 'string' && !seen.has(sid)) {\n"
|
|
" seen.add(sid);\n"
|
|
" pieces.push(await lifecycle());\n"
|
|
" }\n"
|
|
" const args = input?.args ?? {};\n"
|
|
" const search =\n"
|
|
" input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n"
|
|
" if (search) {\n"
|
|
" pieces.push(await augment({\n"
|
|
" hook_event_name: 'PreToolUse',\n"
|
|
" tool_name: search,\n"
|
|
" tool_input: args,\n"
|
|
" cwd: dir,\n"
|
|
" }));\n"
|
|
" } else if (input?.tool === 'read') {\n"
|
|
" const filePath = args.filePath ?? args.file_path ?? args.path;\n"
|
|
" if (typeof filePath === 'string' && filePath) {\n"
|
|
" pieces.push(await augment({\n"
|
|
" hook_event_name: 'PostToolUse',\n"
|
|
" tool_name: 'Read',\n"
|
|
" tool_input: { file_path: filePath },\n"
|
|
" cwd: dir,\n"
|
|
" }));\n"
|
|
" }\n"
|
|
" }\n"
|
|
" const extra = pieces.filter(Boolean).join('\\n');\n"
|
|
" if (extra) {\n"
|
|
" output.output += '\\n' + extra;\n"
|
|
" }\n"
|
|
" },\n"
|
|
" // Documented (experimental) compaction surface: output.context is the\n"
|
|
" // mutable array of context strings for the rebuilt session.\n"
|
|
" 'experimental.session.compacting': async (_input, output) => {\n"
|
|
" const note = await lifecycle();\n"
|
|
" if (note && Array.isArray(output?.context)) {\n"
|
|
" output.context.push(note);\n"
|
|
" }\n"
|
|
" },\n"
|
|
" };\n"
|
|
"};\n");
|
|
|
|
if (sb.failed) {
|
|
free(sb.buf);
|
|
return NULL;
|
|
}
|
|
return sb.buf;
|
|
}
|