Python: Fix hosted MCP replay producing orphan function_call_output (#5581)

* Python: Fix hosted MCP replay producing orphan function_call_output

Resolves part of #5546. After a turn ran a hosted MCP / Foundry-toolbox-MCP
tool, the next turn's replayed input array carried a function_call_output
with an mcp_* call_id and no matching function_call, and the Responses API
returned a 400.

Two layers covered here:

* Chat-client serialize layer (packages/openai): adds mcp_server_tool_call
  and mcp_server_tool_result cases to _prepare_message_for_openai and
  _prepare_content_for_openai. Pairs are coalesced via a post-pass into a
  single mcp_call input item carrying both arguments and output. Orphan
  results are dropped (debug-logged) rather than serialized as orphan
  function_call_output, which is what the Responses API rejected.

* Host read layer (packages/foundry_hosting): _item_to_message and
  _output_item_to_message now route custom_tool_call_output whose
  call_id.startswith("mcp_") to Content.from_mcp_server_tool_result.
  Non-mcp_ call_ids continue to produce Content.from_function_result.
  Symmetric with the host write-side choice for hosted-MCP results.

Two further fixes (agentserver SDK additions, host write-side single-item
emission) remain tracked on the issue and depend on an SDK release.

* Python: Fix pyright unknown-type in _stringify_mcp_output

cast(Sequence[Any], output) after the isinstance check so pyright stops
flagging the loop variable as unknown. Also normalizes a couple of
em-dashes in docstrings I introduced in the prior commit.

* Python: Harden _stringify_mcp_output for dict-shaped MCP outputs

Address Copilot review on PR #5581. Today the helper falls back to
str() for any non-string, non-text-attribute entry, which produces
Python repr (single-quoted dicts) for the canonical MCP raw-JSON
text-content shape `{"type": "text", "text": "..."}` and any other
dict-shaped output.

Three small changes:

* List-entry path: prefer plain string entries, then `.text` attribute
  (Content objects), then `entry["text"]` for Mapping entries in the
  canonical MCP shape, then JSON-encode anything else.
* Final fallback: `json.dumps(output, default=str)` so Mappings and
  scalars produce valid JSON rather than Python repr.
* Two new unit tests covering the dict-with-text shape and the
  non-text-dict JSON fallback.

* Python: Suppress mypy redundant-cast on _stringify_mcp_output narrowing

The cast is needed by pyright (reportUnknownVariableType) but mypy
considers it redundant after the preceding isinstance narrowing.
Pyright's behavior is correct for the strict-mode reporting we run,
so keep the cast and silence mypy on the line.
This commit is contained in:
Evan Mattson
2026-04-30 21:01:52 +00:00
committed by GitHub
parent 6cd81286a9
commit 317ef4491e
4 changed files with 332 additions and 1 deletions
@@ -879,6 +879,30 @@ class TestOutputItemToMessage:
assert msg.contents[0].type == "function_result"
assert msg.contents[0].result == "result text"
def test_custom_tool_call_output_with_mcp_call_id_routes_to_mcp_server_tool_result(self) -> None:
"""When the host wrote a hosted-MCP result via
`aoutput_item_custom_tool_call_output`, the persisted call_id keeps
its `mcp_*` prefix. On read, that result must reconstruct as a
`mcp_server_tool_result` Content (not `function_result`), so the
chat-client serialize layer treats it as a hosted-MCP result and
does not produce an orphan `function_call_output`.
"""
from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput
item = OutputItemCustomToolCallOutput({
"type": "custom_tool_call_output",
"call_id": "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920",
"output": "found 10 cats",
})
msg = _output_item_to_message(item)
assert msg.role == "tool"
assert len(msg.contents) == 1
c = msg.contents[0]
assert c.type == "mcp_server_tool_result", (
f"expected mcp_server_tool_result for mcp_-prefixed call_id; got {c.type}"
)
assert c.call_id == "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920"
def test_apply_patch_call(self) -> None:
from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall
@@ -1329,6 +1353,32 @@ class TestItemToMessage:
assert msg is not None
assert msg.contents[0].result == "123"
def test_custom_tool_call_output_with_mcp_call_id_routes_to_mcp_server_tool_result(self) -> None:
"""Issue #5546: input items carrying a hosted-MCP result (from a
prior turn that the framework wrote via
`aoutput_item_custom_tool_call_output`) must reconstruct as a
`mcp_server_tool_result` Content, not `function_result`. Otherwise
the chat-client serialize layer turns it into an orphan
`function_call_output` with `mcp_*` call_id and the Responses API
rejects the next turn.
"""
from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput
item = ItemCustomToolCallOutput({
"type": "custom_tool_call_output",
"call_id": "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920",
"output": "found 10 cats",
})
msg = _item_to_message(item)
assert msg is not None
assert msg.role == "tool"
assert len(msg.contents) == 1
c = msg.contents[0]
assert c.type == "mcp_server_tool_result", (
f"expected mcp_server_tool_result for mcp_-prefixed call_id; got {c.type}"
)
assert c.call_id == "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920"
def test_apply_patch_call(self) -> None:
from azure.ai.agentserver.responses.models import ApplyPatchToolCallItemParam, ApplyPatchUpdateFileOperation