mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
Tool definitions no longer carry a separate title (the name is the MCP display fallback), so the accessor main added for the pi adapter has no field to read. The adapter already used the name when no title existed. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
361 lines
14 KiB
C
361 lines
14 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 *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: ");
|
|
/* 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 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 = output?.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;
|
|
}
|