fromPlugin wrapper discards custom metadata set by plugin tools via context.metadata() #8744

Open
opened 2026-02-16 18:10:43 -05:00 by yindo · 1 comment
Owner

Originally created by @orgito on GitHub (Feb 6, 2026).

Originally assigned to: @thdxr on GitHub.

Description

Plugin tools that call context.metadata() during execution have their custom metadata silently discarded. The completed tool result in the API response (POST /session/:id/message) only contains { truncated, outputPath } regardless of what the tool set.

Cause

fromPlugin in packages/opencode/src/tool/registry.ts (lines 60-82) wraps plugin tool execution. It calls def.execute(), which returns a string, then constructs a hardcoded metadata object:

function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
  return {
    id,
    init: async (initCtx) => ({
      parameters: z.object(def.args),
      description: def.description,
      execute: async (args, ctx) => {
        const pluginCtx = {
          ...ctx,
          directory: Instance.directory,
          worktree: Instance.worktree,
        } as unknown as PluginToolContext;
        const result = await def.execute(args as any, pluginCtx);
        const out = await Truncate.output(result, {}, initCtx?.agent);
        return {
          title: "",
          output: out.truncated ? out.content : result,
          metadata: {
            truncated: out.truncated,
            outputPath: out.truncated ? out.outputPath : undefined,
          },
          // ^^^^^^^^ hardcoded — custom metadata from context.metadata() is lost
        };
      },
    }),
  };
}

Two things go wrong:

  1. context.metadata() updates the running-state ToolPart, but when the tool completes, processor.ts replaces the entire state with a new completed state using the metadata from the execute return value — overwriting whatever was set during execution.

  2. The return metadata is hardcoded to { truncated, outputPath }. There's no mechanism to capture what context.metadata() set and merge it into the final result.

Suggested Fix

Intercept context.metadata() calls in the fromPlugin wrapper, accumulate custom metadata, and merge it into the final return value:

execute: async (args, ctx) => {
  let customMetadata: Record<string, any> = {};
  const pluginCtx = {
    ...ctx,
    directory: Instance.directory,
    worktree: Instance.worktree,
    metadata(input: { title?: string; metadata?: Record<string, any> }) {
      if (input.metadata)
        customMetadata = { ...customMetadata, ...input.metadata };
      ctx.metadata(input); // still update live TUI state
    },
  } as unknown as PluginToolContext;
  const result = await def.execute(args as any, pluginCtx);
  const out = await Truncate.output(result, {}, initCtx?.agent);
  return {
    title: "",
    output: out.truncated ? out.content : result,
    metadata: {
      ...customMetadata,
      truncated: out.truncated,
      ...(out.truncated && { outputPath: out.outputPath }),
    },
  };
};

The original ctx.metadata() is still called so the TUI gets live updates during execution.

Plugins

Custom tools defined in .opencode/tools/ using @opencode-ai/plugin

OpenCode version

1.1.53

Steps to reproduce

  1. Create a plugin tool in .opencode/tools/my_tool.ts:
import { tool } from "@opencode-ai/plugin";

export default tool({
  description: "A tool that sets custom metadata",
  args: {
    input: tool.schema.string(),
  },
  async execute(args, context) {
    context.metadata({
      metadata: { foo: "bar", custom_field: 123 },
    });
    return "done";
  },
});
  1. Invoke the tool via POST /session/:id/message
  2. Inspect the response — the tool part's state.metadata is { truncated: false }, not { foo: "bar", custom_field: 123 }

Screenshot and/or share link

N/A

Operating System

Linux (Docker container)

Terminal

N/A (API-only usage via HTTP server mode)

Originally created by @orgito on GitHub (Feb 6, 2026). Originally assigned to: @thdxr on GitHub. ### Description Plugin tools that call `context.metadata()` during execution have their custom metadata silently discarded. The completed tool result in the API response (`POST /session/:id/message`) only contains `{ truncated, outputPath }` regardless of what the tool set. ### Cause `fromPlugin` in `packages/opencode/src/tool/registry.ts` (lines 60-82) wraps plugin tool execution. It calls `def.execute()`, which returns a `string`, then constructs a hardcoded metadata object: ```ts function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { id, init: async (initCtx) => ({ parameters: z.object(def.args), description: def.description, execute: async (args, ctx) => { const pluginCtx = { ...ctx, directory: Instance.directory, worktree: Instance.worktree, } as unknown as PluginToolContext; const result = await def.execute(args as any, pluginCtx); const out = await Truncate.output(result, {}, initCtx?.agent); return { title: "", output: out.truncated ? out.content : result, metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined, }, // ^^^^^^^^ hardcoded — custom metadata from context.metadata() is lost }; }, }), }; } ``` Two things go wrong: 1. **`context.metadata()` updates the running-state ToolPart**, but when the tool completes, `processor.ts` replaces the entire `state` with a new `completed` state using the metadata from the execute return value — overwriting whatever was set during execution. 2. **The return metadata is hardcoded** to `{ truncated, outputPath }`. There's no mechanism to capture what `context.metadata()` set and merge it into the final result. ### Suggested Fix Intercept `context.metadata()` calls in the `fromPlugin` wrapper, accumulate custom metadata, and merge it into the final return value: ```ts execute: async (args, ctx) => { let customMetadata: Record<string, any> = {}; const pluginCtx = { ...ctx, directory: Instance.directory, worktree: Instance.worktree, metadata(input: { title?: string; metadata?: Record<string, any> }) { if (input.metadata) customMetadata = { ...customMetadata, ...input.metadata }; ctx.metadata(input); // still update live TUI state }, } as unknown as PluginToolContext; const result = await def.execute(args as any, pluginCtx); const out = await Truncate.output(result, {}, initCtx?.agent); return { title: "", output: out.truncated ? out.content : result, metadata: { ...customMetadata, truncated: out.truncated, ...(out.truncated && { outputPath: out.outputPath }), }, }; }; ``` The original `ctx.metadata()` is still called so the TUI gets live updates during execution. ### Plugins Custom tools defined in `.opencode/tools/` using `@opencode-ai/plugin` ### OpenCode version 1.1.53 ### Steps to reproduce 1. Create a plugin tool in `.opencode/tools/my_tool.ts`: ```ts import { tool } from "@opencode-ai/plugin"; export default tool({ description: "A tool that sets custom metadata", args: { input: tool.schema.string(), }, async execute(args, context) { context.metadata({ metadata: { foo: "bar", custom_field: 123 }, }); return "done"; }, }); ``` 2. Invoke the tool via `POST /session/:id/message` 3. Inspect the response — the tool part's `state.metadata` is `{ truncated: false }`, not `{ foo: "bar", custom_field: 123 }` ### Screenshot and/or share link N/A ### Operating System Linux (Docker container) ### Terminal N/A (API-only usage via HTTP server mode)
yindo added the bug label 2026-02-16 18:10:43 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (Feb 6, 2026):

This issue might be a duplicate of or related to existing issues. Please check:

  • #12519: Feature: Media type validation/conversion for MCP tool results — also involves tool result handling and metadata processing in the API response

Feel free to ignore if this doesn't address your specific case.

@github-actions[bot] commented on GitHub (Feb 6, 2026): This issue might be a duplicate of or related to existing issues. Please check: - #12519: Feature: Media type validation/conversion for MCP tool results — also involves tool result handling and metadata processing in the API response Feel free to ignore if this doesn't address your specific case.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#8744