Skip to content

fix: do not re-finalize mcp_list_tools in closeLastOutputItem#44

Merged
frac merged 1 commit intomainfrom
fix/duplicate-mcp-list-tools-result
Apr 30, 2026
Merged

fix: do not re-finalize mcp_list_tools in closeLastOutputItem#44
frac merged 1 commit intomainfrom
fix/duplicate-mcp-list-tools-result

Conversation

@Ali-Aleph-Alpha
Copy link
Copy Markdown

@Ali-Aleph-Alpha Ali-Aleph-Alpha commented Apr 29, 2026

Summary

Fixes a bug where the Responses streaming layer emitted response.output_item.done twice for an mcp_list_tools output item, surfacing as a duplicate TOOL_CALL_RESULT in downstream AG-UI consumers.

Root Cause

Output-item finalization is split between two places in this codebase:

Item type Where response.output_item.done is emitted
message closeLastOutputItem
reasoning closeLastOutputItem
function_call closeLastOutputItem
mcp_call closeLastOutputItem
mcp_approval_request closeLastOutputItem
mcp_list_tools listMcpToolsStream (full lifecycle)

listMcpToolsStream already emits the full lifecycle for its own item (addedin_progresscompletedoutput_item.done). However, the previous shared branch in closeLastOutputItem matched both mcp_approval_request and mcp_list_tools:

} else if (lastOutputItem?.type === "mcp_approval_request" || lastOutputItem?.type === "mcp_list_tools") {
    yield { type: "response.output_item.done", ... };
}

When handleOneTurnStream later called closeLastOutputItem (e.g. before transitioning to reasoning/text output) and the last output item was still that same mcp_list_tools, this branch re-emitted response.output_item.done for it.

Why this only manifests for mcp_list_tools

PydanticAI's Responses adapter maps each response.output_item.done for an McpListTools item into a BuiltinToolReturnPart, and AGUIAdapter renders each return as a TOOL_CALL_RESULT. The duplicate output_item.done therefore surfaced as two identical TOOL_CALL_RESULT events for the same toolCallId in AG-UI.

Other tool types (function_call, mcp_call, mcp_approval_request) get output_item.done exclusively from closeLastOutputItem and are never re-finalized, so they're unaffected.

Before / After

Before

response.output_item.added      item=mcp_list_tools id=mcpl_123
response.mcp_list_tools.in_progress
response.mcp_list_tools.completed
response.output_item.done       item=mcp_list_tools id=mcpl_123
...
response.output_item.done       item=mcp_list_tools id=mcpl_123  # duplicate

PydanticAI/AG-UI rendered:

TOOL_CALL_RESULT toolCallId=pyd_ai_builtin|openai|mcpl_123
TOOL_CALL_RESULT toolCallId=pyd_ai_builtin|openai|mcpl_123

After

response.output_item.added      item=mcp_list_tools id=mcpl_123
response.mcp_list_tools.in_progress
response.mcp_list_tools.completed
response.output_item.done       item=mcp_list_tools id=mcpl_123

AG-UI now emits exactly one TOOL_CALL_RESULT for the list-tools discovery.

Fix

Split the combined branch in closeLastOutputItem so:

  • mcp_approval_request keeps its existing close behavior (it is genuinely closed only here).
  • mcp_list_tools is treated as already finalized by listMcpToolsStream and produces no events.

Event schema is unchanged; this only prevents the redundant completion. No behavioral change for any other item type.

Test plan

  • pnpm run test -- src/routes/responses/mcpStream.test.ts src/routes/responses/closeOutputItem.test.ts src/routes/responses/handleOneTurn.test.ts (all 148 tests pass)
  • pnpm run check (tsc clean)
  • Prettier check on touched files
  • New test in closeOutputItem.test.ts: closing when last item is mcp_list_tools yields no events
  • Strengthened existing mcpStream.test.ts: listMcpToolsStream emits exactly one response.output_item.done

@github-actions
Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes duplicate response.output_item.done emission for mcp_list_tools by ensuring closeLastOutputItem does not re-finalize items whose lifecycle is already fully emitted by listMcpToolsStream, preventing duplicate downstream TOOL_CALL_RESULT events.

Changes:

  • Split closeLastOutputItem handling so mcp_approval_request still emits response.output_item.done, while mcp_list_tools becomes a no-op.
  • Strengthen listMcpToolsStream test to assert exactly one response.output_item.done.
  • Add a regression test ensuring closeLastOutputItem yields no events when the last item is mcp_list_tools.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/routes/responses/mcpStream.test.ts Adds an assertion to ensure listMcpToolsStream emits response.output_item.done exactly once.
src/routes/responses/closeOutputItem.ts Prevents re-emitting response.output_item.done for mcp_list_tools while keeping existing behavior for mcp_approval_request.
src/routes/responses/closeOutputItem.test.ts Adds a regression test verifying closing a trailing mcp_list_tools item is a no-op.
Comments suppressed due to low confidence (1)

src/routes/responses/closeOutputItem.ts:242

  • The StreamingError message is now misleading: this function also supports reasoning, mcp_approval_request, and explicitly no-ops mcp_list_tools, but the error says it only expects message, function_call, or mcp_call. Please update the message to reflect the full set of supported output item types (or make it generic, e.g. "unexpected output item type").
			throw new StreamingError(
				`Not implemented: expected message, function_call, or mcp_call, got ${(lastOutputItem as ResponseOutputItem)?.type}`
			);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/routes/responses/closeOutputItem.ts
@frac frac merged commit 3fef3ee into main Apr 30, 2026
13 checks passed
@frac frac deleted the fix/duplicate-mcp-list-tools-result branch April 30, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants