1 Commits

Author SHA1 Message Date
037e92b421 fix(bridge): /mcp/execute handles raw-object tool results (not just AgentToolResult)
OpenClaw plugins return tool results in one of two shapes:

  (a) AgentToolResult — { content: [{type:'text', text:'...'}] }
      used when the plugin wraps via asContent() helper. Every
      Dialectic.OpenclawPlugin tool follows this pattern.

  (b) raw JSON-able object — { ok:true, ...domain fields }
      used when the plugin returns data directly. Every
      Fabric.OpenclawPlugin tool follows this pattern
      (fabric-channel-list, fabric-guild-list, fabric-send-message,
      fabric-channel-set-purpose, etc).

The bridge's /mcp/execute handler only handled shape (a). When a
contractor agent (developer / contractor-test) called any fabric
tool through Claude Code, the bridge ran the tool successfully but
fell back to the literal string '(no result)' because
toolResult.content was undefined. Claude Code then dutifully
rendered '(no result)' as the tool result.

Reproduced on prod:
  openclaw agent --agent developer -m 'Call fabric-channel-list ...'
  → claude code session called mcp__openclaw__fabric-channel-list
  → bridge logged: mcp/execute tool=fabric-channel-list ...
  → bridge replied: { result: '(no result)' }
  → claude code rendered: ''

Fix: normalize the result in the bridge. If toolResult is null →
empty string; if it has a .content array → join the text segments
(shape a); if it's a string → use directly; else → JSON.stringify
the whole thing (shape b). Falls back to '(no result)' only when
all of those produce empty string.

Verified on prod after fix:
  agent receives real {"ok":true,"count":1,"channels":[...]}
  JSON payload (one real prod-push-test channel) in the response.
2026-05-24 09:33:12 +01:00

View File

@@ -596,14 +596,47 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
return;
}
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute;
const toolResult = await execFn(randomUUID(), toolArgs);
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<unknown> }).execute;
const toolResultRaw = await execFn(randomUUID(), toolArgs);
// Extract text content from AgentToolResult
const text = (toolResult.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
// Normalize the result shape. OpenClaw plugins return one of:
// (a) AgentToolResult — { content: [{type:'text', text:'...'}, ...] }
// used by tools that wrap via asContent() (e.g. all
// Dialectic.OpenclawPlugin tools).
// (b) raw JSON-able object — { ok:true, ...domain fields }
// used by tools that return data directly (e.g. every
// Fabric.OpenclawPlugin tool: fabric-channel-list,
// fabric-guild-list, fabric-send-message, etc).
// (c) null / undefined — fire-and-forget (rare).
//
// Pre-2026-05-24 the bridge ONLY handled shape (a); shape (b)
// silently returned "(no result)" to Claude Code (bug:
// fabric-channel-list and friends were unusable from contractor
// agents even though the tool actually ran successfully).
let text = "";
if (toolResultRaw == null) {
text = "";
} else if (
typeof toolResultRaw === "object" &&
Array.isArray((toolResultRaw as { content?: unknown }).content)
) {
// Shape (a)
const content = (toolResultRaw as { content: Array<{ type: string; text?: string }> }).content;
text = content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
} else if (typeof toolResultRaw === "string") {
text = toolResultRaw;
} else {
// Shape (b) — serialize the whole object as JSON. Claude Code
// is happy to parse JSON tool results.
try {
text = JSON.stringify(toolResultRaw);
} catch {
text = String(toolResultRaw);
}
}
sendJson(res, 200, { result: text || "(no result)" });
} catch (err) {