Files
codebase-memory-mcp/src/cli/client_adapter.c
T
2026-08-27 18:51:36 +02:00

315 lines
12 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 <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");
}
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\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 *title = cbm_mcp_tool_title(name);
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: ");
sb_append_js_string(&sb, title ? title : name);
sb_append(&sb, ",\n description: ");
sb_append_js_string(&sb, description ? description : "");
sb_append(&sb, ",\n parameters: ");
/* input_schema is compact JSON, which is a valid JavaScript object
* literal; embedding it directly keeps the generated module free of a
* JSON.parse indirection and of any escaping drift. */
sb_append(&sb, schema ? 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 adds only the\n"
"// automatic graph lookup before a grep/glob, which other clients get\n"
"// through their own hook configuration.\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 and accepts Grep/Glob only under
* PreToolUse; omitting it makes the whole hook a silent no-op. */
sb_append(&sb, "function augment(tool, args) {\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', () => resolve(out));\n"
" child.stdin.end(JSON.stringify({\n"
" hook_event_name: 'PreToolUse',\n"
" tool_name: tool,\n"
" tool_input: args ?? {},\n"
" }));\n"
" });\n"
"}\n\n");
sb_append(&sb,
"export const CodebaseMemory = async () => ({\n"
" 'tool.execute.after': async (input, output) => {\n"
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
": null;\n"
" if (!tool) return;\n"
" const extra = await augment(tool, output?.args);\n"
" if (extra && typeof output?.output === 'string') {\n"
" output.output += '\\n' + extra;\n"
" }\n"
" },\n"
"});\n");
if (sb.failed) {
free(sb.buf);
return NULL;
}
return sb.buf;
}