mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
315 lines
12 KiB
C
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;
|
|
}
|