From 263cf22b9c400b512617fd7e404ae6c74a09a908 Mon Sep 17 00:00:00 2001 From: Alex Musichen Date: Sun, 16 Aug 2026 16:41:09 +0200 Subject: [PATCH 1/4] fix(pi): emit parameters and execute in the generated pi adapter The generated Pi extension registered each MCP tool as { name, run }, but Pi's ToolDefinition requires label, description, parameters, and execute. Tools registered that way carried no parameter schema, so strict providers such as xAI/Grok reject the request with a 422 'missing field parameters', and the tools were uncallable because Pi invokes execute, never run. Emit the full tool shape from the registry: label/description via new accessors, the input_schema embedded directly as a JSON object literal, and execute instead of run. Signed-off-by: Alex Musichen --- src/cli/client_adapter.c | 56 ++++++++++++++++++++++++++++++++++---- src/mcp/mcp.c | 24 ++++++++++++++++ src/mcp/mcp.h | 6 ++++ tests/test_agent_clients.c | 18 ++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 839b3d87b..dd7e62570 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -49,6 +49,39 @@ static void sb_append(adapter_sb_t *sb, const char *s) { 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; @@ -170,11 +203,24 @@ char *cbm_client_adapter_pi(const char *binary_path) { if (!name || !name[0]) { continue; } - sb_append(&sb, " pi.registerTool({ name: '"); - sb_append(&sb, name); - sb_append(&sb, "', run: (args, ctx) => call('"); - sb_append(&sb, name); - sb_append(&sb, "', args, ctx?.signal) });\n"); + 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 : "{}"); + sb_append(&sb, ",\n execute: (args, ctx) => call("); + sb_append_js_string(&sb, name); + sb_append(&sb, ", args, ctx?.signal),\n });\n"); } sb_append(&sb, "}\n"); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 411c15ebd..7af4fe159 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -939,6 +939,30 @@ const char *cbm_mcp_tool_name(int index) { return TOOLS[index].name; } +const char *cbm_mcp_tool_title(const char *tool_name) { + if (!tool_name) { + return NULL; + } + for (int i = 0; i < TOOL_COUNT; i++) { + if (strcmp(TOOLS[i].name, tool_name) == 0) { + return TOOLS[i].title; + } + } + return NULL; +} + +const char *cbm_mcp_tool_description(const char *tool_name) { + if (!tool_name) { + return NULL; + } + for (int i = 0; i < TOOL_COUNT; i++) { + if (strcmp(TOOLS[i].name, tool_name) == 0) { + return TOOLS[i].description; + } + } + return NULL; +} + /* Render the top-level --help "Tools:" block from the registry tools/list * serves. The list used to be hand-maintained in the help text and drifted * when check_index_coverage was added (#1361); deriving it here makes that diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index a70b6372b..d55d58f3d 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -72,6 +72,12 @@ const char *cbm_mcp_tool_input_schema(const char *tool_name); int cbm_mcp_tool_count(void); const char *cbm_mcp_tool_name(int index); +/* Tool metadata by name (static; do not free; NULL when unknown). Used by the + * generated client adapters so they can emit descriptions and parameter + * schemas without drifting from tools/list. */ +const char *cbm_mcp_tool_title(const char *tool_name); +const char *cbm_mcp_tool_description(const char *tool_name); + /* Render the top-level --help "Tools:" block from the registry so the help * text cannot drift from tools/list (#1361). Heap-allocated; caller frees. */ char *cbm_mcp_tools_help_list(void); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 15794c2b1..d8dd7026e 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1114,6 +1114,23 @@ TEST(client_adapter_pi_default_exports_its_factory_issue1550) { PASS(); } +/* Pi's ToolDefinition requires `parameters` and `execute`. The adapter used to + * emit `{ name, run }`, which Pi accepted but treated as a tool with no + * parameter schema — strict providers (xAI/Grok) then reject the request with + * a 422 "missing field parameters", and the tool is uncallable because Pi + * invokes `execute`, never `run`. Pin the corrected shape. */ +TEST(client_adapter_pi_emits_parameters_and_execute) { + char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + ASSERT_NOT_NULL(strstr(js, "execute: (args, ctx) => call(")); + ASSERT_NULL(strstr(js, "run: (args, ctx)")); + ASSERT_NOT_NULL(strstr(js, "parameters:")); + /* The registry input_schema is embedded as a JSON object literal. */ + ASSERT_NOT_NULL(strstr(js, "\"type\":\"object\"")); + free(js); + PASS(); +} + /* #616 rejected `"` in its path template but not `\`, so a Windows home like * C:\Users\urs\bin produced `\u` — an invalid unicode escape — and the whole * auto-loaded plugin failed to parse. A broken plugin in an auto-load directory @@ -1204,6 +1221,7 @@ SUITE(agent_clients) { RUN_TEST(agent_clients_continue_refuses_foreign_same_name_and_nonsequence_section); RUN_TEST(client_adapter_pi_registers_every_registry_tool); RUN_TEST(client_adapter_pi_default_exports_its_factory_issue1550); + RUN_TEST(client_adapter_pi_emits_parameters_and_execute); RUN_TEST(client_adapter_escapes_windows_paths_and_quotes); RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_rejects_missing_binary_path); From cf5eb61c20d073ca0116be7365288664befa37f3 Mon Sep 17 00:00:00 2001 From: Alex Musichen Date: Sun, 16 Aug 2026 22:26:34 +0200 Subject: [PATCH 2/4] fix(pi): return a valid AgentToolResult and request raw JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated execute forwarded the raw MCP JSON directly, but pi's ToolDefinition.execute must return { content: [{ type: 'text', text }], details } — a result without a content array crashes the TUI's getTextOutput on result.content.filter(...). Request raw JSON from the CLI ('--json') so the bridge parses the MCP result instead of the human-readable text, then wrap it: pass the content array through, throw on transport errors, and stringify anything else. Adds coverage asserting the corrected execute shape and the --json flag. Signed-off-by: Alex Musichen --- src/cli/client_adapter.c | 17 ++++++++++++++--- tests/test_agent_clients.c | 6 +++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index dd7e62570..f0c92a3fa 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -164,7 +164,7 @@ char *cbm_client_adapter_pi(const char *binary_path) { sb_append( &sb, "async function call(tool, args, signal) {\n" " return new Promise((resolve) => {\n" - " const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n" + " const child = spawn(BIN, ['cli', '--json', tool, JSON.stringify(args ?? {})], {\n" " stdio: ['ignore', 'pipe', 'pipe'],\n" " env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n" " });\n" @@ -218,9 +218,20 @@ char *cbm_client_adapter_pi(const char *binary_path) { * literal; embedding it directly keeps the generated module free of a * JSON.parse indirection and of any escaping drift. */ sb_append(&sb, schema ? schema : "{}"); - sb_append(&sb, ",\n execute: (args, ctx) => call("); + sb_append(&sb, ",\n execute: async (args, ctx) => {\n"); + sb_append(&sb, " const result = await call("); sb_append_js_string(&sb, name); - sb_append(&sb, ", args, ctx?.signal),\n });\n"); + sb_append(&sb, + ", args, 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"); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index d8dd7026e..f15e69a66 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1122,11 +1122,15 @@ TEST(client_adapter_pi_default_exports_its_factory_issue1550) { TEST(client_adapter_pi_emits_parameters_and_execute) { char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); - ASSERT_NOT_NULL(strstr(js, "execute: (args, ctx) => call(")); + ASSERT_NOT_NULL(strstr(js, "execute: async (args, ctx) => {")); + ASSERT_NOT_NULL(strstr(js, "result.content")); ASSERT_NULL(strstr(js, "run: (args, ctx)")); ASSERT_NOT_NULL(strstr(js, "parameters:")); /* The registry input_schema is embedded as a JSON object literal. */ ASSERT_NOT_NULL(strstr(js, "\"type\":\"object\"")); + /* Raw JSON output is required so the bridge can parse the MCP result; the + * human-readable path would leave `call` with nothing to JSON.parse. */ + ASSERT_NOT_NULL(strstr(js, "'cli', '--json'")); free(js); PASS(); } From d918fce2872e0679fabcb3d9f9423f4ef2ccde2a Mon Sep 17 00:00:00 2001 From: Alex Musichen Date: Sun, 16 Aug 2026 22:58:58 +0200 Subject: [PATCH 3/4] style: fix clang-format violation in pi adapter clang-format wants no spaces inside a braced initializer. Signed-off-by: Alex Musichen --- src/cli/client_adapter.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index f0c92a3fa..73cd9f112 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -73,7 +73,7 @@ static void sb_append_js_string(adapter_sb_t *sb, const char *s) { sb_append(sb, "\\r"); break; default: { - char ch[2] = { *p, '\0' }; + char ch[2] = {*p, '\0'}; sb_append(sb, ch); break; } From 109299f26296181000a8491d8ee1d1e6977911c4 Mon Sep 17 00:00:00 2001 From: Alex Musichen Date: Tue, 18 Aug 2026 04:11:49 +0200 Subject: [PATCH 4/4] fix(pi): match ToolDefinition.execute arity on 0.84.2 Pi 0.84.2 calls execute(toolCallId, params, signal, onUpdate, ctx). The generated (args, ctx) shape bound the call id as the MCP arguments. Forward params and signal, pin the @earendil-works/pi-coding-agent contract in the generated header, and lock the 5-arg form in tests. Signed-off-by: Alex Musichen --- src/cli/client_adapter.c | 10 ++++++++-- tests/test_agent_clients.c | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 73cd9f112..cc7454793 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -153,6 +153,10 @@ char *cbm_client_adapter_pi(const char *binary_path) { 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); @@ -218,11 +222,13 @@ char *cbm_client_adapter_pi(const char *binary_path) { * literal; embedding it directly keeps the generated module free of a * JSON.parse indirection and of any escaping drift. */ sb_append(&sb, schema ? schema : "{}"); - sb_append(&sb, ",\n execute: async (args, ctx) => {\n"); + /* 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, - ", args, ctx?.signal);\n" + ", params, signal ?? ctx?.signal);\n" " if (result && typeof result === 'object' && result.error) {\n" " throw new Error(String(result.error));\n" " }\n" diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index f15e69a66..89dda28ba 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1122,7 +1122,11 @@ TEST(client_adapter_pi_default_exports_its_factory_issue1550) { TEST(client_adapter_pi_emits_parameters_and_execute) { char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); - ASSERT_NOT_NULL(strstr(js, "execute: async (args, ctx) => {")); + ASSERT_NOT_NULL(strstr(js, "execute: async (toolCallId, params, signal, _onUpdate, ctx) => {")); + ASSERT_NOT_NULL(strstr(js, ", params, signal ?? ctx?.signal)")); + ASSERT_NULL(strstr(js, "execute: async (args, ctx) => {")); + ASSERT_NULL(strstr(js, ", args, ctx?.signal)")); + ASSERT_NOT_NULL(strstr(js, "@earendil-works/pi-coding-agent")); ASSERT_NOT_NULL(strstr(js, "result.content")); ASSERT_NULL(strstr(js, "run: (args, ctx)")); ASSERT_NOT_NULL(strstr(js, "parameters:"));