From bf45e8f4b734bb10c45448a9a980aa18655bd9b3 Mon Sep 17 00:00:00 2001 From: Azther0z Date: Wed, 19 Aug 2026 22:29:52 +0700 Subject: [PATCH 1/4] fix: normalize MCP tool results Handle native and MCP result shapes explicitly and warn when a non-null result is unrecognized. Refs: #22 --- src/index.ts | 71 ++++++++++++++++++++++++++++----- test/integration/plugin.test.ts | 24 +++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index a104475..b3ac5d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -348,6 +348,44 @@ const formatHookError = (error: unknown) => { } }; +const flattenMcpContent = (content: unknown[]) => + content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object") { + const record = part as Record; + if (typeof record.text === "string") return record.text; + if (typeof record.type === "string") return `[${record.type}]`; + } + return ""; + }) + .filter((text) => text.length > 0) + .join("\n"); + +const normalizeToolResult = (tool: string, output: unknown) => { + const record = + output && typeof output === "object" + ? (output as Record) + : undefined; + const title = typeof record?.title === "string" ? record.title : tool; + + if (typeof record?.output === "string") { + return { title, output: record.output, unexpected: false }; + } + if (Array.isArray(record?.content)) { + return { + title, + output: flattenMcpContent(record.content), + unexpected: false, + }; + } + if (output === undefined || output === null) { + return { title, output: "", unexpected: false }; + } + + return { title, output: "", unexpected: true }; +}; + const createShutdownOnce = (langfuse: LangfuseClient) => { let shutdownPromise: Promise | undefined; @@ -549,17 +587,28 @@ const main = Effect.gen(function* () { "tool.execute.after": (input, output) => runHook( "tool.execute.after", - Effect.try({ - try: () => - langfuse.traceToolEnd({ - sessionID: input.sessionID, - callID: input.callID, - tool: input.tool, - args: input.args, - title: output.title, - output: output.output, - }), - catch: (error) => error, + Effect.gen(function* () { + const normalized = normalizeToolResult(input.tool, output); + + if (normalized.unexpected) { + yield* log( + "warn", + `Tool "${input.tool}" returned an unrecognized result shape; recording empty output`, + ); + } + + yield* Effect.try({ + try: () => + langfuse.traceToolEnd({ + sessionID: input.sessionID, + callID: input.callID, + tool: input.tool, + args: input.args, + title: normalized.title, + output: normalized.output, + }), + catch: (error) => error, + }); }), ), }; diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts index 08ca6e7..9cc39c1 100644 --- a/test/integration/plugin.test.ts +++ b/test/integration/plugin.test.ts @@ -729,6 +729,24 @@ describe.sequential("built plugin", () => { }, { title: "README.md", output: "# Project", metadata: {} }, ); + const mcpCallID = "nested-observations-mcp-call"; + await hooks["tool.execute.before"]?.( + { sessionID, callID: mcpCallID, tool: "mcp_test_tool" }, + { args: { query: "test" } }, + ); + await hooks["tool.execute.after"]?.( + { + sessionID, + callID: mcpCallID, + tool: "mcp_test_tool", + args: { query: "test" }, + }, + { + content: [{ type: "text", text: "MCP tool result" }], + } as unknown as Parameters< + NonNullable + >[1], + ); await emitEvent({ type: "message.part.updated", properties: { @@ -816,6 +834,7 @@ describe.sequential("built plugin", () => { "opencode.message.user", "opencode.generation", "read", + "mcp_test_tool", "webfetch", "opencode.generation.retry", "opencode.generation.compaction", @@ -865,6 +884,11 @@ describe.sequential("built plugin", () => { title: "README.md", output: "# Project", }); + const mcpTool = getSpan(spans, "mcp_test_tool"); + expect(getJsonAttribute(mcpTool, "langfuse.observation.output")).toEqual({ + title: "mcp_test_tool", + output: "MCP tool result", + }); expect(tool.traceId).toBe(generation.traceId); expect(tool.parentSpanId).toBe(generation.spanId); From 40b8d5ad2ba4e73a52ba2017caa52495313a0125 Mon Sep 17 00:00:00 2001 From: Ben Bachem <10088265+bezbac@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:05:57 +0200 Subject: [PATCH 2/4] Align MCP result extraction --- src/index.ts | 36 ++++++++++++++++++++++----------- test/integration/plugin.test.ts | 14 +++++++++++-- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index b3ac5d0..06fa09a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -348,19 +348,31 @@ const formatHookError = (error: unknown) => { } }; -const flattenMcpContent = (content: unknown[]) => - content - .map((part) => { - if (typeof part === "string") return part; - if (part && typeof part === "object") { - const record = part as Record; - if (typeof record.text === "string") return record.text; - if (typeof record.type === "string") return `[${record.type}]`; +const flattenMcpContent = (content: unknown[]) => { + const textParts: string[] = []; + + for (const part of content) { + if (!part || typeof part !== "object") continue; + + const record = part as Record; + if (record.type === "text" && typeof record.text === "string") { + textParts.push(record.text); + continue; + } + + if (record.type === "resource") { + const resource = + record.resource && typeof record.resource === "object" + ? (record.resource as Record) + : undefined; + if (typeof resource?.text === "string") { + textParts.push(resource.text); } - return ""; - }) - .filter((text) => text.length > 0) - .join("\n"); + } + } + + return textParts.join("\n\n"); +}; const normalizeToolResult = (tool: string, output: unknown) => { const record = diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts index 9cc39c1..97f1d74 100644 --- a/test/integration/plugin.test.ts +++ b/test/integration/plugin.test.ts @@ -742,7 +742,17 @@ describe.sequential("built plugin", () => { args: { query: "test" }, }, { - content: [{ type: "text", text: "MCP tool result" }], + content: [ + { type: "text", text: "MCP tool result" }, + { + type: "resource", + resource: { + uri: "foo://bar", + text: "MCP resource contents", + }, + }, + { type: "image", mimeType: "image/png", data: "aW1hZ2U=" }, + ], } as unknown as Parameters< NonNullable >[1], @@ -887,7 +897,7 @@ describe.sequential("built plugin", () => { const mcpTool = getSpan(spans, "mcp_test_tool"); expect(getJsonAttribute(mcpTool, "langfuse.observation.output")).toEqual({ title: "mcp_test_tool", - output: "MCP tool result", + output: "MCP tool result\n\nMCP resource contents", }); expect(tool.traceId).toBe(generation.traceId); expect(tool.parentSpanId).toBe(generation.spanId); From 57836b5a060a22bcde4e14ceaf39b7e251db96b1 Mon Sep 17 00:00:00 2001 From: Ben Bachem <10088265+bezbac@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:35:16 +0200 Subject: [PATCH 3/4] Validate OpenCode tool results --- src/index.ts | 104 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/src/index.ts b/src/index.ts index 06fa09a..708e764 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { Hooks, Plugin } from "@opencode-ai/plugin"; -import { Data, Effect, Layer, Schema } from "effect"; +import { Data, Effect, Layer, Option, Schema } from "effect"; import { LangfuseClientService, @@ -348,26 +348,73 @@ const formatHookError = (error: unknown) => { } }; -const flattenMcpContent = (content: unknown[]) => { +// https://github.com/anomalyco/opencode/blob/v1.15.13/packages/opencode/src/tool/tool.ts#L46-L52 +const NativeToolResultSchema = Schema.Struct({ + title: Schema.String, + metadata: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + output: Schema.String, + attachments: Schema.optional(Schema.Array(Schema.Unknown)), +}); + +// OpenCode returns CallToolResultSchema from its MCP adapter and passes that raw +// value to the hook before normalization: +// https://github.com/anomalyco/opencode/blob/v1.15.13/packages/opencode/src/mcp/index.ts#L158-L187 +const McpToolResultSchema = Schema.Struct({ + content: Schema.Array(Schema.Unknown), + structuredContent: Schema.optional( + Schema.Record({ key: Schema.String, value: Schema.Unknown }), + ), + isError: Schema.optional(Schema.Boolean), + _meta: Schema.optional( + Schema.Record({ key: Schema.String, value: Schema.Unknown }), + ), +}); + +const McpResourceContentsSchema = Schema.Union( + Schema.Struct({ + uri: Schema.String, + mimeType: Schema.optional(Schema.String), + text: Schema.String, + }), + Schema.Struct({ + uri: Schema.String, + mimeType: Schema.optional(Schema.String), + blob: Schema.String, + }), +); + +// These are the MCP content variants OpenCode handles when creating its native +// output: https://github.com/anomalyco/opencode/blob/v1.15.13/packages/opencode/src/session/tools.ts#L153-L176 +const McpContentSchema = Schema.Union( + Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("image"), + data: Schema.String, + mimeType: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("resource"), + resource: McpResourceContentsSchema, + }), +); + +const flattenMcpContent = (content: readonly unknown[]) => { const textParts: string[] = []; for (const part of content) { - if (!part || typeof part !== "object") continue; + const decoded = Schema.decodeUnknownOption(McpContentSchema)(part); + if (Option.isNone(decoded)) continue; - const record = part as Record; - if (record.type === "text" && typeof record.text === "string") { - textParts.push(record.text); + if (decoded.value.type === "text") { + textParts.push(decoded.value.text); continue; } - if (record.type === "resource") { - const resource = - record.resource && typeof record.resource === "object" - ? (record.resource as Record) - : undefined; - if (typeof resource?.text === "string") { - textParts.push(resource.text); - } + if (decoded.value.type === "resource" && "text" in decoded.value.resource) { + textParts.push(decoded.value.resource.text); } } @@ -375,27 +422,30 @@ const flattenMcpContent = (content: unknown[]) => { }; const normalizeToolResult = (tool: string, output: unknown) => { - const record = - output && typeof output === "object" - ? (output as Record) - : undefined; - const title = typeof record?.title === "string" ? record.title : tool; - - if (typeof record?.output === "string") { - return { title, output: record.output, unexpected: false }; + const nativeResult = Schema.decodeUnknownOption(NativeToolResultSchema)( + output, + ); + if (Option.isSome(nativeResult)) { + return { + title: nativeResult.value.title, + output: nativeResult.value.output, + unexpected: false, + }; } - if (Array.isArray(record?.content)) { + + const mcpResult = Schema.decodeUnknownOption(McpToolResultSchema)(output); + if (Option.isSome(mcpResult)) { return { - title, - output: flattenMcpContent(record.content), + title: tool, + output: flattenMcpContent(mcpResult.value.content), unexpected: false, }; } if (output === undefined || output === null) { - return { title, output: "", unexpected: false }; + return { title: tool, output: "", unexpected: false }; } - return { title, output: "", unexpected: true }; + return { title: tool, output: "", unexpected: true }; }; const createShutdownOnce = (langfuse: LangfuseClient) => { From 68ecdc406b0636ecafae0705ea08ea27f38cad80 Mon Sep 17 00:00:00 2001 From: Ben Bachem <10088265+bezbac@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:42:51 +0200 Subject: [PATCH 4/4] Handle MCP tool errors --- src/index.ts | 22 ++++++++++++++++++++-- test/integration/plugin.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 708e764..b78a546 100644 --- a/src/index.ts +++ b/src/index.ts @@ -429,6 +429,7 @@ const normalizeToolResult = (tool: string, output: unknown) => { return { title: nativeResult.value.title, output: nativeResult.value.output, + isError: false, unexpected: false, }; } @@ -438,14 +439,15 @@ const normalizeToolResult = (tool: string, output: unknown) => { return { title: tool, output: flattenMcpContent(mcpResult.value.content), + isError: mcpResult.value.isError === true, unexpected: false, }; } if (output === undefined || output === null) { - return { title: tool, output: "", unexpected: false }; + return { title: tool, output: "", isError: false, unexpected: false }; } - return { title: tool, output: "", unexpected: true }; + return { title: tool, output: "", isError: false, unexpected: true }; }; const createShutdownOnce = (langfuse: LangfuseClient) => { @@ -659,6 +661,22 @@ const main = Effect.gen(function* () { ); } + if (normalized.isError) { + yield* Effect.sync(() => + langfuse.traceToolError({ + sessionID: input.sessionID, + callID: input.callID, + tool: input.tool, + args: input.args, + error: + normalized.output || + `MCP tool "${input.tool}" returned an error`, + completed: Date.now(), + }), + ); + return; + } + yield* Effect.try({ try: () => langfuse.traceToolEnd({ diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts index 97f1d74..118b090 100644 --- a/test/integration/plugin.test.ts +++ b/test/integration/plugin.test.ts @@ -757,6 +757,25 @@ describe.sequential("built plugin", () => { NonNullable >[1], ); + const failedMcpCallID = "nested-observations-failed-mcp-call"; + await hooks["tool.execute.before"]?.( + { sessionID, callID: failedMcpCallID, tool: "mcp_failing_tool" }, + { args: { query: "fail" } }, + ); + await hooks["tool.execute.after"]?.( + { + sessionID, + callID: failedMcpCallID, + tool: "mcp_failing_tool", + args: { query: "fail" }, + }, + { + content: [{ type: "text", text: "MCP tool failed" }], + isError: true, + } as unknown as Parameters< + NonNullable + >[1], + ); await emitEvent({ type: "message.part.updated", properties: { @@ -845,6 +864,7 @@ describe.sequential("built plugin", () => { "opencode.generation", "read", "mcp_test_tool", + "mcp_failing_tool", "webfetch", "opencode.generation.retry", "opencode.generation.compaction", @@ -899,6 +919,14 @@ describe.sequential("built plugin", () => { title: "mcp_test_tool", output: "MCP tool result\n\nMCP resource contents", }); + const failedMcpTool = getSpan(spans, "mcp_failing_tool"); + expect(failedMcpTool.status).toEqual({ + code: 2, + message: "MCP tool failed", + }); + expect( + getJsonAttribute(failedMcpTool, "langfuse.observation.output"), + ).toEqual({ error: "MCP tool failed" }); expect(tool.traceId).toBe(generation.traceId); expect(tool.parentSpanId).toBe(generation.spanId);