Files
codebase-memory-mcp/src/cli/client_adapter.c
T

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;
}