#2550·mcp-use

[Bug] OpenAI Responses streaming: tool-call-args-delta/tool-call-ready never emitted (callBuffers keyed by call_id, but events carry item_id) — MCPAgent.stream() yields zero steps

Author: BlueX888Created Sep 15, 2026Updated Sep 15, 2026
LabelsbugreadyTypeScriptagentclientmcp-use/mcp-use

Describe the bug

On the OpenAI Responses streaming path, streamResponsesTurn keys its tool-call buffers (callBuffers) by call_id, but the streaming events that carry the arguments — response.function_call_arguments.delta and response.function_call_arguments.done — do not have a call_id field; they carry item_id (the output-item id, fc_...) and output_index only. So the buffer lookup key is always the fallback "" and callBuffers.get("") is always undefined. Neither tool-call-args-delta nor tool-call-ready is ever emitted.

Consequence: streamNativeAgentSteps (native_runner.ts) builds AgentSteps only on tool-call-ready, so MCPAgent.stream() / agent.streamEvents() silently yields zero steps for provider "openai" (and openrouter models routed to OpenAIResponsesDriver). The tool still executes (arguments are parsed from response.completed's output), so the bug is silent: the user sees the final text but no tool step at all.

Environment

  • mcp-use agent package version: 2.0.16 (libraries/typescript/packages/agent/package.json)
  • commit: dcaa0b8df5721f11d4d2aefae1f85df2a1903ba5
  • Node: v22.23.2
  • OS: macOS 26.6.2 (Darwin 25.6.0), headless Node (no browser involved)
  • openai SDK: 6.17.0 (lockfile-matched)

To Reproduce

Steps:

  1. Check out dcaa0b8df5721f11d4d2aefae1f85df2a1903ba5.
  2. Run the repro below against src/llm with tsx.

The repro feeds raw OpenAI Responses SSE events with the real SDK event shapes (SSE stream served by a stubbed globalThis.fetch), first asserting on driver.stream() event types, then on streamNativeAgentSteps.

typescript
import { OpenAIResponsesDriver } from "../src/llm/providers/openai-responses-driver.ts";
import { streamNativeAgentSteps } from "../src/llm/native_runner.ts";
import type { LlmStreamEvent } from "../src/llm/types.ts";

// Real OpenAI Responses streaming event shapes, taken from the openai-node SDK
// (ResponseFunctionCallArgumentsDeltaEvent / ...DoneEvent carry item_id +
// output_index, NOT call_id).
const turn1 = [
  { type: "response.output_item.added", output_index: 0,
    item: { type: "function_call", id: "fc_1", call_id: "call_abc", name: "add", arguments: "" } },
  { type: "response.function_call_arguments.delta", item_id: "fc_1", output_index: 0, delta: '{"a":10' },
  { type: "response.function_call_arguments.delta", item_id: "fc_1", output_index: 0, delta: ',"b":20}' },
  { type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: '{"a":10,"b":20}' },
  { type: "response.completed", response: { output: [
      { type: "function_call", id: "fc_1", call_id: "call_abc", name: "add", arguments: '{"a":10,"b":20}' } ] } },
];
const turn2 = [
  { type: "response.output_text.delta", delta: "The answer is 30." },
  { type: "response.completed", response: { output: [
      { type: "message", role: "assistant", content: [{ type: "output_text", text: "The answer is 30." }] } ] } },
];
const sse = (events: unknown[]) => events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");

let call = 0;
(globalThis as any).fetch = async () => {
  call++;
  return new Response(sse(call === 1 ? turn1 : turn2), {
    status: 200,
    headers: { "Content-Type": "text/event-stream" },
  });
};

async function main() {
  const driver = new OpenAIResponsesDriver({ provider: "openai", model: "gpt-5", apiKey: "k" });
  let toolArgSeen: unknown = null;
  const events: LlmStreamEvent[] = [];
  for await (const ev of driver.stream({ messages: [{ role: "user", content: "add 10 and 20" }], tools: [] })) {
    events.push(ev);
  }
  console.log("driver.stream() event types:", JSON.stringify(events.map((e) => e.type)));
  console.log("tool-call-ready count:", events.filter((e) => e.type === "tool-call-ready").length);
  console.log("args-delta count:", events.filter((e) => e.type === "tool-call-args-delta").length);

  call = 0;
  const steps: unknown[] = [];
  const gen = streamNativeAgentSteps(driver, {
    messages: [{ role: "user", content: "add 10 and 20" }],
    tools: [],
    maxSteps: 3,
    callTool: async (_n, args) => {
      toolArgSeen = args;
      return { content: [{ type: "text", text: "30" }] };
    },
  });
  let r = await gen.next();
  while (!r.done) { steps.push(r.value); r = await gen.next(); }
  console.log("callTool received args:", JSON.stringify(toolArgSeen));
  console.log("agent.stream() steps yielded:", steps.length, JSON.stringify(steps));
  console.log("agent.stream() returned text:", JSON.stringify(r.value));
}
main();

Actual output

Verbatim from an independent verifier run (node tsx repro2.ts, unmodified repro above):

driver.stream() event types: ["tool-call-start","done"]
tool-call-ready count: 0
args-delta count: 0
callTool received args: {"a":10,"b":20}
agent.stream() steps yielded: 0 []
agent.stream() returned text: "The answer is 30."

A second independent verifier, running the same shape and an identical probe against the tree src/:

SHAPE A (spec: item_id only): types ["tool-call-start","done"]; args-delta 0; tool-call-ready 0
SHAPE A: callTool got {"name":"add","args":{"a":10,"b":20}}
SHAPE A: agent steps 0 []; final text "The answer is 30."
SHAPE B (control: call_id injected): args-delta 2; tool-call-ready 1
PATCHED, SHAPE A: types ["tool-call-start","tool-call-args-delta","tool-call-args-delta","tool-call-ready","done"]; args-delta 2; tool-call-ready 1
PATCHED, SHAPE A: agent steps 2; baseline vitest run src/llm 47/47 pass, patched also 47/47

Expected behavior

The OpenAI Responses path should emit tool-call-args-delta for each response.function_call_arguments.delta and one tool-call-ready when response.function_call_arguments.done arrives, matching:

  • the openai-node SDK event contract — ResponseFunctionCallArgumentsDeltaEvent = { delta, item_id, output_index, sequence_number, type } and ResponseFunctionCallArgumentsDoneEvent = { arguments, item_id, output_index, sequence_number, type } ([email protected], resources/responses/responses.d.ts; raw.githubusercontent.com/openai/openai-node/src/resources/responses/responses.ts lines 3257 / 3287). Neither declares call_id. ResponseFunctionToolCall declares call_id: string and id?: string — distinct fields; item_id points at the output-item id (fc_...).
  • the sibling provider providers/openai-chat-completions.ts, which emits tool-call-args-delta (line 205) and tool-call-ready (line 226).
  • the internal contract llm/types.ts:157LlmToolCallReadyEvent is documented as "A tool call with complete, parsed arguments", and native_runner.ts:51-58 builds an AgentStep only on tool-call-ready.

So agent.stream() should yield one AgentStep for the add call, instead of 0 [].

Root cause

libraries/typescript/packages/agent/src/llm/providers/openai-responses.ts:328 and :345: both handlers read const callId = typeof parsed.call_id === "string" ? parsed.call_id : "", but these events carry item_id, not call_id, so the key is always "" and callBuffers.get(callId) (line 330 / line 348) is always undefined. The buffer is written under item.call_id at line 311 (populated from response.output_item.added, line 308). Result: the delta and done branches never emit their events, and every downstream consumer of tool-call-ready sees an empty step list.

Fix direction (not a full diff): key callBuffers by the output item id (item.id, i.e. fc_...) and look it up with parsed.item_id (or fall back via output_index to the stored index) in the delta/done handlers, while still yielding the stored call_id as toolCallId on the emitted events.

Screenshots

N/A — headless Node library, no UI screenshot applies.

Desktop (please complete the following information)

  • OS: macOS 26.6.2
  • Version: mcp-use agent 2.0.16 @ dcaa0b8df5721f11d4d2aefae1f85df2a1903ba5, Node v22.23.2, openai 6.17.0
  • Browser: N/A

Smartphone (please complete the following information)

N/A — not a mobile issue.

Additional context

  • Reachability: new MCPAgent({ llm: "openai/gpt-5", ... }).stream({ prompt })agents/mcp_agent.ts:549 streamNativeAgentSteps(driver)llm/native_runner.ts:52createLlmDriver returns OpenAIResponsesDriver (llm/driver.ts:53-62) → streamToolLoopstreamResponsesTurn. Same path for openrouter models matching /^~?openai\//. The inspector client-side chat (useChatMessagesClientSide.ts:436) creates the tool card at tool-call-start with args {} and never receives tool-call-ready.
  • Related issues/PRs found by collision checks: none. Searches for tool-call-ready, function_call_arguments, and "Responses streaming" in mcp-use/mcp-use return only unrelated closed issues.
  • Happy to open a PR with the approach sketched above if you'd like, or happy to be assigned.